Hybrid Retrieval Systems

Fuse lexical and dense retrieval with RRF or normalized weights, place filters and a reranker, choose one engine or two, and measure whether hybrid helps.

Last generated

Lesson 8 of 38 available15 practice questions

SPACED REPETITION · 15 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

The part number and the coffee spill

You run search for the support site of a (made-up) laptop maker. The knowledge base is tiny: six nearly identical fan-replacement pages, one per part number, plus a few troubleshooting articles. Two tickets land on your desk the same morning.

A technician typed XR-7741-B screw count. The first result was the page for the XR-7741-A, which says four screws. The B model has six.

A customer typed "laptop won't turn on after I spilled coffee on it" and got exactly one result: hold the button for 15 seconds to reset the controller. The liquid-damage article, which says do not press the power button, never appeared.

The first ticket came from the embedding search the team shipped last quarter. The second came from the old keyword search that some pages still use. Here is the whole corpus with both retrievers. It is small enough to run on a laptop, and the later blocks build on it, so run them in one Python session.

import re
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

chunks = {}
for sku, screws in [("XR-7740-A", 4), ("XR-7740-B", 6), ("XR-7741-A", 4),
                    ("XR-7741-B", 6), ("XR-7741-C", 5), ("XR-7742-B", 6)]:
    chunks[f"fan-{sku}"] = (f"{sku} cooling fan replacement: remove the {screws} rear "
                            "panel screws, unplug the fan cable and fit the new fan.")
chunks |= {
    "liquid": "Liquid damage: if the unit fails to start after contact with fluids, do "
              "not press the power button; unplug the charger and let it dry for 48 hours.",
    "power-button": "Power button: if the laptop does not turn on, hold the button for "
                    "15 seconds to reset the controller.",
    "battery": "Battery will not charge: check the adapter light, try another outlet, "
               "recalibrate from the setup menu.",
    "warranty": "Warranty: accidental damage such as drops or spills is not covered; "
                "see the extended care plan.",
    "fan-noise": "Fan noise: a rattling fan usually means dust on the blades; clean it "
                 "with compressed air.",
    "keyboard": "Keyboard replacement for XR laptops: remove the bottom cover and "
                "disconnect the ribbon cable.",
}
ids = list(chunks)

STOP = {"a", "an", "the", "on", "in", "of", "to", "for", "and", "or", "is", "it", "i",
        "my", "after", "how", "do", "does", "not", "won", "t", "if", "with", "from"}

def tokens(text):
    # lowercase, keep hyphenated codes such as xr-7741-b whole, drop stopwords
    words = re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())
    return [w for w in words if w not in STOP]

bm25 = BM25Okapi([tokens(chunks[i]) for i in ids])   # rank_bm25: k1=1.5, b=0.75
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
doc_vecs = model.encode([chunks[i] for i in ids])     # unit-length, 384-d

def ranked(scores, keep):
    order = sorted(range(len(ids)), key=lambda i: (-scores[i], ids[i]))
    return [(ids[i], float(scores[i])) for i in order if keep(scores[i])]

def lexical(query, k_lex=5):    # an inverted index only returns chunks sharing a term
    return ranked(bm25.get_scores(tokens(query)), lambda s: s > 0)[:k_lex]

def dense(query, k_dense=5):    # every chunk has a cosine, so dense always returns k_dense
    return ranked(doc_vecs @ model.encode(query), lambda s: True)[:k_dense]

def show(pairs, nd=3):
    return [(d, round(s, nd)) for d, s in pairs]

for q in ["XR-7741-B screw count", "laptop won't turn on after I spilled coffee on it"]:
    print(q)
    print("  lexical:", show(lexical(q, 4), 2))
    print("  dense:  ", show(dense(q, 4)))

It prints:

XR-7741-B screw count
  lexical: [('fan-XR-7741-B', 1.93)]
  dense:   [('fan-XR-7740-A', 0.305), ('fan-XR-7741-A', 0.305), ('fan-XR-7742-B', 0.303), ('fan-XR-7741-B', 0.294)]
laptop won't turn on after I spilled coffee on it
  lexical: [('power-button', 4.6)]
  dense:   [('liquid', 0.483), ('power-button', 0.465), ('keyboard', 0.397), ('battery', 0.299)]

Why dense search lost the part number. The embedding model turns each chunk into one 384-number vector. The six fan pages differ only in a character or two and a screw count, so their vectors almost coincide: the top four cosines sit within 0.012 of each other, and the right page comes fourth. Dense similarity answers "is this about the same thing?", and all six pages are about the same thing.

Why keyword search lost the coffee spill. BM25 only scores words that the query and the chunk share after tokenizing. The customer wrote "won't turn on" and "spilled coffee"; the article says "fails to start" and "contact with fluids". With no shared term the score is 0, and an inverted index does not return a zero-score chunk at all. Meanwhile "laptop" and "turn" both appear in the power-button chunk, and "laptop" appears in no other chunk, so BM25 is confidently wrong.

Each retriever fails exactly where the other one works. That is the case for hybrid retrieval: on every query, run a lexical retriever (BM25, or a learned sparse model) and a dense retriever, then fuse their two ranked lists into one. Fuse these lists with the simplest method in this lesson and you get:

Query Lexical only Dense only Fused (Move 2)
XR-7741-B screw count right page #1 (the only hit) right page #4 right page #1
coffee spill right chunk missing right chunk #1 right chunk #2

