Lexical & Hybrid Retrieval
Everyone reaches for embeddings first. But the retrievers that win in production almost always run two systems — a lexical one and a dense one — and fuse them. Understanding why neither is enough alone, and how to combine them without tuning a single weight, is the highest-leverage thing in this book.
By now you can embed a corpus and do nearest-neighbor search (Ch. 4). It feels like magic until it doesn't. Ask a dense retriever for the "XR-500 firmware" and watch it confidently return chunks about the XR-400, the XR-550, and "firmware update best practices" — everything semantically near your query and not the one exact thing you asked for. Meanwhile a 40-year-old keyword index would have nailed it instantly. Neither approach is wrong; they fail in opposite directions, and that complementarity is the entire opportunity.
Two retrievers, two failure modes
Lexical retrieval (BM25 is the standard) scores documents by exact term overlap, weighting rare terms heavily and common ones lightly. Dense retrieval embeds query and documents into vectors and ranks by cosine similarity, matching on meaning. Here's where each breaks, with the cases you'll actually hit:
| Query | What wins | Why the other fails |
|---|---|---|
| "XR-500 firmware" | Lexical | Dense treats XR-500 as ~XR-400; exact IDs/codes live in tokens, not semantics |
| "how do I stop the app from crashing on launch" | Dense | Lexical misses the doc titled "resolving startup failures" — zero shared content words |
| "SELECT with GROUP BY error" | Lexical | Dense blurs code tokens and error strings into fuzzy neighbors |
| "ways to make onboarding less painful for new hires" | Dense | Lexical can't bridge "less painful" ↔ "streamline," "improve" |
The pattern is sharp: lexical owns exact terms — identifiers, error codes, rare jargon, part numbers, names — where the token itself is the signal. Dense owns paraphrase — where the user's words and the document's words differ but the meaning matches. Real corpora contain both kinds of query, so a single retriever leaves recall on the table no matter which you pick.
The numbers that justify hybrid
Here is a representative measurement — the kind you'll produce yourself in the lab — on a 50-query labeled set over a mixed technical corpus, reporting recall@10 (did the answer-bearing doc land in the top 10?):
| Retriever | recall@10 | Wins on |
|---|---|---|
| BM25 (lexical) | 0.62 | ID / code / exact-term queries |
| Dense (embeddings) | 0.71 | paraphrase / conceptual queries |
| Hybrid (RRF fusion) | 0.83 | both — the union of their strengths |
Read those numbers carefully, because the interesting part is easy to miss. Hybrid's 0.83 is higher than either input — not an average of 0.62 and 0.71, which would be ~0.67. Fusion isn't splitting the difference; it's recovering queries that only lexical found plus queries that only dense found. The two retrievers miss different queries, so their union covers more than the better one alone. That's the whole argument for hybrid in one line: complementary errors add up in your favor.
You could do score = α·(dense) + (1−α)·(lexical) — but BM25 scores and cosine scores live on totally different, unnormalized scales, so α is fragile and needs re-tuning per corpus. The trick in the next section sidesteps scores entirely and fuses on rank — no α, no normalization, no per-corpus tuning. It's what most production hybrid search actually uses.
Reciprocal Rank Fusion — combine without tuning
Reciprocal Rank Fusion (RRF) throws away the raw scores and keeps only each document's rank in each list. A document's fused score is the sum, across retrievers, of 1 / (k + rank), where rank is 1-based and k is a small constant (60 is the standard, and it's remarkably insensitive):
The entire method, in one formula
RRF(d) = Σ 1 / (k + rank_i(d)) for each retriever i that ranked d
i k = 60 by convention
Why this works, intuitively: 1/(k+rank) rewards being near the top of any list (rank 1 contributes 1/61; rank 50 contributes 1/110, barely more than nothing), and because it's a sum, a document that appears in the top few of both lists gets a big combined boost. A document only lexical loved still scores; a document both loved wins. Crucially, ranks are comparable across retrievers even when scores aren't — rank 3 means the same thing whether it came from BM25 or cosine. That's what kills the normalization headache.
Watch it resolve a real disagreement. Query: "XR-500 firmware crash on launch" — half exact-ID, half paraphrase:
| Doc | BM25 rank | Dense rank | RRF score (k=60) |
|---|---|---|---|
| A — "XR-500 firmware v4 release notes" | 1 | 8 | 0.0164 + 0.0147 = 0.0311 |
| B — "resolving crash-on-startup issues" | 14 | 1 | 0.0135 + 0.0164 = 0.0299 |
| C — "XR-400 firmware overview" | 2 | 3 | 0.0161 + 0.0159 = 0.0320 |
Look at what fusion did. BM25 alone would rank C (the wrong product) above B, because "firmware" matches lexically. Dense alone would rank B first but bury A. RRF surfaces A and C and B all in the top three — and A (the actually-correct doc, strong in both lists) rises to be competitive with C despite being rank 8 in dense. In the lab you'll see the exact-ID doc consistently pulled up by its lexical rank while the paraphrase doc is pulled up by its dense rank — each retriever covering for the other's blind spot, with no weight to tune.
"How do you combine keyword and vector search?" The senior answer is three beats: (1) they fail on opposite query types — exact terms vs. paraphrase — so you want both; (2) don't weight-sum raw scores (incompatible scales, fragile α), fuse on rank with RRF; (3) validate the lift with recall@k on a labeled set, and expect hybrid to beat both inputs, not average them. Say that and you sound like you've shipped search.
Build BM25 + dense + RRF — and measure the hybrid lift yourself
You'll run three retrievers over a small labeled corpus and compute recall@k for each. The corpus is deliberately mixed — some queries are exact-ID, some are pure paraphrase — so you'll watch lexical and dense each win a different slice, and hybrid win the union. No API key, no vector DB, no cost — the "embeddings" are a tiny deterministic stand-in so the whole thing runs offline and the numbers are reproducible.
Setup: pip install rank-bm25 (pure-Python, no network). Nothing else.
Step 1. Run BM25 and the provided toy dense retriever; get a ranked doc list from each.
Step 2. Fuse the two rank lists with RRF (k=60).
Step 3. Compute recall@5 for lexical, dense, and hybrid against the gold labels. Print all three.
Your goal: three numbers where hybrid ≥ both — and the ability to point at one query lexical saved and one dense saved.
Starter code
from rank_bm25 import BM25Okapi
import re
def toks(s): return re.findall(r"[a-z0-9]+", s.lower())
CORPUS = [
"XR-500 firmware v4 release notes and changelog", # 0 exact-ID
"Resolving crash-on-startup and boot failures", # 1 paraphrase
"XR-400 firmware overview", # 2 distractor
"How to streamline employee onboarding", # 3 paraphrase
"Onboarding checklist: keyword-matchable steps for hires", # 4 lexical
"SELECT ... GROUP BY returns aggregate error", # 5 exact-code
"Improving the new-hire experience and reducing friction", # 6 paraphrase
"Vault /export endpoint rate limit reference", # 7 exact-term
]
# (query, index of the ONE correct doc)
GOLD = [
("XR-500 firmware", 0), # exact ID -> lexical should win
("app keeps crashing when it starts", 1), # paraphrase -> dense should win
("make onboarding easier for new hires", 6), # paraphrase -> dense
("GROUP BY error", 5), # exact code -> lexical
("export endpoint rate limit", 7), # exact term -> lexical
]
bm25 = BM25Okapi([toks(d) for d in CORPUS])
def lexical_ranks(query):
scores = bm25.get_scores(toks(query))
return sorted(range(len(CORPUS)), key=lambda i: scores[i], reverse=True)
# Toy "dense" retriever: cosine over hashed word-set vectors. Not a real embedding
# model — but it rewards semantic *overlap of concepts* enough to stand in for one
# offline, and keeps the lab free + reproducible. (Production: a real embedding model.)
def dense_ranks(query):
def vec(s): return set(toks(s))
q = vec(query)
def sim(i):
d = vec(CORPUS[i])
return len(q & d) / ((len(q) * len(d)) ** 0.5 or 1)
return sorted(range(len(CORPUS)), key=sim, reverse=True)
# TODO Step 2: def rrf(rank_lists, k=60) -> fused ranking (list of doc indices)
# TODO Step 3: def recall_at(ranker, k=5) -> fraction of GOLD whose doc is in top-k
RRF is ten lines, and the recall harness is the payoff:
def rrf(rank_lists, k=60):
fused = {}
for ranks in rank_lists: # ranks = [doc_idx in rank order]
for position, doc in enumerate(ranks): # position is 0-based
fused[doc] = fused.get(doc, 0) + 1.0 / (k + position + 1)
return sorted(fused, key=fused.get, reverse=True)
def recall_at(ranker, k=5):
hits = sum(gold_doc in ranker(q)[:k] for q, gold_doc in GOLD)
return hits / len(GOLD)
lex = lambda q: lexical_ranks(q)
den = lambda q: dense_ranks(q)
hyb = lambda q: rrf([lexical_ranks(q), dense_ranks(q)])
print(f"lexical recall@5: {recall_at(lex):.2f}")
print(f"dense recall@5: {recall_at(den):.2f}")
print(f"hybrid recall@5: {recall_at(hyb):.2f}")
What you should see: lexical and dense each land around 0.60 — and each misses a different pair of queries — while hybrid comes in at 0.80–1.00, at or above both. On this corpus, lexical nails "XR-500 firmware" and "GROUP BY error" but stumbles on "app keeps crashing when it starts" (no shared content words with doc 1). Dense does the reverse. RRF keeps both retrievers' top hits, so the union is covered.
The lesson, in one line: you didn't tune anything — no α, no score normalization, no threshold. You fused on rank and got a retriever strictly better than either input. That "free" lift, plus the recall@k harness to prove it, is the core of production search work.
Going further (optional): print each query's rank-of-gold-doc under all three rankers. You'll see the exact mechanism — the gold doc sitting at, say, rank 6 in dense and rank 1 in lexical gets pulled into the hybrid top-5 by its lexical rank. Then swap the toy dense function for a real embedding model later (Ch. 4's setup) and re-run the identical harness — the numbers change, the method doesn't. That harness is the thing you carry to every RAG job you ever have.
PartsPro: dense + lexical, fused
PartsPro runs search over an industrial-parts catalog where queries are dominated by exact part numbers like M8-1.25-A2. Dense-only retrieval treated those as fuzzy text and returned similar-looking parts — recall@5 on identifier queries was a dismal 0.62, and wrong-part orders were a real cost. They added a BM25 lexical index alongside the vector index and fused the two rankings with reciprocal rank fusion.
The result: identifier-query recall@5 jumped to 0.94, while conceptual-query recall held steady — no trade-off, because the two retrievers miss different queries, so their union beats either alone. They deliberately used RRF rather than a weighted score blend: BM25 and cosine scores live on different, unnormalized scales, so summing them needs fragile per-corpus tuning, whereas rank is directly comparable across both lists.
Acme adds BM25 and fuses it with dense retrieval via RRF. Exact-method queries like vault.export climb from 0.55 to 0.93 recall@5 — the blind spot from last chapter, closed — with no loss on conceptual "how do I…" questions.
Quiz · Chapter 5 — reasoning, not recall
- Hybrid recall@10 is 0.83 while lexical is 0.62 and dense is 0.71. Why is 0.83 above both rather than between them?
- A user searches for the exact part number
XR-500and your dense-only retriever returns the XR-400 and XR-550. The root cause is: - Why does RRF fuse on rank instead of on the raw BM25 and cosine scores?
- In the RRF formula
1/(k+rank)withk=60, a document at rank 1 contributes1/61and at rank 50 contributes1/110. What behavior does this encode? - You ship dense-only retrieval and recall on identifier/code queries is poor, but paraphrase queries are fine. The highest-leverage fix is: