BM25 Cheat Sheet

Quick reference. Pair with lessons/0003-bm25-keyword-search-still-matters.html.

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:

Our implementation: BM25+

ParamDefaultWhat it controls
k11.5TF saturation. Higher = TF matters more (asymptote grows).
b0.75Length normalization. Higher = longer docs penalized more.
delta1.0BM25+ 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:

tfScore contribution (k1=1.5, δ=1.0, IDF=1)
00 (skipped)
11.60
22.00
52.46
102.65
502.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

Embeddings win on:
  • Paraphrases ("configure folding" ≈ "set up code folding")
  • Semantic queries (no keyword overlap)
  • Cross-lingual matching
BM25 wins on:
  • 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:

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

FieldMeaning
bm25_dropped_thresholdNumber of BM25 hits dropped by the floor this query (0 when suppression fired).
bm25_threshold_suppressedTrue 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

ChoiceDefaultWhen to change
TokenizerWord boundary + lowercaseAdd stemming for morphological languages; keep current for code/identifiers.
Stop wordsNone removed (IDF handles them)Remove if corpus is huge and TF tables bloat.
k11.5Lower to ~1.2 for short, terse docs; raise to ~2.0 for long articles with key terms repeated.
b0.75Lower to 0.3 for uniformly-sized docs (TILs, tweets); raise to 0.9 for mixed-size corpus.
delta1.0 (BM25+)Set to 0 for plain BM25. Set higher for very long docs with short queries.

Complexity

PhaseCost
Index buildO(N · L) where L = avg doc length (tokenization + DF count)
QueryO(|q| · N · L) — for each query term, scan every doc counting occurrences. Hot path is toks.count(token).
StorageO(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

ConceptFile · Symbol
Index classrag_pipeline.py · BM25Index
Hyperparametersrag_pipeline.py · BM25Index.__init__
Tokenizerrag_pipeline.py · BM25Index._tokenize
Build (DF computation)rag_pipeline.py · BM25Index.add_documents
Query (the formula)rag_pipeline.py · BM25Index.search
Save/loadrag_pipeline.py · to_json_dict / from_json_dict
Hybrid fusionrag_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 signaltiming["bm25_threshold_suppressed"] · timing["bm25_dropped_threshold"]

Glossary

TermMeaning
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 saturationDiminishing returns: the 50th occurrence of "neovim" matters less than the 1st.
Length normalizationPenalizing 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.
TokenThe unit of indexing. In our code: word-bounded alphanumeric + #, length 2–51.
StemmingReducing tokens to roots ("running" → "run"). We don't do this.
Stop wordsCommon 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 suppressionIf 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.