Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°18

BM25 Scoring

Medium AI EngineeringRetrievalSearch

The keyword-ranking function every fancy retriever still has to beat. Score documents for a query with BM25 — term frequency that saturates, rare terms weighted up, long documents normalized down. The lexical half of every hybrid search pipeline.

The problem

Given a query (list of tokens) and docs (list of documents, each a list of tokens), return a list of BM25 scores, one per document. Use k1 = 1.5, b = 0.75. For each query term: idf = ln(1 + (N - df + 0.5) / (df + 0.5)) where N is the number of documents and df the number containing the term; the term contribution to a document is idf · tf·(k1+1) / (tf + k1·(1 - b + b·len/avglen)) where tf is the term count in the document, len its length, and avglen the mean document length. A document score is the sum over query terms.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement bm25_scores(query, docs) → one score per document, using the standard BM25 formula with k1 = 1.5, b = 0.75 and the +1-smoothed idf.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input query = ['cat'], docs = [['the','cat','sat'], ['the','dog','ran']]
Output [0.6931…, 0.0]
only the cat document scores; that is exactly ln 2, the idf, because one occurrence at average length makes the tf factor 1
EXAMPLE 2
Input query = ['cat'], docs = [['cat','a','b','c'], ['cat','cat','b','c']]
Output [0.1823…, 0.2604…]
tf = 2 beats tf = 1, but by 10/7 (about 1.43×), not 2×: k1 saturates repeats
EXAMPLE 3
Input query = ['cat'], docs = [['cat','a'], ['cat','a','b','c','d','e']]
Output [0.2352…, 0.1488…]
same tf, and the shorter document wins: b normalizes length
EXAMPLE 4
Input query = ['zebra'], docs = [['the','cat','sat'], ['the','dog','ran']]
Output [0.0, 0.0]
edge case: a term in no document still has a finite idf (ln 6), and tf = 0 zeroes it
CONSTRAINTS
  • k1 = 1.5, b = 0.75 exactly.
  • A query term absent from every document contributes 0 to every score (its idf still computes, tf = 0 zeroes it).
  • avglen = total tokens across docs ÷ number of docs.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Precompute N, avglen, and df per query term (how many docs contain it) before scoring.
  2. idf uses document frequency, not term counts: a term appearing 10 times in one doc still has df = 1.
  3. The tf part is tf·(k1+1) / (tf + k1·(1 − b + b·len/avglen)) — note tf appears in numerator AND denominator: that is the saturation.
  4. Sum contributions per query term; a term with tf = 0 contributes 0 automatically.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach applies the formula literally, and it is what the reference does: for every document and every query term, scan all n documents to count df, then count tf with d.count(term). It is correct because it computes exactly the stated sum. With T total tokens it costs O(n·q·T) time for q query terms, since each df is a full pass over the corpus, and O(1) extra space. The waste is that df and idf depend only on the term, never on the document being scored, so the same corpus scan is repeated once per document.

The approach

Split the formula by what each part depends on. idf = ln(1 + (n − df + 0.5) / (df + 0.5)) needs only the term, so compute it once per query term. The length factor k1 · (1 − b + b · len/avglen) needs only the document, so compute it once per document. Each (document, term) pair is then one tf lookup in that document’s token counts.

Saturation comes from tf sitting in both the numerator and the denominator of tf·(k1+1) / (tf + K): the factor is 0 at tf = 0, equals exactly 1 for one occurrence in an average-length document, and climbs toward k1 + 1 = 2.5 without reaching it. The reference uses these names (n, avglen, df, idf, tf, denom) but computes df inside for d in docs. Moving the df and idf lines above that loop gives the optimal bound and changes no score.

Walk through an example

Scoring query = ["cat", "sat"] over three documents (n = 3, avglen = 8/3, “cat” in two of them, “sat” in one) returns [1.374, 0.0, 0.730]: the short d2 says “cat” twice, yet d0 wins by also matching the rarer “sat”.

stepdoc · termidftfdenomaddsscore
1d0 = [the, cat, sat] · cat0.47012.6410.4450.445
2d0 · sat0.98112.6410.9291.374
3d1 = [the, dog, ran] · cat0.47001.64100
4d1 · sat0.98101.64100.0
5d2 = [cat, cat] · cat0.47023.2190.7300.730
6d2 · sat0.98101.21900.730

Complexity

With df and idf hoisted and each document’s tokens counted once, scoring costs O(T + n·q) time for T total tokens, with O(q) space for the idf table plus one document’s counts. The literal version recomputes df for every document: O(n·q·T) time, O(1) extra space. An inverted index lets a query touch only documents containing its terms.

Edge cases to test

  • A term in every document: bm25_scores(["the"], [["the","cat","sat"], ["the","dog","ran"]]) returns [0.1823…, 0.1823…]. The +1 inside the log keeps idf = ln 1.2 positive; the classic ln((N − df + 0.5) / (df + 0.5)) turns negative whenever df is more than N/2.
  • A term in no document: ["zebra"] on the same docs returns [0.0, 0.0]. Its idf is a finite ln 6 and tf = 0 zeroes it, so no special case is needed.
  • A repeated query term counts twice: ["cat", "cat"] on the first example returns [1.3862…, 0.0], double the single-term score, because the reference sums over the query list as given.
  • An empty document scores 0: bm25_scores(["cat"], [[], ["cat"]]) returns [0.0, 0.478…]. If every document is empty, avglen is 0 and the reference raises ZeroDivisionError; an empty docs list raises it too.
  • Membership is exact: term in doc tests list equality, so "cat" does not match "cats". Tokenizing and stemming happen before BM25, not inside it.

