16. Hybrid Search
Hybrid search runs dense (vector) and keyword (BM25) retrieval together and fuses the results — covering each method's blind spot.
Why both
| Strength | Blind spot | |
|---|---|---|
| Dense (vectors) | meaning, paraphrases | exact terms: codes, names, acronyms |
| BM25 (keywords) | exact tokens | synonyms, rephrasing |
A query like ERR_4012 on checkout needs the exact code (BM25) and the concept
"checkout error" (dense). Either alone underperforms.
The pipeline
┌─▶ DENSE ─▶ ranked list A ─┐
query ─────────┤ ├─▶ RRF ─▶ fused results
└─▶ BM25 ─▶ ranked list B ─┘
The fusion step is Reciprocal Rank Fusion, which merges the two incompatible score scales by rank.
Code
from rank_bm25 import BM25Okapi
import numpy as np
# BM25 over tokenized chunks
bm25 = BM25Okapi([c["text"].split() for c in chunks])
def hybrid(query, query_vec, k=10):
# dense ranking
dense = sorted(chunks, key=lambda c: float(query_vec @ c["vector"]), reverse=True)
dense_ids = [c["id"] for c in dense[:k]]
# keyword ranking
bm = np.argsort(bm25.get_scores(query.split()))[::-1][:k]
bm25_ids = [chunks[i]["id"] for i in bm]
# fuse
return reciprocal_rank_fusion([dense_ids, bm25_ids])
Practical notes
- Weighting — some stores let you weight dense vs keyword; RRF's
kalso tunes influence. Start balanced. - Almost always a win — for real apps with names/codes/jargon, hybrid beats pure vector search consistently.
Next: Reranking & Next Steps →