Neither retriever alone gets both right chunks into the top 3. The fused list does. That is what hybrid is for: first recall, getting the evidence into the candidate list. The second row also shows the price: fusion pushed the liquid chunk from #1 to #2. Most of this lesson is about that trade.

# Symptom in the results or metrics Move
1 One retriever returns nothing useful for a whole kind of query Run both retrievers side by side
2 The two lists' scores are in different units Fuse ranks (RRF)
3 One side should count more, or big score gaps should matter Normalize, then weight
4 The right chunk is in neither list, or just below the cut Size the pools, then rerank
5 A filter leaves two results, or another tenant's chunk leaks in Filter inside every leg, before fusion
6 "Hybrid feels better", with no numbers Measure per query type
7 A deleted document still comes back, through one leg only One engine, or two kept in sync

This lesson assumes the query path from Foundations of Modern AI Search. How BM25 and learned sparse models score text is covered in Sparse vs Dense Retrieval, filters in Metadata & Filtering, and rerankers in Reranking Models. Here you learn how those pieces are wired together and tuned.

Two notes on the code. rank_bm25's BM25Okapi uses k1 = 1.5, b = 0.75 and its own IDF formula, so its scores will not match Elasticsearch's. And the tokenizer keeps xr-7741-b as one token on purpose; many real analyzers split it, and Move 7 shows what that does.

Move 1: Run both retrievers, side by side

Symptom: a whole kind of query fails with one retriever. Dense search misses identifiers; lexical search misses paraphrases.

Send every query down two legs at once and join them at the fusion step:

                  +-> lexical leg: analyze -> inverted index -> top k_lex ---+
query + filter ---+                                                          +-> fuse -> rerank -> context set
                  +-> dense leg:   embed   -> ANN index      -> top k_dense -+
                      (the same filter runs inside both legs)

The dense leg has to embed the query before it can search. The lexical leg doesn't wait for that, so it runs while the embedding is computed.

What it costs in latency. Take some example numbers (measure your own): query embedding 8 ms, ANN search 12 ms, BM25 15 ms, fusion under 1 ms. Run one leg after the other and you pay 8 + 12 + 15 = 35 ms. Run them in parallel and you pay the slower leg: max(8 + 12, 15) = 20 ms.

⚠️ Tails get worse when you wait for two things. Suppose both legs have the same p95 of t ms and their delays are independent. Then at least one leg is slower than t on 1 − 0.95 × 0.95 = 9.75% of queries, so the combined step's p95 is above t. Measure the p95 of the whole fused step under real load. Don't add up per-leg p95s.

Why not a fallback, or a cascade?

Two cheaper-looking designs keep showing up in design docs.

Fallback: "use BM25, and if it returns nothing, try dense." Look at the coffee query again. BM25 returned one chunk, the wrong one, so the fallback never fires. Any shared word gives BM25 a hit, so "no results" is a poor signal for "wrong results".

Cascade: "BM25 picks the top 200, and dense re-scores only those." It's cheap, because only 200 vectors get scored. But the liquid-damage chunk has a BM25 score of 0. It isn't in the top 200, or in the top 20,000. A cascade can never return a chunk its first stage cannot see, so the first stage's recall caps the whole system. The mirror image (dense top 200, then lexical re-scoring) caps identifier recall the same way when sibling pages crowd the dense list.

Running both legs in parallel costs two indexes, two queries per request and a fusion step. You pay that so each leg can bring in what only it can see.

Your turn. Your retrieval budget is a p95 of 30 ms. The measured p95s are 9 ms for query embedding, 14 ms for ANN search and 18 ms for BM25. Sequential or parallel? What would you check before you sign off?

Check your answer

Sequential is 9 + 14 + 18 = 41 ms, which blows the budget. Parallel is max(9 + 14, 18) = 23 ms on paper, which leaves 7 ms of headroom. That estimate adds p95s, which is only an approximation, and waiting for two legs fattens the tail. Before signing off, load-test the fused step and read its own p95.

Move 2: Fuse ranks, not scores

Symptom: the lexical leg says 4.6 and the dense leg says 0.483. Which is bigger?

That question has no answer. BM25 scores are unbounded and depend on corpus statistics. Cosine scores have a scale that depends on the model. Any rule that adds them raw is really choosing a winner by unit.

Reciprocal Rank Fusion (RRF) sidesteps the problem by ignoring scores and using positions only (Cormack, Clarke & Büttcher, SIGIR 2009):

RRF(d) = sum over the ranked lists r of   1 / (k + rank_r(d))      ranks start at 1, k = 60

A chunk missing from a list gets nothing from that list. The authors fixed k = 60 after a pilot run and reported that the exact choice was not critical there.

def rrf(ranked_lists, k=60):
    fused = {}
    for ranking in ranked_lists:
        for rank, doc_id in enumerate(ranking, start=1):    # ranks start at 1
            fused[doc_id] = fused.get(doc_id, 0.0) + 1 / (k + rank)
    return sorted(fused.items(), key=lambda kv: (-kv[1], kv[0]))

def hybrid_rrf(query, k_each=5, k=60):
    lex = [d for d, _ in lexical(query, k_each)]
    den = [d for d, _ in dense(query, k_each)]
    return rrf([lex, den], k)

