Skip to Content

Hybrid Search (BM25 + Vector with RRF)

When to use

Combine exact-match lexical search with semantic vector search for the highest-quality retrieval, especially for queries that mix keywords and concepts.

Analogy

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.

Data-flow diagram

  query -> BM25 top-N AND vector top-N
              \          /
               RRF merge (or weighted score)
                       |
                       v
               combined top-K results

Deep explanation

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.

Examples

Example 1

# 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.

Example 2

# 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.

Example 3

# 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.

Common mistake

Normalising scores before RRF. RRF works on ranks; don’t try to combine raw cosine + BM25 directly.

Key takeaway

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.

Production Failure Playbook

Failure scenario 1: score-mixing-broken

Failure scenario 2: alpha-not-tuned