Hybrid Search
You run one search for meaning and one for exact words, then merge the two orderings into a single list. Each half catches what the other drops, so a rare error code and a reworded question both get found.
Why Does This Exist?
Ask your help center "E4021 keeps failing at checkout" and you can get back nothing useful, even though the answer sits two clicks away. The passage you need says "error E4021 blocks payment completion." You reworded half the query and kept the one code that matters, and each half defeats a different search method.
That split is why hybrid search exists. Dense retrieval matches meaning through embeddings, so "keeps failing" finds "blocks completion" with no shared words, which is the whole subject of semantic search. But vectors blur exact tokens, and a rare code like E4021 can drown among semantic neighbours. Lexical retrieval does the reverse: BM25 matches literal tokens through an inverted index, so E4021 snaps to the right rows, yet "keeps failing" against "blocks completion" scores zero.
This page covers the merge: running both lists and fusing them into one ordering. The lexical half lives on BM25 and inverted indexes, the dense half on semantic search, and the pipeline that consumes the merged list on RAG architecture. Read those for depth; here you'll see the fusion rule, one worked ordering, and what the combination costs.
Think of It Like This
A library desk with two clerks
Say you lose a library book and remember two things: what it was about, and the call number on its sticker. One clerk knows every plot and walks you to the right shelf from a vague description, yet shrugs at "QA 76.73". The other clerk finds "QA 76.73" in seconds from the card catalog, yet stares blankly when you describe the plot.
You ask both and combine their shortlists, putting titles either clerk rated highly at the top. That's hybrid search: dense retrieval is the plot clerk, lexical retrieval is the call number clerk, and reciprocal rank fusion is the rule for merging their two orderings.
Where it stops: clerks understand you and can ask follow up questions. Retrieval can't. It matches patterns you indexed ahead of time, and a book neither clerk filed stays lost no matter how you merge.
How It Actually Works
Two rankings from one query
One query runs through two independent systems over the same corpus. The dense side embeds the query and returns the closest document vectors by cosine similarity. The lexical side tokenizes the query, pulls postings lists, and scores matches with BM25, which is blind to word order and meaning in exactly the way bag of words describes. Each side returns its own ordering, best first.
The two raw scores never meet. A cosine near 0.8 and a BM25 of 12.4 sit on unrelated scales, and the BM25 scale shifts with query length and corpus counts. So the merge ignores scores entirely and uses only positions. The side by side evidence for when each side wins is laid out in the dense versus sparse versus hybrid retrieval comparison.
Fuse ranks, not scores
Write for the position of document in the dense list, counting from 1, and for its position in the lexical list. Let be a small constant that softens the gap between the very top ranks; 60 is the common default. Then the fused score for document is:
In plain words: each list contributes one fraction, large when the rank is small. A document absent from a list contributes zero for that list. Add the two contributions and sort by the total, largest first. A document placed well in both lists collects two large fractions and rises past a document that tops one list but misses the other.
The price is concrete. You build and store two indexes, you pay for two lookups on every query, and you tune the merge. Don't use hybrid search when one signal clearly dominates your queries: a catalog of part numbers is a lexical problem, a forum of reworded how to questions is a dense problem, and adding the second system buys latency without recall. The method assumes the two failure modes complement each other on your corpus. When they don't, the fused list is just a slower copy of the better half, and a reranker on top can't recover candidates neither half retrieved.
Worked example
Query: "E4021 checkout keeps failing". Four chunks, :
- D1: "E4021 blocks payment completion" (code plus a rewording of checkout failure)
- D2: "E4021 timeout on retry" (exact code, different problem)
- D3: "Payment completion keeps failing for buyers" (the paraphrase, no code)
- D4: "E4021 in the legacy code table" (the code in unrelated context)
Dense ordering by meaning: 1st D1, 2nd D3, 3rd D2. D4 is absent. Lexical ordering by exact terms: 1st D2, 2nd D1, 3rd D4. D3 is absent. So while , and the rest follow from the two lists.
Now apply the rule with . The three fractions you need are , , and :
- D1:
- D2:
- D3:
- D4:
Final order: D1 first, D2 second, D3 third, D4 last. D1 wins because it places well twice, not because it tops either list alone. D2 pairs a first place with a third and lands just behind. D3 and D4 each appear in one list only, so each keeps a single fraction and trails. These are the numbers drawn in the diagram above and printed by the snippet below.
Code
The point of this snippet is the merge itself on the worked fixture: two orderings in, one fused ordering out.
def reciprocal_rank_fusion(ranked_lists: list[list[str]], k: int = 60) -> list[str]: scores: dict[str, float] = {} for ranked_list in ranked_lists: for rank, doc_id in enumerate(ranked_list, start=1): # ranks count from 1 scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank) return sorted(scores, key=lambda d: scores[d], reverse=True)
dense: list[str] = ["D1", "D3", "D2"]lexical: list[str] = ["D2", "D1", "D4"]
fused = reciprocal_rank_fusion([dense, lexical], k=60)print(fused) # -> ['D1', 'D2', 'D3', 'D4']print(round(1 / 61 + 1 / 62, 5)) # -> 0.03252D1 tops the fused list at 0.03252 because it collects from dense and from lexical. D4 trails at 0.01587 with only its lexical third place to show.
Watch Out For
Averaging a cosine with a BM25 score
It feels tidy to add the two scores or take their mean. Do that and whichever scale runs hotter wins every query for no reason about relevance. BM25 is unbounded and query dependent while cosine sits in a fixed range, so a BM25 of 12.4 buries a cosine of 0.8 before meaning gets a vote. The symptom is a fused list that mirrors one half: exact matches crowd out paraphrases, or the reverse, and tuning the weight just moves the bias around.
Never average raw scores from two retrieval scales. Fuse by rank position with reciprocal rank fusion, or map both sides onto one shared scale first and let a reranker rescore the shortlist before anything reaches the model.
Assuming two systems always beat one
Hybrid search usually helps, but "usually" is doing real work. If your queries are all serial numbers, lexical alone already wins; if they are all reworded questions with no codes, dense alone wins. The symptom of a needless hybrid is a slower query path with evaluation numbers that sit inside the noise of the better half. You pay double retrieval and still debug two indexes.
Measure all three on your own queries: dense alone, lexical alone, fused. Keep the second system only when the fused list wins by a margin you would defend in review, and tune against that same eval instead of copying 60 blindly.
The Quick Version
- Hybrid search runs dense meaning retrieval and lexical exact term retrieval side by side, then merges their two orderings into one list.
- Reciprocal rank fusion scores each document as 1 over k plus its rank in each list, with k commonly 60, so only positions matter and raw score scales never mix.
- A document placed well in both lists beats one that tops a single list: D1 at 0.03252 tops D2 at 0.03227 because D1 places first and second while D2 places third and first.
- The combination costs two indexes and two lookups per query, so skip hybrid search when your queries clearly favor one signal.
- A reranker can reorder the fused shortlist but cannot recover a document neither half retrieved.