for q in ["XR-7741-B screw count", "laptop won't turn on after I spilled coffee on it"]:
    print(q, show(hybrid_rrf(q)[:3], 5))

Predict first. For the coffee query, the lexical list is [power-button] and the dense list is [liquid, power-button, keyboard, battery, warranty]. Which chunk comes out on top?

Check your answer

power-button, with 1/61 + 1/62 = 0.03252. The liquid chunk is #1 in only one list: 1/61 = 0.01639. Being in both lists at #1 and #2 beats being #1 in one list. The run agrees:

XR-7741-B screw count [('fan-XR-7741-B', 0.03202), ('fan-XR-7740-A', 0.01639), ('fan-XR-7741-A', 0.01613)]
laptop won't turn on after I spilled coffee on it [('power-button', 0.03252), ('liquid', 0.01639), ('keyboard', 0.01587)]

For the part number, BM25's single hit plus dense #4 gives 1/61 + 1/64 = 0.03202. That is almost double any chunk found by one leg alone.

What RRF actually rewards

  • Two votes beat one. A chunk ranked r in both lists scores 2/(60 + r). A chunk ranked #1 in only one list scores 1/61. The two-list chunk wins whenever r ≤ 61 (at r = 62 they tie). So with k = 60, a chunk that both legs put around #50 outranks the single-list #1. RRF trusts agreement, and that is exactly why the liquid chunk fell to #2.
  • It is not a consistency bonus. For the same rank sum, an uneven pair scores slightly higher. Ranks (1, 5) give 0.031778; ranks (3, 3) give 0.031746. What wins is appearing in more lists and ranking high in at least one.
  • Score gaps vanish. Query XR-7741-B fan. BM25 gives the right page 2.622 and each of the five sibling pages 0.687, so it is 3.8 times more sure. The dense leg ranks the right page #3 behind XR-7741-A and XR-7740-A. RRF turns all that into 0.032266 for the right page against 0.032258 for XR-7740-A: a win by 0.000008. Even that tiny win comes from how ties were broken. The five siblings tied at 0.687 were ordered alphabetically, so XR-7740-A got lexical #2. Had XR-7741-A been second, it would have won with 1/62 + 1/61 = 0.03252.

The k knob. Here is each rank's contribution for three values of k:

k rank 1 rank 2 rank 10 rank 50 rank 1 ÷ rank 10
1 0.5000 0.3333 0.0909 0.0196 5.5
10 0.0909 0.0833 0.0500 0.0167 1.82
60 0.0164 0.0161 0.0143 0.0091 1.15

A small k lets the top of each list dominate. A large k flattens the ranks, so "how many lists" decides. Re-running the Move 6 harness with other values, k = 10, 60 and 200 gave identical metrics, and k = 1 changed the prose MRR from 0.56 to 0.67. Treat k as a parameter you can tune, not a law.

Why it works. A rank means the same thing in every list, so no calibration is needed. Because 1/(k + rank) decays slowly, a chunk that one leg ranks high and the other at least returns climbs above chunks that only one leg found. And there is nothing to train.

What it costs. RRF throws away how much better the #1 was. Rank scores also tie often, so break ties deterministically (the code above sorts by chunk id). Otherwise the same query can come back in a different order.

⚠️ Check your engine's k. Elasticsearch, OpenSearch, Milvus and Vespa default to k = 60. Qdrant defaults to k = 2 and counts ranks from zero, so its top result scores 1/2. Move 7 shows what that does to our part number.

Move 3: Normalize, then weight

Symptom: you want one leg to count more, because identifier queries are most of your traffic. Or a big score gap should matter, because the exact part number scored 3.8 times the next hit. Plain RRF can't express either one. Weighted RRF (Move 7) can make one leg count more, but no RRF variant sees a score gap.

The raw-sum trap

Blend raw scores with a weight α for the coffee query. The power-button chunk has BM25 4.60 and cosine 0.465. The liquid chunk has BM25 0 and cosine 0.483. Liquid wins only when α × 0.483 > α × 0.465 + (1 − α) × 4.60, which means α > 0.996. Every α from 0 to 0.99 gives the same winner. With raw scores, α doesn't mix anything: the leg with the bigger numbers decides.

Min-max per query, then α

Normalize each leg's scores over its own candidate list for this query, with (s − min) / (max − min), and then blend:

hybrid = α · dense_norm + (1 − α) · lexical_norm        α = 1: pure dense, α = 0: pure lexical

This course lets α weight the dense side. Weaviate uses the same direction: in its hybrid query, alpha = 1 is pure vector search, and the server default is 0.75 (docs). Check any other product's docs before you copy an α from one system to another.

def minmax(pairs):
    scores = dict(pairs)
    if not scores:                          # the lexical leg can come back empty
        return {}
    lo, hi = min(scores.values()), max(scores.values())
    if hi == lo:                            # one candidate, or all tied
        return {d: 1.0 for d in scores}
    return {d: (s - lo) / (hi - lo) for d, s in scores.items()}

def hybrid_convex(query, alpha, k_each=5):  # alpha weights the dense side
    den, lex = minmax(dense(query, k_each)), minmax(lexical(query, k_each))
    fused = {d: alpha * den.get(d, 0.0) + (1 - alpha) * lex.get(d, 0.0)
             for d in den.keys() | lex.keys()}
    return sorted(fused.items(), key=lambda kv: (-kv[1], kv[0]))

