Skip to content
intermediate Phase 7 · Embeddings and Vector Search

Hybrid Search Basics

Combine keyword BM25 scores with vector similarity for robust retrieval.

50m
0 problems
Topic Progress 0%

Two Scorers, Two Different Failures

Run a keyword retriever and a vector retriever over the same query and they disagree — which means one of them is wrong about something specific. Knowing what each scorer is blind to is the entire design problem.

BM25 sums a per-term score over the query:

                       ┌──► BM25 over inverted index ──► ranked K ┐
                       │                                          │
query ─────────────────┤                                          weighted fusion ──► top-k
                       │                                          ▲
                       └──► embed ──► ANN index ──► ranked V ─────┘

Three knobs, three behaviors: IDF makes a rare term outweigh the; term frequency f grows but saturates at k1, so the fifth occurrence adds little; b normalizes by document length so long documents stop winning by size alone. Under all of it sits exact token matching — a term is either in the inverted index or it is not.

The vector scorer never sees tokens. It embeds the query, probes the ANN index, and returns documents whose vectors point in a similar direction (ANN Index Types). It has no notion of spelling, so E_CONN_RESET can sit near connection reset by peer — and SKU-7741-BLK can sit near SKU-7741-RED, which is exactly wrong.

Query BM25 Vector
SKU-7741-BLK exact hit may rank the red variant right behind it
'ways to get my money back' no shared terms, empty or junk results finds the refund policy
E_CONN_RST typo misses entirely lands near the real error string

Fusing the Two Scored Lists

The naive merge adds the scores. It never works: BM25 runs to tens or hundreds while cosine tops out at 1.0, so the keyword channel wins every query and the vector channel is decoration. Each list has to be put on a common scale first.

def normalize(scores):
    lo, hi = min(scores.values()), max(scores.values())
    if hi == lo:
        return {k: 0.0 for k in scores}   # one doc or all tied: no signal to pass on
    return {k: (v - lo) / (hi - lo) for k, v in scores.items()}

def fuse(bm25, vec, alpha=0.6):
    b, v = normalize(bm25), normalize(vec)
    pooled = {d: alpha * b.get(d, 0.0) + (1 - alpha) * v.get(d, 0.0)
              for d in set(bm25) | set(vec)}
    return sorted(pooled, key=pooled.get, reverse=True)

bm25 = {'d-sku': 14.2, 'd-policy': 3.1, 'd-faq': 0.9}
vec  = {'d-policy': 0.81, 'd-faq': 0.74, 'd-sku': 0.31}
print(fuse(bm25, vec))

Input: the two scored lists above, alpha=0.6 favoring keywords.

Output: ['d-sku', 'd-policy', 'd-faq'] — combined 0.600, 0.499, 0.344.

Read the numbers against the raw inputs. d-sku scored only 0.31 from the vector channel, but its BM25 score was the best in the list, so after normalization it holds 1.0 there and takes the top slot. d-policy is the vector winner at 0.81 and lands second because its keyword showing was weak. Min-max per query is what makes alpha mean anything: without it, alpha is a rounding error next to a BM25 of 14.2. Documents present in only one list get 0.0 in the other — strong single-channel hits can still surface, they just cannot pretend they were corroborated.

When score ranges are unknown or unstable, rank-based fusion removes the calibration problem entirely:

def rrf(rankings, k=60):
    totals = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):   # ranks are 1-based
            totals[doc] = totals.get(doc, 0.0) + 1 / (k + rank)
    return sorted(totals, key=totals.get, reverse=True)

print(rrf([['d-sku', 'd-policy'], ['d-policy', 'd-faq', 'd-sku']]))

Output: ['d-policy', 'd-sku', 'd-faq'] — d-policy appears in both lists and wins on consistency even though d-sku holds the top keyword rank. RRF throws the scores away and uses positions, so it is immune to scale differences between channels; its cost is that score magnitude information is discarded. Fusing Multiple Retrievers covers the k constant and per-retriever logging.

Weight the Mix, Do Not Guess It

Which channel deserves the weight depends on the query, and the query mix is a property of your product:

Query shape Example Channel that carries it
exact identifier SKU-7741-BLK, E_CONN_RESET BM25
paraphrase with no shared words 'how do I get my money back' vector
jargon inside prose pgvector in a setup guide both — fusion's actual job
single common word 'refunds' vector narrows; BM25's IDF is noisy here

