AI & Machine Learning

Hybrid Search: Combining BM25 and Vector Search

Python NLP RAG Databases Java NumPy Data Science Transformers Embeddings Vector Search Semantic Search Hybrid Search
1,518 words Includes Code

Hybrid Search: Combining BM25 and Vector Search

How to combine lexical and semantic retrieval for better RAG systems — with Python implementation

🎯 Key Takeaway: BM25 finds exact keyword matches. Vector search finds semantic meaning. Hybrid search combines both, typically improving precision by 15-25% over either method alone.

You search for "Python exception handling."

BM25 finds documents containing "exception" and "handling" — exact keyword matches.

Vector search finds documents about "error handling with try/except blocks" — semantically similar but different words.

Hybrid search finds both.

This is why hybrid search is the standard for production RAG systems. It combines the precision of keyword matching with the understanding of semantic search.

Architecture Overview

Hybrid search architecture showing BM25 and vector search paths with score fusion
Figure 1: Hybrid Search Architecture — BM25 + Vector + Score Fusion

BM25: Lexical Search

BM25 (Best Matching 25) is a ranking function based on term frequency and inverse document frequency (TF-IDF).

How BM25 Works

# BM25 scoring formula (simplified)
score(q, d) = Σ IDF(qi) * (tf(qi, d) * (k1 + 1)) / (tf(qi, d) + k1 * (1 - b + b * |d|/avgdl))

Where:
- IDF(qi) = Inverse Document Frequency of term qi
- tf(qi, d) = Term frequency in document d
- k1 = Term frequency saturation (typically 1.2-2.0)
- b = Length normalization (typically 0.75)
- |d| = Document length
- avgdl = Average document length

BM25 Characteristics

Aspect BM25
Strengths Fast, precise, explainable, handles rare terms well
Weaknesses No semantic understanding, vocabulary mismatch
Best for Exact keywords, product names, codes, IDs

Example

# BM25 finds exact matches
query = "Python exception handling"

# BM25 scores:
doc1: "How to handle Python exceptions" → score: 0.95 (contains all terms)
doc2: "Error handling in Python" → score: 0.30 (no "exception")
doc3: "Java exception handling" → score: 0.40 (wrong language)
💡 Key Insight: BM25 is excellent for exact matches but fails when the query uses different words for the same concept.

Vector Search: Semantic Retrieval

Vector search uses embeddings to find documents with similar meaning, even if they use different words.

How Vector Search Works

# Vector search uses embeddings
query = "Python exception handling"
query_embedding = embedding_model.encode(query)  # [0.12, 0.34, ...]

# Compare with document embeddings
doc1_embedding = embedding_model.encode("How to handle Python exceptions")
similarity = cosine_similarity(query_embedding, doc1_embedding)  # 0.92

doc2_embedding = embedding_model.encode("Error handling in Python")
similarity = cosine_similarity(query_embedding, doc2_embedding)  # 0.88

Vector Search Characteristics

Aspect Vector Search
Strengths Semantic understanding, handles synonyms, paraphrases
Weaknesses Misses exact terms, slower, less explainable
Best for Conceptual queries, natural language, synonyms

Example

# Vector search finds semantic matches
query = "Python exception handling"

# Vector search scores:
doc1: "How to handle Python exceptions" → score: 0.92 (semantic match)
doc2: "Error handling in Python" → score: 0.88 (semantically similar!)
doc3: "Java exception handling" → score: 0.75 (similar concept, wrong language)
💡 Key Insight: Vector search understands that "error handling" and "exception handling" are similar concepts, but may miss exact keyword matches.

Hybrid Search: Best of Both Worlds

Hybrid search combines both methods using score fusion:

Hybrid search combining BM25 and vector search with score fusion
Figure 2: Hybrid Search — Combining Lexical and Semantic Retrieval

Score Fusion Methods

Method How It Works Pros/Cons
Reciprocal Rank Fusion (RRF) Combine ranks, not scores Most common, robust
Weighted Average Weight scores by importance Simple, requires tuning
Convex Combination Linear combination of scores Flexible, needs normalization

RRF Formula

# Reciprocal Rank Fusion (RRF)
def rrf_score(rank, k=60):
    """RRF score for a given rank"""
    return 1.0 / (k + rank)