coffee = "laptop won't turn on after I spilled coffee on it"
print("coffee, a=0.5:", show(hybrid_convex(coffee, 0.5)[:3]))
print("coffee, a=0.9:", show(hybrid_convex(coffee, 0.9)[:3]))
print("part,   a=0.5:", show(hybrid_convex("XR-7741-B screw count", 0.5)[:3]))
coffee, a=0.5: [('power-button', 0.962), ('liquid', 0.5), ('keyboard', 0.312)]
coffee, a=0.9: [('power-button', 0.932), ('liquid', 0.9), ('keyboard', 0.562)]
part,   a=0.5: [('fan-XR-7741-B', 0.517), ('fan-XR-7740-A', 0.5), ('fan-XR-7741-A', 0.481)]

Now α does something, and the output shows two ways min-max bends the truth.

A lone hit becomes a perfect score. The coffee query's lexical list holds one chunk. Min-max has nothing to compare it with (max − min = 0), and this code maps a lone hit to 1.0, as strong as an exact part-number match. So liquid needs α > 0.930 to win. Any rule you pick for that case (1.0, 0.5, or 0) is a policy decision; Qdrant's DBSF, for example, uses 0.5. The first if matters too. The query "computer is really loud and whirring" shares no word with any chunk, and without that guard min() raises ValueError on the empty list.

Noise gets stretched to full scale. For the part-number query, the five dense cosines run from 0.293 to 0.305. Min-max spreads that 0.012 of noise across the whole 0-to-1 range. The right page's 0.294 becomes 0.03 while XR-7740-A's 0.305 becomes 1.0, and the right page wins by only 0.017. Min-max always stretches whatever spread exists, however small it is.

z-scores and DBSF

A z-score replaces each score with (s − mean) / standard deviation, computed over the leg's list. DBSF (distribution-based score fusion, in Qdrant since 1.11) maps mean − 3σ to 0 and mean + 3σ to 1 in each list without clipping, then sums the legs (Qdrant docs).

None of the three fixes bunched noise. Each one is a straight-line rescaling fitted to the list, so a list whose scores span 0.012 comes out exactly like the same pattern stretched to span 0.3. They differ in what sets the scale. Min-max looks only at the top and bottom scores and always hands each list the full 0-to-1 range. z-score and DBSF use every score (the mean and the standard deviation), so one outlier doesn't flatten the rest of its list to near 0. DBSF also keeps each list in a narrower band, with a lone hit at 0.5. Neither is free. z-scores go negative, so if a chunk missing from a list counts as 0, that leg gives it more credit than a chunk it returned below its mean. DBSF rarely drops below 0, but it isn't clipped either, so an outlier can land above 1. Every one of these normalizers is computed per query over the returned candidates, so changing k_dense from 5 to 50 changes every normalized score.

RRF or weighted?

Bruch, Gai & Ingber (ACM TOIS, 2023) compared the two in in-domain and out-of-domain experiments. A tuned convex combination (the α formula) beat RRF in both settings. RRF turned out to be sensitive to its parameters. And tuning α needed only a small set of labeled examples. The toy set in Move 6 points the same way: α = 0.5 matches RRF's recall and beats its prose MRR (0.67 against 0.56).

A workable rule: start with RRF when you have no labeled queries. Switch to normalized weighting once you have a labeled set to tune α on and a held-out split to confirm it. Re-check α whenever the embedding model, the analyzer or the corpus changes.

Your turn. Four chunks D1–D4. Dense cosines: 0.62, 0.61, 0.60, 0.59. BM25 scores: 3.0, 2.9, 12.0, 0.5. With α = 0.7, which chunk wins after min-max, and which wins with raw scores?

Check your answer

Min-max turns the cosines into 1.0, 0.667, 0.333, 0 and the BM25 scores into 0.217, 0.209, 1.0, 0. At α = 0.7 the fused scores are D1 0.765, D2 0.529, D3 0.533 and D4 0, so D1 wins. With raw scores, 0.7 × cosine + 0.3 × BM25 gives D3 4.02, far ahead. Neither answer is "right" in general. Min-max turned a 0.03 cosine spread, which may be pure noise, into a full-strength vote that outweighs a 4× BM25 lead. Only labeled queries can tell you which behaviour your data rewards.

Move 4: Size the pools, then rerank

Symptom: the right chunk is in neither list. Or it sits at #7 in the fused list and only five chunks go into the prompt.

lexical top k_lex   (100) --+
                            +--> union: 100 to 200 chunks --> fused order --> top 100 --> reranker --> context set (3-10)
dense   top k_dense (100) --+

The union holds between max(k_lex, k_dense) and k_lex + k_dense chunks. How much the two lists overlap tells you how often the legs agree.

The recall ceiling. Fusion and reranking only reorder the union. Say the right chunk sits at dense #140 with k_dense = 100 and is missing from the lexical list. Then nothing downstream can find it. The fix is upstream: a larger k_lex or k_dense, a better embedding model or analyzer, or a higher efSearch if the ANN index is losing it (ANN Algorithms). Measure the recall of the union on your labeled set. That number is the ceiling for everything after fusion.

⚠️ Engine defaults are small. Elasticsearch's rrf and linear retrievers default to rank_window_size = 10, and a Qdrant prefetch defaults to limit = 10. With 10 per leg, your reranker sees at most 20 chunks.