Failure modes that show up in real hybrid stacks:

  • Fusing raw scores. The BM25 channel dominates every query and the demo still looks fine, because the demo queries were keyword-shaped.
  • Fusing two top-8 lists. Fusion can only merge what each retriever returned. Over-fetch — top 40 or 50 per channel — fuse, then cut to the 8 that fit the prompt.
  • Analyzer mismatch on identifiers. A standard analyzer splits SKU-7741-BLK into pieces, so neither channel matches the string the user pasted. Keyword or ngram subfields are the fix.
  • Query embedded with a different model than the corpus. The vector channel returns near-random neighbors while BM25 still works, which hides the break — Embedding Models Explained covers the invariant.
  • A weight tuned on paraphrase-shaped test queries. Those flatter vector search; tune alpha on sampled production queries or you will ship a keyword-proof system that fails on part numbers.
  • Duplicate ids counted twice. The same document returned by both channels must be deduplicated before fusion, or double-listing reads as double evidence.

None of these can be settled by inspection. The weight is a number that moves recall@k, so it belongs on an evaluation set — labeled query-to-document pairs, recall@k and MRR computed per candidate alpha, best value shipped. That machinery, and how to know when the numbers lie, is Evaluating Embedding Quality.

Quiz

1. Why can BM25 and cosine scores not simply be added together?

Question 1 options

2. A document appears in the BM25 list but not in the vector list. Under the fusion code above, what score does it get from the vector channel?

Question 2 options

3. What is the main reason to prefer reciprocal rank fusion over weighted score fusion?

Question 3 options

4. Retrieval looks strong on the test queries but production users get bad results. What is the most likely mismatch?

Question 4 options

Flashcards

Question

What does BM25 reward that vector search does not?

Answer

Exact rare-term matches, weighted by IDF, with term-frequency saturation and document-length normalization — which is why it nails identifiers like SKU codes and error strings.

Question

What does vector search handle that BM25 cannot?

Answer

Paraphrase: queries that share no tokens with the answer. The match is geometric rather than lexical, so wording differences do not break it.

Question

Why normalize each channel's scores before fusing?

Answer

BM25 and cosine live on scales that differ by orders of magnitude. Per-query min-max scaling makes a weight like alpha=0.6 mean 60/40 instead of a rounding error.

Question

How does reciprocal rank fusion combine two lists?

Answer

Sum 1/(k + rank) per document across channels, with k around 60. It uses positions only, so no score calibration is needed and consistency across channels beats one channel's peak.

Question

Why over-fetch before fusing?

Answer

Fusion can only merge documents each retriever actually returned. Pulling top 40 per channel before cutting to the 8 that fit the prompt keeps candidates alive that either channel alone would have dropped.

Revision Notes

Key Takeaways

  • 1. BM25 and vector search fail orthogonally — exact tokens versus meaning — so both channels earn their place
  • 2. Raw scores cannot be added across channels; normalize per query, or use ranks via reciprocal rank fusion
  • 3. Fusion is capped by what each retriever returned, so retrieve wide and cut after merging
  • 4. The mixing weight is an evaluation-set number, not a default: it moves recall@k and must be measured

Interview Tips

  • • Explain BM25 from its three knobs — IDF, frequency saturation, length normalization — then name the exact failure (paraphrase) that motivates the vector channel
  • • Write score fusion and RRF from memory, and justify the per-query normalization step when asked why raw addition fails
  • • Say how you would tune alpha: labeled pairs, recall@k across a weight sweep, held-out production queries
  • • Answer 'when is hybrid unnecessary' honestly: pure keyword for identifier-only corpora, pure vector for short paraphrase-heavy queries with no exact-match needs

Cheat Sheet

Hybrid Search — Cheat Sheet

Pipeline

query ──┬──► BM25 ──► ranked K ──┐
        │                        ├──► normalize each ──► alpha·K + (1-alpha)·V ──► top-k
        └──► embed ──► ANN ──► ranked V ──┘

Score fusion

b = {k: (v - lo) / (hi - lo) for ...}      # per query, per channel
score = alpha * b.get(d, 0.0) + (1 - alpha) * v.get(d, 0.0)

RRF: Σ 1 / (60 + rank) per channel — no calibration, rank only.

Channel guide

Query Winner
exact id, SKU, error code BM25
paraphrase, no shared words vector
jargon in prose fusion
one common word vector; BM25 IDF is noisy

Tuning: over-fetch 40–50 per channel, fuse, cut to k; pick alpha by recall@k over labeled production queries, not on the demo set.

Failure modes: raw-score addition · top-8 lists fused · analyzer splitting identifiers · query embedded with the wrong model · weight tuned on corpus-shaped queries · duplicate ids counted twice.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading