Lesson 0003 Retrieval · ~12 min · hands-on exercise

BM25: Why Keyword Search Still Matters

Anchored in rag_pipeline.pyclass BM25Index · config.py · BM25Index.search

Last lesson you saw the "fold" trap: the same word in two sentences dragged a Neovim folding question and a laundry folding question to a 0.43 cosine score. The embedding model did its best — it raised the score because of the shared word — but it also dampened it because the contexts diverge. What we want in many cases is something else entirely: a hard, unapologetic match on the word "fold" and then a downstream re-ranker to figure out the meaning.

That's what BM25 does. It's the workhorse of search engines for a reason that's older than transformers: exact word matches are still the strongest signal we have for many queries, and you don't need a GPU to compute them.

1. The mental model

BM25 ranks documents by how well they match the words in a query. It is a scoring function, not a model. There are no learned weights, no GPU, no 80 MB download. It's just an equation.

Three ideas compose it:

  1. Term frequency (TF) — the more often a query word appears in a document, the more relevant that document is. But with diminishing returns: the difference between 1 occurrence and 5 is huge; between 50 and 54 is basically nothing.
  2. Inverse document frequency (IDF) — words like "the" and "is" appear in every document and tell you nothing. Words like "neovim" or "polysemy" appear in few documents and are highly discriminative. Weight them more.
  3. Length normalization — a 5,000-word blog post is more likely to contain any given word by accident than a 200-word note. Don't reward it as much.

BM25 combines these three with two tunable knobs (k1 and b). It's the formula Elasticsearch uses by default, and the formula your BM25Index implements. The Elastic blog has the canonical deep dive →

2. The formula, line by line

For a single query q and a single document D, BM25 is:

score(q, D) = Σ_{t ∈ q} IDF(t) · ( tf(t, D) · (k1 + 1) / ( tf(t, D) + k1 · (1 - b + b · |D|/avgdl ) ) + δ )

That's a wall of symbols. Break it into the three pieces:

PieceWhat it does
tf(t, D) How many times query term t appears in document D.
IDF(t) = log((N - df(t) + 0.5) / (df(t) + 0.5) + 1) How rare the term is across the whole corpus. N = total docs, df(t) = number of docs containing t.
|D| / avgdl Length penalty: how long this document is, relative to the average. Shorter docs get a boost.

The big fraction in the middle is the saturating TF. As tf grows, the numerator grows linearly but the denominator grows linearly plus a constant, so the whole thing asymptotes to (k1 + 1). That's the "diminishing returns" — the 51st occurrence contributes much less than the 1st.

Our code has one extra symbol: δ (delta). With δ = 0 this is plain BM25. With δ > 0 this is BM25+ — an extension that gives a small baseline score to documents that contain a query term at least once, even when the TF saturating function would otherwise return 0. It's a small change that noticeably helps long-doc-vs-short-query behavior. Zilliz's BM25 walkthrough has the BM25+ derivation.

3. What k1 and b actually control

These are the two knobs. The defaults (k1=1.5, b=0.75) are not magic — they were empirically tuned on early TREC datasets and have stuck. But you can and should reason about them.

ParamRangeEffect of raising itWhen to lower it
k1 0 – ∞ (typical 1.2–2.0) TF contributes more. A document with 5 hits scores much higher than one with 1 hit. When your corpus is short and terse — TF saturation kicks in fast anyway.
b 0 – 1 (typical 0.75) Length penalty is stronger. Long documents are penalized more for the same TF. When your documents are uniformly sized (TIL notes, tweets) — length is uninformative.

Mental shortcuts:

For our blog corpus: the documents are mixed sizes — some are 200-word TILs, others are 3,000-word guides. The default b=0.75 is sensible. k1=1.5 is on the high end of typical; you could try k1=1.2 if you find long blog posts are dominating top results unfairly. But measure, don't guess.

4. Now read your own code

Open rag_pipeline.py and find class BM25Index. The whole class is ~80 lines. Here are the four pieces worth understanding:

rag_pipeline.py · BM25Index.__init__

self.k1 = 1.5       # TF saturation knob
self.b = 0.75       # Length normalization knob
self.delta = 1.0    # BM25+ lower bound (vs plain BM25's 0.0)

The constructor takes three hyperparameters. The defaults are the standard "BM25+ with mild lower bound" recipe.

rag_pipeline.py · BM25Index._tokenize

def _tokenize(self, text: str) -> list[str]:
    return re.findall(r"\b[a-zA-Z][a-zA-Z0-9#]{1,50}\b", text.lower())