Where the reranker sits. It goes after fusion and scores the top of the fused list; this course defaults to 50–200 candidates, and 100 is a fine start. A cross-encoder (or an LLM prompted to rank) reads the query and each chunk together, so it can judge relevance more precisely than either leg: the dense leg squeezed each chunk into one vector before the query existed, and BM25 only counts shared words. It is also far too slow to run over the whole corpus, which is why it only sees the pool. Two rules follow:

  • Its scores are not calibrated probabilities, and their scale depends on the model and library. The MiniLM cross-encoder below returns raw logits such as −9.37; BAAI/bge-reranker-base loaded with sentence-transformers' CrossEncoder returns sigmoid values between 0 and 1, which are still not probabilities. Use the scores to order the list, tune any cut-off per model on labeled data, and don't add them to fused scores.
  • Its cost grows with the number of pairs. Suppose your reranker scores 1,000 pairs per second on your hardware (measure it). Then 100 candidates cost 100 ms and 200 cost 200 ms.

The details are in Reranking Models.

🧠 A reranker is a better judge, not an oracle. We ran two small open rerankers over the fused pool for the coffee query: cross-encoder/ms-marco-MiniLM-L6-v2 and BAAI/bge-reranker-base. Both still put the power-button chunk first (MiniLM scored it −1.52 and the liquid chunk −9.37). What saves this query is recall. The liquid chunk is in the top 3, so a context set of 3 hands the generator the sentence "do not press the power button". Whether a reranker helps your queries is a number you measure (Move 6).

Your turn. Each leg returns 50 chunks, and 18 chunks appear in both lists. How many chunks does fusion see? Your reranker handles 1,000 pairs per second and gets 60 ms. What do you rerank, and what would raising k_dense to 100 buy you?

Check your answer

The union is 50 + 50 − 18 = 82 chunks. With 60 ms at 1,000 pairs per second you can rerank the top 60 of the fused 82. Raising k_dense to 100 can only add chunks the dense leg ranked 51–100. It raises the ceiling if right chunks live there, which the union recall on your labeled set will show. It cannot make the reranker look at more than 60 of them.

Move 5: Filter inside every leg, before fusion

Symptom: a filter leaves two results. Or a chunk from tenant B shows up for tenant A, but only on keyword-heavy queries.

Hybrid means two retrievers. Filters are a separate axis, not a third retriever. Every hard constraint (tenant, access list, language, product line, date) must run inside both legs. Then each leg returns only eligible chunks, and fusion only ever sees eligible chunks. Whether a leg still finds the true top k eligible chunks depends on how its engine filters inside the index. pgvector with an HNSW index applies the WHERE clause after the index scan and can come back short unless iterative index scans (0.8.0+) are on. Qdrant checks the filter during the search, but its filtered recall depends on creating the payload index before the graph is built.

There are two classic ways to get this wrong.

Filtering after fusion. Suppose you take the fused top 10 and then apply a filter that keeps 5% of the corpus. If eligibility is unrelated to rank, you expect 10 × 0.05 = 0.5 survivors, and there's a 0.95¹⁰ ≈ 60% chance that none survive at all. To expect 10 survivors you would fetch about 200 before filtering, and unlucky queries would still come up short.

Filtering one leg only. The dense query carries the tenant filter. The lexical query is built by another code path and doesn't. Tenant B's chunk that contains the exact error code user A typed enters through the lexical leg. RRF gives it 1/61, which is plenty to reach the top 5 when the dense list is weak.

The fix is boring, and it works:

  • Build the filter once from the authenticated user.
  • Pass the same filter object to both legs, or use the engine's single filter slot. Qdrant applies the query_filter of a query_points call inside every prefetch (checked on 1.19.1). Elasticsearch's rrf and linear retrievers (Enterprise licence, see Move 7) take one filter that they apply to every sub-retriever. A filter set on one prefetch or one sub-retriever covers that leg only.
  • Fail closed if either leg can't apply it.
  • Plant a canary: a chunk in another tenant that matches a test query in both legs. It must never come back.

How pre-, post- and in-graph filtering work inside an ANN index is covered in Metadata & Filtering.

Move 6: Measure it per query type

Symptom: "hybrid feels better." Somebody set α = 0.5 because it's in the middle.

The recipe:

  1. Collect real queries from your logs, in their real proportions.
  2. Label the relevant chunk ids for each query and tag its type (identifier, natural language, mixed).
  3. Run every configuration: lexical only, dense only, RRF, and a few α values.
  4. Report two numbers per type, plus the p95 latency of each configuration. Recall@k here is retrieval recall, not ANN recall: the share of relevant chunks that land in the top k, with k the size of your context set. MRR (mean reciprocal rank) is the average over queries of 1 / (rank of the first relevant chunk), counted as 0 when it is missing.
  5. Choose on a tuning split and confirm on a held-out split.

Here is the tiny version on our corpus: 12 queries, 6 of each type, one relevant chunk per query.

