Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
BM25 Scoring
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.
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.
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.
Examples and constraints 4 examples
query = ['cat'], docs = [['the','cat','sat'], ['the','dog','ran']][0.6931…, 0.0]query = ['cat'], docs = [['cat','a','b','c'], ['cat','cat','b','c']][0.1823…, 0.2604…]query = ['cat'], docs = [['cat','a'], ['cat','a','b','c','d','e']][0.2352…, 0.1488…]query = ['zebra'], docs = [['the','cat','sat'], ['the','dog','ran']][0.0, 0.0]- 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
- Precompute N, avglen, and df per query term (how many docs contain it) before scoring.
- idf uses document frequency, not term counts: a term appearing 10 times in one doc still has df = 1.
- 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.
- 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”.
| step | doc · term | idf | tf | denom | adds | score |
|---|---|---|---|---|---|---|
| 1 | d0 = [the, cat, sat] · cat | 0.470 | 1 | 2.641 | 0.445 | 0.445 |
| 2 | d0 · sat | 0.981 | 1 | 2.641 | 0.929 | 1.374 |
| 3 | d1 = [the, dog, ran] · cat | 0.470 | 0 | 1.641 | 0 | 0 |
| 4 | d1 · sat | 0.981 | 0 | 1.641 | 0 | 0.0 |
| 5 | d2 = [cat, cat] · cat | 0.470 | 2 | 3.219 | 0.730 | 0.730 |
| 6 | d2 · sat | 0.981 | 0 | 1.219 | 0 | 0.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 keepsidf = ln 1.2positive; the classicln((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 finiteln 6andtf = 0zeroes 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,avglenis 0 and the reference raisesZeroDivisionError; an emptydocslist raises it too. - Membership is exact:
term in doctests 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 = 0reduces tf to presence or absence, and a largek1approaches raw tf;b = 0ignores length andb = 1normalizes fully. A common starting range isk1from 1.2 to 2.0 withb = 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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →
- MMR RerankingTop-k by similarity returns five copies of the same paragraph. Implement Maximal Marginal Relevance: greedily pick results that are relevant to the query AND different from what you already picked — λ·relevance − (1−λ)·redundancy, with cosine similarity. Solve it in Python or TypeScript.Read →
- Consistent Hashing RingHow distributed caches decide which node owns a key — with tiny churn when nodes join or leave. Build a hash ring with virtual nodes and route keys clockwise. A provided hash keeps Python and TypeScript in sync. Hidden tests.Read →
- Bloom FilterA tiny bit-array that answers "have I seen this?" in a fraction of a set’s memory — with occasional false positives but never a false negative. Build one with double hashing. Provided hashes keep both languages in sync. Hidden tests.Read →
- Implement a TrieThe prefix tree behind autocomplete and spell-check: insert, search, and startsWith in time proportional to word length. Replay the operations on a tree keyed by characters. Solve it in Python or TypeScript, with hidden tests.Read →