Hybrid search runs two different retrieval methods, almost always BM25 keyword search and dense vector search, against the same query, and merges their two ranked lists into one before anything reaches a reranker or an LLM. Neither method alone is enough: keyword search misses paraphrase and synonymy, dense search blurs exact identifiers and rare tokens. By 2025-2026, running both and fusing the output isn’t a specialized technique reserved for teams with retrieval expertise; it’s the default, out-of-the-box configuration recommended by Qdrant, Weaviate, Elasticsearch, Pinecone, and Azure AI Search alike.
The Blind Spots It Closes
Sparse retrieval (BM25) matches shared vocabulary and is exact, cheap, and unbeatable on product codes, legal citations, or a person’s name, but it cannot connect “heart attack” to “myocardial infarction” because the two share no terms. Dense retrieval matches shared meaning via learned embeddings and generalizes across phrasing, but that same compression can lose an exact SKU or a rare acronym the encoder barely saw during training. These failure modes are near-perfect complements of each other, not overlapping weaknesses, which is what makes fusing the two so effective: a document that either method alone would miss is often caught by the other. See Dense Retrieval vs. Sparse Retrieval for the full mechanics of each side.
Anatomy of a Hybrid Query
graph TB
classDef default fill:#ffffff,stroke:#4338CA,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef data fill:#EEF0F7,stroke:#0D9488,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef process fill:#F7F8FC,stroke:#6366F1,stroke-width:2px,color:#0F172A,rx:8px,ry:8px;
classDef output fill:#4338CA,stroke:#4338CA,stroke-width:2px,color:#ffffff,rx:8px,ry:8px;
Q([User Query]):::data --> SP[Sparse Leg:<br/>BM25 over inverted index]:::process
Q --> DN[Dense Leg:<br/>Embedding + ANN search]:::process
SP -->|Ranked list A| FU[Fusion]:::output
DN -->|Ranked list B| FU
FU -->|Single fused ranking| RR[Optional: Reranker]:::process
RR --> OUT[Final Results]:::output
Both legs run against the same document collection and the same incoming query, independently, and only meet at the fusion step. This is what makes hybrid search additive rather than a trade-off: neither leg has to compromise its own scoring to accommodate the other.
Two Ways to Fuse
| Score-based (weighted) fusion | Rank-based fusion (RRF) | |
|---|---|---|
| Input | Raw similarity/relevance scores | Only rank position |
| Normalization needed | Yes: min-max, z-score, or similar, since BM25 and cosine similarity live on incompatible scales | None |
| Tuning knob | A weight (e.g. Weaviate’s alpha, 0 = pure BM25, 1 = pure dense) | A single constant k (almost always 60) |
| Sensitivity to outliers | Higher; one extreme score can skew the normalized range | Lower; an extreme score doesn’t distort rank position |
| Best when | Scores are well-calibrated and a domain-specific weight has been tuned | You want something that works out of the box, no calibration step |
Score-based fusion is easiest to reason about with a concrete pair of documents in front of you. The widget below fuses a fixed BM25 score and a fixed dense score for four candidates answering an internal engineering query, drag alpha and watch which document actually wins:
Score-based fusion, shown in the widget above and the code example in the frontmatter, is intuitive: normalize each leg’s scores to a common range, then take a weighted sum. Its weakness is that it depends on the normalization holding up across queries, which isn’t guaranteed when score distributions shift. Rank-based fusion sidesteps that entirely by throwing away raw scores and combining only rank position:
# Scenario: fuse a BM25 ranking and a dense ranking using only rank
# position, no score normalization needed
def reciprocal_rank_fusion(bm25_ranked_ids, dense_ranked_ids, k=60):
scores = {}
for rank, doc_id in enumerate(bm25_ranked_ids):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
for rank, doc_id in enumerate(dense_ranked_ids):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
bm25_ranked_ids = ["doc_7", "doc_2", "doc_9"] # BM25's ranking, best first
dense_ranked_ids = ["doc_2", "doc_9", "doc_7"] # dense search's ranking, best first
print(reciprocal_rank_fusion(bm25_ranked_ids, dense_ranked_ids))
# doc_2 and doc_9 rank well in both lists, so RRF pushes them above doc_7,
# which only one method rated first, with no score calibration required.
Reciprocal Rank Fusion (RRF) is the dominant version of this approach and is covered in full there, including the formula’s derivation, the k constant, and why it has become the default in most native hybrid APIs.
Reference Implementations Across Vector Databases
| System | Fusion approach |
|---|---|
| Qdrant | Universal Query API; RRF or Distribution-Based Score Fusion (DBSF) as a fusion parameter, combining dense and sparse (including SPLADE) vectors server-side |
| Weaviate | alpha-weighted score fusion (rankedFusion or relativeScoreFusion), plus an RRF-style mode |
| Elasticsearch / OpenSearch | Native rrf retriever combining a BM25-based retriever with a knn vector retriever in one query |
| Pinecone | Single sparse-dense index; a record holds both a sparse and a dense vector, queried together and combined by weighting |
| Azure AI Search | RRF as the default and only fusion method for hybrid queries |
| Postgres / pgvector stacks | No native fusion primitive; RRF is typically implemented by hand in SQL, joining a pgvector ANN query with a tsvector full-text query |
Collapsing Two Models Into One: BGE-M3 and Unified Encoders
Running hybrid search traditionally means operating two separate models: a BM25 index and a dense embedding model. BGE-M3 and similar unified encoders collapse this into a single forward pass, producing dense, sparse, and multi-vector (ColBERT-style) representations from one model call, so a hybrid pipeline no longer requires hosting and maintaining two entirely different systems. This has made hybrid search meaningfully cheaper to operate, not just more effective, which is part of why adoption accelerated through 2025.
Hybrid Search Plus Reranking: The Three-Stage Pipeline
Fusion produces a single candidate list, but it’s still built from two coarse first-stage rankings. Production RAG and enterprise search systems increasingly treat that fused list as input to a third stage:
| Stage | Purpose |
|---|---|
| Retrieval | Generate a sparse ranking (BM25) and a dense ranking (embeddings) independently |
| Fusion | Combine both into one candidate list, cheaply, with no model inference |
| Reranking | Precisely reorder the fused shortlist with a cross-encoder |
Fusion is essentially free, so it’s applied to a generous candidate pool (top 50-100 from each leg) before the more expensive reranking step narrows that down to the final top-k. “Retrieve, fuse, rerank” is now the standard shape of a high-quality RAG retrieval pipeline:
# Scenario: the full three-stage pipeline, end to end
def hybrid_search_with_rerank(query, bm25_index, dense_index, reranker, doc_store, top_k=5):
bm25_hits = bm25_index.search(query, top_k=100) # stage 1a: sparse leg
dense_hits = dense_index.search(query, top_k=100) # stage 1b: dense leg
fused_ids = reciprocal_rank_fusion(
[hit.id for hit in bm25_hits], [hit.id for hit in dense_hits]
) # stage 2: fusion, no model calls
shortlist = [doc_store[doc_id] for doc_id in fused_ids[:50]]
pairs = [(query, doc.text) for doc in shortlist]
scores = reranker.predict(pairs) # stage 3: precise reordering
return sorted(zip(shortlist, scores), key=lambda x: x[1], reverse=True)[:top_k]
What’s New (2025-2026)
- Hybrid as the default, not the advanced option. Qdrant’s Universal Query API, Weaviate’s hybrid mode, and Elasticsearch’s RRF retriever all reflect the same shift: vector database vendors now recommend hybrid search out of the box rather than treating it as a manual pipeline power users assemble themselves.
- Learned sparse retrieval as the sparse leg. SPLADE and similar learned sparse models moved into production through 2025-2026 as an alternative to raw BM25 on the sparse side, keeping the inverted-index infrastructure while narrowing the vocabulary-mismatch gap that plain BM25 can’t close.
- Multi-vector as a third leg. Some systems now fuse three signals instead of two: dense, sparse, and ColBERT-style multi-vector similarity, all combinable in a single query API call.
- Unified embedding models going mainstream. BGE-M3-style models that emit dense, sparse, and multi-vector representations from one encoder have become the practical default for teams that don’t want to operate a separate BM25 stack alongside a separate embedding service.
Tuning and Practical Guidance
| Scenario | Recommendation |
|---|---|
| Legal, medical, or product-catalog search with exact codes or IDs | Lean sparse: low alpha (weighted fusion) or trust BM25’s contribution heavily |
| Conversational, paraphrased natural-language queries | Lean dense: high alpha, or rely on RRF’s balanced treatment of both legs |
| General-purpose enterprise search or RAG | Start with balanced fusion (alpha ≈ 0.5, or RRF with k = 60) and adjust from evaluation data |
| No labeled data to tune a weight against | RRF; it needs no score calibration and works reasonably well without tuning |
| Multilingual or cross-lingual search | Dense-leaning hybrid with a multilingual encoder, or a BGE-M3-style unified model |
| Tight latency budget | Weighted fusion with cached/precomputed scores, or a single unified encoder to avoid running two model calls |
Hybrid search isn’t a compromise between lexical and semantic retrieval, it’s an acknowledgment that they fail in different, non-overlapping ways. The dominant pattern in 2025-2026 production systems is to stop choosing between BM25 and embeddings altogether, run both, fuse the results, and let a downstream reranker sort out which evidence actually answers the query.
How to Use: Weighted (alpha) score fusion for hybrid search
import numpy as np
def min_max_normalize(scores: np.ndarray) -> np.ndarray:
lo, hi = scores.min(), scores.max()
if hi == lo:
return np.zeros_like(scores)
return (scores - lo) / (hi - lo)
# Raw scores from two different retrieval systems, on incompatible scales
bm25_scores = np.array([12.4, 3.1, 9.8, 0.0]) # unbounded, corpus-dependent
dense_scores = np.array([0.71, 0.85, 0.62, 0.44]) # cosine similarity, bounded [0, 1]
# Normalize each to [0, 1] before combining, otherwise BM25's larger
# numeric range would dominate the fused score regardless of relevance
bm25_norm = min_max_normalize(bm25_scores)
dense_norm = min_max_normalize(dense_scores)
def hybrid_score(alpha: float) -> np.ndarray:
# alpha=1.0 -> pure dense, alpha=0.0 -> pure BM25 (Weaviate's convention)
return alpha * dense_norm + (1 - alpha) * bm25_norm
for alpha in (0.0, 0.5, 1.0):
fused = hybrid_score(alpha)
print(f"alpha={alpha}: {np.round(fused, 3)}")
# See /glossary/reciprocal-rank-fusion-rrf for the rank-based alternative,
# which skips normalization entirely by using rank position instead of raw scores.
Ready to build?
Leverage AI technologies to build your product stack
Superteams can help you build, deploy and launch AI application stacks using open source technologies — from architecture through to production.
Talk to Superteams