labeled = [  # (query, the one relevant chunk, query type)
    ("XR-7741-B screw count", "fan-XR-7741-B", "id"),
    ("XR-7741-B fan", "fan-XR-7741-B", "id"),
    ("XR-7742-B fan", "fan-XR-7742-B", "id"),
    ("XR-7740-B screws", "fan-XR-7740-B", "id"),
    ("how many screws on the XR-7741-C fan", "fan-XR-7741-C", "id"),
    ("new fan XR-7740-A", "fan-XR-7740-A", "id"),
    ("laptop won't turn on after I spilled coffee on it", "liquid", "prose"),
    ("dropped my laptop, is the repair free?", "warranty", "prose"),
    ("laptop gets loud when gaming", "fan-noise", "prose"),
    ("laptop fan is noisy", "fan-noise", "prose"),
    ("plugged in but percentage never goes up", "battery", "prose"),
    ("a few letters on my laptop stopped working", "keyboard", "prose"),
]

def evaluate(search, k_context=3, depth=5):
    """Per query type: recall@k_context and MRR over the top `depth` results."""
    out = {}
    for qtype in ("id", "prose"):
        rows = [(q, rel) for q, rel, t in labeled if t == qtype]
        hits = rr = 0.0
        for q, rel in rows:
            ranking = [d for d, _ in search(q)][:depth]   # same depth for every system
            if rel in ranking[:k_context]:
                hits += 1
            if rel in ranking:
                rr += 1 / (ranking.index(rel) + 1)
        out[qtype] = (round(hits / len(rows), 2), round(rr / len(rows), 2))
    return out

systems = {"lexical": lexical, "dense": dense, "rrf": hybrid_rrf}
systems |= {f"convex a={a}": (lambda q, a=a: hybrid_convex(q, a))
            for a in (0.2, 0.4, 0.5, 0.6, 0.8)}
for name, search in systems.items():
    print(f"{name:13}", evaluate(search))

With one relevant chunk per query, recall@3 is simply the share of queries whose chunk made the top 3. Each pair below is (recall@3, MRR over the top 5):

lexical       {'id': (1.0, 1.0), 'prose': (0.0, 0.0)}
dense         {'id': (0.67, 0.42), 'prose': (1.0, 0.92)}
rrf           {'id': (1.0, 1.0), 'prose': (1.0, 0.56)}
convex a=0.2  {'id': (1.0, 1.0), 'prose': (1.0, 0.58)}
convex a=0.4  {'id': (1.0, 1.0), 'prose': (1.0, 0.58)}
convex a=0.5  {'id': (1.0, 1.0), 'prose': (1.0, 0.67)}
convex a=0.6  {'id': (0.83, 0.71), 'prose': (1.0, 0.75)}
convex a=0.8  {'id': (0.83, 0.49), 'prose': (1.0, 0.83)}

Reading the table.

  • Lexical is perfect on identifiers and loses every prose query. Dense is the reverse, and it is shaky on identifiers: recall@3 is 0.67.
  • RRF puts every right chunk in the top 3 for both types. But prose MRR drops from dense's 0.92 to 0.56, because the right chunk usually sits at #2. In four of the six prose queries the chunk ahead of it is power-button, which BM25 matched on "laptop".
  • α = 0.5 ties RRF on recall and beats it on prose MRR. At α = 0.6 and above, identifier queries start slipping out of the top 3.
  • Which one to ship depends on what reads the list. A RAG prompt with three chunks cares about recall@3, where RRF and α ≤ 0.5 tie. A results page where people click the first link cares about MRR.

Be honest about the size. With six queries per type, one query moves a type's recall by 0.17. Picking α on these 12 queries and reporting on the same 12 flatters whatever you picked. On real data, tune on one split and report on another, with enough queries per type that one query moves the number by a point or two. That means around 50 or more per type, not 6. Retrieval Metrics and Ranking Quality cover the metrics in depth.

What others measured. Anthropic reported in September 2024 its top-20 retrieval failure rate (1 − recall@20) on its evaluation sets:

  • 5.7% with plain embeddings
  • 3.7% with contextual embeddings
  • 2.9% after adding BM25 over the same contextualized chunks, fused by rank
  • 1.9% after also reranking the top 150 down to 20

Those are their numbers on their data. The part that transfers is the method: the same labeled queries, one change at a time.

When hybrid does not help

  • One leg already clears the bar on every query type. Think of a parts search where nearly every query is a SKU. The second leg then adds an index, a query per request and a sync problem, and buys nothing.
  • One leg is bad everywhere. Maybe the embedding model doesn't cover your language, or the analyzer mangles it. Fusion then lets the bad leg outvote the good one. Check each leg per type before you fuse.
  • Fusion rewards agreement on the wrong chunk. In Move 7, the same coffee query in a different engine drops the right chunk to #4.

Route by query shape

Sometimes the table says α wants to be low for identifiers and high for prose. Don't average the two; route. A regex or a small classifier decides the query type; for these part numbers the pattern could be XR-\d{4}-[A-Z]. Then each type gets its own α, or RRF weights, or an exact-match lookup on a part_number field. The cost is that a misrouted query gets the wrong settings, so measure the router's accuracy on the same labeled set. Classifying intent in general is covered in Query Understanding & Intent.

Move 7: One engine, or two kept in sync

Symptom: a policy deleted at 10:00 is still quoted at 15:00, and only paraphrased questions find it.

Hybrid inside one engine

Many engines now store a sparse and a dense representation of the same chunk and fuse them in one request. One write updates both legs, filters apply in one place, and there is nothing to keep in sync. Here is the same corpus in Qdrant, using qdrant-client 1.19.1 in memory and the chunks, doc_vecs and model from the first block. The lexical leg is a sparse vector built by the Qdrant/bm25 model, which the client computes locally with the fastembed package; the engine applies IDF.

