BM25 Scoring
Background
BM25 is the sparse, term-weighted retrieval scorer that powers Lucene, Elasticsearch, OpenSearch, and the lexical half of every production hybrid-RAG system. Where dense embeddings excel at semantic similarity, BM25 dominates on rare named entities, error codes, version numbers, and any query whose target shares specific vocabulary — without any model training. Knowing it cold is table stakes for a production-RAG / search interview.
Problem statement
Implement bm25_score(query, doc, corpus, k1=1.5, b=0.75) returning the BM25 score for a single pair, computed against the supplied corpus (used to derive IDF and the average document length). Use the Robertson-Sparck-Jones / Lucene IDF variant (always non-negative) and the standard length-normalised TF:
with
where is the count of term in document , is 's length in tokens, is the corpus mean document length, is the corpus size, and is the number of documents containing .
Input
query—list[str]of query tokens. Duplicates count once (BM25 sums per unique query term).doc—list[str]of document tokens (the candidate being scored).corpus—list[list[str]]of every document used to derive IDF and . Must containdocifdocis part of the indexed corpus (otherwise the IDF underweights it).k1—float, term-frequency saturation knob (typical ; default ).b—float, length-normalisation strength in (default ). disables length normalisation; enforces it fully.
Output
Returns a Python float — the BM25 score for (query, doc). Larger is better. Returns 0.0 when no query term appears in doc or the query is empty.
Examples
Example 1 — rare-term query lights up the doc that contains it
Input:
query = ["cat"]
corpus = [["the","cat","sat","on","the","mat"],
["the","dog","ran","fast"],
["cats","love","milk"]]
doc = corpus[0]
Output: ~0.8361
Explanation: , , so . With , , , the normalised TF is . Product .
Example 2 — common term has low IDF
Input:
query = ["the"]
corpus = same as Example 1
doc = corpus[0]
Output: ~0.5975
Explanation: "the" appears in 2 of 3 docs, so , lower than the rare-term case despite the higher TF.
Example 3 — query term absent from the doc
Input:
query = ["dog"]
doc = corpus[0] # the cat-sat-on-mat doc
Output: 0.0
Explanation: no query term appears in doc, so every per-term contribution is 0.
Constraints
- Score is the sum over unique query terms — duplicates in
querydon't double-count. - Match terms by exact equality (no stemming, no lowercasing; the caller does that upstream).
- If
corpusis empty orqueryis empty, return0.0without a division-by-zero error. - Return a Python
float. Tests compare withatol ≈ 1e-6.
Notes
- Why this IDF form. The wrapping is the Lucene / RSJ variant — non-negative everywhere, unlike the classical which goes negative when more than half the corpus contains the term. Production search engines all use the form for that reason.
- TF saturation. The term means doubling the in-document term frequency does not double the score — a doc that mentions the query term 20 times scores only marginally higher than one that mentions it 5 times. That's the "saturation" that prevents keyword-stuffed pages from winning.
- Length normalisation. The factor penalises long documents for having more chances to match a term by accident. Set to remove the penalty entirely.
▶ Run executes the 3 visible sample tests below in your browser. Submit runs the full suite — including hidden tests — on the server for an official verdict.
- •Reference example: rare-term cat score
- •Sample example two: common-term the score
- •Example three: absent term scores zero