Somewhere around 2022, the industry collectively decided lexical search was legacy technology, a relic to be swept aside by embeddings. Then production traffic arrived and disagreed loudly: users searching for PROJ-4812, “the Meridian contract”, “Sarah’s Q3 deck”, sso_max_session_hours. Dense retrieval, which understands meaning beautifully and was rightly celebrated for that, shrugged at every one of these queries, because exact rare strings barely register as distinctive signal in a semantic embedding space built to capture meaning rather than exact tokens. Teams quietly turned BM25 back on across the industry, and by 2026 “hybrid” isn’t an advanced technique reserved for sophisticated teams. It’s the default checkbox on every serious retrieval stack, and shipping dense-only retrieval is the choice that now has to be actively justified in a design review.
Two engines, two failure modes
Dense retrieval matches by meaning rather than by exact wording. It wins on paraphrases, for instance “can I fly business?” correctly finding the travel policy document that never once uses the phrase “fly business” but clearly covers the topic, it wins on cross-lingual queries where the words themselves differ entirely, and it wins on vague, underspecified descriptions where the user isn’t sure of the exact terminology. It loses on exact identifiers, rare proper names, and negations, none of which carry much distinctive semantic content for an embedding model to latch onto, and critically, it degrades quietly when it fails: you get wrong-but-plausible neighboring results rather than an honest empty result set that would at least signal something went wrong.
BM25 matches by term statistics: term frequency within a document, inverse document frequency across the corpus, and length normalization to avoid unfairly favoring longer documents. It’s fifty years of information retrieval research distilled into one battle-hardened formula that still holds up remarkably well. It wins every rare-token query that dense retrieval loses, precisely because IDF weighting rewards rare terms heavily, and it loses every paraphrase query that dense retrieval wins, since a query sharing zero literal terms with the answer scores essentially zero under BM25 regardless of how closely related the meanings actually are.
The failure modes are complementary, and that complementarity is the entire trick that makes hybrid search work as well as it does. Run both retrieval methods over the same query, and each one covers precisely the blind side the other one has.
flowchart LR
Q["Query"] --> DE["Dense leg\nHNSW, top 50\n(ACL filtered)"]
Q --> BM["Lexical leg\nBM25, top 50\n(ACL filtered)"]
DE --> F["RRF fusion\nscore = sum 1/(60+rank)"]
BM --> F
F --> OUT["Top 40 fused candidates\n-> reranker (next lesson)"]
style Q fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style DE fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style BM fill:#f0fdf9,stroke:#0D9488,color:#0F172A
style F fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style OUT fill:#f0fdf9,stroke:#0D9488,color:#0F172A
Reciprocal rank fusion: dumb, robust, standard
You now have two ranked lists whose scores mean completely, structurally different things: a cosine similarity sitting around 0.83 from the dense leg versus a BM25 score of 24.7 from the lexical leg, numbers that live on entirely unrelated scales and can’t be meaningfully compared directly. The tempting move is normalizing both to a common range and then averaging them together. Resist that temptation: both scales are corpus-dependent in ways that shift as your corpus grows and changes, and any normalization scheme you build today will silently decay in accuracy as your document collection evolves, without any obvious signal telling you it’s drifting.
Reciprocal rank fusion sidesteps the entire problem by ignoring raw scores entirely and working only with rank positions. A document’s fused score is simply the sum, across every list it appears in, of 1 / (k + rank), with k=60 by near-universal convention across implementations:
# Reciprocal Rank Fusion in ~15 lines. No tuning, no
# normalization, no per-corpus calibration to rot.
def rrf(result_lists: list[list[str]], k: int = 60) -> list[str]:
"""result_lists: each inner list is chunk IDs, best first."""
scores: dict[str, float] = {}
for results in result_lists:
for rank, chunk_id in enumerate(results):
# rank 0 contributes 1/60, rank 9 contributes 1/69.
# k=60 damps the head so one list cannot dominate;
# appearing in BOTH lists is what really pays.
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
fused = rrf([dense_ids, bm25_ids])[:40]
Why k=60 specifically, and not something smaller like 6? With a small k, being ranked first in just one of the two lists is worth vastly more than being ranked, say, fifth in both lists simultaneously, which lets one leg’s particular quirks and biases dominate the fused result unfairly. At k=60 the curve flattens out considerably: a document that shows up consistently across both lists, even at moderate rank in each, beats a document that got lucky and landed first place in only one list. The original RRF paper picked 60 empirically back in 2009, and seventeen years of subsequent benchmarking across wildly different domains and corpora has mostly concluded that yes, that number really does hold up well as a sensible default.
One subtle amplifier worth connecting back to Module 3: if you ran contextual enrichment on your chunks, make sure you index the enriched text, situating sentence included, in the BM25 leg too, not just in the dense embedding. The situating sentences add exactly the vocabulary, product names, section topics, the kind of terms that lexical matching depends heavily on for exact-term recall. This combination, contextual enrichment feeding both legs of a hybrid search simultaneously, is where a meaningful share of the biggest published retrieval gains in this space actually come from, and it’s a connection that’s easy to miss if you think of contextual retrieval as purely a dense-embedding trick.
One round trip in production
Fusing the two result lists client-side, in your own application code, is perfectly functional. But modern vector engines increasingly fuse server-side instead, which halves the number of network round trips per query and keeps large candidate sets out of your application’s memory entirely. Qdrant’s Query API shape illustrates the pattern well, with both legs correctly filtered:
# Server-side hybrid: both legs run inside the engine,
# RRF happens there too, one network call total.
results = client.query_points(
collection_name="kb_chunks",
prefetch=[
# Leg 1: dense, ACL-filtered
{"query": query_vector, "using": "dense",
"filter": acl_filter, "limit": 50},
# Leg 2: sparse/BM25, same filter
{"query": sparse_vector, "using": "sparse",
"filter": acl_filter, "limit": 50},
],
query={"fusion": "rrf"},
limit=40,
)
On pgvector, the equivalent pattern is a CTE that joins an HNSW vector query against a tsvector full-text query and computes the RRF math directly in SQL. It’s less syntactically elegant than a purpose-built hybrid API, but entirely workable at moderate corpus scale, and it keeps your whole hybrid retrieval stack inside one database rather than coordinating two separate systems.
Notice the output size in both examples above: 40 candidates emerge from fusion, not the tight 8 you’ll eventually want to hand the generation model. That’s entirely deliberate. Fusion’s job here is coverage, making reasonably sure the right chunk is present somewhere in the pool at all; it is comparatively mediocre at fine-grained ordering within that pool, since neither RRF nor either individual leg was built to do careful pairwise comparison between candidates. The stage that turns a merely good candidate pool into a genuinely good top-8 ordering is the reranker, and getting that stage right is exactly where we’re headed next.