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
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)
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)
Hybrid Search: Best of Both Worlds
Hybrid search combines both methods using score fusion:
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 |
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:
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.