Hybrid search: why vector-only retrieval fails on real documents
Vector search is excellent at meaning and terrible at exactness. Real documents are full of exact things: section numbers, statute names, part codes, invoice IDs, version strings, and rare acronyms. Those are precisely where embeddings struggle.
Hybrid search combines lexical retrieval (BM25) with dense retrieval (embeddings) to cover both. This is not a fad; it is the default in every serious document retrieval system I've worked on.
What each retriever is good at
| BM25 (lexical) | Dense (embeddings) | |
|---|---|---|
| Exact terms | Strong | Weak |
| Paraphrase | Weak | Strong |
| Rare tokens / IDs | Strong | Weak |
| Typos | Weak | Moderate |
| Domain jargon | Strong if present in corpus | Depends on model |
| Cost | Cheap | Higher (embedding + ANN) |
BM25 in one paragraph
BM25 scores a document for a query using term frequency, inverse document frequency, and document-length normalization. Rare terms that appear in the query and the document score highly; common terms are discounted.
score(q, d) = Σ idf(t) ·
(tf(t,d) · (k1 + 1)) /
(tf(t,d) + k1 · (1 - b + b · |d| / avgdl))
# typical: k1 = 1.5, b = 0.75
It has no notion of meaning. That's the point: it's a precise, interpretable signal that complements embeddings.
How to combine the scores
Option A — weighted score fusion
Normalize each retriever's scores, then take a weighted sum.
hybrid = alpha * normalize(bm25) + (1 - alpha) * normalize(dense)
Min-max normalization per query is the simplest approach, but it's sensitive to outliers. Z-score normalization or rank-based normalization can be more stable.
Option B — reciprocal rank fusion (RRF)
Ignore raw scores and combine ranks. It's robust when the two retrievers produce incomparable score distributions.
RRF(d) = Σ 1 / (k + rank_r(d)) # k ≈ 60
RRF is a strong default. It requires no score calibration and tends to behave well across domains.
Option C — retrieve-then-rerank
Run both retrievers independently, merge the candidate sets, then rerank with a cross-encoder or a hosted reranker. This is often the highest-quality option and the most expensive.
Tuning alpha
alpha controls how much weight BM25 gets. Don't guess — measure.
for alpha in [0.3, 0.5, 0.7, 0.9]:
index = HybridIndex(documents, alpha=alpha)
score = evaluate(index, golden_set)
print(alpha, score)
In practice, document-heavy domains with lots of identifiers often prefer 0.6–0.8 BM25. Conversational queries prefer more weight on dense.
Add metadata filtering before similarity
Structure-aware metadata is a third signal that often beats both retrievers. If a user asks
about "the 2023 amendment," filter to year = 2023 before ranking.
candidates = filter_by(documents, {"type": "msa", "year": 2023})
results = hybrid.search(query, candidates=candidates, k=20)
Pre-filtering reduces the candidate space and eliminates a whole class of semantically similar but legally irrelevant results.
Reranking: the cheapest quality win
Retrieve 50 candidates with hybrid search, rerank them with a cross-encoder, and send the top 5–8 to the generator.
- Cross-encoders score query-passage pairs jointly, capturing interaction that bi-encoders miss.
- They're too slow for the whole corpus, but fine for 50 candidates.
- They consistently improve precision@k, which reduces hallucination.
A minimal implementation
from rag_eval import load_dataset, build_index, evaluate
dataset = load_dataset("corpus.jsonl", "queries.jsonl")
index = build_index("hybrid", dataset.documents, alpha=0.6)
rankings = {
qid: [doc for doc, _ in index.search(query, k=10)]
for qid, query in dataset.queries.items()
}
print(evaluate(rankings, dataset.qrels, k=5))
The RAG Eval Kit includes a BM25 implementation, a dense baseline, and hybrid fusion, so you can compare all three on your own golden set.
When not to use hybrid
- Pure semantic search over short, conversational content
- Corpora where exact identifiers never appear
- Latency budgets that can't tolerate a second index
- Very small corpora where the difference is noise
Default recommendation: hybrid retrieval with RRF or alpha ≈ 0.6, metadata pre-filtering, then a cross-encoder reranker. Measure recall@k before and after each addition.
Retrieval returning the wrong passages?
I run a retrieval-only diagnostic — 25 queries, recall@k and MRR, failure isolated by layer. Usually fixable in days, not weeks.
Book a 20-minute diagnostic