BM25 from first principles¶
As asked: "Everyone uses BM25. Derive the term-frequency component from the probabilistic relevance model, and tell me what
k₁andbactually control."
Where the formula comes from¶
Start from the probabilistic relevance framework: rank documents by the odds that a document is relevant given the query. Under a term-independence assumption, taking logs turns the product over query terms into a sum, and the weight for each term is the log odds ratio
where N is the number of documents and n_t the number containing t. The 0.5 terms are a
smoothing correction; the shape is the point. This is the Robertson–Sparck Jones weight, and
it falls out of the odds ratio rather than being chosen for convenience.
Note it goes negative once a term appears in more than about half the collection. A term in most documents is weak evidence against relevance, which is the principled version of a stop list.
Why term frequency saturates¶
The naive model treats each occurrence of a term as independent evidence, so evidence grows
linearly with tf. That is wrong, and the 2-Poisson model says why: documents are a mixture
of an elite set, about which the term is genuinely a topic, and a non-elite set where it
occurs incidentally. What we want to estimate is the probability of eliteness given tf, and
that probability is concave and bounded — it rises quickly over the first few occurrences
and then flattens, because a document mentioning a term twenty times is not twice as on-topic as
one mentioning it ten times.
The 2-Poisson estimate has no closed form that is cheap to compute, so BM25 uses the simplest function with the right shape:
k₁ is the saturation rate. At k₁ → 0 the function becomes a step: presence or absence,
and BM25 degenerates toward a boolean model. At large k₁ it is nearly linear over the range of
real tf values, recovering the naive count model. Typical values sit around 1.2–2.0, where
the third occurrence of a term is worth noticeably less than the first and the tenth is worth
almost nothing extra.
Why length normalisation is interpolated¶
A long document has more term occurrences by construction, so raw tf rewards length. But
length is ambiguous evidence: a document can be long because it is verbose (should be
penalised) or because it is comprehensive (should not). BM25 refuses to choose and
interpolates:
b = 0 applies no length normalisation. b = 1 applies it fully, scaling tf by the
ratio of document length to the collection average. b ≈ 0.75 is the usual compromise. Putting
it together:
The (k₁+1) in the numerator is cosmetic — it makes a single-occurrence term score 1 when
|d| = avgdl, and changes no ranking.
What a strong answer adds¶
1 · The failure mode that is not in the formula. BM25 scores whatever the analyzer gives
it. This repository shipped a bug — #1 —
where SQLite's default unicode61 tokenizer split ERR_CONN_RESET into err, conn, reset,
each appearing in every incident report. IDF for all three was near zero, so the query matched
everything and ranked nothing. The scoring function was flawless and the results were wrong.
Nothing errored. That is the answer that separates someone who has run BM25 from someone who has
read about it.
2 · Where the derivation's assumptions break. Term independence is false — "machine" and "learning" are not independent. BM25 works anyway because ranking only needs the ordering to be approximately right, not the probabilities to be calibrated. Being able to say which assumption is violated and why it does not matter is the mark of understanding rather than recall.
3 · What it structurally cannot do. No setting of k₁ or b gives BM25 a synonym. It is
exact lexical matching with a good weighting scheme, which is exactly why the dense leg exists
and why fusion is not optional on a corpus with vocabulary mismatch.
Measure it here¶
nanorag/store.py implements BM25 over SQLite FTS5.
Notebook 04
§4.3 builds the inverted index by hand and sweeps k₁ and b, and
EX-11 has you score three documents with a pen before
running anything.
Standalone: gist — the IDF, and the sign flip, runnable. Stdlib only, no clone needed.