BM25 Cheat Sheet
The formula
For a single query q and document D:
score(q, D) = Σt ∈ q IDF(t) · ( tf(t, D)·(k1+1) / (tf(t,D) + k1·(1 - b + b·|D|/avgdl)) + δ )
Three pieces:
- tf(t, D) — how many times term
tappears inD - IDF(t) = log((N - df(t) + 0.5) / (df(t) + 0.5) + 1) — how rare
tis in the corpus - |D|/avgdl — length penalty
Our implementation: BM25+
| Param | Default | What it controls |
|---|---|---|
k1 | 1.5 | TF saturation. Higher = TF matters more (asymptote grows). |
b | 0.75 | Length normalization. Higher = longer docs penalized more. |
delta | 1.0 | BM25+ floor. 0 = plain BM25. Higher = bigger boost for any hit. |
The four key code blocks
Constructor
self.k1 = 1.5
self.b = 0.75
self.delta = 1.0
self._built = False
Tokenizer
def _tokenize(self, text: str) -> list[str]:
return re.findall(r"\b[a-zA-Z][a-zA-Z0-9#]{1,50}\b", text.lower())
Case-folded, word-boundary, letter-prefixed, length 2–51, includes #.
Index build
self._doc_tokens = [self._tokenize(t) for t in texts]
self.doc_lengths = [len(toks) for toks in self._doc_tokens]
self.avg_doc_length = sum(self.doc_lengths) / max(self.total_docs, 1)
# Per-document frequency: each term counted ONCE per doc, even if repeated
for toks in self._doc_tokens:
for token in set(toks):
term_to_df[token] = term_to_df.get(token, 0) + 1
self.doc_freqs = term_to_df
Search
idf = np.log((n - df + 0.5) / (df + 0.5) + 1.0) # ≥ 0 always
denom = tf + self.k1 * (1 - self.b + self.b * dl / self.avg_doc_length)
score = idf * (tf * (self.k1 + 1) / denom + self.delta)
TF saturation intuition
For k1=1.5, the per-term score contribution as TF grows:
| tf | Score contribution (k1=1.5, δ=1.0, IDF=1) |
|---|---|
| 0 | 0 (skipped) |
| 1 | 1.60 |
| 2 | 2.00 |
| 5 | 2.46 |
| 10 | 2.65 |
| 50 | 2.81 |
| ∞ (asymptote) | 2.50 + δ = 3.50 (BM25+) or 2.50 (BM25) |
Notice BM25+ has no true asymptote — the +δ adds a constant. That's the floor.
Embeddings vs BM25
- Paraphrases ("configure folding" ≈ "set up code folding")
- Semantic queries (no keyword overlap)
- Cross-lingual matching
- Exact technical terms ("neovim", "
--watch") - Code identifiers ("
socket.id") - Brand names, version numbers, error codes
- Speed at scale (no GPU, no model load)
- Out-of-corpus terms (predictable behavior)
Threshold filter (production noise control)
BM25_THRESHOLD = 1.0 (in config.py) — the floor on BM25 raw score below which a hit is dropped from the BM25 rank list before it reaches RRF. Defense against two real failure modes:
- Single-token incidental matches. A chunk containing the word "set" (e.g.
.set({...})in a JS code block) gets a non-zero BM25 score for the query "how to set up neovim folding" — but the chunk isn't actually about Neovim. A floor around 1.0 filters most of these. - Stopword piles. A chunk that mentions 20 common words but none of the actual rare query terms can still score above 0. The floor cuts them off.
The rule
if score >= BM25_THRESHOLD:
keep
else:
drop # can still surface via vector search if ranked there
BM25_THRESHOLD = 0.0 disables the filter (backward-compat default).
Adaptive override — the "no good match" case
The hard floor has its own failure mode: an adversarial query with zero corpus overlap would have its BM25 list silently wiped clean. The adaptive override detects this and keeps every hit so vector search + RRF can still do useful work. Mirrors the cosine-threshold logic in HybridSearch.search exactly:
if self.bm25_threshold > 0 and bm25_results:
top_bm25 = float(bm25_results[0].get("score", 0))
if top_bm25 < self.bm25_threshold:
# No good lexical match anywhere — let vector carry the load.
bm25_threshold_suppressed = True # every BM25 hit kept as-is
else:
# apply the normal `>=` filter
...
When the top BM25 hit itself is below the floor, the filter is suppressed and every hit is kept. bm25_threshold_suppressed in the timing dict records that the override fired.
Timing dict fields
| Field | Meaning |
|---|---|
bm25_dropped_threshold | Number of BM25 hits dropped by the floor this query (0 when suppression fired). |
bm25_threshold_suppressed | True if the adaptive override fired — top BM25 hit was below the floor, so the filter was suppressed. |
Symmetric with the cosine threshold
The cosine and BM25 thresholds are now symmetric: both peek at the top hit, suppress the filter if it's below the floor, otherwise apply the filter normally. Both rank lists are noise-filtered symmetrically, and both fail gracefully when the corpus has no good match for the query. The two suppression flags (vec_threshold_suppressed / bm25_threshold_suppressed) tell operators exactly which side had nothing.
See Lesson 0003 §7 for the production-noise-control coda — two failure modes (zero overlap vs weak overlap), the live sweep data behind the numbers, and tuning advice.
Production decisions
| Choice | Default | When to change |
|---|---|---|
| Tokenizer | Word boundary + lowercase | Add stemming for morphological languages; keep current for code/identifiers. |
| Stop words | None removed (IDF handles them) | Remove if corpus is huge and TF tables bloat. |
| k1 | 1.5 | Lower to ~1.2 for short, terse docs; raise to ~2.0 for long articles with key terms repeated. |
| b | 0.75 | Lower to 0.3 for uniformly-sized docs (TILs, tweets); raise to 0.9 for mixed-size corpus. |
| delta | 1.0 (BM25+) | Set to 0 for plain BM25. Set higher for very long docs with short queries. |
Complexity
| Phase | Cost |
|---|---|
| Index build | O(N · L) where L = avg doc length (tokenization + DF count) |
| Query | O(|q| · N · L) — for each query term, scan every doc counting occurrences. Hot path is toks.count(token). |
| Storage | O(N · L) — the tokenized docs are kept in memory. |
For 100k docs × 200 tokens: ~20M ints in memory. Fine. For 10M docs, switch to a real inverted index (tantivy, Lucene) where cost is O(|q| · matches), not O(|q| · N · L).
Where to look in the codebase
| Concept | File · Symbol |
|---|---|
| Index class | rag_pipeline.py · BM25Index |
| Hyperparameters | rag_pipeline.py · BM25Index.__init__ |
| Tokenizer | rag_pipeline.py · BM25Index._tokenize |
| Build (DF computation) | rag_pipeline.py · BM25Index.add_documents |
| Query (the formula) | rag_pipeline.py · BM25Index.search |
| Save/load | rag_pipeline.py · to_json_dict / from_json_dict |
| Hybrid fusion | rag_pipeline.py · HybridSearch.search (next lesson) |
| Threshold floor (default) | config.py · BM25_THRESHOLD = 1.0 |
| Threshold filter (adaptive) | rag_pipeline.py · HybridSearch.search (BM25 block) |
| Suppression signal | timing["bm25_threshold_suppressed"] · timing["bm25_dropped_threshold"] |
Glossary
| Term | Meaning |
|---|---|
| TF (term frequency) | How many times a term appears in a document. |
| DF (document frequency) | How many documents contain a term (at least once). |
| IDF (inverse document frequency) | log((N - df + 0.5) / (df + 0.5) + 1). Higher for rarer terms. |
| TF saturation | Diminishing returns: the 50th occurrence of "neovim" matters less than the 1st. |
| Length normalization | Penalizing long documents for the same TF, since they hit terms by chance. |
| BM25+ | BM25 with a +δ lower bound. Avoids 0 scores for any document containing a term. |
| Token | The unit of indexing. In our code: word-bounded alphanumeric + #, length 2–51. |
| Stemming | Reducing tokens to roots ("running" → "run"). We don't do this. |
| Stop words | Common words ("the", "is"). We don't filter them; IDF downweights them. |
| Threshold (BM25) | Raw-score floor; hits with score < BM25_THRESHOLD are dropped from the BM25 rank list before RRF. Default 1.0; set to 0.0 to disable. |
| Adaptive suppression | If the top BM25 hit's score is itself below the floor, the filter is suppressed and every hit is kept. Prevents "adversarial query wipes BM25 list clean". Symmetric to the cosine-threshold suppression. |