Hybrid Search: Why Vector Retrieval Alone Keeps Missing Your Documents
Hybrid search fixes what embeddings cannot do: exact IDs, rare terms, negation. How Reciprocal Rank Fusion merges the two ranked lists.

Table of contents
- Key takeaways
- Why does vector search miss an exact match?
- What BM25 does that embeddings cannot
- The problem at the heart of hybrid search
- Reciprocal Rank Fusion, and what the paper actually says
- What k = 60 actually means
- A worked example
- Where reranking fits, and where it does not
- When hybrid search is the wrong answer
- Choosing an approach
- Detecting the failure you already have
- Implementation notes
- The short version
A user searches your knowledge base for ERR_CONN_4021. The vector index returns eleven documents about network reliability. Not one contains the error code. This is the standard failure of pure embedding retrieval, and hybrid search is the standard fix.
The interesting part is not the advice to combine keyword and vector results. Everyone says that. It is the mechanics of how you combine two ranked lists whose scores mean nothing to each other.
Key takeaways#
- Embeddings encode meaning. So they blur the exact tokens that identifiers, codes and rare terms depend on.
- BM25 matches literal terms. Therefore it wins precisely where vectors fail.
- You cannot add a cosine similarity to a BM25 score. The two scales are unrelated.
- Reciprocal Rank Fusion solves that by discarding scores and merging ranks instead.
- The formula is one line, and its constant
k = 60is far less magical than most write-ups suggest.
Why does vector search miss an exact match?#
Because an embedding model is trained to place similar meanings near each other. That is the feature, and also the problem.
An error code carries almost no semantic content. ERR_CONN_4021 and ERR_CONN_4022 are different failures, yet they sit at nearly the same point in vector space. Meanwhile a tokenizer often splits an unfamiliar string into fragments, so the model never sees the code as a unit at all.
Three query shapes fail this way consistently:
Identifiers and codes. Part numbers, SKUs, error codes, ticket IDs, function names. The user wants that exact string. Similarity gets them a neighbourhood instead.
Rare and domain-specific terms. A drug name, an internal project codename, a legal citation. If the term appeared rarely in training, its embedding is poorly placed.
Negation and small edits. "Deployments that do not use Docker" often retrieves documents about Docker deployments. The word "not" barely moves the vector, though it inverts the intent.
There is a fourth case that surprises people. Exact quotations fail too. Searching a phrase you know is in the corpus can return paraphrases ranked above the literal source, because the paraphrase happens to sit closer to the query in embedding space.
What BM25 does that embeddings cannot#
BM25 is a lexical ranking function, and it has been the information-retrieval baseline for decades. It scores a document by how often the query terms appear in it, adjusted for two things.
First, term rarity. A term appearing in few documents carries more weight, so a rare error code contributes far more than the word "error". Second, document length. Longer documents naturally contain more terms, so BM25 normalises to stop them dominating.
The result is a method that finds the exact string, ranks the rare term highly, and has no opinion whatsoever about meaning. Ask it for "car" and it will not return documents about automobiles. That is its weakness, and it is exactly the weakness embeddings fix.
So the two methods fail in opposite directions. Consequently, hybrid search is not a clever trick. It is the obvious response to two complementary error profiles.
The problem at the heart of hybrid search#
Here is where most hybrid search implementations go wrong. The naive approach is to run both retrievers, then add or average the scores.
That does not work, because the scores are incomparable. A cosine similarity is bounded, typically between 0 and 1, and clusters tightly. A BM25 score is unbounded, depends on corpus statistics, and might be 4.2 for one query and 31.8 for another. Adding them lets whichever number happens to be larger dominate.
Normalising first is the usual next attempt. You min-max scale each list to 0 and 1, then average. This is better and still fragile. The scaling depends on the top and bottom values in that specific result set, so one outlier compresses everything else. A query where BM25 returns one strong match and nine weak ones produces a very different scale from a query where all ten are moderate.
You could tune a weight per retriever. However, that weight is corpus-specific, query-type-specific, and drifts as the index grows.
Reciprocal Rank Fusion, and what the paper actually says#
Cormack, Clarke and Büttcher proposed a method that sidesteps the whole problem. Their insight: throw the scores away and keep only the ranks.
A rank is comparable across systems by construction. First place in a BM25 list and first place in a vector list mean the same thing, whatever numbers produced them. That single property is what makes hybrid search tractable.
The formula from the paper:
RRFscore(d) = Σ 1 / (k + r(d))
r∈RFor each ranked list r in the set of rankings R, take the rank of document d in that list, add k, and take the reciprocal. Sum across lists. Sort descending.
That is the entire algorithm. It has no training step, no per-corpus tuning, and no score normalisation.
The authors give a clear reason for the shape. Highly ranked documents matter more, but the importance of lower-ranked ones should not vanish the way it would with an exponential decay. The constant k also mitigates the impact of high rankings from outlier systems, so a single retriever cannot dominate on the strength of one confident guess.
What k = 60 actually means#
Nearly every write-up presents k = 60 as a carefully tuned optimum. The paper says something more modest.
In the authors' words, k = 60 "was fixed during a pilot investigation and not altered during subsequent validation". Their pilot results indicated that 60 "was near-optimal, but that the choice was not critical".
That distinction matters in practice. If you are debugging poor retrieval, k is not the dial to turn. The paper's own evidence says the method is insensitive to it, so a change from 60 to 45 will not rescue a bad index or a bad chunking strategy.
What k does control is how steeply rank position is discounted. A small k sharpens the difference between first and second place. A large k flattens the curve, so deep results contribute more.
| k | Score at rank 1 | At rank 10 | Ratio |
|---|---|---|---|
| 1 | 0.500 | 0.091 | 5.5× |
| 10 | 0.091 | 0.050 | 1.8× |
| 60 | 0.0164 | 0.0143 | 1.15× |
At k = 60 the top ten results are within about 15% of each other. In other words, RRF at the default is close to a vote on presence rather than a ranking of confidence. That is deliberate, and it is why the method is robust.
A worked example#
Suppose a query returns these two lists. The corpus is a support knowledge base, and the user asked about ERR_CONN_4021.
| Rank | BM25 | Vector |
|---|---|---|
| 1 | Doc A | Doc C |
| 2 | Doc B | Doc D |
| 3 | Doc C | Doc A |
| 4 | Doc E | Doc F |
Doc A is the release note containing the literal error code. Doc C is a general article on connection reliability.
Now compute. Using k = 60:
- Doc A: 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226
- Doc C: 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03226
- Doc B: 1/(60+2) + 0 = 0.01613
- Doc D: 0 + 1/(60+2) = 0.01613
Docs A and C tie, because each is first in one list and third in the other. Meanwhile B and D, each strong in a single retriever, fall below both.
That is the property worth understanding. Hybrid search under RRF rewards documents that both retrievers consider relevant, and it demotes documents that only one of them likes. A single confident retriever cannot carry a result on its own.
Notice also what did not happen. No score was normalised, no weight was chosen, and the calculation stays identical if you swap the embedding model tomorrow.
Where reranking fits, and where it does not#
Fusion and reranking get discussed together, and they solve different problems. So it helps to be precise about the order.
RRF is cheap. It runs on ranks you already have, and it costs a dictionary lookup per document. A cross-encoder reranker is the opposite. It reads the query and each candidate document together, then scores the pair directly, which is far more accurate and far slower.
The sensible pipeline runs both. Retrieve broadly with BM25 and vectors, fuse with RRF to get one list, then rerank only the top 20 or 50 with the cross-encoder. Fusion decides which documents deserve the expensive model, and the reranker decides their final order.
Reversing that order is the common mistake. Reranking each retriever's list separately, then fusing the reranked lists, pays the cross-encoder cost twice and throws away the calibrated scores it produced.
There is also a question of how many documents survive to the prompt. More context is not automatically better, because models retrieve less reliably from the middle of a long input. The context window piece covers the mechanics, and why those limits exist at all covers the reason they are not simply raised. In practice, a tight set of five well-ordered chunks usually beats twenty mediocre ones.
When hybrid search is the wrong answer#
It costs something, so it is worth naming the cases where it is not justified.
You run one retriever and it works. If your corpus is prose and your queries are conceptual, pure vector retrieval may be entirely adequate. Hybrid search then doubles your query cost for no measurable recall gain.
Latency is your binding constraint. Hybrid means two queries, then a merge. On a well-tuned setup that is often only a few milliseconds more, though both retrievers must return before you can fuse. Your p99 becomes the slower of the two.
Your corpus has no lexical anchors. Fiction, transcripts, or paraphrase-heavy content contain few exact tokens worth matching. BM25 contributes little there.
You need score semantics downstream. RRF scores are not probabilities and not similarities. If a threshold gates whether you answer at all, you have lost the calibrated number that threshold was set against.
That last one catches teams migrating an existing pipeline. A cosine cut-off of 0.78 has no RRF equivalent, so the gate has to be rebuilt on rank position instead.
Choosing an approach#
| Your situation | Use | Why |
|---|---|---|
| Codes, SKUs, error strings in queries | Hybrid | Vectors blur exact tokens |
| Conceptual questions over prose | Vector only | Lexical adds little |
| Known-item lookup, users quote titles | BM25 first | Literal match is the intent |
| Mixed query types, one endpoint | Hybrid | Neither retriever covers both |
| Strict latency budget, simple corpus | Vector only | Two queries buy nothing |
| Multilingual with shared identifiers | Hybrid | Vectors bridge language, BM25 pins the code |
Detecting the failure you already have#
Most teams do not know which of their queries fail, and averages hide it. So measure by query type rather than in aggregate.
Take a sample of real queries. Then split them into buckets: contains an identifier, contains a rare term, purely conceptual, contains negation. Compute recall@10 for each bucket separately.
A healthy vector-only system shows high recall on the conceptual bucket and a visible collapse on the identifier bucket. If overall recall looks acceptable but the identifier bucket sits far below the rest, hybrid search will help you. If every bucket looks similar, your problem lies elsewhere, probably in chunking or in the embedding model.
One more diagnostic is worth running. For each failed query, check whether the correct document appears anywhere in the top 100. If it does, you have a ranking problem, and fusion or reranking will fix it. If it does not, you have a recall problem, and no amount of reordering will help.
Implementation notes#
Most vector databases now ship RRF as a built-in fusion mode, so you rarely implement the formula yourself. Even so, two details are worth checking.
First, confirm how deep each list goes before fusion. Retrieving 10 from each and fusing gives a very different result from retrieving 100 and fusing, because a document ranked 40th by one system never gets the chance to be rescued by the other. Fusing deeper lists and truncating afterwards is usually better.
Second, check whether your engine applies weights. Several implementations add a per-retriever multiplier on top of RRF. That reintroduces the tuning problem the method was designed to avoid, so leave the weights equal until a measurement tells you otherwise.
If you are hand-rolling it, the implementation is genuinely short:
from collections import defaultdict
def rrf(rankings, k=60):
scores = defaultdict(float)
for ranking in rankings: # each is an ordered list of doc ids
for rank, doc in enumerate(ranking, start=1):
scores[doc] += 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)The short version#
Embeddings and BM25 fail in opposite directions, so a system that runs only one will reliably miss a predictable class of query. That is the case for hybrid search. Combining their scores does not work, because those scores share no scale. Reciprocal Rank Fusion merges the ranked lists instead, in one line, with no tuning and no normalisation. Treat k = 60 as a sensible default rather than a lever, because the authors themselves found the choice was not critical. And before you add anything, measure recall by query type, since that is what tells you whether ranking or retrieval is the actual problem.
Tags
- Rag
- Retrieval
- Search
- Embeddings


