search_ads_recsys / 43 · dense & hybrid retrieval search · 4 / 10

Dense & hybrid retrieval — fixing lexical's blind spot

BM25 matches tokens; it cannot see that "flat" and "punctured tyre" are the same intent. Dense retrieval matches meaning but fumbles the exact part number BM25 nails. Production search is neither — it is the fusion of both, and the fusion is the part worth getting right.

The plan

Lexical's blind spot, made concrete

Lesson 42 built BM25 over an inverted index: a query term scores a document by how often the term appears (term frequency), discounted by how common the term is across the corpus (inverse document frequency), normalised by document length. It is fast, exact, and unreasonably strong. But re-read what it actually scores: shared tokens. If the query and the relevant document share no tokens, BM25 returns a score of zero — not "low", zero — because every term in the query has term-frequency 0 in the document, so every per-term contribution is 0.

Here is the failure with numbers you can check by hand. Query: "how to fix a flat". Relevant document: "repairing a punctured tyre". After stop-word removal the query content terms are {fix, flat} and the document content terms are {repairing, punctured, tyre}. The intersection is empty. BM25 is a sum over query terms of (idf × saturated tf); with tf = 0 for both fix and flat in this document, the score is 0 · idf(fix) + 0 · idf(flat) = 0. A perfect-intent match scores identically to a document about quantum chromodynamics. This is vocabulary mismatch: the same meaning expressed in disjoint words. Synonyms (fix/repair), morphology (tyre/tire), paraphrase (flat / punctured tyre), and abbreviation all trigger it. Studies going back to Furnas et al. (1987) found two people pick the same word for a concept under ~20% of the time — vocabulary mismatch is the common case, not the edge case.

Classical search papered over this with hand-built tricks: stemming (tyre→tyr), synonym expansion dictionaries (fix→{repair, mend}), and spelling correction. Each helps a little and each is brittle — a curated synonym list never has "flat ↔ punctured tyre", and adding loose synonyms hurts precision. The structural fix is to match on a representation of meaning, which is what dense retrieval does.

The reverse blind spot — why you can't just delete BM25
Now flip it. Query: "MX-4 thermal paste". A user wants exactly the Arctic MX-4 product. BM25 lights up: MX-4 is a rare token (high idf), it appears verbatim in the right product titles, and length normalisation keeps short titles competitive. BM25 nails this — it ranks the exact product first because exact rare-token match is what idf is built to reward.

A dense bi-encoder can miss it. "MX-4" is a near-out-of-vocabulary token; the encoder maps it to a fuzzy region of embedding space near other thermal pastes (MX-5, MX-6, generic compounds) and may rank a semantically similar but wrong product above the exact one. Dense retrieval is built to generalise across surface form — which is precisely what you do not want for a part number, SKU, error code, person's name, or legal citation. The lesson writes itself: lexical owns exact rare-token precision; dense owns semantic recall. A production system that drops either one is strictly worse on some real query class. That tension is the entire motivation for hybrid.
Query classBM25 (lexical)Dense bi-encoderWho wins
"how to fix a flat" → "punctured tyre" doc0 (no shared token)high cosine (same intent)dense
"MX-4 thermal paste" → exact SKUtop-1 (rare-token idf)may rank MX-5 abovelexical
"cheap noise cancelling headphones"good (tokens overlap)good (intent overlap)tie — fuse
error code "0x80070057"exact (rare token)poor (no learned meaning)lexical
"films like Inception"poor (matches the word "Inception")good (concept of mind-bending sci-fi)dense

Dense bi-encoders for search

The machinery is the two-tower model of lesson 2, re-pointed at text. One tower encodes the query, the other encodes the document (or, as we will see, a passage); both emit a vector in the same d-dimensional space; relevance is their dot product or cosine. At index time you encode every document once and load the vectors into an ANN index. At query time you encode the query once and run an ANN lookup — HNSW or IVF-PQ, exactly the index families of lesson 3. We do not re-derive any of that here; the geometry (dot vs cosine vs L2), the MIPS-is-not-a-metric subtlety, and the recall/latency/RAM triangle are all lesson 3's job. Dense retrieval for search is "two-tower + ANN with text towers." What is genuinely new is everything below.

The DPR recipe — contrastive training with hard negatives

DPR (Dense Passage Retrieval, Karpukhin et al. 2020) is the canonical recipe and the one to be able to sketch on a whiteboard. Encode query q with a BERT-style encoder E_Q (take the [CLS] vector), encode passage p with a separate encoder E_P; score is s(q,p) = ⟨E_Q(q), E_P(p)⟩. Train with a contrastive loss: for a batch of (query, positive-passage) pairs, push the positive's score up and every other passage's score down. This is precisely the in-batch-negatives softmax from lesson 2:

L = − Σi log [ exp(s(qi, pi⁺) / τ) / ( exp(s(qi, pi⁺)/τ) + Σj exp(s(qi, pj⁻)/τ) ) ]

The negatives pj are the other passages in the batch (free) plus mined hard negatives — and the hard negatives are what separate a strong retriever from a mediocre one. The DPR paper's single most cited finding: adding one BM25 hard negative per query (a passage that BM25 ranks high but that does not answer the query) is worth more than dozens of random negatives. Why? In-batch negatives are almost all trivially easy — a random passage from another query is obviously irrelevant, so it produces a near-zero gradient. The model learns the easy boundary fast and then stops improving on the boundary that matters: distinguishing a passage that looks relevant (shares words, shares topic) from one that is relevant. Hard negatives live exactly on that boundary. This is the search-flavoured instance of the negative-sampling story built in lesson 6 — the same logQ correction and mixed-negative reasoning applies; here the "hard" miner of choice is usually BM25 itself, which is a nice symmetry (lexical retrieval generates the hard cases that teach the dense model).

Worked numbers — why a hard negative dominates a random one
Batch with one query, positive score s⁺ = 0.80, temperature τ = 0.1. Compare two negatives. A random negative scores s⁻ = 0.05; a hard (BM25) negative scores s⁻ = 0.65. Divide by τ: positive logit 8.0 in both cases.

With the random negative the softmax over {positive, negative} is e8.0/(e8.0+e0.5) = 2981/(2981+1.65) ≈ 0.9994. Loss ≈ −log(0.9994) ≈ 0.0006 — essentially no gradient; the model already aces this.

With the hard negative: e8.0/(e8.0+e6.5) = 2981/(2981+665) ≈ 0.818. Loss ≈ −log(0.818) ≈ 0.201~340× larger. All the learning signal is in the hard negative. That is why DPR mines them rather than relying on batch size alone.

Query–document asymmetry and why search chunks

Two search-specific design facts that recsys two-tower glosses over:

A late-interaction variant, ColBERT, keeps a vector per token instead of one per passage and scores via a sum-of-max-similarities ("MaxSim") — far more expressive, far more storage. That is lesson 45's territory (it sits between bi-encoder retrieval and cross-encoder reranking); here we stay with single-vector bi-encoders, which are what feed the ANN index.

Learned sparse retrieval — the bridge from 42 to dense

Before fusing two indexes, there is a clever middle path: keep the inverted index of lesson 42 but make a model decide which terms appear and with what weight. You get semantic recall and lexical efficiency and exact-match — all inside the data structure you already operate.

MethodMechanismWhat it buys over BM25
doc2query / docT5query A seq2seq model (T5) reads each document and generates likely queries it could answer; append those query terms to the document before indexing. The document "repairing a punctured tyre" gets expanded with generated queries like "how to fix a flat tyre", so the token fix and flat now physically appear in its posting lists. Vocabulary mismatch healed at index time. BM25 then runs unchanged — the magic is in the expanded document. Cheap to serve (it's still BM25); cost is the offline generation pass.
uniCOIL Learn a single scalar weight per term in the document (replacing BM25's tf-based weight) using a contextual encoder, stored in the posting list. Same vocabulary as the text, but term importance is learned, not hand-derived. Context-aware term weighting — "bank" in a finance doc weighted differently than in a river doc.
SPLADE For each document, the model predicts a sparse weight over the entire vocabulary (~30K WordPiece terms), via the masked-LM head with a ReLU + log saturation and an L1 penalty that forces most weights to zero. The document is represented by a few hundred weighted terms — including terms that never appeared in it (true expansion) — written straight into an inverted index. Both expansion and learned weighting, while staying sparse enough to use an inverted index and keeping exact rare-token match. Often matches dense retrieval quality on benchmarks.

Position this clearly in interviews: learned sparse is the bridge between lexical and dense. It has dense's superpower (semantic expansion, learned weights) but lives in lexical's home (the inverted index), so it inherits exact-match on rare tokens and the operational maturity of lesson 42's infrastructure — no second index, no second serving system, no fusion needed in principle. The catch: the expansion is bounded by the vocabulary and the model's context window, it can over-expand (precision loss when it injects loosely related terms), and posting lists get longer (the L1 penalty trades quality for index size). Many teams run BM25 or SPLADE as the lexical branch and still fuse with a dense branch, because dense's continuous space catches paraphrase that even SPLADE's term-level expansion misses.

Hybrid fusion — the core of the lesson

You now have (at least) two ranked lists for a query: one from the lexical branch (BM25 / SPLADE over the inverted index) and one from the dense branch (bi-encoder over the ANN index). Each is strong where the other is weak. Fusion combines them into one candidate set for the ranker. The naïve idea — "just add the scores" — is where careers go to learn humility.

Why you cannot add raw scores

BM25 scores are unbounded sums of idf terms; a query with rare terms can produce a BM25 score of 25, a query with common terms a score of 4. Dense cosine scores live in [−1, 1]. Adding slex + sdense means BM25 utterly dominates whenever its scale happens to be large — fusion silently degenerates into "BM25 with a rounding error." The scales are incomparable, and worse, BM25's scale varies per query (it depends on the idf of the query terms), so there is no single constant you can divide by. The train/serve pitfall: you tune a fusion weight on one query distribution, the query mix shifts, BM25's typical magnitude shifts with it, and your carefully chosen weight is now wrong with no error anywhere. Two families of fix:

Family A — normalise, then linearly combine

Map each list's scores to a common range, then take a weighted sum. Min-max normalisation per list per query: s' = (s − min) / (max − min), giving each list a [0,1] range. Then

sfused(d) = α · s'lex(d) + (1 − α) · s'dense(d)

with α ∈ [0,1] tuned on a labelled set. (Z-score normalisation — s' = (s − μ)/σ per list — is the alternative; it centres and scales by spread rather than range.) This is intuitive and lets you dial the lexical/dense balance. Its weakness is exactly the thing min-max is sensitive to: an outlier score blows up the denominator. If one document gets a freak-high BM25 score (a query term that is astronomically rare), then max − min is huge, every other document's normalised score is squashed toward 0, and the lexical branch effectively contributes nothing for that query. We will watch this happen with numbers below.

Family B — fuse on ranks, not scores: Reciprocal Rank Fusion

Reciprocal Rank Fusion (RRF, Cormack et al. 2009) throws away the scores entirely and uses only each document's rank in each list:

RRF(d) = Σlists ℓ 1 / ( k + rank(d) )

where rank(d) is d's 1-based position in list ℓ (rank 1 = best), and k is a smoothing constant, conventionally k ≈ 60. A document absent from a list contributes 0 from that list. Because it consumes only ranks, RRF is completely immune to the score-scale problem — BM25's magnitude, dense's [−1,1], a freak outlier: none of it matters, because rank 1 is rank 1 regardless of whether the top score was 25 or 0.9. This robustness is why RRF is the default fusion in Elasticsearch, OpenSearch, and Vespa hybrid search. The constant k controls how sharply the top ranks dominate: small k (say 10) makes rank-1 worth far more than rank-5; large k (say 100) flattens the curve so deep-but-agreed-upon documents can outscore a single list's rank-1. k = 60 is the empirically robust middle the original paper recommends.

Worked numbers — RRF on two ranked lists

Six documents, A–F. The lexical (BM25) branch and the dense branch each rank the ones they found. Note: E was found only by dense (BM25 score 0 — vocabulary mismatch), and F only by lexical (a rare SKU the dense encoder fumbled). C is ranked mediocre by both — neither puts it top — but both agree it's relevant.

DocBM25 rankDense rank1/(60+rank) lex1/(60+rank) denseRRF totalFused rank
A131/61 = 0.0163931/63 = 0.0158730.0322661
D411/64 = 0.0156251/61 = 0.0163930.0320182
C321/63 = 0.0158731/62 = 0.0161290.0320023
B251/62 = 0.0161291/65 = 0.0153850.0315144
E— (absent)401/64 = 0.0156250.0156255
F5— (absent)1/65 = 0.01538500.0153856

Sorting by RRF total gives the fused ranking: A (0.032266) > D (0.032018) > C (0.032002) > B (0.031514) > E (0.015625) > F (0.015385). Three things to read off this, each a teaching point:

Worked numbers — min-max linear fusion, and how an outlier wrecks it

Now the same documents under min-max + linear fusion, to feel the contrast. We need raw scores. Suppose BM25 raw scores are A=12.0, B=9.0, C=6.0, D=4.0, F=2.0 (E absent → 0), and dense cosines are D=0.81, C=0.78, A=0.74, E=0.70, B=0.55 (F absent → 0). Min-max each (per list, over present docs):

Lexical range [2.0, 12.0], span 10.0: A=1.00, B=0.70, C=0.40, D=0.20, F=0.00. Dense range [0.55, 0.81], span 0.26: D=1.00, C=0.885, A=0.731, E=0.577, B=0.00. With α = 0.5:

Doclex' dense'0.5·lex' + 0.5·dense'
A1.000.7310.866
D0.201.000.600
C0.400.8850.643
B0.700.000.350
E0.000.5770.289
F0.000.000.000

Fused order: A > C > D > B > E > F — broadly sane, fairly close to RRF. Now inject the outlier. A new query term is astronomically rare, so document A's BM25 score jumps to 120 (idf is a log of N/df; one ultra-rare term can dominate). The lexical range becomes [2.0, 120.0], span 118: now A=1.00, B=0.059, C=0.034, D=0.017, F=0.00. The whole lexical branch except A has been crushed toward zero by the blown-up denominator. Re-fuse with α=0.5:

Doclex' (after outlier)dense'fused
A1.0000.7310.866
D0.0171.0000.508
C0.0340.8850.459
E0.0000.5770.289
B0.0590.0000.030
F0.0000.0000.000

B, which was fused rank 4, has collapsed to rank 5 — and more importantly the entire lexical signal for B, C, D has been wiped out: their fused scores are now driven almost entirely by the dense branch, because min-max squashed their lexical contributions to near zero. One outlier in one list silently turned a hybrid system into a near-dense-only system. RRF is immune to this: A's BM25 score jumping from 12 to 120 does not change its rank (still rank 1), so the RRF table above is completely unaffected. That immunity — not some accuracy edge — is the practical reason RRF is the production default. (Z-score has the same outlier disease: a single huge value inflates σ and shifts μ, distorting every normalised score.)

Learned fusion

The grown-up version: stop hand-tuning α and k, and learn the combination. Feed the per-document signals — BM25 score, dense score, both ranks, query length, query-has-rare-token, etc. — into a small model (logistic regression or GBDT) trained on relevance labels to predict the fused score. This is conceptually learning to rank with fusion features, which is exactly where lesson 44 picks up. The trade-off: more power and per-query adaptivity (it can learn "trust lexical more when the query has a rare token"), at the cost of needing labelled data, a training pipeline, and the usual train/serve discipline. Most teams start with RRF (zero training, robust), add learned fusion once they have judgments and a measurable gap.

The hybrid architecture

query "how to fix a flat" │ ┌─────────────────┴──────────────────┐ │ │ ┌────────▼─────────┐ ┌────────▼─────────┐ │ LEXICAL branch │ │ DENSE branch │ │ │ │ │ │ analyze/tokenize │ │ query encoder │ │ (+ doc2query / │ │ E_Q → q vector │ │ SPLADE expand)│ │ │ │ │ │ │ │ │ │ ┌─────▼───────┐ │ │ ┌─────▼───────┐ │ │ │ INVERTED │ │ │ │ ANN │ │ │ │ INDEX │ │ │ │ (HNSW/IVF- │ │ │ │ BM25 score │ │ │ │ PQ, ls.03) │ │ │ └─────┬───────┘ │ │ └─────┬───────┘ │ └────────┼──────────┘ └────────┼──────────┘ │ top-K_lex ranked list │ top-K_dense ranked list │ (exact rare-token match) │ (semantic / paraphrase) └─────────────────┬─────────────────────┘ │ ┌────────▼─────────┐ │ FUSION │ RRF (k≈60) / min-max+α / learned │ rank- or score- │ → robust to incomparable scales │ based combine │ └────────┬─────────┘ │ unified candidate set (top-N, deduped to parent docs) ┌────────▼─────────┐ │ RANKER │ LTR / cross-encoder rerank │ (lessons 44, 45) │ sees both branches' survivors └────────┬─────────┘ │ results

Two operational notes the diagram encodes. First, fusion happens before the ranker: it builds the candidate set the ranker scores, so a document only one branch found still gets a fair hearing from the ranker. Second, the dense branch retrieves passages but the candidate set is parent documents — you dedupe chunks back to their document before (or during) fusion, usually keeping the document's best-scoring chunk.

Interactive · hybrid fusion explorer

Two fixed ranked lists over a shared 6-document set — a lexical ranking and a dense ranking. One document, ★ ideal, is the truly-relevant answer; it is ranked mediocre by both branches (the classic "consensus but never #1" case) so a good fusion method should still float it up. Pick a fusion method and dial its knob; watch where the ideal document lands. The arithmetic is the real RRF / min-max formula above — no approximation.

Fuse the lexical and dense lists — where does the ideal doc land?
Lists are fixed presets. RRF uses 1/(k+rank); linear uses per-list min-max normalisation then α·lex + (1−α)·dense on the shown raw scores. The "outlier" toggle multiplies the lexical top score by 10× to demonstrate min-max's fragility (it does not affect RRF — that's the point).
ideal doc (★) fused rank
ideal doc lex / dense rank
top-3 fused
verdict
Reading

When NOT to bother with dense retrieval

Dense and hybrid are not free — a second model, a second index, a second serving path, GPU/encoder cost on the query path, and fusion to tune and monitor. Reach for them by argument, not reflex. Skip or defer dense when:

The senior framing
"Do we need dense retrieval?" is answered by query analysis, not fashion. Bucket your query log: what fraction is navigational/known-item (lexical wins), what fraction is descriptive/exploratory (dense wins), what fraction has zero-result or low-quality BM25 sessions (the vocabulary-mismatch tax you're paying today)? If the third bucket is large, dense or hybrid pays for itself. If it's tiny and you're 95% navigational, spend the engineering elsewhere. Then, if you do go hybrid, start with BM25 + RRF + dense — it needs no training and is robust — and only graduate to learned fusion once judgments justify it.

Interview prompts you should be ready for

  1. "BM25 returns nothing useful for 'how to fix a flat' on a corpus full of 'punctured tyre' repair guides. Why, and what fixes it?" (Vocabulary mismatch — zero shared tokens means a literal BM25 score of 0, not a low score. Fixes, in order of infrastructure cost: synonym/stemming dictionaries → doc2query/SPLADE expansion into the same index → a dense branch fused with lexical. The senior names the spectrum, not just "use embeddings".)
  2. "Your team replaced BM25 with a dense retriever and exact part-number search regressed. Diagnose." (Dense generalises across surface form, so rare IDs/SKUs/codes map to a fuzzy neighbourhood and the exact item drops below semantic near-misses. The fix is not to abandon dense — it's to keep a lexical branch and fuse. Probes whether they understand dense's complementary weakness, not just its strength.)
  3. "Why can't you just add the BM25 score and the cosine score?" (Incomparable scales — BM25 is an unbounded idf sum, cosine is [−1,1], and BM25's magnitude varies per query with the idf of the query terms, so there's no fixed constant to rescale by. Leads to either per-query normalisation, which is outlier-fragile, or rank-based fusion.)
  4. "Walk me through RRF and why k=60. Then: why is RRF preferred over min-max linear fusion in production?" (score(d)=Σ 1/(k+rank); k smooths how steeply top ranks dominate, 60 is the robust empirical default. Preferred because it uses only ranks, so it's immune to incomparable scales and to a single outlier score that blows up a min-max denominator and silently crushes the rest of one branch. Bonus: it rewards cross-list consensus.)
  5. "In DPR-style training, why is one BM25 hard negative worth more than many random negatives?" (Random negatives are trivially separable → near-zero gradient; the model already aces them. Hard negatives sit on the relevant/looks-relevant boundary, which is where almost all the learning signal lives. Connects to lesson 6's negative-sampling and logQ correction. Bonus: the symmetry that lexical retrieval mines the hard cases that teach the dense model.)
  6. "When would you NOT add dense retrieval to an existing BM25 system?" (Small/structured corpus, navigational/known-item-dominated query mix, tight latency/cost or CPU-only infra, or no eval set to prove lift. The judgement signal: bucket the query log and let the zero-result / vocabulary-mismatch fraction decide; consider learned sparse as the no-second-index middle path.)
Takeaway
Lexical retrieval matches tokens and owns exact rare-token precision; dense retrieval matches meaning and owns paraphrase recall — and each fails exactly where the other is strong, so production search runs both and fuses. Fusion is the lesson: raw scores don't add (incomparable, per-query-varying scales), min-max linear fusion is outlier-fragile, and Reciprocal Rank Fusion sidesteps the whole problem by combining ranks instead of scores — which is why it's the production default and why a consensus document neither branch ranked #1 can still win. Learned sparse (SPLADE/doc2query) is the bridge that buys semantic recall inside the inverted index. Reach for dense by query-log argument, not reflex; start with BM25 + RRF + dense, and graduate to learned fusion (lesson 44) only once judgments justify it.