What is hybrid search?
Hybrid search runs two retrievers over one query. A keyword retriever scores documents with BM25 against an inverted index. A vector retriever finds nearest neighbors to an embedding of the query. Each returns its own ranked list, and a fusion step merges the two into one ranking.
The reason to run both is that they miss different things.
Two retrievers, opposite failure modes
BM25 is literal. It finds the error code, the part number, the surname, the exact phrase someone typed, and it misses the same idea written another way.
Vector search is about meaning. It finds the paraphrase, the synonym, the other language, and it will also hand back things that are merely on topic.
A query like retry a failed request needs both: the token retry, and the idea of backoff, try-again, transient failure.
A question in clauses
Take “Which services retried a failed request, by team?”. Each clause asks for something different.
- Which services is a count:
COUNT(DISTINCT service). - By team is a
GROUP BY: one count per team. - Retried is a word to match. BM25 finds the events that say it.
- A failed request has no fixed wording. Vector search finds “try again”, “transient failure”, “exponential backoff”.
Fusing two incompatible scores
BM25 similarity rises for a better match, while vector distance falls. The two numbers are not on the same scale and adding them means inventing a weight. Reciprocal rank fusion sidesteps that by using position in each list instead of the scores themselves.
contribution(rank) = 1 / (60 + rank) # first in a list contributes 1/61 # a document first on both sides: 1/61 + 1/61 ≈ 0.0328 # first on one side only: 1/61 ≈ 0.0164 # tenth on both sides: 1/70 + 1/70 ≈ 0.0286
Each list is best-first. A hit at rank r (first place is 1) contributes 1 / (60 + r). The two contributions add, and the sum is the emitted score. A document both retrievers liked climbs above a document that only one of them ranked first.
60 is the constant from the 2009 paper that introduced the method, where fusing runs this way beat every individual system and Condorcet Fuse. It keeps fusion deterministic across queries.
Fusion needs a stable identity to merge on. A row's _id is what makes the same document one document, even when the two indexes store it differently.
k controls recall
Each lane retrieves k hits and fusion keeps the top k by fused score. A result LIMIT is a separate number: how many of those rows the query returns.
- A result page uses a modest
k(50-200) and a smallLIMIT. - An aggregate over matching events uses a large
k(thousands) soGROUP BYandCOUNTsee the full candidate cohort. - The disagreement set (rows only one retriever found) also needs a deep
k. Shallowkhides the misses. - Raising
kcosts work on both lanes and increases retrieval depth without changing fusion weights.
Seeing where the two disagree
Fusion hides the split by design. To measure it, run each retriever on its own and join the two relations on _id. The rows on only one side are the ones a single retriever never returns.
-- where do keyword and meaning disagree, and how much does each add WITH sides AS ( SELECT CASE WHEN v._id IS NULL THEN 'keyword only' -- literal, no paraphrase WHEN k._id IS NULL THEN 'meaning only' -- the same thing, said differently ELSE 'both agreed' END AS found_by, k.score AS bm25, v.score AS cosine FROM bm25_search('tickets', 'body', 'disk full on ingest', 100) k FULL OUTER JOIN vector_search('tickets', 'embedding', :q, 100) v ON v._id = k._id -- one snapshot, both sides ) SELECT found_by, count(*) AS docs, round(avg(bm25), 3) AS avg_bm25, -- nulls skip themselves round(avg(cosine), 3) AS avg_cosine FROM sides GROUP BY found_by ORDER BY docs DESC;
Run it per query class. When the “meaning only” bucket grows, the embeddings and the vocabulary are drifting apart. Note that k.score is BM25 (higher is better) while v.score is a distance (lower is better), which is the reason fusion compares rank positions rather than adding those columns.
What sits around retrieval
Hybrid search is the retrieval middle: one keyword query, one vector query, fusion, a ranked list. The quality stages on either side of it belong to the application.
- Query rewrite sits in front. Several rewritten queries can run as separate searches or a
UNION. - A cross-encoder reranker sits after, on the dozens of rows that came back. Rescoring candidates inside an ANN index is a different thing that shares the name.
- Chunking, contextual retrieval, and a labeled eval set are ingest and evaluation work.
In Infino, both indexes live inside the same Parquet files, both lanes read one pinned snapshot, and the fused result is a SQL relation: hybrid search on Infino · the query surface.