Hybrid Search: BM25 and Dense Retrieval Combined › Code Implementation
Language AI HandbookAll chapters Hybrid Search: BM25 and Dense Retrieval Combined › Code ImplementationOne section · Sections in this chapter6 / 10 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let us build a hybrid search system step by step. We will use the rank_bm25 library for the BM25 half and sentence-transformers for the dense half, then implement both RRF and weighted fusion.
First, install the required libraries.
import subprocess
subprocess.run(
["uv", "pip", "install", "rank-bm25", "sentence-transformers", "--quiet"],
check=True,
)Setting Up the Corpus
We define a small corpus of documents that tests both semantic and lexical retrieval. Some queries will favor BM25 (exact term matches), others will favor dense retrieval (semantic similarity).
# A small corpus illustrating different retrieval scenarios
corpus = [
"Machine learning models require large amounts of training data to generalize well.",
"Deep neural networks have transformed computer vision with convolutional architectures.",
"Natural language processing lets computers to understand and generate human text.",
"The transformer architecture uses self-attention to model long-range dependencies.",
"BERT pre-training uses masked language modeling to learn bidirectional representations.",
"Retrieval-augmented generation combines search with language model generation.",
"BM25 is a probabilistic ranking function widely used in information retrieval.",
"Vector databases store high-dimensional embeddings for approximate nearest neighbor search.",
"Fine-tuning adapts a pre-trained model to a specific downstream task.",
"Product ID AB-1042-X failed quality inspection due to dimensional tolerances.",
]
doc_ids = list(range(len(corpus)))
corpus_size = len(corpus)Corpus size: 10 documents
The corpus deliberately mixes conceptual machine learning documents with one highly specific product record (document 9). This makes possible us show BM25's strength on exact lexical matches and dense retrieval's strength on semantic paraphrase queries.
BM25 Retrieval
We tokenize the corpus and build a BM25 index using rank_bm25.
from rank_bm25 import BM25Okapi
# Tokenize corpus: lowercase and split on whitespace
tokenized_corpus = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
vocab_size = len(set(tok for doc in tokenized_corpus for tok in doc))BM25 index built. Vocabulary size: 88 unique tokens
BM25Okapi implements the standard BM25 variant with default parameters and . We use simple whitespace tokenization here; production systems would apply stemming and stopword removal for better coverage.
Now we write a BM25 retrieval function that returns ranked document indices with their scores.
def bm25_search(query: str, top_k: int = 5):
"""Return top_k documents ranked by BM25 score."""
tokens = query.lower().split()
scores = bm25.get_scores(tokens)
ranked_indices = np.argsort(scores)[::-1][:top_k]
return [(int(idx), float(scores[idx])) for idx in ranked_indices]Dense Retrieval
We encode the corpus with a sentence-transformer model. In production systems you would pre-compute and store these embeddings, but here we compute them inline to keep the code self-contained.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
# Encode all documents at once (efficient batch encoding)
corpus_embeddings = model.encode(
corpus, normalize_embeddings=True, show_progress_bar=False
)
embeddings_shape = corpus_embeddings.shapeCorpus embeddings shape: (10, 384)
The model produces 384-dimensional normalized embeddings for each document. Because the embeddings are L2-normalized, dot product and cosine similarity are equivalent, which makes retrieval a simple matrix-vector multiplication.
def dense_search(query: str, top_k: int = 5):
"""Return top_k documents ranked by cosine similarity."""
query_embedding = model.encode(query, normalize_embeddings=True)
# Since embeddings are L2-normalized, dot product equals cosine similarity
scores = np.dot(corpus_embeddings, query_embedding)
ranked_indices = np.argsort(scores)[::-1][:top_k]
return [(int(idx), float(scores[idx])) for idx in ranked_indices]Reciprocal Rank Fusion
We now implement RRF. It takes multiple ranked lists, each as a list of (doc_id, score) tuples, and combines them.
def reciprocal_rank_fusion(ranked_lists: list, k: int = 60, top_k: int = 5):
"""
Combine multiple ranked lists using Reciprocal Rank Fusion.
Args:
ranked_lists: List of [(doc_id, score), ...] sorted by descending score
k: Smoothing constant (default 60)
top_k: Number of results to return
Returns:
List of (doc_id, rrf_score) sorted by descending RRF score
"""
rrf_scores = {}
for ranked_list in ranked_lists:
for rank, (doc_id, _score) in enumerate(ranked_list, start=1):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + 1.0 / (k + rank)
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_docs[:top_k]Weighted Score Combination
For weighted fusion, we normalize scores and apply a mixing weight .
def normalize_scores(ranked_list: list) -> dict:
"""Min-max normalize scores from a ranked list. Returns {doc_id: normalized_score}."""
if not ranked_list:
return {}
scores = [score for _, score in ranked_list]
min_score = min(scores)
max_score = max(scores)
score_range = max_score - min_score
if score_range == 0:
return {doc_id: 1.0 for doc_id, _ in ranked_list}
return {
doc_id: (score - min_score) / score_range
for doc_id, score in ranked_list
}
def weighted_fusion(
bm25_results: list, dense_results: list, alpha: float = 0.5, top_k: int = 5
):
"""
Combine BM25 and dense retrieval results using weighted score combination.
Args:
bm25_results: [(doc_id, bm25_score), ...]
dense_results: [(doc_id, dense_score), ...]
alpha: Weight for dense scores (1 - alpha goes to BM25)
top_k: Number of results to return
Returns:
List of (doc_id, combined_score) sorted by descending combined score
"""
bm25_norm = normalize_scores(bm25_results)
dense_norm = normalize_scores(dense_results)
# Collect all unique doc IDs
all_doc_ids = set(bm25_norm.keys()) | set(dense_norm.keys())
combined = {}
for doc_id in all_doc_ids:
b_score = bm25_norm.get(doc_id, 0.0)
d_score = dense_norm.get(doc_id, 0.0)
combined[doc_id] = alpha * d_score + (1 - alpha) * b_score
sorted_docs = sorted(combined.items(), key=lambda x: x[1], reverse=True)
return sorted_docs[:top_k]Running Hybrid Search
Let us test all three retrieval modes on two contrasting queries: one semantic, one lexical.
def display_results(results: list, label: str):
"""Pretty-print retrieval results."""
print(f"\n{'=' * 60}")
print(f" {label}")
print(f"{'=' * 60}")
for rank, (doc_id, score) in enumerate(results, start=1):
snippet = corpus[doc_id][:75] + (
"..." if len(corpus[doc_id]) > 75 else ""
)
print(f" {rank}. [doc {doc_id}] (score: {score:.4f})")
print(f" {snippet}")TOP_K = 5
# Query 1: Semantic: no exact keyword overlap expected with best answer
semantic_query = "how do language models learn to stand for text meaning"
bm25_r1 = bm25_search(semantic_query, top_k=TOP_K)
dense_r1 = dense_search(semantic_query, top_k=TOP_K)
rrf_r1 = reciprocal_rank_fusion([bm25_r1, dense_r1], top_k=TOP_K)
wf_r1 = weighted_fusion(bm25_r1, dense_r1, alpha=0.6, top_k=TOP_K)
============================================================
BM25: 'how do language models learn to stand for text meaning'
============================================================
1. [doc 4] (score: 3.0653)
BERT pre-training uses masked language modeling to learn bidirectional repr...
2. [doc 0] (score: 2.1108)
Machine learning models require large amounts of training data to generaliz...
3. [doc 7] (score: 1.8541)
Vector databases store high-dimensional embeddings for approximate nearest ...
4. [doc 2] (score: 1.1594)
Natural language processing lets computers to understand and generate human...
5. [doc 5] (score: 0.8408)
Retrieval-augmented generation combines search with language model generati...
============================================================
Dense: 'how do language models learn to stand for text meaning'
============================================================
1. [doc 4] (score: 0.4868)
BERT pre-training uses masked language modeling to learn bidirectional repr...
2. [doc 5] (score: 0.4779)
Retrieval-augmented generation combines search with language model generati...
3. [doc 2] (score: 0.4726)
Natural language processing lets computers to understand and generate human...
4. [doc 8] (score: 0.3273)
Fine-tuning adapts a pre-trained model to a specific downstream task.
5. [doc 0] (score: 0.3144)
Machine learning models require large amounts of training data to generaliz...
============================================================
RRF Hybrid: 'how do language models learn to stand for text meaning'
============================================================
1. [doc 4] (score: 0.0328)
BERT pre-training uses masked language modeling to learn bidirectional repr...
2. [doc 0] (score: 0.0315)
Machine learning models require large amounts of training data to generaliz...
3. [doc 5] (score: 0.0315)
Retrieval-augmented generation combines search with language model generati...
4. [doc 2] (score: 0.0315)
Natural language processing lets computers to understand and generate human...
5. [doc 7] (score: 0.0159)
Vector databases store high-dimensional embeddings for approximate nearest ...
============================================================
Weighted (alpha=0.6) Hybrid: 'how do language models learn to stand for text meaning'
============================================================
1. [doc 4] (score: 1.0000)
BERT pre-training uses masked language modeling to learn bidirectional repr...
2. [doc 2] (score: 0.6077)
Natural language processing lets computers to understand and generate human...
3. [doc 5] (score: 0.5690)
Retrieval-augmented generation combines search with language model generati...
4. [doc 0] (score: 0.2284)
Machine learning models require large amounts of training data to generaliz...
5. [doc 7] (score: 0.1822)
Vector databases store high-dimensional embeddings for approximate nearest ...# Query 2: Lexical: exact product code that dense retrieval may miss
lexical_query = "AB-1042-X quality inspection"
bm25_r2 = bm25_search(lexical_query, top_k=TOP_K)
dense_r2 = dense_search(lexical_query, top_k=TOP_K)
rrf_r2 = reciprocal_rank_fusion([bm25_r2, dense_r2], top_k=TOP_K)
wf_r2 = weighted_fusion(bm25_r2, dense_r2, alpha=0.6, top_k=TOP_K)
============================================================
BM25: 'AB-1042-X quality inspection'
============================================================
1. [doc 9] (score: 5.5623)
Product ID AB-1042-X failed quality inspection due to dimensional tolerance...
2. [doc 8] (score: 0.0000)
Fine-tuning adapts a pre-trained model to a specific downstream task.
3. [doc 7] (score: 0.0000)
Vector databases store high-dimensional embeddings for approximate nearest ...
4. [doc 6] (score: 0.0000)
BM25 is a probabilistic ranking function widely used in information retriev...
5. [doc 5] (score: 0.0000)
Retrieval-augmented generation combines search with language model generati...
============================================================
Dense: 'AB-1042-X quality inspection'
============================================================
1. [doc 9] (score: 0.8179)
Product ID AB-1042-X failed quality inspection due to dimensional tolerance...
2. [doc 6] (score: 0.1952)
BM25 is a probabilistic ranking function widely used in information retriev...
3. [doc 3] (score: 0.1142)
The transformer architecture uses self-attention to model long-range depend...
4. [doc 1] (score: 0.0462)
Deep neural networks have transformed computer vision with convolutional ar...
5. [doc 7] (score: 0.0408)
Vector databases store high-dimensional embeddings for approximate nearest ...
============================================================
RRF Hybrid: 'AB-1042-X quality inspection'
============================================================
1. [doc 9] (score: 0.0328)
Product ID AB-1042-X failed quality inspection due to dimensional tolerance...
2. [doc 6] (score: 0.0318)
BM25 is a probabilistic ranking function widely used in information retriev...
3. [doc 7] (score: 0.0313)
Vector databases store high-dimensional embeddings for approximate nearest ...
4. [doc 8] (score: 0.0161)
Fine-tuning adapts a pre-trained model to a specific downstream task.
5. [doc 3] (score: 0.0159)
The transformer architecture uses self-attention to model long-range depend...
============================================================
Weighted (alpha=0.6) Hybrid: 'AB-1042-X quality inspection'
============================================================
1. [doc 9] (score: 1.0000)
Product ID AB-1042-X failed quality inspection due to dimensional tolerance...
2. [doc 6] (score: 0.1192)
BM25 is a probabilistic ranking function widely used in information retriev...
3. [doc 3] (score: 0.0567)
The transformer architecture uses self-attention to model long-range depend...
4. [doc 1] (score: 0.0042)
Deep neural networks have transformed computer vision with convolutional ar...
5. [doc 5] (score: 0.0000)
Retrieval-augmented generation combines search with language model generati...For the semantic query, dense retrieval ranks the BERT document (doc 4) near the top because it understands that "bidirectional representations" relates to "stand for text meaning," even though the words do not overlap. BM25 ranks it lower because the query terms are not present verbatim. For the product code query, BM25 immediately surfaces doc 9, which contains "AB-1042-X" exactly. The hybrid systems inherit the best of both behaviors.
Visualizing Score Distributions and Fusion
Let us visualize how the BM25 and dense scores distribute across documents for a single query, and how RRF flattens the rank-score curve compared to raw scores.


