aka BM25 + Dense, Lexical + Semantic Retrieval
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.
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:
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.
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.
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]]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?")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])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).
Each retriever returns scores normalised to [0,1]; final score is a tunable convex combination (e.g. 0.4 * sparse + 0.6 * dense).
A classifier inspects the query and routes to sparse OR dense retrieval (not both). 'How do I X' goes dense; 'order #4471' goes sparse.
specialises → naive-ragcomposes-with → cross-encoder-rerankingcomposes-with → contextual-retrievalcomposes-with → query-rewritingcomposes-with → modular-ragcomposes-with → hierarchical-retrieval