Hybrid Search

aka BM25 + Dense, Lexical + Semantic Retrieval

category: retrieval · status: mature

Combine sparse lexical retrieval (BM25) with dense vector retrieval and fuse the results. A coding-assistant searches its codebase for 'how do we authenticate with Stripe?' Pure semantic search misses files that mention 'stripe-api-key' verbatim; pure keyword search misses files that talk about 'payment processor authentication'. Hybrid search runs both at once: a keyword scorer catches the exact tokens, an embedding scorer catches the conceptual matches, and a fusion step blends the two ranked lists.

2. Problem

A team is running a retrieval pipeline over a corpus where the user queries fall into two very different shapes. Some queries are short and exact, hinging on matching specific identifiers, product codes, person names, or technical terms verbatim. Other queries are longer and rely on semantic similarity between paraphrased ideas, where the surface vocabulary may differ between query and source. A single retrieval method serves only one of these well.

Dense vector retrieval handles paraphrase and semantic similarity but misses queries that hinge on an exact identifier the embedding has flattened away. Sparse keyword retrieval — BM25 and similar lexical methods — handles exact terms but misses paraphrased queries whose vocabulary does not overlap with the source text. Picking either method alone means leaving recall on the table for whichever query shape was not chosen, and no downstream re-ranking stage can rescue a chunk that was never retrieved in the first place.

Competing forces:

3. When to Use

4. When Not to Use

5. Architecture Diagram

flowchart TD
  Q[Query] --> S[Sparse retrieval BM25]
  Q --> D[Dense retrieval embeddings]
  S --> F[Fusion / Reciprocal Rank Fusion]
  D --> F
  F --> R[Top-k results]

Hybrid Search runs sparse and dense retrievers in parallel and fuses their rankings before downstream stages.

6. Components

7. Tools

8. Guardrails

The retrieval set is the fusion of sparse and dense top-k; neither alone is the input to downstream stages.

Therefore: index the corpus both lexically and semantically and fuse the rankings, so that exact-match recall and semantic recall both contribute to the final top-N.

9. Failure Modes

10. Evaluation Metrics

11. Code Examples

Two retrievers, rank-based fusion (RRF) so BM25 and dense scores never get mixed on raw scales.unverified

function hybrid_search(query, k=10):
    bm25_hits  = bm25_index.search(query, k=k)
    dense_hits = vector_index.search(embed(query), k=k)

    # Reciprocal Rank Fusion — rank-based, scale-free
    scores = {}
    C = 60                                                # standard RRF constant
    for rank, doc in enumerate(bm25_hits):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (C + rank)
    for rank, doc in enumerate(dense_hits):
        scores[doc.id] = scores.get(doc.id, 0) + 1 / (C + rank)

    fused = sort_desc(scores.items(), by=value)
    return [docs[doc_id] for doc_id, _ in fused[:k]]

QueryFusionRetriever combines a vector retriever and BM25Retriever with relative-score fusion. · source: https://developers.llamaindex.ai/python/examples/retrievers/relative_score_dist_fusion · sdk: llama-index>=0.12, llama-index-retrievers-bm25

from llama_index.core import VectorStoreIndex
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.retrievers.bm25 import BM25Retriever

index = VectorStoreIndex.from_documents(documents)

vector_retriever = index.as_retriever(similarity_top_k=5)
bm25_retriever = BM25Retriever.from_defaults(
    docstore=index.docstore,
    similarity_top_k=10,
)

retriever = QueryFusionRetriever(
    [vector_retriever, bm25_retriever],
    retriever_weights=[0.6, 0.4],
    similarity_top_k=10,
    mode="relative_score",
    use_async=True,
)

nodes = retriever.retrieve("What is task decomposition?")

WeaviateHybridRetriever runs BM25 and vector search inside Weaviate; `alpha` tunes the BM25/vector blend. · source: https://docs.haystack.deepset.ai/docs/weaviatehybridretriever · sdk: haystack-ai>=2.0, weaviate-haystack

from haystack_integrations.document_stores.weaviate.document_store import (
    WeaviateDocumentStore,
)
from haystack_integrations.components.retrievers.weaviate import (
    WeaviateHybridRetriever,
)

document_store = WeaviateDocumentStore(url="http://localhost:8080")
retriever = WeaviateHybridRetriever(
    document_store=document_store,
    top_k=10,
    alpha=0.5,                                  # 0 = pure BM25, 1 = pure vector
)

result = retriever.run(
    query="What is task decomposition?",
    query_embedding=[0.0] * 768,                # supply your real embedding here
)
for d in result["documents"]:
    print(d.content[:200])

Expected benefits

Variants

Reciprocal Rank Fusion (RRF)

Each retriever produces a ranked list; final score for a doc is the sum of 1/(k+rank) across lists. Tunes via the constant k (~60 in published work).

Distinguishing factor: rank-based fusion, no score normalisation

When to use: Default when retrievers produce non-comparable scores (BM25 floats vs cosine similarities).

Weighted score fusion

Each retriever returns scores normalised to [0,1]; final score is a tunable convex combination (e.g. 0.4 * sparse + 0.6 * dense).

Distinguishing factor: score-based fusion, requires normalisation

When to use: When retrievers produce comparable scores or you have an eval set to tune weights against.

Router-based hybrid

A classifier inspects the query and routes to sparse OR dense retrieval (not both). 'How do I X' goes dense; 'order #4471' goes sparse.

Distinguishing factor: one retriever per query

When to use: Latency budget cannot afford two retrievers and the query mix is bimodal.

Known uses

Related patterns

References