Hybrid RAG: When Vector Search Alone Stops Being Enough
Vector-only RAG pipelines fail in a specific, predictable way: they retrieve semantically similar chunks but miss the exact document the user needed. Teams rebuild their entire retrieval layer after launch because they assumed dense embeddings would handle everything. They don't.
The failure mode is quiet. Similarity scores look fine. The retrieved chunks are related. But the answer is wrong because the model got the third-best match instead of the document that contained the exact product SKU, the precise regulatory clause, or the specific error code. Semantic proximity isn't factual precision — and conflating the two is how you ship a RAG system that feels 80% good and stays stuck there.
Hybrid retrieval — combining dense vector search with sparse keyword (BM25-style) search — is the fix. But "just add BM25" is bad advice without understanding when each signal dominates, how to fuse the scores, and where the new failure modes live.
When Dense Search Fails and When Sparse Search Fails
Dense retrieval (bi-encoders like text-embedding-3-large, bge-large, e5-mistral) converts query and document into vectors and retrieves by angular or dot-product similarity. It generalizes well across paraphrases and handles conceptual queries. Ask "what's the refund policy" and it'll find chunks that never use the word "refund" but describe the returns process. That's genuinely useful.
But it degrades badly on:
- Exact-match queries: product codes, account numbers, named regulations ("DIFC Law 5 of 2018"), version strings
- Low-frequency or domain-specific terms poorly represented in the embedding model's training data
- Negation: embeddings for "not covered" and "covered" are often close in vector space
- Queries shorter than ~5 tokens: the embedding signal is weak; BM25 wins at short queries by a large margin in benchmark literature
Sparse retrieval (BM25, BM25+, SPLADE) works on term frequency and inverted indexes. It's deterministic, interpretable, and extremely fast — sub-millisecond on millions of documents with a proper inverted index. It wins on exact lexical matches and rare terms. It fails on synonyms, abbreviations, and any vocabulary mismatch between query and document.
Neither is universally better. The research is settled on this: BEIR benchmark results consistently show that hybrid approaches outperform either alone across heterogeneous corpora, typically by 3–8 points in NDCG@10 depending on the dataset. That's not a marginal improvement — it's the difference between a system that earns trust and one that doesn't.
The Three Ways Teams Wire Hybrid Retrieval (and Which One to Pick)
1. Parallel retrieval + score fusion
Run both retrievers independently, get the top-K from each, merge the candidate sets, and re-rank by a fused score.
pythonfrom rank_bm25 import BM25Okapi import numpy as np def reciprocal_rank_fusion(results_a: list[str], results_b: list[str], k: int = 60) -> list[str]: """ RRF score = sum(1 / (rank + k)) across result lists. k=60 is the standard default from the original Cormack et al. paper. """ scores: dict[str, float] = {} for rank, doc_id in enumerate(results_a): scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + 1 + k) for rank, doc_id in enumerate(results_b): scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + 1 + k) return sorted(scores, key=scores.get, reverse=True)
Reciprocal Rank Fusion (RRF) is the right default for score fusion. It's score-agnostic — it only uses rank positions — so you don't need to normalize cosine similarity against BM25 scores, which live on completely different scales. Direct score interpolation (alpha * dense_score + (1-alpha) * sparse_score) can work but requires careful calibration per corpus and breaks when either scorer changes.
Use RRF unless you have a clear offline evaluation harness to tune alpha. Most teams don't, so most teams should use RRF.
2. Cascaded retrieval
Run BM25 first as a pre-filter (cheap, fast), then re-rank the shortlist with dense embeddings or a cross-encoder. This is the right architecture when:
- Your corpus is large (10M+ documents) and full dense retrieval is cost-prohibitive
- The query set skews lexical (support tickets, code search, legal clause lookup)
- Latency budget is tight and you can afford to sacrifice some recall at the BM25 stage
The risk: if BM25 misses a document in the first stage, it never gets a second chance. Set your BM25 top-K high (100–200) and accept that the cascade will be slower than pure BM25 alone.
3. Single-index hybrid (Elasticsearch / OpenSearch / Weaviate native)
Modern search platforms now support hybrid natively. Elasticsearch's knn + query combination, Weaviate's hybrid search, and Qdrant's sparse-dense fusion all run the two passes inside the engine and return a merged result set. This is the right choice for teams that don't want to maintain separate BM25 and vector indexes.
json{ "query": { "bool": { "should": [ { "match": { "content": "DIFC Law 5 refund clause" } }, { "knn": { "field": "embedding", "query_vector": ["..."], "num_candidates": 100 } } ] } } }
The tradeoff: you're now dependent on the platform's fusion logic, which may or may not be RRF. Check the docs. Some platforms use linear interpolation with a default alpha that's corpus-agnostic and therefore wrong for your data.
The Reranking Layer You're Probably Skipping
Hybrid retrieval expands your candidate pool. Reranking collapses it back to the 3–5 chunks you actually pass to the LLM. Skip reranking and you'll inflate your context window with low-value chunks, increase hallucination surface, and burn tokens for nothing.
Cross-encoders (Cohere Rerank, bge-reranker-v2-m3, ms-marco-MiniLM) take (query, document) pairs and score relevance jointly — much more accurate than bi-encoder similarity but O(N) inference cost. Run them on your top 20–50 candidates from hybrid retrieval, not on the full corpus.
A decision table:
| Corpus size | Query volume | Reranker tier |
|---|---|---|
| < 100K docs | Low | Cross-encoder locally (free) |
| 100K–5M docs | Medium | Cohere Rerank API or hosted model |
| 5M+ docs | High | Cascaded: BM25 → dense → cross-encoder on top-50 |
If you're watching token spend carefully — and you should be, given how quickly retrieval context inflates costs in agentic loops — tighter reranking before the LLM call is one of the highest-leverage levers available. I covered the full cost logic in more depth in Stop Paying for Tokens You Don't Need.
Chunking Strategy Is a Retrieval Decision, Not a Preprocessing Detail
Hybrid retrieval doesn't save you from bad chunking. Most teams chunk by fixed token count (256, 512, 1024 tokens) because it's the default. Fixed-size chunking fractures sentences, splits tables, and decapitates structured data. BM25 will happily index half a table. Your embedding will faithfully encode a sentence fragment. Neither will retrieve correctly.
For hybrid pipelines specifically:
- Use semantic chunking (split on topic boundaries, not token counts) for narrative documents. LlamaIndex and LangChain both ship sentence-window and semantic splitters.
- Preserve structure for semi-structured docs (contracts, specs, PDFs with headers): chunk by section, keep the header in every chunk as context.
- For tables and lists: extract separately, store as structured metadata, retrieve via a dedicated structured query path — don't embed a flattened table and expect either dense or sparse retrieval to handle it.
- Parent-child chunking: index small chunks for retrieval precision, return the larger parent chunk to the LLM for context richness. This is the single chunking pattern that survives the most corpus types.
Imagine a team indexing a 500-page regulatory document with fixed 512-token splits. They'll get good semantic recall on general policy questions and terrible precision on anything that references a specific article number — because the article number and its surrounding context landed in different chunks. Parent-child chunking with section-aware splitting fixes this class of problem cleanly.
Evaluation: If You're Not Measuring Retrieval Separately from Generation, You're Flying Blind
The worst habit in RAG development is evaluating end-to-end answer quality only. When answers are wrong, you can't tell if retrieval failed (wrong chunks returned) or generation failed (right chunks, wrong synthesis). These have completely different fixes.
Measure retrieval independently:
- Recall@K: did the correct document appear in the top K results?
- MRR (Mean Reciprocal Rank): how high did the correct document rank?
- NDCG@K: graded relevance across the ranked list
You need a labeled evaluation set — at minimum 100–200 (query, relevant_doc_id) pairs that cover your query distribution. This feels like work. It is work. But it's the only way to know whether switching from pure dense to hybrid actually improved your system, or whether you just shuffled the errors around.
Tools that remove most of the friction: RAGAS for end-to-end evaluation, Arize Phoenix for retrieval-layer tracing, and TruLens for feedback-driven evaluation loops. None of these require a dedicated ML engineer to operate — a senior backend engineer can instrument them in a day.
The Query Routing Layer Teams Reach for Too Late
Not every query needs the same retrieval path. A query like "list all invoices for vendor ID V-4821" is a structured lookup — it belongs in your database, not your vector index. Sending it through RAG is slower, more expensive, and less accurate than a direct SQL query.
Build a lightweight query router — a small classifier or a prompted LLM call — that sends queries to the right retrieval path before you touch any index:
- Exact lookup → structured database query
- Keyword-dominant → BM25 primary, dense secondary
- Semantic/conceptual → dense primary, BM25 secondary
- Multi-hop reasoning → retrieval + agent loop (different problem entirely; see Agent Memory: The Bottleneck Killing Multi-Agent Systems)
A router adds one fast LLM call or a small classification model call per query. In production, this pays back in retrieval accuracy and reduced token cost almost immediately. The engineering is a day's work; the real effort is building enough production query logs to know which bucket each query type falls into. Let traffic teach you the distribution before you hard-code routing rules.
What to Actually Do
- Instrument retrieval before you change anything. Add Recall@10 and MRR logging against a 100-query eval set this week. You need a baseline or every change is guesswork.
- Add BM25 as a parallel path. Start with RRF fusion, no alpha tuning. Run it against your eval set. If NDCG@10 improves (it almost certainly will on any mixed corpus), ship it.
- Add a cross-encoder reranker on the top 30 candidates.
bge-reranker-v2-m3runs locally for free. Measure precision improvement before reaching for a paid API. - Audit your chunking. If you're on fixed-size splits, migrate the noisiest document types (PDFs, contracts, tables) to parent-child or section-aware chunking first.
- Build the query router last — once you know your query distribution from production logs, routing decisions become obvious. Don't design them upfront from assumptions.
The teams that get stuck at 80% retrieval quality almost always have the same problem: they optimized the generator before they fixed the retrieval. Get the right chunks first. Everything else follows.
Working on something like this? I take on a few fractional-CTO and AI engagements at a time.
Get my AI playbooks — straight to your inbox
Practical notes on shipping production AI, scaling teams, and the calls a CTO actually has to make. A few times a month. No spam, no fluff.