Combine exact-match lexical search with semantic vector search for the highest-quality retrieval, especially for queries that mix keywords and concepts.
Looking for a recipe in a cookbook: search by ingredient (BM25 hits ‘cardamom’ exactly) AND by description similarity (vector finds ‘nutty spice mix’ nearby). Hybrid returns both.
query -> BM25 top-N AND vector top-N
\ /
RRF merge (or weighted score)
|
v
combined top-K results
Hybrid search ranks documents by combining a lexical or semantic score. Reciprocal Rank Fusion (RRF) merges two ranked lists by adding 1 / (k + rank_i) for each list; alpha (linear) merges by weighted sum (alpha * vector_score + (1 - alpha) * bm25_score). Use RRF when both ranking signals are already noise-tolerant; use alpha when you want to tune the geometric/lexical tradeoff. Hybrid beats either alone for most enterprise retrieval.
# RRF merge of two ranked lists
def rrf(bm25_ranks, vec_ranks, k=60):
scores = {}
for r, idx in enumerate(bm25_ranks): scores[idx] = scores.get(idx, 0) + 1.0 / (k + r + 1)
for r, idx in enumerate(vec_ranks): scores[idx] = scores.get(idx, 0) + 1.0 / (k + r + 1)
return sorted(scores, key=scores.get, reverse=True)
bm25 = ['c','a','d']; vec = ['a','b','c']
print('RRF top:', rrf(bm25, vec)[:5])
RRF is parameter-light and beats either signal alone for most enterprise queries.
# weighted alpha on raw scores
def hybrid(vec_score, bm25_score, alpha=0.7):
return alpha * vec_score + (1 - alpha) * bm25_score
# alpha=0.7 means vector dominates; alpha=0.3 means BM25 dominates
alpha weighting lets you tune how much the exact term or the semantic similarity matters.
# end-to-end: bm25 + vector + RRF on a corpus
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel
tfidf = TfidfVectorizer().fit_transform(docs)
q = TfidfVectorizer().fit_transform([query])
bm25_ranks = list(linear_kernel(q, tfidf).argsort()[0][-50:][::-1]) # top-50
vec_ranks = index.search(query_emb, k=50).indices[0].tolist() # top-50
final = rrf(bm25_ranks, vec_ranks)[:10]
Top-K from each signal plus RRF is a common production pattern: ~50 from BM25, ~50 from vector, RRF over both for top 10.
Normalising scores before RRF. RRF works on ranks; don’t try to combine raw cosine + BM25 directly.
Hybrid search is a near-universal upgrade. RRF is the default merge; alpha weighting is the tunable. Tune alpha on a golden set with both keyword-heavy and concept-heavy queries.