The left panel shows that BM25 and dense scores for the same query are not directly comparable: BM25 scores reflect term-frequency-weighted IDF sums while cosine similarities are bounded between -1 and 1 with a very different distribution. This is exactly why naive score addition fails. The right panel illustrates the RRF curve for three values of : smaller creates a steeper drop from rank 1 to rank 2 (amplifying top-rank advantages), while larger flattens the curve (treating many top-ranked documents more equally). The default strikes a practical balance.
Evaluating Hybrid Retrieval Quality
To quantitatively assess hybrid search, we compute Mean Reciprocal Rank (MRR) and Recall@K. We need ground-truth relevance judgments. For this example we define them manually.
MRR measures how high in the ranked list the first relevant document appears. For a set of queries , MRR is defined as:
where:
- : the total number of queries in the evaluation set
- : the rank position of the first relevant document for query in the retrieved list
- : the reciprocal rank for query ; this equals 1 when the relevant document is ranked first, 0.5 when it is ranked second, and so on
A perfect system reaches MRR = 1.0, meaning the first relevant document is always ranked first. If no relevant document appears in the retrieved list for a query, that query contributes 0 to the sum.
MRR is particularly well-suited for tasks where there is a single correct answer or a small set of highly relevant documents, and the user's primary goal is to find any relevant document quickly. If a relevant document is first in the ranked list for 3 out of 4 queries but ranked 2nd for one query, MRR = . This makes the metric intuitive: every drop in rank position for the first relevant document is penalized proportionally.
# Ground truth: for each query, which document indices are relevant
ground_truth = {
"how do language models learn to stand for text meaning": {4}, # BERT doc
"AB-1042-X quality inspection": {9}, # Product ID doc
"transformer self-attention mechanism": {3}, # Transformer doc
"approximate nearest neighbor vector search": {7}, # Vector DB doc
}
def recall_at_k(results: list, relevant_docs: set, k: int = 5) -> float:
"""Fraction of relevant docs found in top-k results."""
retrieved_ids = {doc_id for doc_id, _ in results[:k]}
return len(retrieved_ids & relevant_docs) / len(relevant_docs)
def reciprocal_rank(results: list, relevant_docs: set) -> float:
"""Reciprocal of rank of first relevant document."""
for rank, (doc_id, _) in enumerate(results, start=1):
if doc_id in relevant_docs:
return 1.0 / rank
return 0.0queries = list(ground_truth.keys())
methods = {"BM25": [], "Dense": [], "RRF Hybrid": [], "Weighted (a=0.6)": []}
for query in queries:
relevant = ground_truth[query]
b_res = bm25_search(query, top_k=10)
d_res = dense_search(query, top_k=10)
r_res = reciprocal_rank_fusion([b_res, d_res], top_k=10)
w_res = weighted_fusion(b_res, d_res, alpha=0.6, top_k=10)
for label, results in [
("BM25", b_res),
("Dense", d_res),
("RRF Hybrid", r_res),
("Weighted (a=0.6)", w_res),
]:
rr = reciprocal_rank(results, relevant)
r5 = recall_at_k(results, relevant, k=5)
methods[label].append((rr, r5))Method MRR Recall@5 --------------------------------------------- BM25 1.0000 1.0000 Dense 1.0000 1.0000 RRF Hybrid 1.0000 1.0000 Weighted (a=0.6) 1.0000 1.0000
All four methods score perfectly on this ten-document toy corpus. That is useful as an implementation sanity check, but it is not a meaningful quality comparison: with one relevant document per query and only ten candidates, the task is too easy to expose complementary errors. The figure below therefore uses an explicitly illustrative held-out benchmark profile to show the trade-off that a real labeled evaluation should measure. In production, replace these example values with metrics from a representative query set.
Visualizing Fusion Performance

Visualizing Alpha Sensitivity in Weighted Fusion
The alpha parameter in weighted fusion controls the balance between lexical and semantic signals. Because the tiny live corpus above saturates, the following sensitivity profile is explicitly illustrative: it shows the common pattern of dense-favored queries improving as alpha rises while BM25-favored queries decline. A production system should generate this curve by sweeping alpha on a labeled development set.
# Build an illustrative MRR sensitivity profile as alpha varies from 0 to 1.
alpha_vals = np.linspace(0, 1, 41)
dense_favored_mrr = 0.56 + 0.31 * (1 - np.exp(-2.4 * alpha_vals)) / (
1 - np.exp(-2.4)
)
bm25_favored_mrr = 0.89 - 0.29 * alpha_vals**1.35
The illustrative sensitivity plot reveals the key tuning trade-off. BM25-favored queries reach their highest MRR near alpha 0, while dense-favored queries improve as more weight shifts to the dense score. A system serving a mixed workload should therefore tune alpha on a representative sample of labeled queries rather than defaulting to 0.5. If the workload skews toward exact identifiers and lexical lookups, a lower alpha is often safer. If it skews toward paraphrased natural-language questions, a higher alpha may work better.
Key Parameters
The key parameters for building and tuning a hybrid search system are:
- k1 (BM25): Term frequency saturation constant, typically 1.2 to 1.5. Higher values allow term frequency to contribute more before saturating; lower values saturate faster and give more equal weight to each term occurrence.
- b (BM25): Length normalization strength, typically 0.75. A value of 1.0 applies full length normalization; 0.0 disables it entirely.
- k (RRF): Smoothing constant for reciprocal rank fusion, typically 60. Larger values flatten the rank-score curve, giving more equal credit to documents ranked anywhere in the top list.
- alpha (weighted fusion): Mixing weight for dense scores, in . Values near 0.5 to 0.7 often work well on mixed query benchmarks; tune on labeled data specific to your domain.
- top_k: Number of candidates to retrieve from each system before fusion. Larger values increase recall but also increase fusion computation time.