Why does hybrid retrieval (BM25 plus dense vectors) typically outperform either alone in production RAG?
Same topic, related formats. Practice these next.
Same topic, related formats. Practice these next.
BM25 nails exact-token hits like IDs and proper nouns; dense vectors nail paraphrase and concept matches. Their failure modes are uncorrelated, so fusing the rankings lifts recall on both axes.
Imagine searching a library with two helpers. The first helper has a perfect memory for unusual words. Ask for the book that mentions a specific product code or a rare author's name and they find it instantly, but ask for a book on 'how to cook without dairy' and they get lost if the book actually says 'milk free recipes'. The second helper is great at understanding meaning. They know 'milk free' and 'without dairy' are the same thing, but they sometimes forget which book had that specific product code in chapter seven. The trick is to send both helpers, see what each one brings back, and merge the results. Each one covers the other's blind spot, and almost nothing falls through the cracks.
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example.
Everything important, quickly.
3 min: contrast lexical versus semantic priors, give concrete queries each one wins on, explain why RRF beats raw score averaging, then close with how a reranker layers on top of the fused candidates.
from collections import defaultdict
def reciprocal_rank_fusion(rankings, k=60, top_k=20):
"""
rankings: dict of retriever_name -> ordered list of doc_ids (best first).
Returns fused top_k by RRF.
"""
scores = defaultdict(float)
for retriever, doc_ids in rankings.items():
for rank, doc_id in enumerate(doc_ids):
scores[doc_id] += 1.0 / (k + rank + 1)
fused = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
return [doc_id for doc_id, _ in fused[:top_k]]
# Usage in a hybrid RAG pipeline
bm25_hits = bm25_index.search(query, top_k=50) # list of doc ids
dense_hits = vector_index.search(embed(query), top_k=50) # list of doc ids
fused = reciprocal_rank_fusion({
"bm25": bm25_hits,
"dense": dense_hits,
}, k=60, top_k=20)
# Optional: rerank fused with a cross-encoder before passing to the LLM.| Property | BM25 (lexical) | Dense retrieval (semantic) |
|---|---|---|
| Matches on | Exact token overlap with IDF weighting | Cosine similarity of learned embeddings |
| Strong on | Rare tokens, IDs, code, proper nouns | Paraphrase, synonyms, conceptual queries |
| Weak on | Synonyms and paraphrased intent | Specific rare tokens, exact strings |
| Cost at query | Inverted-index lookup, very fast | ANN over vector index, moderate |
| Indexing cost | Tokenize and update postings | Embed each chunk through a model |
| Output | Score per doc, easy to interpret | Cosine in [-1, 1], requires calibration |
Real products, models, and research that use this idea.
What an interviewer would ask next. Try answering before peeking at the approach.
Red flags and common mistakes that signal junior thinking. Click to expand.
Assuming modern dense embeddings make BM25 obsolete. They do not. Lexical hits on IDs, codes, and rare tokens remain a category where BM25 wins.
The night-before-the-interview bullets. Scan these on the way to the call.
Primary sources. Skim if you want the original framing.