from qdrant_client import QdrantClient, models

client = QdrantClient(":memory:")      # or QdrantClient("http://localhost:6333")
client.create_collection(
    "hybrid_kb",
    vectors_config={"dense": models.VectorParams(size=384, distance=models.Distance.COSINE)},
    sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
client.upsert("hybrid_kb", points=[
    models.PointStruct(
        id=n,
        vector={"dense": doc_vecs[n].tolist(),
                "bm25": models.Document(text=chunks[cid], model="Qdrant/bm25")},
        payload={"chunk_id": cid},
    )
    for n, cid in enumerate(ids)
])

def qdrant_hybrid(query, fusion, limit=2):
    result = client.query_points(
        "hybrid_kb",
        prefetch=[   # both legs run inside the engine, then the engine fuses them
            models.Prefetch(query=models.Document(text=query, model="Qdrant/bm25"),
                            using="bm25", limit=20),
            models.Prefetch(query=model.encode(query).tolist(), using="dense", limit=20),
        ],
        query=fusion,
        limit=limit,
    )
    return [(p.payload["chunk_id"], round(p.score, 4)) for p in result.points]

q = "XR-7741-B fan"
print("RRF, default k:", qdrant_hybrid(q, models.FusionQuery(fusion=models.Fusion.RRF)))
print("RRF, k=60:     ", qdrant_hybrid(q, models.RrfQuery(rrf=models.Rrf(k=60))))
print("DBSF:          ", qdrant_hybrid(q, models.FusionQuery(fusion=models.Fusion.DBSF)))

Predict first. Our plain-Python RRF put the right page first for XR-7741-B fan, if only by 0.000008. Will Qdrant's?

Check your answer

No, not with RRF:

RRF, default k: [('fan-XR-7741-A', 0.8333), ('fan-XR-7741-B', 0.75)]
RRF, k=60:      [('fan-XR-7741-A', 0.0331), ('fan-XR-7741-B', 0.0328)]
DBSF:           [('fan-XR-7741-B', 1.4114), ('fan-XR-7741-A', 1.2381)]

One thing changed: the analyzer. Qdrant/bm25 splits XR-7741-B into xr, 7741 and b, so the XR-7741-A page now matches xr and 7741 too. The lexical scores are 6.22 for the right page against 4.10 for XR-7741-A, which lifts the sibling out of the five-way tie to lexical #2. The dense ranks are the same as before: XR-7741-A #1, the right page #3. RRF sees only ranks, and lexical #2 + dense #1 beats lexical #1 + dense #3. Qdrant counts ranks from zero, so the top result gets 1/k. With k = 60 that gives 1/60 + 1/62 = 0.0328 for the right page against 1/61 + 1/60 = 0.0331 for the sibling. With the default k = 2 it is 0.75 against 0.8333. The paper's formula, with ranks from 1, picks the sibling too (0.03227 against 0.03252). DBSF keeps the lexical gap (6.22 against 4.10), and the right page wins.

The same engine also hurts the coffee query. Its stemmer turns "spilled" into spill, which matches "spills" in the warranty chunk, and "laptop" now matches "laptops" in the keyboard chunk. So three chunks sit in both lists, and the liquid chunk drops out of the top 3. It comes fourth with RRF at k = 60 (power-button 0.0331, keyboard 0.0323, warranty 0.0320, liquid 0.0167) and with DBSF. With the default k = 2 it ties with two other chunks at 0.5, and where it lands among them depends on the tie-break. The corpus and the dense vectors didn't change; the analyzer and the fusion rule decided. Re-run your labeled set whenever either one changes.

⚠️ "Weight" means different math in different engines. Qdrant 1.17+ accepts per-prefetch weights in Rrf. Its weight divides the rank: with weights 3 and 1, a chunk ranked third in the first list scores like one ranked first in the second. Elasticsearch 9.2+ instead multiplies each list's RRF term by its weight. Compute a few fused scores by hand before you trust a weight.

The engines, as of September 2026. All of these can run both legs and fuse them in one request. The last column lists defaults and conditions worth checking.

Engine Hybrid in one request Fusion Check this
Qdrant 1.19 prefetch sparse + dense, then fuse RRF (1.10+), DBSF (1.11+), RRF k (1.16+), weights (1.17+) RRF k = 2; prefetch limit 10
Elasticsearch 9.x rrf retriever (GA 8.16), linear retriever (8.18/9.0) RRF; weighted sum with minmax or l2_norm window 10; both retrievers need an Enterprise licence (Basic rejects them); set the linear normalizer explicitly (it is none unless set per retriever or, from 9.2, at the top level)
OpenSearch 3.x hybrid query + search pipeline min_max (default), l2, z_score (3.0+); RRF via score-ranker-processor (2.19+) weights must sum to 1
Weaviate hybrid query relative score fusion (default since 1.24), ranked fusion alpha 1 = pure vector, server default 0.75
Postgres + pgvector 0.8.6 full-text query + vector query, fused in SQL or app code RRF or a cross-encoder, as the README suggests ts_rank is not BM25: it uses no corpus-wide IDF

Milvus has had built-in BM25 full-text search since 2.5, with RRFRanker (k = 60) and WeightedRanker (docs). Vespa exposes reciprocal_rank_fusion in its global ranking phase (docs). For choosing between products, see Vector DB Selection. For Qdrant's Query API in depth, see Qdrant and Advanced Qdrant.

What you give up with one engine: its text analysis may be less configurable than a dedicated search engine's (synonyms, phrase queries, per-language analyzers), and you get only the fusion options it ships.

If you run two systems, keep them in sync

Two systems can be justified: an existing Elasticsearch or OpenSearch cluster with tuned analyzers you don't want to rebuild, a vector engine with features the text engine lacks, or very different scaling needs. If you go that way, here is what you take on.

  • Same chunks, same ids. Both stores must index the same chunk boundaries under the same id, or fusion compares different text.
  • No atomic write across two stores. Use one ingestion pipeline keyed by chunk id, with idempotent upserts and retries. Delivery through an outbox or change-data-capture stream is asynchronous, so there is always some lag between the stores.
  • Deletes are the dangerous case. A chunk deleted from one store keeps coming back through the other leg, and RRF happily fuses a hit from a single leg. That is exactly the 10:00/15:00 symptom.
  • Reconcile. A periodic job compares (chunk id, content hash) across both stores and alerts when mismatches or lag exceed your freshness target.

Put numbers on the exposure. If the vector index is rebuilt nightly at 02:00, a chunk deleted at 10:00 stays retrievable through the dense leg for 16 hours, and a chunk added at 10:00 is invisible to it for the same 16 hours. Freshness targets are covered in Data Freshness & Lifecycle, and ingestion in Embedding Pipeline.

Final round: no label on the problem

Real tickets don't say which move they need. Find the symptom first.

1. A teammate blends raw scores, alpha * cosine + (1 - alpha) * bm25. They tried α = 0.3 and α = 0.7, and "nothing changed". A typical chunk X has BM25 21.0 and cosine 0.41; chunk Y has BM25 6.0 and cosine 0.79. What's going on?

Check your answer

BM25's range (up to about 21 here) dwarfs cosine's, so BM25 decides at every α they tried. Y beats X only when α × 0.79 + (1 − α) × 6.0 > α × 0.41 + (1 − α) × 21.0, which means α > 0.975. The fix is Move 3: normalize both legs per query (min-max, z-score or DBSF), or switch to RRF. Then sweep α on labeled queries.

2. A Norwegian help center adds a dense leg to its BM25 search. On 300 labeled queries, recall@5 is 0.78 for BM25 alone, 0.31 for dense alone and 0.70 for RRF. Ship it?

Check your answer

No. The dense leg is weak across the board (0.31), and fusion lets it drag the good leg down (0.78 → 0.70). This is "one leg is bad everywhere". Check whether the embedding model supports Norwegian at all (Vector Embeddings). Test a multilingual model on the same labeled set, and keep lexical-only until a dense leg earns its place on the per-type numbers.

3. Both legs find the right chunk, at lexical #12 and dense #15, yet the reranker never sees it. The engine's hybrid retriever uses its defaults.

Check your answer

It's the per-leg window. If each leg contributes only its top 10 (Elasticsearch's rank_window_size and Qdrant's prefetch limit both default to 10), a chunk at #12 and #15 never reaches fusion. Raise the per-leg window to the candidate pool you actually want to rerank (Move 4), then check the reranker's latency at that size.

4. The same query on a static index returns the same five chunks but in a different order on two runs.

Check your answer

Look for ties. Scores tie more often than you'd think: in Move 2 five sibling pages tied at 0.687 in BM25, and fused RRF scores can tie too. If ties are broken by arrival order from two parallel legs, the order can flip between runs. Break ties on a stable key such as the chunk id. Distributed engines can add their own nondeterminism, such as per-shard statistics or replica choice, so rule that out on a single node.

Cheat sheet: symptom → move → cost

Symptom Move What it costs How you'd measure it
Identifier queries return sibling pages Add a lexical leg; keep codes whole in the analyzer A second index and query recall@k on identifier queries
Paraphrases get nothing Add a dense leg Embedding model, vectors, ANN memory recall@k on natural-language queries
Scores in different units RRF, k = 60 unless tuned Loses score gaps; ties MRR vs each single leg
One leg should count more, or gaps matter Normalize (min-max, z-score, DBSF), then α α to tune and re-check α sweep on a tuning split, confirmed on held-out
Right chunk in neither list Larger k_lex / k_dense; better model or analyzer Latency; more to rerank Recall of the union
Right chunk just below the cut Rerank the top 50–200 of the fused list pairs ÷ throughput recall@k or nDCG@10 before and after
Filter leaves two results; tenant leak The same filter inside both legs Nothing worth skipping it Canary leak test; results per filter
Mixed traffic pulls α both ways Route by query type Router mistakes Router accuracy; per-type metrics
Deleted document returns via one leg One engine, or one pipeline plus reconciliation Engine limits, or ops work Id/hash mismatches; sync lag

Before moving on, take a search system you know and write 20 real queries with their right chunks, tagged by type. Run lexical only, dense only and RRF. For every number in your table, explain which leg saw the chunk, which didn't, and why fusion put it where it did. If you can do that, you can tune any hybrid system.

Next: Sparse vs Dense Retrieval for what the lexical leg can and cannot see (BM25, learned sparse models, analyzers), Metadata & Filtering for filters that don't starve or leak, and Reranking Models for the stage after fusion.