Hybrid Search: BM25 + Vector Search for Production RAG with RRF
TL;DR — Hybrid search combines BM25 sparse retrieval with dense vector search, fused via Reciprocal Rank Fusion (RRF). TopReviewed: "Pure vector search tops out at 0.72 recall — hybrid pushes that to 0.91. Four stages: parallel retrieval → RRF fusion → cross-encoder reranker → LLM." AIWorkflowLab: "RRF ignores scores entirely: RRF_score(d) = Σ 1/(k + rank_i(d)), k=60 default. Dense-only: p50=18ms. Hybrid: p50=24ms (6ms cost). Hybrid + FlashRank: p50=76ms. Hybrid + Cohere: p50=230ms." DigitalApplied: "RRF operates on ranks not scores, solving the score-incompatibility problem. Qdrant, Weaviate, Elasticsearch, OpenSearch, Milvus all support native hybrid." AnishRaj: "Hybrid (RRF): 95% accuracy. BM25 only: 90%. Dense only: 85%." Learn more with cosine vs dot product, reranking, what is RAG, and build from scratch.
TopReviewed quantifies the improvement: "Pure vector search tops out around 0.72 recall on domain-specific corpora — hybrid search RAG, combining BM25 with dense embeddings and reciprocal-rank fusion, pushes that to 0.91 in measured evaluations."
DigitalApplied explains why: "BM25 and dense vector search each have systematic blind spots. Hybrid retrieval fuses both via Reciprocal Rank Fusion — a rank-only algorithm that sidesteps the score-incompatibility problem that breaks naively-weighted pipelines."
Hybrid Search Architecture
(vector search)"] Query --> Sparse["Sparse Retrieval
(BM25 keyword)"] Dense --> DenseResults["Top 50-100
ranked by cosine similarity"] Sparse --> SparseResults["Top 50-100
ranked by BM25 score"] DenseResults --> RRF["Reciprocal Rank Fusion
RRF_score(d) = Σ 1/(k + rank_i(d))
k = 60 default"] SparseResults --> RRF RRF --> Fused["Fused Candidate Set
top 50 merged by rank"] Fused --> Rerank["Cross-Encoder Reranking
(optional, top 50 → top 10)"] Rerank --> TopK["Final Top-K
for LLM context"] TopK --> LLM["LLM Generation
with citations"]
Hybrid Search Performance Comparison
| Metric | Dense Only | BM25 Only | Hybrid (RRF) | Hybrid + Rerank |
|---|---|---|---|---|
| Recall | 0.72 | 0.78 | 0.91 | 0.93+ |
| Accuracy | 85% | 90% | 95% | 96%+ |
| Hit Rate @5 | 17/20 | 18/20 | 19/20 | 20/20 |
| p50 latency | 18ms | 15ms | 24ms | 76ms (FlashRank) |
| p95 latency | 45ms | 35ms | 58ms | 145ms (FlashRank) |
| Semantic matching | ✓ | ✗ | ✓ | ✓ |
| Keyword matching | ✗ | ✓ | ✓ | ✓ |
| Acronym matching | ✗ | ✓ | ✓ | ✓ |
Per-Retriever Weighting Guide
| Query Type | BM25 Weight | Dense Weight | Rationale |
|---|---|---|---|
| Catalog/identifier | 0.7 | 0.3 | Exact matches matter most |
| Conceptual/paraphrase | 0.3 | 0.7 | Semantic similarity matters most |
| General/mixed | 0.5 | 0.5 | Balanced (default) |
| Error codes | 0.8 | 0.2 | Exact token matching critical |
| How-to/explain | 0.2 | 0.8 | Semantic intent dominant |
Implementation
import asyncio
import math
from dataclasses import dataclass
from typing import Optional
from enum import Enum
from collections import defaultdict
class RetrievalMode(Enum):
DENSE = "dense"
SPARSE = "sparse"
HYBRID = "hybrid"
@dataclass
class HybridSearchEngine:
"""Hybrid search: BM25 + dense vectors + RRF fusion."""
def __init__(self, embedder, dense_index, bm25_index,
reranker=None):
self.embedder = embedder
self.dense_index = dense_index
self.bm25_index = bm25_index
self.reranker = reranker
async def search(self, query: str,
top_k: int = 5,
mode: RetrievalMode = RetrievalMode.HYBRID,
bm25_weight: float = 0.5,
dense_weight: float = 0.5,
rrf_k: int = 60,
over_fetch: int = 50,
use_reranking: bool = True) -> dict:
"""Run hybrid search with RRF fusion."""
import time
start = time.time()
if mode == RetrievalMode.DENSE:
return await self._dense_search(
query, top_k, start)
elif mode == RetrievalMode.SPARSE:
return await self._sparse_search(
query, top_k, start)
else:
return await self._hybrid_search(
query, top_k, bm25_weight, dense_weight,
rrf_k, over_fetch, use_reranking, start)
async def _hybrid_search(self, query: str, top_k: int,
bm25_weight: float,
dense_weight: float,
rrf_k: int, over_fetch: int,
use_reranking: bool,
start: float) -> dict:
"""Hybrid search: parallel retrieval + RRF + reranking."""
# Stage 1: Parallel retrieval
retrieve_start = time.time()
dense_task = self._dense_retrieve(query, over_fetch)
sparse_task = self._sparse_retrieve(query, over_fetch)
dense_results, sparse_results = await asyncio.gather(
dense_task, sparse_task)
retrieve_time = (time.time() - retrieve_start) * 1000
# Stage 2: RRF Fusion
fuse_start = time.time()
fused = self._rrf_fusion(
dense_results, sparse_results,
dense_weight, bm25_weight, rrf_k)
fuse_time = (time.time() - fuse_start) * 1000
# Stage 3: Reranking (optional)
rerank_time = 0
if use_reranking and self.reranker:
rerank_start = time.time()
fused = await self._rerank(query, fused, top_k)
rerank_time = (time.time() - rerank_start) * 1000
final = fused[:top_k]
else:
final = fused[:top_k]
total_time = (time.time() - start) * 1000
return {
"mode": "hybrid",
"query": query,
"results": final,
"total_results": len(final),
"candidates_dense": len(dense_results),
"candidates_sparse": len(sparse_results),
"candidates_fused": len(fused),
"timing": {
"retrieve_ms": round(retrieve_time, 1),
"fusion_ms": round(fuse_time, 1),
"rerank_ms": round(rerank_time, 1),
"total_ms": round(total_time, 1),
},
"weights": {
"bm25": bm25_weight,
"dense": dense_weight,
},
"rrf_k": rrf_k,
}
async def _dense_retrieve(self, query: str,
top_k: int) -> list:
"""Dense vector retrieval."""
query_embedding = await self.embedder.embed(query)
results = await self.dense_index.search(
vector=query_embedding, limit=top_k)
return [{"id": r["id"], "payload": r["payload"],
"rank": i, "score": r.get("score", 0)}
for i, r in enumerate(results)]
async def _sparse_retrieve(self, query: str,
top_k: int) -> list:
"""BM25 sparse retrieval."""
results = self.bm25_index.search(query, k=top_k)
return [{"id": r["id"], "payload": r["payload"],
"rank": i, "score": r.get("score", 0)}
for i, r in enumerate(results)]
def _rrf_fusion(self, dense: list, sparse: list,
dense_weight: float = 0.5,
bm25_weight: float = 0.5,
k: int = 60) -> list:
"""Reciprocal Rank Fusion of dense and sparse results.
RRF_score(d) = Σ weight_i / (k + rank_i(d))
Uses rank positions, not scores, solving the
score-incompatibility problem between BM25 and cosine.
"""
scores = defaultdict(float)
results_map = {}
for result in dense:
doc_id = result["id"]
scores[doc_id] += dense_weight / (k + result["rank"] + 1)
results_map[doc_id] = result
for result in sparse:
doc_id = result["id"]
scores[doc_id] += bm25_weight / (k + result["rank"] + 1)
results_map[doc_id] = result
sorted_ids = sorted(scores, key=scores.get, reverse=True)
return [
{**results_map[doc_id], "rrf_score": scores[doc_id]}
for doc_id in sorted_ids
]
async def _rerank(self, query: str, candidates: list,
top_k: int) -> list:
"""Cross-encoder reranking of fused candidates."""
if not self.reranker:
return candidates
pairs = [(query, c["payload"]["content"])
for c in candidates[:top_k * 3]]
scores = await self.reranker.predict(pairs)
for candidate, score in zip(candidates[:top_k * 3], scores):
candidate["rerank_score"] = float(score)
return sorted(
candidates[:top_k * 3],
key=lambda x: x.get("rerank_score", 0),
reverse=True,
)
async def _dense_search(self, query: str, top_k: int,
start: float) -> dict:
"""Dense-only search."""
results = await self._dense_retrieve(query, top_k)
return {
"mode": "dense",
"query": query,
"results": results[:top_k],
"timing": {"total_ms": round((time.time() - start) * 1000, 1)},
}
async def _sparse_search(self, query: str, top_k: int,
start: float) -> dict:
"""Sparse-only (BM25) search."""
results = await self._sparse_retrieve(query, top_k)
return {
"mode": "sparse",
"query": query,
"results": results[:top_k],
"timing": {"total_ms": round((time.time() - start) * 1000, 1)},
}
def classify_query(self, query: str) -> RetrievalMode:
"""Classify query to recommend retrieval mode."""
# Quoted phrases → sparse
if '"' in query:
return RetrievalMode.SPARSE
# Short keywords (1-3 words) → sparse
words = query.split()
if len(words) <= 3 and all(len(w) < 15 for w in words):
return RetrievalMode.SPARSE
# Semantic signals → dense
semantic_signals = ["explain", "how", "why", "similar",
"like", "compare", "difference"]
if any(signal in query.lower() for signal in semantic_signals):
return RetrievalMode.DENSE
# Mixed/ambiguous → hybrid
return RetrievalMode.HYBRID
Hybrid Search Checklist
- [ ] Use hybrid search (BM25 + dense + RRF) as the production default for RAG
- [ ] Hybrid pushes recall from 0.72 (dense-only) to 0.91 in measured evaluations
- [ ] Hybrid accuracy: 95% vs BM25 90% vs dense 85%
- [ ] BM25 and dense have complementary blind spots — hybrid covers both
- [ ] BM25 misses semantic matches (paraphrases, synonyms)
- [ ] Dense misses exact keyword matches (acronyms, product names, error codes)
- [ ] Dense embeddings smear rare tokens (EBITDA, CRISPR, Kafka, Kubernetes)
- [ ] BM25 exact token matching catches rare tokens and identifiers
- [ ] RRF uses rank positions, not scores — solves score-incompatibility
- [ ] RRF formula: RRF_score(d) = Σ weight_i / (k + rank_i(d))
- [ ] RRF default k = 60 (reliable starting point)
- [ ] Adjust k to 30-40 if top-1 precision matters more than top-10 recall
- [ ] Per-retriever weighting: catalog/identifier → BM25 0.7, dense 0.3
- [ ] Per-retriever weighting: conceptual/paraphrase → BM25 0.3, dense 0.7
- [ ] Per-retriever weighting: general/mixed → BM25 0.5, dense 0.5 (default)
- [ ] Four-stage pipeline: parallel retrieval → RRF fusion → reranker → LLM
- [ ] Stage 1: query both sparse and dense indexes in parallel (top 50-100 each)
- [ ] Stage 2: merge ranked lists with RRF into single candidate set
- [ ] Stage 3: cross-encoder reranks top 50 fused candidates to top 10
- [ ] Stage 4: pass reranked passages to LLM with citation metadata
- [ ] Dense-only: p50=18ms, p95=45ms
- [ ] Hybrid (BM25+dense, RRF): p50=24ms, p95=58ms (6ms cost)
- [ ] Hybrid + FlashRank rerank: p50=76ms, p95=145ms
- [ ] Hybrid + Cohere Rerank API: p50=230ms, p95=410ms
- [ ] Reranker is the expensive step — profile before deciding
- [ ] Self-hosted cross-encoder on GPU adds tens of milliseconds
- [ ] Cohere Rerank API adds network round-trip
- [ ] Qdrant: native hybrid via Query API with built-in BM25 (v1.10+)
- [ ] Qdrant:
models.FusionQuery(fusion=models.Fusion.RRF) - [ ] Weaviate: hybrid search with selectable fusion type
- [ ] Pinecone: alpha-weighted single-index hybrid
- [ ] Elasticsearch: rrf retriever
- [ ] OpenSearch: native hybrid support
- [ ] Milvus: sparse + dense in same collection
- [ ] Single-system setup: one upsert updates both indexes atomically
- [ ] No need for separate Elasticsearch cluster alongside vector store
- [ ] LangChain: EnsembleRetriever with RRF (k=60 hardcoded)
- [ ] LangChain: weights parameter scales per-retriever contribution
- [ ] LlamaIndex: QueryFusionRetriever handles RRF natively
- [ ] pgvector + BM25Okapi for PostgreSQL-based hybrid search
- [ ] FAISS + BM25Okapi for lightweight hybrid search
- [ ] Dynamic query routing: quoted phrases → sparse, semantic → dense, mixed → hybrid
- [ ] Over-fetch candidates (50-100) for reranking — do not rerank only top_k
- [ ] RRF promotes chunks both retrievers agree on
- [ ] Hybrid-exclusive hits: FAISS finds via semantic, BM25 misses via lexical
- [ ] Profile each stage: retrieval, fusion, reranking, generation
- [ ] Read cosine vs dot product for dense search
- [ ] Add reranking after hybrid fusion
- [ ] Read what is RAG for architecture
- [ ] Build RAG from scratch with hybrid
- [ ] Choose embedding model for dense path
- [ ] Optimize chunk size for both retrieval paths
- [ ] Apply chunking strategies before indexing
- [ ] Prevent hallucinations with grounded retrieval
- [ ] Evaluate with RAG metrics
- [ ] Test: hybrid recall > dense-only recall on domain-specific data
- [ ] Test: RRF produces same ranking regardless of score scales
- [ ] Test: parallel retrieval completes in ~6ms over dense-only
- [ ] Test: query router classifies queries correctly
- [ ] Test: per-retriever weighting improves results for query type
- [ ] Document hybrid configuration: weights, k, over_fetch, reranker
FAQ
What is hybrid search in RAG?
Hybrid search combines sparse keyword retrieval (BM25) with dense vector search, fused via Reciprocal Rank Fusion (RRF). AIWorkflowLab: "The production pattern that has settled the debate: BM25 + dense vectors → RRF fusion → cross-encoder reranker → LLM. Four stages: (1) Parallel candidate generation — query both sparse and dense indexes, retrieving top 50-100 from each. (2) Fusion — merge ranked lists with RRF. (3) Reranking — pass top 50 fused candidates to cross-encoder. (4) Generation — pass reranked passages to LLM with citations." DigitalApplied: "BM25 and dense vector search each have systematic blind spots. Hybrid retrieval fuses both via RRF — a rank-only algorithm that sidesteps the score-incompatibility problem that breaks naively-weighted pipelines." TopReviewed: "Pure vector search tops out around 0.72 recall on domain-specific corpora — hybrid search pushes that to 0.91 in measured evaluations."
How does Reciprocal Rank Fusion (RRF) work?
RRF merges ranked lists by using rank positions instead of scores, solving the problem that BM25 and cosine scores are on incompatible scales. AIWorkflowLab: "RRF sidesteps the problem by ignoring scores entirely. RRF_score(d) = Σ 1/(k + rank_i(d)) where k is typically 60. The default k=60 is a reliable starting point; adjust toward k=30-40 if your use case favors top-1 precision over top-10 recall." DigitalApplied: "RRF operates on ranks not scores, which solves the score-incompatibility problem that makes naive weighted averaging fail in production RAG pipelines." Maanik23: "Instead of normalizing incompatible scores from different retrievers, RRF uses rank position: RRF_score(d) = Σ 1/(k + rank_i(d)). This produces a single ranked list that consistently outperforms either retriever alone." Per-retriever weighting: catalog/identifier queries: BM25 weight 0.7, dense 0.3. Conceptual/paraphrase queries: BM25 0.3, dense 0.7.
What are the latency benchmarks for hybrid search?
Hybrid search adds minimal latency over dense-only, with reranking being the expensive step. AIWorkflowLab: "Dense-only retrieval: p50 = 18ms, p95 = 45ms. Hybrid (BM25 + dense, RRF): p50 = 24ms, p95 = 58ms. The 6ms parallel-retrieval cost is negligible. Hybrid + FlashRank rerank top 50: p50 = 76ms, p95 = 145ms. Hybrid + Cohere Rerank API top 50: p50 = 230ms, p95 = 410ms." TopReviewed: "Two parallel retrieval calls plus RRF fusion plus a reranker must fit inside a response window users will tolerate. Dense retrieval to Qdrant and sparse retrieval to Meilisearch can run concurrently. The reranker is the expensive step — a self-hosted cross-encoder on GPU adds tens of milliseconds. The Cohere Rerank API adds a network round-trip. Profile each stage before deciding whether the reranker is worth it for your latency budget."
Which vector databases support native hybrid search?
Qdrant, Weaviate, Elasticsearch, OpenSearch, and Milvus all support BM25 and dense vectors in the same collection with native RRF fusion. AIWorkflowLab: "Qdrant, Weaviate, Elasticsearch, OpenSearch, and Milvus all support BM25 (or a sparse-vector equivalent) and dense vectors in the same collection, with native RRF fusion. You do not need a separate Elasticsearch cluster alongside your vector store. The single-system setup also simplifies write semantics — one upsert, both indexes update atomically." DigitalApplied: "Qdrant added server-side hybrid fusion in v1.10 via the Query API with built-in RRF via models.FusionQuery(fusion=models.Fusion.RRF). Weaviate hybrid search with selectable fusion type. Pinecone alpha-weighted single-index. Elasticsearch rrf retriever. The vendor landscape has converged on retrieve → fuse → optionally rerank." AnishRaj: "Hybrid (RRF): 95% accuracy, 19/20 hit rate. BM25 only: 90%. Dense only: 85%."
Why does hybrid search outperform dense-only or BM25-only?
Dense and sparse retrieval have complementary blind spots that hybrid search covers. DigitalApplied: "BM25 and dense vector search each have systematic blind spots. BM25 misses semantic matches (paraphrases, synonyms). Dense misses exact keyword matches (acronyms, product names, error codes, version numbers)." AnishRaj: "Why BM25 alongside FAISS? Dense embeddings smear rare tokens. EBITDA, CRISPR, Kafka, Kubernetes map to broad semantic regions but-wrong terms. BM25 exact token matching catches these. The hybrid-exclusive hit demonstrates RRF value: FAISS finds a chunk via semantic similarity while BM25 misses it due to zero lexical overlap. RRF promotes chunks both retrievers agree on." TopReviewed: "Pure vector search tops out around 0.72 recall on domain-specific corpora — hybrid pushes that to 0.91." Maanik23: "Router classifies queries: quoted phrases → sparse, short keywords → sparse, semantic signals → dense, mixed/ambiguous → hybrid." Hybrid: 95% accuracy vs BM25 90% vs dense 85%.
Want a self-hosted AI company brain that does all of this out of the box?
Book a demo →