# Combine BM25 and vector ranks
def hybrid_score(bm25_rank, vector_rank, k=60):
    """Combined RRF score"""
    return rrf_score(bm25_rank, k) + rrf_score(vector_rank, k)

# Example
# BM25 ranks: doc1=1, doc2=3, doc3=2
# Vector ranks: doc1=2, doc2=1, doc3=3

hybrid_scores = {
    'doc1': rrf_score(1) + rrf_score(2) = 0.0164 + 0.0161 = 0.0325,
    'doc2': rrf_score(3) + rrf_score(1) = 0.0159 + 0.0164 = 0.0323,
    'doc3': rrf_score(2) + rrf_score(3) = 0.0161 + 0.0159 = 0.0320
}

# Final ranking: doc1 > doc2 > doc3

Python Implementation

Here's a complete hybrid search implementation:

import numpy as np
from typing import List, Dict, Tuple
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
import chromadb

class HybridSearch:
    """
    Hybrid search combining BM25 and vector search.
    """
    
    def __init__(self, embedding_model: str = "nomic-embed-text"):
        # Initialize components
        self.bm25 = None
        self.vector_store = chromadb.Client()
        self.collection = self.vector_store.create_collection("docs")
        self.embedding_model = SentenceTransformer(embedding_model)
        self.documents = []
    
    def add_documents(self, documents: List[str]):
        """Add documents to both indexes."""
        self.documents = documents
        
        # BM25 index
        tokenized_docs = [doc.lower().split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized_docs)
        
        # Vector index
        embeddings = self.embedding_model.encode(documents)
        self.collection.add(
            documents=documents,
            embeddings=embeddings.tolist(),
            ids=[f"doc_{i}" for i in range(len(documents))]
        )
    
    def bm25_search(self, query: str, k: int = 10) -> List[Tuple[int, float]]:
        """BM25 lexical search."""
        tokenized_query = query.lower().split()
        scores = self.bm25.get_scores(tokenized_query)
        
        # Get top-k indices
        top_k_idx = np.argsort(scores)[::-1][:k]
        return [(idx, scores[idx]) for idx in top_k_idx]
    
    def vector_search(self, query: str, k: int = 10) -> List[Tuple[int, float]]:
        """Vector semantic search."""
        query_embedding = self.embedding_model.encode([query])
        
        results = self.collection.query(
            query_embeddings=query_embedding.tolist(),
            n_results=k
        )
        
        # Extract indices and distances
        indices = [int(id.split('_')[1]) for id in results['ids'][0]]
        distances = [1 - d for d in results['distances'][0]]  # Convert to similarity
        
        return list(zip(indices, distances))
    
    def rrf_score(self, rank: int, k: int = 60) -> float:
        """Reciprocal Rank Fusion score."""
        return 1.0 / (k + rank)
    
    def hybrid_search(
        self, 
        query: str, 
        k: int = 10,
        bm25_weight: float = 0.5,
        vector_weight: float = 0.5
    ) -> List[Dict]:
        """
        Hybrid search combining BM25 and vector search.
        """
        # Get results from both methods
        bm25_results = self.bm25_search(query, k=k*2)
        vector_results = self.vector_search(query, k=k*2)
        
        # Calculate RRF scores
        doc_scores = {}
        
        for rank, (idx, _) in enumerate(bm25_results, 1):
            doc_scores[idx] = doc_scores.get(idx, 0) + self.rrf_score(rank)
        
        for rank, (idx, _) in enumerate(vector_results, 1):
            doc_scores[idx] = doc_scores.get(idx, 0) + self.rrf_score(rank)
        
        # Sort by combined score
        ranked_docs = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
        
        # Return top-k with scores
        results = []
        for idx, score in ranked_docs[:k]:
            results.append({
                'document': self.documents[idx],
                'score': score,
                'index': idx
            })
        
        return results

# Example usage
search = HybridSearch()

documents = [
    "Python exception handling with try/except blocks",
    "Error handling best practices in Python",
    "Java exception handling tutorial",
    "Python debugging techniques",
    "How to catch exceptions in Python"
]

search.add_documents(documents)

# Hybrid search
results = search.hybrid_search("Python exception handling", k=3)

for r in results:
    print(f"Score: {r['score']:.4f} | {r['document']}")

Expected Output

Score: 0.0325 | Python exception handling with try/except blocks
Score: 0.0323 | How to catch exceptions in Python
Score: 0.0320 | Error handling best practices in Python

BM25 vs Vector vs Hybrid

Query Type BM25 Vector Hybrid
"Python exception" ✅ Finds exact ✅ Finds similar ✅ Best
"How to handle errors" ⚠️ May miss ✅ Finds similar ✅ Best
"Error handling patterns" ❌ Misses ✅ Finds similar ✅ Best
"product-12345" ✅ Exact match ❌ May not find ✅ Best
"explain like I'm 5" ❌ Misses ✅ Understands ✅ Best
💡 Research Finding: Hybrid search with RRF fusion achieves 91% recall@10 without reranking, compared to 78% for vector-only and 72% for BM25-only.

Production Implementation

With Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, Distance, SparseVectorParams,
    NamedSparseVector, NamedVector
)

# Initialize Qdrant with hybrid search support
client = QdrantClient(":memory:")

# Create collection with both dense and sparse vectors
client.create_collection(
    collection_name="documents",
    vectors_config={
        "dense": VectorParams(size=768, distance=Distance.COSINE)
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams()
    }
)

# Hybrid search query
from qdrant_client.models import FusionQuery, Prefetch

results = client.query_points(
    collection_name="documents",
    query=FusionQuery(
        prefetch=[
            Prefetch(
                query=dense_embedding,
                using="dense",
                limit=20
            ),
            Prefetch(
                query=sparse_vector,
                using="sparse",
                limit=20
            )
        ]
    ),
    limit=10
)

With Elasticsearch

# Elasticsearch hybrid search
from elasticsearch import Elasticsearch

es = Elasticsearch()

# Hybrid search query
query = {
    "query": {
        "bool": {
            "should": [
                {"match": {"content": "Python exception handling"}},
                {"knn": {
                    "field": "embedding",
                    "query_vector": query_embedding,
                    "k": 10
                }}
            ]
        }
    }
}

results = es.search(index="documents", body=query)

When to Use Hybrid Search

Use Hybrid When:

  • Queries contain both keywords and natural language
  • You need to find exact matches AND similar concepts
  • Precision is critical (production RAG)
  • Users search with mixed terminology

Use BM25 Only When:

  • Search is primarily keyword-based (product codes, IDs)
  • Speed is critical and semantic understanding isn't needed
  • Vocabulary is standardized

Use Vector Only When:

  • Queries are purely conceptual
  • Synonyms and paraphrases are common
  • Exact keyword matching isn't important

Try It Yourself

Implement hybrid search with these BestWordz resources:

🔍 Search Tools

BestWordz regex and search tools

Explore Tools →

🏗️ RAG Architecture

Complete guide to RAG components

Learn More →

🎯 Reranking in RAG

Improve retrieval precision

Read Guide →

📊 RAG Evaluation

Measure retrieval quality

Explore →

Conclusion

Hybrid search combines the best of both worlds:

  • BM25: Fast, precise, handles exact terms
  • Vector search: Semantic understanding, handles synonyms
  • Hybrid: Best of both, typically +15-25% precision

The implementation is straightforward:

# 1. Search with both methods
bm25_results = bm25_search(query, k=20)
vector_results = vector_search(query, k=20)

# 2. Combine with RRF
combined = rrf_fusion(bm25_results, vector_results)

# 3. Return top-k
final_results = combined[:5]

For production RAG, hybrid search is the standard. It's not more complex than single-method search, but it's significantly more effective.

Further Reading

Continue Learning: RAG Fundamentals

From embeddings to production RAG systems

  1. The Five Types of Agent Memory
  2. Why RAG Exists: The Hallucination Problem
  3. What Are Embeddings?
  4. Hybrid Search: Combining BM25 and Vector Search (this article)
  5. RAG Architecture Explained: Every Component of a Retrieval-Augmented AI System

💬 Discuss on BestWordz Community

Join the conversation about Python, NLP, RAG on the BestWordz Community forum.

Visit Forum →