Skip to main content

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

StrengthBlind spot
Dense (vectors)meaning, paraphrasesexact terms: codes, names, acronyms
BM25 (keywords)exact tokenssynonyms, 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 k also 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 →