One line. Three things it does:

  1. text.lower() — case folding. "Neovim" and "neovim" are the same token.
  2. \b[a-zA-Z][a-zA-Z0-9#]{1,50}\b — word boundaries, must start with a letter, can include letters/digits/#, length 2–51. This drops punctuation, single-character tokens (so "I" and "a" don't pollute the index), and supports things like C# and .NET variants.
  3. Returns a list, not a set. BM25 needs the actual TF count, not a unique set.

What this tokenizer doesn't do: no stemming ("running" and "runs" are different tokens), no stop-word removal ("the" and "is" are indexed and get low IDF at query time), no n-grams (no "neovim folding" as a single token). These are all choices. The IDF term in the BM25 score handles the stop-word issue by downweighting them; the choice not to stem trades recall for predictability. The Elastic blog explains the standard BM25+stemmer combination →

rag_pipeline.py · BM25Index.add_documents

def add_documents(self, doc_ids: list[str], texts: list[str]):
    self.doc_ids = doc_ids
    self.doc_texts = texts
    self._doc_tokens = [self._tokenize(t) for t in texts]
    self.doc_lengths = [len(toks) for toks in self._doc_tokens]
    self.total_docs = len(texts)
    self.avg_doc_length = sum(self.doc_lengths) / max(self.total_docs, 1)

    term_to_df: dict = {}
    for toks in self._doc_tokens:
        for token in set(toks):         # ← set, not list
            term_to_df[token] = term_to_df.get(token, 0) + 1
    self.doc_freqs = term_to_df
    self._built = True

This is the index build. It's just three pieces of state:

Note set(toks) — we count each term once per document for DF purposes, even if it appears 100 times. The TF is what we count for the per-document term frequency.

rag_pipeline.py · BM25Index.search

for token in query_tokens:
    df = self.doc_freqs.get(token, 0)
    if df == 0:
        continue                        # ← term not in corpus, skip
    idf = np.log((n - df + 0.5) / (df + 0.5) + 1.0)
    for i in range(n):
        toks = self._doc_tokens[i]
        tf = toks.count(token)         # ← O(|doc|) per query
        if tf == 0:
            continue
        dl = self.doc_lengths[i]
        denom = tf + self.k1 * (1 - self.b + self.b * dl / self.avg_doc_length)
        if denom > 0:
            scores[i] += idf * (tf * (self.k1 + 1) / denom + self.delta)

This is the full formula. Notice:

5. Embeddings vs BM25 — when to use which

We've now seen both. Here's the working rubric from production RAG systems:

Signal typeEmbeddings winBM25 wins
Paraphrase ✅ "configure folding" ≈ "set up code folding" — no shared words, embedding catches it ❌ Zero overlap; returns nothing
Exact technical term 🟡 Embedding can be confused by polysemy ("fold") ✅ "neovim" or "--watch" matches the literal string every time
Code/identifier socket.id tokenized into pieces, may not match ✅ Treated as one token, exact match
Out-of-vocabulary / typos ❌ Encoder may not know the word ❌ Lexical match won't work either, but at least it's predictable
Cross-lingual / semantic ✅ Cross-lingual models handle "dog" = "perro" ❌ No overlap, returns nothing
Speed at scale Needs ANN index past ~100k Inverted index scales further; O(query terms × matches)

Production RAG systems don't pick one — they run both and fuse the rankings. That's the next lesson (RRF). OpenSearch's hybrid retrieval post explains why this is the consensus →

6. Hands-on exercise

Compare BM25 vs vector search on the real rag-blog index

Save as playground_bm25.py at the repo root:

"""Lesson 0003 — compare BM25 vs vector search on the real index."""
import json
from rag_pipeline import BM25Index, Embedder, VectorStore

# Load existing BM25 index (built during ingest)
print("Loading BM25 index...")
with open("data/lancedb/bm25_data.json", encoding="utf-8") as f:
    bm25 = BM25Index.from_json_dict(json.load(f))

print("Loading vector store + embedder...")
embedder = Embedder()
vstore = VectorStore("data/lancedb", embedder.dimension)

# Four queries that should differentiate the two methods
queries = [
    "how to configure neovim folding",       # paraphrase + technical term
    "polysemy",                              # single rare term
    "neovim",                                # exact brand match
    "How do I do a barrel roll in Neovim?",  # sentence-shaped, no overlap likely
]

print(f"\nBM25 corpus: {bm25.total_docs} docs, {len(bm25.doc_freqs)} unique terms")
print(f"Avg doc length: {bm25.avg_doc_length:.1f} tokens\n")

for q in queries:
    print(f"=== Query: {q!r} ===")
    bm25_results = bm25.search(q, top_k=3)
    print(f"  BM25 top 3:")
    for r in bm25_results:
        title = r["content"].split("\n")[0].lstrip("#").strip()[:60]
        print(f"    {r['score']:6.2f}  {r['doc_id']:25s}  {title}")

    qvec = embedder.embed_one(q)
    vec_results = vstore.vector_search(qvec, top_k=3)
    print(f"  Vector top 3 (cosine):")
    for r in vec_results:
        title = r["content"].split("\n")[0].lstrip("#").strip()[:60]
        cos = 1 - r["score"]
        print(f"    {cos:6.3f}  {r['doc_id']:25s}  {title}")
    print()

Run it: uv run python playground_bm25.py.

What to look for:

  1. "neovim": BM25 should return chunks containing the literal word with high confidence. Vector search will return the same chunks but the score will be lower if the chunk text is short.
  2. "polysemy": BM25 returns nothing if no chunk contains that exact word. Vector search can return semantically related content (chunks about word-meaning ambiguity). This is the failure mode from Lesson 0001 in reverse.
  3. "How do I do a barrel roll in Neovim?": BM25 ranks on "neovim", "barrel", "roll". If no chunk contains "barrel roll", only the "neovim" hits return. Vector search returns chunks semantically about doing unusual things in Neovim, even without word overlap.
  4. Note the BM25 score range. It's unbounded positive (with our delta) and not normalized. RRF in the next lesson will be the trick for combining this with vector cosine scores.

Things to try if you have time:

7. The threshold filter — when "below floor" means "no real match"

(Added in a later session, after we built the cosine-threshold feature in Lesson 0002 and applied the same pattern here. Read it as the production-noise-control coda to the BM25 chapter.)

The BM25_THRESHOLD in config.py (default 1.0) is a raw-score floor: a BM25 hit with score < 1.0 is dropped from the BM25 rank list before it reaches the RRF fusion. This is 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 (the backward-compat default if you don't tune the floor).

The adaptive override — "no good match" means "let vector do the work"

The hard floor has two failure modes worth distinguishing:

1. Truly zero lexical overlap. A nonsense token like "aardvark" or "xyzzy123" has df=0 in the corpus. BM25Index.search filters via if scores[idx] > 0 at the end of the search loop, so the BM25 list comes back as [] and the filter block in HybridSearch.search short-circuits on the if bm25_results guard. That's already a graceful degradation — RRF falls back to pure vector search, and the LLM gets vector hits with no BM25 contribution.

2. Weak but non-zero overlap. A query like "best pizza in naples italy" against our Neovim-folding corpus has weak overlap: common words like "in" and "best" appear in many chunks (high df, low IDF), and tokens like "pizza"/"naples"/"italy" have df=0 and don't contribute. The BM25 list comes back with ~8–10 weak hits, with the top hit scoring somewhere in the [1.0, 3.0] range at our default floor of 1.0. This is the case the override is built for:

rag_pipeline.py · HybridSearch.search (BM25 block)

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
    else:
        # apply the normal `>=` filter
        ...

When the top BM25 hit itself is below the floor, the filter is suppressed and every hit is kept. The bm25_threshold_suppressed flag in the timing dict records that the override fired, so operators can see "this query had no lexical match in the corpus" — and the LLM gets a richer context than it would from a silently wiped BM25 list.

For the full timing-dict schema and the keep/drop rule, see the BM25 cheat sheet's Threshold filter section.

Symmetric with the cosine threshold

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.

This is the exact same pattern as the cosine-threshold feature in HybridSearch.search (covered in Lesson 0002). The two thresholds are now symmetric:

Cosine thresholdBM25 threshold
Filter rule cosine_sim < floor → dropped bm25_score < floor → dropped
Adaptive override If top cosine < floor → suppress If top BM25 < floor → suppress
Suppression flag timing["vec_threshold_suppressed"] timing["bm25_threshold_suppressed"]
Drop count timing["vec_dropped_threshold"] timing["bm25_dropped_threshold"]
When suppression fires Top cosine similarity in corpus is below the floor Top BM25 score in corpus is below the floor

Both rank lists are now noise-filtered symmetrically, and both fail gracefully when the corpus has no good match for the query. The two suppression flags tell operators exactly which side had nothing. Production RAG systems often log both and alert when both fire on the same query — that's the "this query is off-topic for the corpus" signal.

Tuning the floor

For this corpus (83 chunks of TILs and guides, all about Neovim/editor workflows), BM25_THRESHOLD = 1.0 is the right default. You can sweep the floor empirically using the playground_bm25_threshold_sweep.py pattern: run the same 4 canary queries at floor ∈ {0.0, 1.0, 3.0, 5.0, 10.0} and watch the dropped count + top-5 shift. The sweet spot is the lowest floor that filters single-token noise without dropping any chunk that contributes to the RRF top-5.

For a 100k+ chunk corpus where the number of noise hits matters more, 5.0 is a reasonable next step. Above that, you're trading recall for marginal noise reduction.

8. What you now know

9. What you don't know yet (next lessons)


🧠 Ask follow-up questions. Want to try BM25+ vs plain BM25 (delta=0)? Try a stemming tokenizer? Discuss why our hybrid weights are 70/30 instead of 50/50? Tell me where to dig.

Citations & further reading

  1. Elastic, Practical BM25 (Part 2): The BM25 Algorithm and Its Variables — the definitive explanation of k1 and b.
  2. Zilliz, Mastering BM25 — implementation-level walkthrough with BM25+ derivation.
  3. OpenSearch, Improving Document Retrieval with Sparse Semantic Encoders — why hybrid search is the production default.
  4. Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond — the academic foundation; dense but authoritative.