Intermediate9 min read7 of 10

Hybrid Search: Combining Dense, Sparse, and Keyword

How combining three search engines — keyword, sparse, and dense — pushes NDCG from 0.85 to 0.91, with score normalization, weight tuning, and the architecture that makes it fast.

Hybrid Search: Combining Dense, Sparse, and Keyword

No single search method wins on all query types. Dense vectors excel at semantic meaning. Sparse encoding handles exact technical terms. Keyword (BM25) search is fastest and most predictable. Hybrid search combines all three — and the quality improvement is consistent and significant.

EngineNDCG@10 overallExact termsSynonymsConcepts
Keyword (BM25)0.720.950.350.32
Sparse (TF-IDF)0.740.890.520.47
Dense (embeddings)0.850.680.910.93
Hybrid (all three)0.910.960.890.91

The 7% lift from 0.85 to 0.91 is the result of each engine catching what the others miss.

The three engines

Dense and sparse working together

Keyword (BM25): Exact term matching with inverse document frequency weighting. Fastest engine (2ms P95). Dominates on precise, controlled vocabulary queries. Fails completely on paraphrasing.

Sparse (TF-IDF + expansion): Term-weighted sparse vectors. Handles domain vocabulary plus light synonym expansion. 5ms P95. Bridges keyword and semantic.

Dense (embeddings + HNSW): Full semantic understanding. Best for natural language queries, paraphrasing, multi-language. 45ms P95. Fails on exact codes and IDs.

The combination problem

Scores from different engines are not comparable. BM25 scores might range 0–15. Cosine similarity ranges -1 to 1. Before combining, normalize each engine's results to [0, 1]:

normalized_score = (score - min_score) / (max_score - min_score)

Applied per-query, per-engine, over the result set being merged.

Score normalization before combination

The combination formula

After normalization, combine with weighted sum:

combined_score = w_keyword × score_kw + w_sparse × score_sp + w_dense × score_dn Default weights: (0.3, 0.3, 0.4) ← dense gets slightly more weight

Reciprocal Rank Fusion (RRF)

RRF is the industry-standard alternative to weighted sum, and it's the default fusion method used when multiple queries run in parallel in most production search systems.

Instead of combining raw scores, RRF combines rankings. Each engine votes for documents based on their rank position:

RRF_score(doc) = Σ 1 / (k + rank_in_engine_i) where k = 60 (smoothing constant, standard default)

For each engine, a document ranked 1st contributes 1/(60+1) = 0.0164, ranked 10th contributes 1/(60+10) = 0.0143, ranked 100th contributes 1/(60+100) = 0.0063.

Why RRF is robust: it doesn't require score normalization (rank-based, not score-based), it's insensitive to score scale differences between engines, and it naturally down-weights documents that appear in only one engine's results.

Query: "machine learning optimization" Keyword results: rank 1 = Doc A, rank 2 = Doc B, rank 5 = Doc C Dense results: rank 1 = Doc C, rank 2 = Doc A, rank 8 = Doc D RRF scores: Doc A: 1/61 + 1/62 = 0.0325 (top-2 in both → highest) Doc C: 1/65 + 1/61 = 0.0318 (top-5 keyword + top-1 dense) Doc B: 1/62 = 0.0161 (only in keyword) Doc D: 1/68 = 0.0147 (only in dense, ranked 8th)

Documents that appear in both engines and rank highly in both float to the top. Documents only one engine finds rank lower.

[Key Insight] Use RRF when you don't have labeled data to tune weights — it works well out of the box with no calibration. Use weighted sum when you have a labeled evaluation set and want to optimize specifically for your query distribution. In practice, well-tuned weighted sum outperforms RRF by 2–5% NDCG on domain-specific corpora.

Oversampling for better fusion

When merging results from multiple engines, each engine may return different documents in its top-k. A document that ranks 15th in dense search might rank 3rd in keyword search — but if you only asked each engine for top-10, you'd miss it.

Oversampling addresses this: request more candidates from each engine than you need in the final result, fuse them, then return the true top-k:

Final result needed: top-10 Oversample factor: 3–5× Request from each engine: top-30 to top-50 Fuse all candidates (union across engines) Return top-10 after fusion

A 3–5× oversampling factor captures nearly all documents that would rank in the top-10 after fusion. The cost is marginal — you're running 3–5× more scoring comparisons against the inverted indexes, but the HNSW traversal cost (the expensive part) doesn't change.

Combination methods

Six approaches are commonly used:

Comparison of combination methods

MethodFormulaWhen to use
Weighted sumΣ(wi × si)Default — tunable per domain
Arithmetic meanΣsi / nEqual trust in all engines
Harmonic meann / Σ(1/si)Penalizes when engines disagree
Geometric mean(Π si)^(1/n)Requires consensus across engines
Maxmax(si)Any one engine is sufficient
Minmin(si)All engines must agree (conservative)

Weighted sum is the right default. It's interpretable, tunable, and has no degenerate edge cases.

Weight tuning by use case

The default (0.3, 0.3, 0.4) is a starting point. Tune for your query distribution:

Weight tuning across use cases

Use caseKeywordSparseDenseReasoning
E-commerce catalog0.40.30.3SKU/product name precision critical
Technical documentation0.50.30.2Function names, error codes dominate
Customer Q&A0.20.30.5User phrasing varies widely
Knowledge base0.30.30.4Balanced

[Key Insight] Tune weights empirically: collect 200–500 labeled query-result pairs (user clicks, explicit relevance judgments), compute NDCG@10 across a grid of weight combinations, pick the highest. Re-tune every quarter or after a major corpus change.

Architecture

Hybrid search architecture

User query │ ├─► Keyword index (inverted index) ─────────────────────┐ ├─► Sparse encoder + inverted index ────────────────────┤ → Normalize → Combine → Rank └─► Embedding model → HNSW index ──────────────────────┘ Run all three concurrently: wall-clock ~35ms (not 52ms sum)

The three retrieval paths run in parallel. Wall-clock time is dominated by the slowest engine (dense at ~45ms), not the sum of all three.

Strengths of each method

Why it works

Each engine catches what the others miss:

  • Query: "fast concurrent code" → dense finds "parallel programming" (synonym); keyword finds "concurrent.futures" (exact term)
  • Query: "prod error NullPointerException" → keyword/sparse find the exact exception; dense finds related debugging articles
  • Query: "how to reduce memory usage" → dense understands "reduce memory" = "optimize memory"; keyword/sparse find explicit "memory" matches

No single engine covers all three. Together they do.

Performance summary

EngineLatency P95Memory (1M docs)NDCG@10
Keyword2ms200 MB0.72
Sparse5ms300 MB0.74
Dense45ms3 GB0.85
Hybrid35ms3.6 GB0.91

Hybrid is actually faster than dense alone at P95 (35ms vs 45ms) because concurrent execution and early termination reduce tail latency.