Hybrid Search: BM25 and Dense Retrieval Combined › Code Implementation

Michael BrenndoerferJanuary 29, 202616 min read
Language AI HandbookAll chapters
Part 44 of 67Retrieval-Augmented Generation
Chapter 342Hybrid Search: BM25 and Dense Retrieval Combined › Code Implementation
Reading section 6 of 10 · Code Implementation · 16 minSection 6 of 10 · 16 min

Book contents

Section 6 of 10 · 16 min read

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.

In[7]:
Code
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).

In[8]:
Code
# 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)
Out[9]:
Console
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.

In[10]:
Code
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))
Out[11]:
Console
BM25 index built.
Vocabulary size: 88 unique tokens

BM25Okapi implements the standard BM25 variant with default parameters k1=1.5k_1 = 1.5 and b=0.75b = 0.75. 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.

In[12]:
Code
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.

In[13]:
Code
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.shape
Out[14]:
Console
Corpus 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.

In[15]:
Code
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.

In[16]:
Code
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 α\alpha.

In[17]:
Code
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]

Let us test all three retrieval modes on two contrasting queries: one semantic, one lexical.

In[18]:
Code
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}")
In[19]:
Code
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)
Out[20]:
Console

============================================================
  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 ...
In[21]:
Code
# 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)
Out[22]:
Console

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

Out[23]:
Visualization
Grouped bar chart comparing normalized BM25 scores and dense cosine similarity scores for each of 10 corpus documents on a semantic query, showing the incompatible scales that motivate rank-based fusion.
BM25 and dense retrieval score distributions for a semantic query across all 10 corpus documents. BM25 scores (normalized for display) reflect term-frequency-weighted IDF sums, while cosine similarities are bounded near 1. The two score scales are incompatible for direct summation, motivating rank-based fusion methods like RRF.
Line chart showing RRF score versus rank position from 1 to 50 for k values of 10, 60, and 120. Smaller k produces a steeper decline from rank 1 to rank 2 while larger k flattens the curve.
RRF score decay curves for three values of k (10, 60, 120) across the top 50 rank positions. Smaller k creates a steeper drop from rank 1 to rank 2, amplifying top-rank advantages. Larger k flattens the curve, treating many top-ranked documents more equally. The default k=60 gives a practical balance between these extremes.

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 kk: smaller kk creates a steeper drop from rank 1 to rank 2 (amplifying top-rank advantages), while larger kk flattens the curve (treating many top-ranked documents more equally). The default k=60k = 60 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.

Mean Reciprocal Rank (MRR)

MRR measures how high in the ranked list the first relevant document appears. For a set of queries QQ, MRR is defined as:

MRR=1∣Q∣∑q∈Q1rankq\text{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\text{rank}_q}

where:

  • ∣Q∣|Q|: the total number of queries in the evaluation set
  • rankq\text{rank}_q: the rank position of the first relevant document for query qq in the retrieved list
  • 1/rankq1 / \text{rank}_q: the reciprocal rank for query qq; 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 = (1+1+1+0.5)/4=0.875(1 + 1 + 1 + 0.5) / 4 = 0.875. This makes the metric intuitive: every drop in rank position for the first relevant document is penalized proportionally.

In[24]:
Code
# 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.0
In[25]:
Code
queries = 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))
Out[26]:
Console
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

Out[27]:
Visualization
Grouped bar chart of illustrative MRR and Recall at 5 values for BM25, dense retrieval, RRF hybrid, and weighted fusion with alpha 0.6. The hybrid methods have the highest example scores, with numeric labels above each bar.
Illustrative held-out benchmark profile comparing MRR and Recall@5 across BM25, dense retrieval, RRF hybrid, and weighted fusion (alpha=0.6). The example values show how complementary lexical and semantic evidence can improve both first-relevant-result ranking and top-five coverage. Production systems should replace these values with measurements from their own labeled query set.

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.

In[28]:
Code
# 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
Out[29]:
Visualization
Line chart of illustrative MRR versus alpha from 0 to 1. The dense-favored query curve rises as alpha increases, while the BM25-favored query curve falls, and the curves cross near the middle of the range.
Illustrative MRR sensitivity profile as the alpha mixing parameter moves from pure BM25 to pure dense retrieval. Dense-favored queries improve as alpha rises, while BM25-favored queries decline. Their crossing region shows why a balanced alpha can be a practical compromise for mixed workloads, but production tuning should use a labeled development set.

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 [0,1][0, 1]. 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.