Common mistakes

  • Using raw term frequency without saturation, so a term repeated many times in a long document dominates unfairly.
  • Dropping the document-length normalization — long documents then score artificially high.
  • Mishandling IDF edge cases (a term in every document, or in none).

Follow-ups an interviewer asks next

How do you make this fast over a million documents?
Build an inverted index once: for each term, its postings list of (document, tf) pairs, plus every document’s length. A query then reads only its own terms’ postings, and df is a postings-list length. Engines go further with dynamic pruning such as WAND and block-max WAND, which skip documents whose best possible score cannot enter the current top k.
How would you choose k1 and b?
Tune them against labeled queries. k1 = 0 reduces tf to presence or absence, and a large k1 approaches raw tf; b = 0 ignores length and b = 1 normalizes fully. A common starting range is k1 from 1.2 to 2.0 with b = 0.75, then grid-search on your own data.
How do you combine BM25 with vector search?
Run both retrievers and fuse the ranked lists. Reciprocal Rank Fusion gives each document the sum of 1 / (60 + rank) over the lists, which needs no score calibration. Blending raw scores linearly also works but needs normalization and tuning, because BM25 scores have no fixed range and grow with query length, while cosine scores sit in [−1, 1].

Where this shows up

BM25 is the default lexical ranking function in production search (Lucene, Elasticsearch, OpenSearch) and the lexical half of hybrid retrieval. It nails the exact identifiers, names, SKUs, and error codes that embeddings blur, which is why serious RAG systems run BM25 and vector search together and fuse the results.

Reference solution

Reference solution — Python
import math

def bm25_scores(query, docs, k1=1.5, b=0.75):
    n = len(docs)
    avglen = sum(len(d) for d in docs) / n
    scores = []
    for d in docs:
        s = 0.0
        for term in query:
            df = sum(1 for doc in docs if term in doc)
            idf = math.log(1 + (n - df + 0.5) / (df + 0.5))
            tf = d.count(term)
            denom = tf + k1 * (1 - b + b * len(d) / avglen)
            s += idf * tf * (k1 + 1) / denom
        scores.append(s)
    return scores


print(bm25_scores(['cat'], [['the','cat','sat'], ['the','dog','ran']]))
Reference solution — TypeScript
function bm25Scores(query: string[], docs: string[][], k1 = 1.5, b = 0.75): number[] {
  const n = docs.length;
  const avglen = docs.reduce((a, d) => a + d.length, 0) / n;
  return docs.map((d) => {
    let s = 0;
    for (const term of query) {
      const df = docs.filter((doc) => doc.includes(term)).length;
      const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
      const tf = d.filter((t) => t === term).length;
      const denom = tf + k1 * (1 - b + (b * d.length) / avglen);
      s += (idf * tf * (k1 + 1)) / denom;
    }
    return s;
  });
}

console.log(bm25Scores(['cat'], [['the','cat','sat'], ['the','dog','ran']]));

Questions about BM25 Scoring

What do k1 and b do in BM25?

k1 controls term-frequency saturation. The tf factor, tf times (k1 + 1) divided by (tf + k1 times the length term), starts at 0, rises with each extra occurrence, and levels off below k1 + 1, so the tenth occurrence of a word adds far less than the second. b controls length normalization. At b = 0 document length is ignored; at b = 1 a document twice the average length needs twice the term frequency to earn the same factor. This challenge fixes k1 = 1.5 and b = 0.75, a common default that real systems then tune on their own queries.

Why does this BM25 add 1 inside the IDF logarithm?

The classic form, ln((N - df + 0.5) / (df + 0.5)), goes negative once a term appears in more than half the documents, so a very common query word would lower a document’s score just for containing it. Adding 1 inside the logarithm keeps idf positive for every df, while rare terms still weigh far more than common ones. With two documents, a term in both gets ln 1.2, about 0.18, and a term in one gets ln 2, about 0.69. That is why the first example scores exactly ln 2: one occurrence in an average-length document makes the tf factor exactly 1.

Is BM25 still useful when you have embeddings?

Yes. BM25 matches exact tokens, so it finds identifiers, product codes, error messages and rare names that an embedding model can place near unrelated text. Embeddings find paraphrases that share no words with the query. The two fail on different queries, which is why hybrid retrieval runs both and fuses the ranked lists, often with Reciprocal Rank Fusion. BM25 also needs no training and no GPU, and its score can be explained term by term, which makes it the standard baseline new retrievers are measured against.

What is the difference between BM25 and TF-IDF?

Both multiply a term-frequency signal by an inverse-document-frequency weight. Classic TF-IDF uses raw or log-scaled term frequency, so a word repeated 50 times keeps adding score, and it has no length normalization unless you add one, such as normalizing each document vector to unit length. BM25 saturates term frequency through k1 and normalizes for document length through b against the average length, so long, repetitive documents do not win on volume alone. For keyword ranking, BM25 is the usual default.

Finished this one? 0 / 76 Challenges done
NextTop-K Retrieval Next on the “RAG & retrieval” ladder · rung 5 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges