HinterBuild logoHinterBuild
AI Systems · 9 min read

Hybrid Search: BM25 + Vector Search for Production RAG

Hybrid Search guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 9 min read

  • RAG
  • Embeddings
  • Vector Databases
  • Evaluation

Table of Contents:

Why Hybrid Search Beats Pure Vector Search

Short answer: Hybrid search combining BM25 and vector search fixes the two biggest RAG retrieval failures — missing exact keyword matches and returning semantically similar but factually wrong chunks — improving precision@5 by 15-25% over pure vector search alone.

Pure vector search finds semantically similar text. It fails on exact matches: error codes, product SKUs, legal citations, person names, and domain-specific terminology that embeddings treat as noise. BM25 keyword search finds exact token matches but misses paraphrases and synonyms.

Together, they cover each other's blind spots. This is why every production RAG system we build at HinterBuild uses hybrid search as a baseline — before reranking, before query transformation, before any advanced technique.

This guide covers hybrid search with BM25 and vector search for RAG retrieval. For the full retrieval pipeline, see why RAG pipelines fail. For vector database selection, see our Qdrant vs Pinecone vs pgvector comparison.

Key Takeaways:

  • Pure vector search misses exact keyword matches 30-40% of the time on technical content
  • BM25 excels at exact matches, rare terms, and proper nouns
  • Reciprocal Rank Fusion (RRF) is the standard method for combining ranked result lists
  • Default weights (0.7 vector, 0.3 BM25) work for most content; tune with your evaluation set
  • Hybrid search adds 10-30ms latency — negligible compared to reranking (50-150ms)
  • Always hybrid search before adding reranking — it is the highest-ROI retrieval improvement

How BM25 Keyword Search Works

Short answer: BM25 (Best Matching 25) is a probabilistic keyword ranking algorithm that scores documents by term frequency, inverse document frequency, and document length normalization — finding exact token matches that vector search misses.

BM25 Scoring Intuition

BM25 asks: "How important is each query term in each document?"

  • Term frequency (TF): How often does the query term appear in the document? More occurrences = higher score, with diminishing returns.
  • Inverse document frequency (IDF): How rare is the term across all documents? Rare terms (error codes, product names) score higher than common words ("the", "is").
  • Length normalization: Shorter documents with the query term score higher than long documents that mention it once.
python
import math
from collections import Counter
from dataclasses import dataclass

@dataclass
class BM25Config:
    k1: float = 1.5      # Term frequency saturation
    b: float = 0.75      # Length normalization strength

class BM25Index:
    def __init__(self, config: BM25Config = BM25Config()):
        self.config = config
        self.documents: dict[str, str] = {}
        self.doc_lengths: dict[str, int] = {}
        self.avg_doc_length: float = 0
        self.term_doc_freq: Counter = Counter()
        self.doc_term_freq: dict[str, Counter] = {}
        self.total_docs: int = 0

    def add_document(self, doc_id: str, text: str):
        tokens = self._tokenize(text)
        self.documents[doc_id] = text
        self.doc_lengths[doc_id] = len(tokens)
        self.doc_term_freq[doc_id] = Counter(tokens)
        
        unique_terms = set(tokens)
        for term in unique_terms:
            self.term_doc_freq[term] += 1
        
        self.total_docs += 1
        self.avg_doc_length = (
            sum(self.doc_lengths.values()) / self.total_docs
        )

    def search(self, query: str, top_k: int = 10) -> list[dict]:
        query_tokens = self._tokenize(query)
        scores: dict[str, float] = {}
        
        for doc_id in self.documents:
            score = 0.0
            doc_len = self.doc_lengths[doc_id]
            term_freqs = self.doc_term_freq[doc_id]
            
            for term in query_tokens:
                if term not in term_freqs:
                    continue
                
                tf = term_freqs[term]
                df = self.term_doc_freq[term]
                idf = math.log(
                    (self.total_docs - df + 0.5) / (df + 0.5) + 1
                )
                
                tf_norm = (tf * (self.config.k1 + 1)) / (
                    tf + self.config.k1 * (
                        1 - self.config.b
                        + self.config.b * doc_len / self.avg_doc_length
                    )
                )
                score += idf * tf_norm
            
            if score > 0:
                scores[doc_id] = score
        
        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [
            {
                "id": doc_id,
                "score": score,
                "text": self.documents[doc_id],
            }
            for doc_id, score in ranked[:top_k]
        ]

    @staticmethod
    def _tokenize(text: str) -> list[str]:
        return text.lower().split()

BM25 vs Vector Search: Complementary Strengths

Query TypeBM25 ResultVector Search Result
"error code E-4521"✅ Exact match❌ Returns "error handling" docs
"how to reset password"⚠️ Misses "credential recovery"✅ Semantic match
"John Smith refund case"✅ Exact name match⚠️ May match other "refund" docs
"what happens when server crashes"⚠️ Misses "system failure recovery"✅ Semantic match
"Section 4.2.1 compliance"✅ Exact citation❌ Returns general compliance docs
"similar products to Widget Pro"❌ No keyword overlap✅ Semantic similarity

For production BM25, use battle-tested libraries: Tantivy (Rust, via tantivy-py), Elasticsearch, OpenSearch, or rank_bm25 (Python, good for prototyping).

Our backend API engineering team builds BM25 indexes alongside vector stores in every RAG ingestion pipeline.


Where Vector Search Falls Short

Short answer: Vector search fails on exact identifiers, rare domain terms, and queries where users use different vocabulary than documents — the three most common RAG retrieval failure modes in production.

Failure Mode 1: Exact Identifiers

Users search for specific codes, IDs, and references. Embeddings treat "E-4521" and "E-4522" as nearly identical vectors. BM25 treats them as completely different terms.

Query: "ticket INC-88421 status"
Vector top result: "How to create a support ticket" (similar topic)
BM25 top result: "INC-88421: Resolved — database migration completed" (exact match)
Hybrid top result: INC-88421 document (BM25 boost + vector confirmation)

Failure Mode 2: Rare Domain Terms

Embedding models trained on general text underweight domain-specific terminology. A medical RAG system searching for "idiopathic pulmonary fibrosis" may return general lung disease documents instead of IPF-specific guidelines.

BM25 gives high IDF scores to rare terms — "idiopathic" and "fibrosis" appear in few documents, so documents containing both rank highest.

Failure Mode 3: Vocabulary Mismatch

Users say "cancel subscription." Documents say "terminate service agreement." Vector search handles this well. But users also say "E-4521" while documents say "Error 4521: Connection timeout" — vector search fails, BM25 succeeds.

This is why embedding model selection matters — but even the best embeddings cannot replace keyword matching for exact terms.

Understand the full retrieval debugging workflow in our RAG pipeline guide.


Reciprocal Rank Fusion (RRF) Implementation

Short answer: Reciprocal Rank Fusion (RRF) combines BM25 and vector search ranked lists by summing reciprocal ranks — a robust fusion method that does not require score normalization between incompatible scoring systems.

Why Not Just Add Scores?

BM25 scores range from 0 to 30+. Vector cosine similarity ranges from -1 to 1. You cannot add them directly. RRF ignores raw scores and uses rank positions instead:

RRF_score(doc) = Σ  1 / (k + rank_i)

Where k is a constant (typically 60) and rank_i is the document's rank in each result list.

Production Hybrid Search Implementation

python
from dataclasses import dataclass
from typing import Optional

@dataclass
class HybridSearchConfig:
    vector_weight: float = 0.7
    bm25_weight: float = 0.3
    rrf_k: int = 60
    top_k_retrieve: int = 20
    top_k_final: int = 10

class HybridRetriever:
    def __init__(
        self,
        vector_store,
        bm25_index: BM25Index,
        embed_pipeline,
        config: HybridSearchConfig = HybridSearchConfig(),
    ):
        self.vector_store = vector_store
        self.bm25_index = bm25_index
        self.embed_pipeline = embed_pipeline
        self.config = config

    async def search(
        self,
        query: str,
        top_k: Optional[int] = None,
        filters: Optional[dict] = None,
    ) -> list[dict]:
        top_k = top_k or self.config.top_k_final
        retrieve_k = self.config.top_k_retrieve
        query_embedding = await self.embed_pipeline.embed_query(query)
        
        vector_results, bm25_results = await asyncio.gather(
            self.vector_store.similarity_search(
                embedding=query_embedding,
                top_k=retrieve_k,
                filter=filters,
            ),
            asyncio.to_thread(
                self.bm25_index.search, query, retrieve_k
            ),
        )

        # Tag results with source
        for r in vector_results:
            r["source"] = "vector"
            r["vector_score"] = r.get("score", 0)
        for r in bm25_results:
            r["source"] = "bm25"
            r["bm25_score"] = r.get("score", 0)

        # Reciprocal Rank Fusion
        fused = self._reciprocal_rank_fusion(
            [vector_results, bm25_results],
            [self.config.vector_weight, self.config.bm25_weight],
        )

        return fused[:top_k]

    def _reciprocal_rank_fusion(
        self,
        result_lists: list[list[dict]],
        weights: list[float],
    ) -> list[dict]:
        scores: dict[str, float] = {}
        doc_map: dict[str, dict] = {}

        for results, weight in zip(result_lists, weights):
            for rank, item in enumerate(results):
                doc_id = item["id"]
                scores[doc_id] = scores.get(doc_id, 0) + (
                    weight / (self.config.rrf_k + rank + 1)
                )
                if doc_id not in doc_map:
                    doc_map[doc_id] = item
                else:
                    doc_map[doc_id].update({
                        k: v for k, v in item.items()
                        if k not in doc_map[doc_id]
                    })

        ranked_ids = sorted(
            scores.keys(), key=lambda x: scores[x], reverse=True
        )
        return [
            {**doc_map[doc_id], "rrf_score": scores[doc_id]}
            for doc_id in ranked_ids
        ]

Alternative Fusion Methods

MethodProsConsWhen to Use
RRFNo score normalization needed, robustIgnores score magnitudeDefault choice
Weighted sumUses score magnitudeRequires normalizationWhen scores are calibrated
CombSUMSimple addition after normalizationSensitive to score distributionResearch/benchmarking
Cross-encoder rerankHighest quality50-150ms added latencyAfter hybrid search

Use RRF as your default. Add cross-encoder reranking after fusion for production quality — see advanced RAG techniques.


Weight Tuning and Score Normalization

Short answer: Start with 0.7 vector / 0.3 BM25 weights for general content; increase BM25 weight to 0.4-0.5 for technical docs with codes and identifiers; tune with your evaluation set, not intuition.

Weight Tuning by Content Type

Content TypeVector WeightBM25 WeightReason
General documentation0.700.30Semantic matching dominates
Technical support / error codes0.500.50Exact matches critical
Legal / compliance0.550.45Citations and clause numbers
E-commerce / product catalog0.600.40Product names and SKUs
Conversational / FAQ0.750.25Paraphrased questions
Medical / scientific0.500.50Rare terminology

Automated Weight Tuning

python
async def tune_hybrid_weights(
    test_cases: list[dict],
    retriever_factory,
    weight_range: list[tuple[float, float]] = None,
) -> dict:
    """Find optimal vector/BM25 weights using evaluation set."""
    if weight_range is None:
        weight_range = [
            (0.9, 0.1), (0.8, 0.2), (0.7, 0.3),
            (0.6, 0.4), (0.5, 0.5), (0.4, 0.6),
        ]
    
    best_weights = (0.7, 0.3)
    best_precision = 0.0
    
    for vec_w, bm25_w in weight_range:
        config = HybridSearchConfig(
            vector_weight=vec_w,
            bm25_weight=bm25_w,
        )
        retriever = retriever_factory(config)
        
        precisions = []
        for case in test_cases:
            results = await retriever.search(case["query"], top_k=5)
            retrieved_ids = {r["id"] for r in results}
            expected_ids = set(case["expected_doc_ids"])
            hits = len(retrieved_ids & expected_ids)
            precisions.append(hits / min(len(results), 5))
        
        avg_precision = sum(precisions) / len(precisions)
        if avg_precision > best_precision:
            best_precision = avg_precision
            best_weights = (vec_w, bm25_w)
    
    return {
        "best_vector_weight": best_weights[0],
        "best_bm25_weight": best_weights[1],
        "precision_at_5": best_precision,
    }

Run weight tuning once during initial deployment. Re-tune when content type distribution changes significantly (e.g., adding a product catalog to a documentation RAG).

Deploy tuned configurations with observability and monitoring to track retrieval quality over time.


Production Hybrid Search Architecture

Short answer: Production hybrid search runs BM25 and vector queries in parallel, fuses with RRF, then reranks — all within 100-200ms p95 latency for a complete retrieval pipeline.

End-to-End Retrieval Pipeline

Query → [Parallel: Vector Search + BM25 Search]
      → RRF Fusion
      → Metadata Filter (post-fusion or pre-fusion)
      → Cross-Encoder Reranking
      → Deduplication
      → Top-K to LLM
python
class ProductionHybridPipeline:
    def __init__(
        self,
        hybrid_retriever: HybridRetriever,
        reranker=None,
        dedup_threshold: float = 0.85,
    ):
        self.retriever = hybrid_retriever
        self.reranker = reranker
        self.dedup_threshold = dedup_threshold

    async def retrieve(
        self,
        query: str,
        top_k: int = 5,
        filters: dict = None,
    ) -> list[dict]:
        # Stage 1: Hybrid search (over-fetch)
        candidates = await self.retriever.search(
            query,
            top_k=top_k * 4,
            filters=filters,
        )

        # Stage 2: Rerank
        if self.reranker:
            pairs = [(query, c["text"]) for c in candidates]
            scores = self.reranker.predict(pairs)
            candidates = sorted(
                zip(candidates, scores),
                key=lambda x: x[1],
                reverse=True,
            )
            candidates = [c for c, _ in candidates]

        # Stage 3: Deduplicate
        candidates = deduplicate_chunks(
            candidates, self.dedup_threshold
        )

        return candidates[:top_k]

Indexing Pipeline: Keeping BM25 and Vector in Sync

Both indexes must contain the same documents. Use an event-driven pattern:

python
async def index_document(doc_id: str, text: str, metadata: dict):
    """Index into both BM25 and vector store atomically."""
    embedding = (await embed_pipeline.embed_documents([text]))[0]
    
    try:
        await asyncio.gather(
            vector_store.upsert({
                "id": doc_id,
                "embedding": embedding,
                "text": text,
                "metadata": metadata,
            }),
            asyncio.to_thread(bm25_index.add_document, doc_id, text),
        )
    except Exception as e:
        await rollback_partial_index(doc_id)
        raise IndexingError(f"Failed to index {doc_id}: {e}")

Build hybrid search pipelines as part of RAG & LLM systems engagements — with proper chunking from our chunking strategies guide.


Hybrid Search with Metadata Filtering

Short answer: Apply metadata filters before search when filter selectivity is high (tenant isolation); apply after fusion when filters are complex or differ between indexes — never skip tenant filtering.

Pre-Fusion vs Post-Fusion Filtering

StrategyWhenLatencySafety
Pre-fusion (both indexes)Tenant isolation, high selectivityLower (fewer candidates)Highest
Post-fusionComplex cross-field filtersHigherGood
Vector pre + BM25 postDifferent filter support per indexMediumRequires validation
python
async def tenant_safe_hybrid_search(
    query: str,
    tenant_id: str,
    hybrid_retriever: HybridRetriever,
    top_k: int = 10,
) -> list[dict]:
    """Always filter by tenant BEFORE search — non-negotiable."""
    filters = {"tenant_id": tenant_id}
    
    results = await hybrid_retriever.search(
        query,
        top_k=top_k,
        filters=filters,
    )
    
    # Defense in depth: verify no cross-tenant leakage
    for result in results:
        assert result.get("metadata", {}).get("tenant_id") == tenant_id, \
            f"Cross-tenant leak detected: {result['id']}"
    
    return results

Multi-tenant RAG without metadata filtering is the most common cause of embarrassing retrieval failures — returning one customer's data to another. Always filter by tenant before similarity scoring.

Integrate hybrid search into AI agent tool calling where retrieval is one tool among many — see MCP patterns for standardized tool interfaces.


Benchmarks and Evaluation

Short answer: Hybrid search improves precision@5 by 15-25% over pure vector search on mixed content — with the largest gains on technical documentation containing error codes, product names, and domain-specific terminology.

Benchmark: Support Documentation RAG

Test set: 200 queries against 15,000 support articles (mix of FAQ, troubleshooting, API docs).

MethodPrecision@5Recall@10P95 Latency
Vector only62%71%35ms
BM25 only48%65%8ms
Hybrid (0.7/0.3)76%82%42ms
Hybrid + reranking87%88%145ms
Hybrid + reranking + dedup89%88%150ms

Query Category Breakdown

Query CategoryVector P@5Hybrid P@5Improvement
Error codes / IDs35%78%+43%
How-to questions72%79%+7%
Product names45%71%+26%
Conceptual questions78%80%+2%
Multi-term technical55%74%+19%

The biggest hybrid search wins come from queries with exact identifiers. For purely conceptual questions, vector search alone performs nearly as well — but you cannot predict which query type users will send.

Reduce hallucination from improved retrieval with grounding techniques.

Contact us to benchmark hybrid search on your content.


Primary references: official documentation, official documentation, official documentation, official documentation.

Frequently Asked Questions

What is hybrid search in RAG?

Hybrid search in RAG combines keyword search (BM25) with vector semantic search, then fuses results using Reciprocal Rank Fusion (RRF). It retrieves chunks that match both exact keywords and semantic meaning — fixing the two most common pure vector search failures.

Use hybrid search for all production RAG systems unless your content is purely conversational FAQ with no codes, names, or identifiers. The 10-30ms latency cost is negligible. The 15-25% precision improvement is not.

What are the best BM25 and vector weight ratios?

Start with 0.7 vector / 0.3 BM25 for general content. Increase BM25 to 0.4-0.5 for technical documentation with error codes, product SKUs, and legal citations. Tune with your evaluation set — do not guess.

How does RRF differ from simply adding BM25 and vector scores?

RRF uses rank positions, not raw scores. BM25 scores (0-30+) and cosine similarity (-1 to 1) are incompatible scales. RRF assigns each document a fusion score based on its rank in each list: 1/(k + rank). This avoids score normalization problems and works robustly across different scoring systems.

Can I use hybrid search with pgvector?

Yes, but BM25 requires a separate index. pgvector handles vector search in PostgreSQL. Add BM25 via the pg_search extension, PostgreSQL full-text search (tsvector), or an external index (Elasticsearch, Tantivy). Fuse results in application code with RRF. Qdrant and Pinecone offer native hybrid search in a single platform.

Does hybrid search replace reranking?

No — hybrid search and reranking are complementary. Hybrid search improves recall (finding relevant documents). Reranking improves precision (ranking the most relevant first). Use both: hybrid search to retrieve 20 candidates, reranking to select the best 5.

How much latency does hybrid search add?

Hybrid search adds 10-30ms over pure vector search when BM25 and vector queries run in parallel. Total retrieval pipeline (hybrid + reranking) typically runs 100-200ms p95 — acceptable for most RAG applications.

BM25-only works for known-item search — when users search for specific document titles, error codes, or IDs. For natural language questions, BM25 alone misses paraphrases and synonyms. Never use BM25-only as your primary RAG retrieval method.


Conclusion

Hybrid search with BM25 and vector search is the highest-ROI improvement you can make to a RAG retrieval pipeline:

  1. BM25 catches exact keyword matches, error codes, and rare terms
  2. Vector search catches semantic matches and paraphrases
  3. RRF fusion combines both without score normalization headaches
  4. Weight tuning optimizes for your content type
  5. Reranking on top of hybrid search delivers production-ready precision

Implement hybrid search before query transformation, before graph RAG, before agentic retrieval. Fix the basics first.

At HinterBuild:

Schedule a retrieval pipeline audit — hybrid search is usually the first fix.

Free consultation

Book a free consultation call on hybrid search & RAG retrieval

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading