TAI BUI
Back to curriculum
CapstonePython

Hybrid Retrieval with BM25 and Dense Embeddings

Lexical and semantic retrieval fail on opposite query distributions. Reciprocal rank fusion does not interpolate scores, it votes, and the vote holds up when one retriever misses.

TIME: ~90 minutesPREREQUISITES: Phase 11 lessons 04 (embeddings), 06 (RAG); Phase 19 Track B foundations (lessons 20-29); Phase 19 lesson 64 (chunking strategies)

Learning Objectives

  • Implement BM25 from scratch from the Robertson and Sparck Jones formulation, with field weighting, document length normalization, and tunable k1 and b.
  • Build a dense retriever on top of a deterministic mock embedding so the loop runs offline.
  • Implement reciprocal rank fusion exactly as Cormack, Clarke, and Buettcher published it in 2009, and explain why it dominates score-weighted interpolation.
  • Tune the RRF k constant and the per-modality weights and read the trade-offs on a small fixture corpus.

The Problem

Hybrid retrieval runs a lexical retriever and a dense retriever on the same query and merges their two ranked lists into one. Each retriever is strong where the other is weak.

Lexical search wins when the query carries a literal identifier the corpus contains verbatim. A query for AbortMultipartOnFail returns the right Go function via BM25 in microseconds. The same query, embedded, sits at the boundary of three similarity clusters, so a real embedding model can rank a neighboring file above the right one. The hashed mock embedding in the demo keeps d1 on top for this query, so the demo understates the weakness.

Dense search wins when the query is paraphrased away from the corpus's literal tokens. A user asking "how do we handle cancelled uploads" never typed the word abort or multipart. BM25 tends to favor the documentation chunk on "uploading large files" because that page contains the word uploads, while a real embedding model can match the abort function whose summary mentions cancellation. The hashed mock embedding in the demo is cruder, so its dense list does not always show this.

Which one wins depends on the query distribution. A production RAG system serves both kinds of query from one endpoint, so retrieval has to handle both at once. The merge step is where this goes right or wrong.

Warm-up0/3
1. A doc says "abort the transfer" and a user searches "cancel the upload". Which approach is more likely to find it?Warm-up
2. Two embedding vectors have each been scaled to length 1. What range can their cosine similarity take?Warm-up
3. In a search over a document collection, a word like "the" appears in nearly every document. How useful is it for telling documents apart?Warm-up
Check: The Problem0/1
1. Why does a production RAG endpoint need both lexical and semantic retrieval at once?Explain

The Concept

Mermaid diagram

BM25 in one paragraph

BM25 scores a query-document pair by summing, over the query terms, an inverse document frequency factor multiplied by a saturating term-frequency factor that includes a length-normalization correction. It has two knobs. k1 controls term-frequency saturation. The default of 1.5 is the published recommendation, and I wouldn't move it without a benchmark. b controls how much document length matters. The default of 0.75 penalizes longer documents, but not linearly.

The IDF uses the smoothed Robertson and Sparck Jones definition, log((N - df + 0.5) / (df + 0.5) + 1). The plus-one inside the log keeps the IDF positive when a term appears in more than half the corpus, which matters in small corpora where stopwords are technically rare.

Field weighting lets you tell BM25 that a match on the symbol name counts for more than a match in the body. You implement it as a multiplier on the term counts at indexing time, not at scoring time. The math stays identical and you avoid keeping a separate score per field.

Dense retrieval in one paragraph

Dense retrieval embeds each chunk into a fixed-dimension vector with an embedding model. At query time you embed the query, cosine-rank every chunk against it, and return the top-k. The embedding model decides quality. The retrieval algorithm itself is two lines: a dot product and a sort.

Here the embedding is a deterministic hash, so you can read the fusion math without a network call. It sums token-keyed offsets into a 96-dimensional vector and normalizes. The cosine ranks come out the same on every run, which is what the test suite needs.

Reciprocal rank fusion, the published formula

Reciprocal rank fusion (RRF) merges ranked lists by giving each candidate a score of 1 / (k + rank) from every list it appears in, summing those contributions, and sorting by the total. The 2009 paper used k = 60 as the default. That is the whole algorithm.

The constant k = 60 isn't arbitrary. At k = 60 the rank-1 contribution is 1 / 61 and the rank-10 contribution is 1 / 70, so the contribution decays slowly and deep candidates still vote. A smaller k lets the top results dominate, and a larger k flattens the curve.

This implementation exposes two knobs: the k constant, and a pair of per-modality weights for when you have prior evidence that BM25 or dense is better on your corpus. The weight multiplies the rank contribution. That is the simplest principled option, since it preserves the rank-decay shape and stays scale-free.

Why fusion beats score-weighted interpolation

BM25 scores are unbounded and corpus-dependent, while cosine similarities sit between -1 and 1. A linear combination alpha * bm25 + (1 - alpha) * cosine needs alpha tuned per corpus and breaks whenever you reindex. Rank-based fusion avoids that, because two ranks are comparable across modalities. The published RRF baseline beats score-interpolation in every public TREC track since 2010.

Vespa and Weaviate make the same argument in their documentation and reach the same conclusion: stay rank-based unless you have very strong evidence for interpolating scores.

Check: The Concept0/16
1. What is BM25?Define
2. What is reciprocal rank fusion (RRF)?Define
3. What does the BM25 parameter k1 control?Define
4. In BM25, what happens when b is set to 0?Define
5. What is field weighting in this BM25 index?Define
6. How does rank-based fusion differ from score-based fusion?Define
7. Why does the smoothed BM25 IDF add 1 inside the log?Explain
8. Why does the demo embed text with a deterministic hash instead of a trained embedding model?Explain
9. Why is rank-based fusion preferred over a linear interpolation of BM25 and cosine scores?Explain
10. In this RRF implementation, what do the per-modality weights multiply?Explain
11. What happens to RRF when you make the constant k very small?Explain
12. Which problem does field weighting through token repetition avoid?Explain
13. Using the lesson's IDF, log((N - df + 0.5) / (df + 0.5) + 1), what is the IDF for a term in 2 of 7 documents (natural log)?Apply
14. With k1 = 1.5 and a document of average length, a term's frequency factor is f(k1 + 1) / (f + k1). What does doubling f from 5 to 10 do?Apply
15. A team ranks with 0.5 x bm25 + 0.5 x cosine, tuned last quarter. After reindexing a much larger corpus with the same code, results degrade. What is the most likely cause?Analyze
16. A keyword engine returns scores from 0 to 40 and a vector store returns cosine similarities. The index is rebuilt weekly with new documents. Which merge needs the least recalibration?Design

Build It

code/main.py implements:

  • tokenize(text): a fast regex tokenizer.
  • BM25Index: field-weighted, with add and search and tunable k1, b.
  • mock_embed, DenseIndex: the same deterministic embedding as lesson 64, so chunks are comparable.
  • rrf(rankings, k, weights): the published fusion, with per-modality weights.
  • HybridRetriever: combines BM25 and dense.
  • A demo main() that loads a small fixture corpus, runs three queries that target each retriever's strength and weakness, and prints the rankings each modality produced plus the fused list.

Run it:

bash
python3 code/main.py

Read the demo output side by side. For the literal query AbortMultipartOnFail, BM25 and dense both rank d1 first, so RRF returns d1 first at 0.0328 and the fusion is invisible. For the paraphrased query, BM25 ranks d2 first while dense ranks d3 first and d2 second. RRF scores both at 0.0325, so d2 and d3 tie at the top and both outrank everything else. For the mixed query, BM25 and dense agree on d4 and RRF keeps it first. The fusion never drops the document either modality ranked first, and it surfaces both when they disagree.

Check: Build It0/7
1. The tokenizer lowercases the text and keeps runs of letters and digits. What does tokenize("v1.2-rc3") return?Apply
2. In the demo's paraphrased query, BM25 ranks d2 first and dense ranks d3 first with d2 second. With k = 60 and equal weights, what does RRF return at the top?Apply
3. For the literal query, BM25 returns only d1, while dense ranks d1 first and d7 second. With k = 60 and equal weights, what are the fused scores of d1 and d7?Apply
4. rrf is called with k = 60 and equal weights on two lists. List 1 ranks [a, b, c] and list 2 ranks [b, a, c]. What are the fused scores?Apply
5. rrf is called with k = 60 on two single-item lists, [a] and [b], and weights [2.0, 1.0]. What does it return?Apply
6. A user queries the BM25 index with "abort", and d1 (titled AbortMultipartOnFail, body starting "Aborts an in-flight...") is not returned. What explains it?Analyze
7. You index chunk summaries separately and want them fused as a third ranked list in this code. What does the change involve?Design

Tuning the knobs

KnobDefaultMove it up whenMove it down when
BM25 k11.5Terms repeat in documents and you want frequency to matter moreDocuments are short and term repetition is noise
BM25 b0.75Long documents really do say less per wordDocument length is uncorrelated with topic
RRF k60Deep candidates should keep votingThe top-1 should dominate
BM25 weight1.0Your corpus contains literal identifiers and queries match themYour queries are user-paraphrased
Dense weight1.0Queries are paraphrasedQueries are literal

Tune by re-running lesson 68's eval harness on your held-out query set. Don't tune by intuition.

Check: Tuning the knobs0/4
1. Per the tuning table, when should you raise the dense weight relative to BM25?Explain
2. On the paraphrased query, equal weights tie d2 and d3 at about 0.0325. d3 is dense rank 1 and BM25 rank 2. What happens at k = 60 if the dense weight is raised to 2.0?Apply
3. The documents are short, and repeated terms are noise. An engineer raises k1 from 1.5 to 3.0 and relevance gets worse. Which reading fits the tuning table?Analyze
4. Your users mostly paste exact function names, so you suspect BM25 deserves more weight. What does the lesson recommend first?Design

Failure modes the demo will hide

Out-of-vocabulary tokens. BM25's IDF is computed from the corpus, so a term that appears only in the query contributes zero. A dense embedding still produces a vector for that same term, so on out-of-corpus identifiers the dense modality returns plausible-looking but wrong neighbors. The fusion absorbs this because BM25 returns nothing and its rank contribution drops out, but only if you de-duplicate by document, not by chunk.

Stop-token domination. BM25 against the word "the" carries almost no ranking signal. In the demo corpus it appears in six of seven documents, so its IDF is tiny and the six matches score between 0.40 and 0.23, ordered only by term frequency and length normalization. Filter stop tokens in the indexer, or accept that high-IDF terms dominate naturally.

Identical content across modalities. If the corpus is small enough that BM25's top-1 is also dense's top-1, RRF returns the same top-1 with the same neighbors. That is correct behavior, not a failure, but it makes the fusion look invisible. Add an adversarial query pair to your eval to verify the fusion is actually working.

Check: Failure modes the demo will hide0/3
1. What happens with an out-of-corpus identifier under the two modalities?Explain
2. A word like "the" appears in almost every document, so as a BM25 query term it carries almost no ranking signal. What should you do about it?Explain
3. If BM25's top-1 and dense's top-1 agree on a small corpus, what does the lesson say about the fused ranking?Explain

Use It

Production patterns:

  • Index BM25 in process. The bottleneck is the term-frequency dictionary, not the vectors.
  • Index dense vectors in a separate store. This code uses a flat list; in production you would use HNSW.
  • Run both queries in parallel. The fusion is a sort over the union of the two candidate lists, which stays cheap because each list holds only the top few dozen hits.
  • Persist the modality of each retrieved hit so a downstream reranker can see which modality voted for it.
Check: Use It0/1
1. Why does the fusion step stay cheap in production?Explain

Ship It

Lesson 66 takes the fused top-k from here and reranks it with a cross-encoder. Lesson 68 evaluates the entire pipeline with precision, recall, MRR, and nDCG. This hybrid retriever is the first stage of the end-to-end system in lesson 69.

Exercises

  1. Replace mock_embed with a real model from your provider. Re-run the demo and report how the dense-only ranking changes on the paraphrased query.
  2. Add a third modality: chunk summaries indexed separately and fused as a third ranked list. Measure the gain.
  3. Sweep RRF k across 10, 30, 60, 100, 200. Plot the recall@k curve from lesson 68. Report the value of k where the curve peaks on your corpus.
  4. Implement BM25F properly (per-field length normalization rather than the multiplier trick) and compare on a corpus where symbol matches matter most.

Key Terms

TermWhat people sayWhat it actually means
BM25"Lexical search"Probabilistic ranking with idf x saturating tf x length normalization
RRF"Rank fusion"Sum of 1 / (k + rank) across ranked lists; k = 60 default
k1"TF saturation"Controls how fast a repeated term stops adding more score
b"Length penalty"0 means ignore document length, 1 means full normalization
Field weighting"Symbol boost"Repeat tokens during indexing to boost matches in that field
Rank-based vs score-based fusion"Why RRF beats linear"Ranks are comparable across modalities; scores are not
Author's Note

I'd start with rank-based fusion and leave score interpolation alone until I had strong evidence for it. BM25 scores are unbounded and corpus-dependent, so an alpha tuned today breaks on the next reindex, and ranks don't have that problem.

My bet is that the weights are the knob to touch last. I'd leave both at 1.0 and let the eval harness from lesson 68 argue for a change before I trusted my own intuition about which modality suits the corpus.

I'd also keep an adversarial query pair in the eval. When both modalities agree, the fusion looks invisible, and without that pair I couldn't tell a working fusion from a broken one.

Further Reading

  • Cormack, Clarke, Buettcher, "Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods", SIGIR 2009
  • Robertson, Walker, Beaulieu, Gatford, Payne, "Okapi at TREC-3" (the original BM25 paper)
  • Vespa: Hybrid Retrieval with BM25 and Embeddings
  • Weaviate: Hybrid Search
  • Phase 11 lesson 06 - RAG fundamentals
  • Phase 19 lesson 64 - chunkers whose output is indexed here
  • Phase 19 lesson 66 - cross-encoder reranker that consumes the fused top-k

Code