Skip to content

Hybrid Search#

Hybrid search rag runs a BM25 keyword retriever and a dense vector retriever side by side, then fuses their ranked lists into one. The combined pipeline catches both exact-token matches like error codes and semantic matches like paraphrases, which is why almost every production RAG stack uses it as the default retrieval layer.

flowchart LR
  Q([Query]) --> BM[BM25 retriever]
  Q --> DV[Dense vector retriever]
  BM --> RRF[RRF fuser]
  DV --> RRF
  RRF --> TopK([Top-K to reranker])

  classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
  classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
  classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
  class Q,TopK client;
  class BM,DV service;
  class RRF compute;

A user types a question. Both retrievers see the same query string. The BM25 side scores documents by exact token overlap weighted with IDF and document length. The dense side embeds the query into a vector and asks an ANN index for the nearest documents. The fuser takes the two ranked lists and produces one merged list, which then goes to a reranker before the LLM.

Why pure vector retrieval misses#

Dense embeddings compress language into a smooth similarity space. That smoothing is what gives them their semantic superpower: paraphrases land near each other even when they share no words. The same smoothing washes out rare tokens.

A query like CVE-2024-3094 returns mostly noise from a dense-only retriever. The embedder never saw that exact identifier in training, so the vector for the query sits in a generic neighbourhood and the actual advisory document does not score high. The same happens for product SKUs, internal acronyms, function names like parse_json_strict, error codes like ERR_INVALID_ARG_TYPE, and version strings like v1.21.4-rc2.

Out-of-vocabulary tokens, specific entities, and acronyms are the three failure modes you hit first when you ship a vector-only RAG to real users.

Why pure BM25 retrieval misses#

BM25 is a token-matching algorithm. It does not know that delete and remove are synonyms, that cancel my subscription and unsubscribe from billing mean the same thing, or that how to crash a Postgres node and Postgres OOM kill reasons answer the same question.

This is the semantic gap. Users ask in their own words, documents are written in the author's words, and the two vocabularies almost never line up perfectly. BM25 alone gets you a high-precision keyword search and a low-recall question-answering system.

The hybrid recipe#

The recipe is simple and the order matters.

  1. Send the query to both retrievers in parallel; they share nothing.
  2. Each returns its own top-50 list with its own scores.
  3. A fuser merges the two lists by rank, not by raw score.
  4. The merged top-K goes to a reranker, then to the LLM.
flowchart TB
  Q([Query]) --> Par[Parallel fan-out]
  Par --> BM[BM25]
  Par --> DV[Dense ANN]
  BM --> L1[List A top-50]
  DV --> L2[List B top-50]
  L1 --> F[RRF fuser]
  L2 --> F
  F --> TK[Top-20 candidates]
  TK --> RR[Cross-encoder reranker]
  RR --> Top5([Top-5 for LLM])

  classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
  classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
  classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
  class Q,Top5 client;
  class BM,DV,Par service;
  class F,RR compute;
  class L1,L2,TK service;

The two retrievers cover each other's blind spots. BM25 carries the rare-token queries; dense carries the paraphrase queries. The fuser does not need to know which is which.

Fusion: reciprocal rank fusion in one paragraph#

Reciprocal rank fusion (RRF) is the workhorse merge. For each document, sum 1 / (k + rank_in_list_i) across the lists where it appeared. Default k = 60. A document ranked first in both lists gets 1/61 + 1/61 which is around 0.033. A document ranked first in one list and absent from the other gets 1/61 which is around 0.016. Sort by the sum and you have the merged ranking. No score normalization, no calibration, no tuning.

The other option is a linear interpolation: final = alpha * dense_score + (1 - alpha) * bm25_score with alpha typically around 0.5 to 0.7. It works but requires score normalization and a per-corpus alpha sweep. RRF skips both, which is why it dominates in practice.

When hybrid is not the right answer#

Hybrid is the default, not the only choice. Skip it when:

  • The corpus is tiny (under a few thousand chunks). Both retrievers will return overlapping top-Ks and the fuser adds latency without lifting recall.
  • The corpus is highly homogeneous. A medical literature corpus where every doc uses the same vocabulary saturates BM25 quickly, and dense alone wins.
  • Latency is hard-pinned below ~50ms. A single retriever shaves the fan-out and the fuser.

For everything else, ship hybrid as the baseline and tune later.

Where this lives in production#

Pinecone, Weaviate, Vespa, Elastic, and Qdrant all expose hybrid search as a first-class API. The shape is identical across them: send a query, receive a merged ranked list, optionally feed a reranker. The deep dive in the next page walks through the BM25 mechanics, the dense ANN setup, the RRF math, the alpha-interpolation alternative, latency budgets, and the production failure modes.

Hybrid search rag is the production-default retrieval layer for grounded LLM systems. It runs a sparse keyword retriever (BM25 or BM25F) and a dense vector retriever in parallel against the same corpus, then fuses their ranked lists into a single ordering. The combined pipeline catches both exact-token queries that dense embeddings smooth away and paraphrased queries that BM25 cannot match.

This guide walks the BM25 internals, the dense ANN side, the fusion math, the alpha-interpolation alternative, latency budgets with deadline propagation, and where the pattern fails. A Python example at the end stitches the pieces together.

The two failure modes hybrid solves#

A dense-only RAG handles paraphrase well and crumbles on rare tokens. A BM25-only RAG handles rare tokens well and crumbles on paraphrases. Hybrid covers both.

flowchart TB
  subgraph Dense[Dense vector retriever]
    Q1([Paraphrase query]) --> E1[Embedder] --> A1[(ANN index)]
    A1 --> H1[Strong recall]
    Q2([Rare token query]) --> E2[Embedder] --> A2[(ANN index)]
    A2 --> M1[Weak recall]
  end
  subgraph BM25[BM25 retriever]
    Q3([Paraphrase query]) --> I1[(Inverted index)]
    I1 --> M2[Weak recall]
    Q4([Rare token query]) --> I2[(Inverted index)]
    I2 --> H2[Strong recall]
  end

  classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
  classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
  classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
  class Q1,Q2,Q3,Q4 client;
  class E1,E2,H1,H2,M1,M2 service;
  class A1,A2,I1,I2 datastore;

The asymmetry is structural. Dense embedders learn token co-occurrence patterns in the training corpus, so out-of-distribution tokens like CVE-2024-3094, parse_json_strict, or an internal acronym RPM-DLQ-7 end up in low-density regions of the vector space. BM25 ignores semantics entirely; an exact token match is an exact token match. The two views are complementary, not competing.

BM25 internals, briefly#

BM25 scores a document d against a query q by summing per-term contributions:

score(q, d) = sum over t in q of:
              IDF(t) * (tf(t, d) * (k1 + 1)) / (tf(t, d) + k1 * (1 - b + b * |d| / avgdl))

Three knobs make the formula behave well:

  • Term frequency (tf): how often the term appears in the document, with diminishing returns past a saturation point controlled by k1 (typical 1.2 to 2.0).
  • Inverse document frequency (IDF): rare terms across the corpus score higher. This is what makes acronyms and identifiers stand out.
  • Document length normalization (b): penalises long documents that match by sheer accident. b = 0.75 is the standard.

The win for hybrid is the IDF term: queries with rare tokens always promote the matching documents because rare tokens dominate the sum. Dense retrievers do not have a direct IDF analogue.

See the search engine deep dive for the full BM25 derivation; for hybrid, the takeaway is that BM25 is cheap, deterministic, and unbeatable on exact-token queries.

Dense retrieval, briefly#

The dense side embeds chunks at index time with a bi-encoder (BGE, E5, OpenAI text-embedding-3, Voyage, Cohere embed) and stores the vectors in an ANN index (HNSW, IVF-PQ, ScaNN). At query time the same embedder produces the query vector and the ANN returns the K nearest documents by cosine similarity or dot product.

flowchart LR
  subgraph Index[Index time]
    D[Docs] --> Ch[Chunker] --> Emb1[Embedder] --> ANN[(HNSW index)]
  end
  subgraph Query[Query time]
    Q([Query]) --> Emb2[Embedder] --> Lookup[ANN top-50]
    ANN --> Lookup
  end

  classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
  classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
  classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
  classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
  class Q client;
  class Emb1,Emb2,Lookup service;
  class Ch compute;
  class ANN,D datastore;

The bi-encoder is cheap at query time (one forward pass, around 5ms on GPU, 30ms on CPU). The ANN index lookup is logarithmic in corpus size and lands around 10-30ms for tens of millions of chunks on HNSW with reasonable parameters.

Fusion strategies#

Once both retrievers return their top-N lists, you have to merge. Three options dominate.

Reciprocal Rank Fusion (RRF)#

RRF ignores the raw scores entirely and uses only ranks:

RRF_score(d) = sum over retriever i where d appears: 1 / (k + rank_i(d))

Default k = 60. The 1-based rank makes the first document worth 1/61, the second 1/62, and so on. A document appearing in both lists at rank 1 gets 2/61 which is roughly 0.033. A document at rank 1 in one list and absent from the other gets 1/61 which is roughly 0.016. Half the score, which is the right ratio: presence in both lists is meaningfully stronger evidence than presence in one.

The killer feature is no score normalization. BM25 produces unbounded positive scores; cosine similarity sits in [-1, 1]; dot product is arbitrary. Comparing them directly is meaningless. RRF replaces all of it with rank, which is comparable by construction.

Linear interpolation with alpha#

final_score(d) = alpha * normalize(dense_score(d)) + (1 - alpha) * normalize(bm25_score(d))

Pick alpha between 0 (BM25 only) and 1 (dense only). Typical production values: 0.5 to 0.7, leaning slightly dense for natural-language queries. Two complications:

  1. Score normalization: usually min-max scaling within the candidate pool. Both retrievers must produce comparable [0, 1] ranges before blending.
  2. Per-corpus tuning: alpha varies with corpus. Code-heavy corpora favour BM25 (alpha ~ 0.3). Chatty support docs favour dense (alpha ~ 0.7). You need a labeled tuning set to sweep.

Linear interpolation can squeeze one or two points of nDCG above RRF when tuned, but the maintenance cost is real.

Learned reranker on top#

Both fusion strategies just widen the candidate pool. A cross-encoder reranker on the merged top-20 lifts top-5 precision another 10 to 20 points by scoring (query, doc) jointly. Hybrid + cross-encoder rerank is the workhorse stack.

flowchart LR
  Q([Query]) --> BM[BM25 top-50]
  Q --> DV[Dense top-50]
  BM --> F[RRF fuser]
  DV --> F
  F --> M[Merged top-20]
  M --> CE[Cross-encoder rerank]
  CE --> Top5([Top-5 for LLM])

  classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
  classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
  classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
  class Q,Top5 client;
  class BM,DV,M service;
  class F,CE compute;

Latency budget and deadline propagation#

The two retrievers run in parallel and the budget is set by the slower one, not their sum. A typical RAG turn budget:

Stage Budget
BM25 lookup 5-20ms
Dense embed + ANN 30-80ms
RRF fuse < 1ms
Cross-encoder rerank top-20 50-150ms
LLM generation 800-2000ms

The retrieval slice is ~80-100ms p50, dominated by the dense path. Two practical patterns matter.

Parallel fan-out. Issue both retriever calls on separate goroutines / async tasks with a shared asyncio.gather (or equivalent). The slower path sets the wall clock.

Deadline propagation. Push a hard deadline (e.g. 120ms) into both retrievers. If one returns within the deadline and the other does not, treat the missing list as empty and let the fuser return a degraded ranking. This is much better than blocking the LLM call.

sequenceDiagram
  participant C as Client
  participant API as Retrieval API
  participant BM as BM25
  participant DV as Dense ANN
  participant F as RRF Fuser
  C->>API: query, deadline=120ms
  par BM25 path
    API->>BM: search(query)
    BM-->>API: top-50 in 15ms
  and Dense path
    API->>DV: embed + ANN
    DV-->>API: top-50 in 70ms
  end
  API->>F: merge(bm25_list, dense_list)
  F-->>API: top-20
  API-->>C: results in 75ms

When hybrid does not help#

Hybrid is the right default but not universal. Skip or simplify when:

  • Very small corpora. Under a few thousand chunks, BM25 and dense often return overlapping top-Ks. The fuser adds latency without recall gain.
  • Very homogeneous corpora. Medical literature or legal opinions where every document uses the same vocabulary saturate BM25 fast; dense alone is enough.
  • Pure code search. Token-level matching dominates; pure BM25 (or a code-tuned sparse retriever like SPLADE) often beats hybrid because dense over code embeddings underperforms unless you have a code-tuned embedder.
  • Hard latency caps. If the budget is under 50ms end-to-end, run dense only (or BM25 only) and skip the fan-out.
  • Highly multilingual corpora with a weak tokenizer. BM25 needs a sane tokenizer per language. If the corpus is mixed-script and you do not have language-specific analyzers, dense often wins outright.

The right test is empirical: measure Recall@20 and Recall@5 for dense-only, BM25-only, and hybrid on a labeled set. If hybrid does not lift Recall@20 by 5 to 10 points, drop it.

Real production: how the vector DBs surface hybrid#

Every major vector DB now exposes hybrid as a first-class API.

Vendor API shape Default fusion
Pinecone index.query(vector=..., sparse_vector=..., alpha=0.5) Linear interpolation with alpha
Weaviate query.hybrid(query, alpha=0.5, fusion_type=rankedFusion) Choice of rankedFusion (RRF) or relativeScoreFusion
Vespa YQL with rank-profile combining bm25 + closeness Linear or weighted via rank-profile
Elastic _search with knn clause + query clause + rank: rrf RRF native since 8.9
Qdrant query_points with prefetch arrays + fusion: rrf RRF
OpenSearch hybrid query DSL with normalization-processor Linear with normalization

The pattern is the same across vendors: send query string + query vector, receive merged ranked list. The differences are in the fusion default and how sparse vectors are encoded (SPLADE-style learned sparse vs raw BM25).

Code example: BM25 + dense + RRF in Python#

This is a concrete, near-runnable example using rank-bm25 for BM25 and a vector DB client placeholder for dense. The RRF function is the small bit you write yourself.

import asyncio
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
import pinecone

# Index time
embedder = SentenceTransformer("BAAI/bge-small-en-v1.5")
corpus = [doc.text for doc in load_docs()]
tokenized = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized)

pc = pinecone.Pinecone(api_key="...")
index = pc.Index("hybrid-demo")
# (vectors are upserted offline, omitted)

async def bm25_search(query: str, top_k: int = 50):
    scores = bm25.get_scores(query.lower().split())
    ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
    return [{"id": str(i), "score": float(s), "rank": r + 1}
            for r, (i, s) in enumerate(ranked[:top_k])]

async def dense_search(query: str, top_k: int = 50):
    qv = embedder.encode(query).tolist()
    res = index.query(vector=qv, top_k=top_k, include_metadata=False)
    return [{"id": m["id"], "score": m["score"], "rank": r + 1}
            for r, m in enumerate(res["matches"])]

def rrf_merge(lists, k: int = 60, top_k: int = 20):
    fused = {}
    for retriever_list in lists:
        for hit in retriever_list:
            doc_id = hit["id"]
            fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + hit["rank"])
    ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)
    return [{"id": doc_id, "rrf_score": score}
            for doc_id, score in ranked[:top_k]]

async def hybrid_search(query: str, deadline_ms: int = 120, top_k: int = 20):
    deadline = deadline_ms / 1000.0
    try:
        bm_task = asyncio.create_task(bm25_search(query))
        dv_task = asyncio.create_task(dense_search(query))
        bm_list, dv_list = await asyncio.wait_for(
            asyncio.gather(bm_task, dv_task, return_exceptions=True),
            timeout=deadline,
        )
    except asyncio.TimeoutError:
        bm_list = bm_task.result() if bm_task.done() else []
        dv_list = dv_task.result() if dv_task.done() else []
    bm_list = bm_list if isinstance(bm_list, list) else []
    dv_list = dv_list if isinstance(dv_list, list) else []
    return rrf_merge([bm_list, dv_list], k=60, top_k=top_k)

The function fans out both calls under a shared deadline, falls back to whichever list arrived in time, and runs RRF on whatever it has. In production you would also push the deadline into the retriever clients themselves (Pinecone, BM25 worker) so they cancel server-side work instead of letting it run to completion.

Production failure modes#

  • Tokenizer drift between BM25 and embedder. BM25 tokenizes one way, the embedder tokenizes another. Make sure both see the same text (lowercased, punctuation-stripped, language-segmented) at index and query time. Mismatched tokenization silently halves BM25 recall.
  • Stale BM25 index after dense reindex. A new embedder model triggers a vector reindex but the BM25 index is often forgotten. Both indexes must refresh together or queries route to a known-good version.
  • Alpha drift. A tuned alpha = 0.6 from launch decays as the corpus distribution shifts. Re-sweep alpha quarterly against a fresh labeled set, or switch to RRF and forget it.
  • Empty BM25 result from over-stemming. Aggressive stemmers turn running into run and then fail to match runner. Test stemmer choice against a sample of real queries.
  • Cost of dual storage. You pay for the vector index plus the BM25 index. On large corpora (hundreds of millions of chunks) the BM25 side is often cheaper, but it is not free; budget both.

Evaluation#

Three numbers to track on a labeled set:

  1. Recall@K: did the ground-truth chunk make it into the merged top-K? This is the hybrid pipeline's job.
  2. MRR / nDCG@10: where in the merged list did it land? The reranker stage tunes this.
  3. End-to-end answer quality: LLM-as-judge or human eval on full RAG output. This is what users see.

A typical lift pattern on public benchmarks (BEIR, MTEB retrieval):

Pipeline Recall@20 nDCG@10
BM25 only 0.55 0.35
Dense only 0.68 0.42
Hybrid (RRF) 0.78 0.50
Hybrid + rerank 0.78 0.62

Hybrid mostly buys you recall; rerank converts that recall into precision.

FAQ#

What is hybrid search in RAG?#

Hybrid search runs a BM25 keyword retriever and a dense vector retriever in parallel against the same corpus, then fuses their ranked lists into one with reciprocal rank fusion. The combined list catches both exact-term and semantic matches.

Why does pure vector search miss results?#

Dense embeddings smooth language into a similarity space, so rare tokens like product codes, error names, version numbers, and acronyms get washed out. BM25 still finds them because it scores exact token overlap.

What is reciprocal rank fusion?#

Reciprocal rank fusion is a rank-only merge: each document's final score is the sum of one over k plus its rank in each retriever. With k around sixty it works well without score calibration.

When is hybrid search not worth it?#

On tiny corpora the second retriever adds latency without lifting recall. On highly homogeneous domain text where every doc shares the same vocabulary, BM25 saturates and dense alone is enough.

Sweep alpha from zero to one in steps of 0.1 on a held-out set with labeled relevance and pick the value that maximises Recall at K or nDCG. Most production systems land between 0.5 and 0.7 favouring dense slightly.

Should I use RRF or linear interpolation?#

Use RRF unless you already have a labeled tuning set. RRF needs no score normalization across retrievers and gives within 1 to 2 points of a tuned linear blend on most public benchmarks.

How do I run hybrid search with parallel retrievers?#

Send both retriever calls concurrently with a shared deadline. Propagate the deadline to each call and merge the results that arrived before timeout; treat the missing list as empty so the fuser still returns a ranking.

Does hybrid search replace a reranker?#

No. Hybrid lifts recall by widening the candidate pool. A cross-encoder reranker still adds 10 to 20 points of top-5 precision on top of hybrid because it sees the query and document together.

How big should the per-retriever top-K be?#

Common defaults are top-50 per retriever feeding into a fuser that returns top-20 to a reranker that returns top-5 to the LLM. Larger pools lift recall up to a point but cost latency at the rerank stage.

Run BM25 and dense vector retrieval in parallel against the same corpus, then fuse the ranked lists with reciprocal rank fusion before reranking and prompting the LLM.

What is the default RRF constant?#

k equals sixty is the textbook default and matches what most vector DB vendors ship. Tweaking k between thirty and one hundred rarely changes ranking by more than one point.

What is the typical alpha for linear hybrid fusion?#

Between zero point five and zero point seven, leaning slightly toward dense for natural-language queries and toward BM25 for code-heavy or identifier-heavy corpora.

Which vector DBs support hybrid out of the box?#

Pinecone, Weaviate, Vespa, Elastic, OpenSearch, and Qdrant all expose hybrid search APIs. Pinecone and OpenSearch default to linear blend; Weaviate, Elastic, and Qdrant default to RRF.

Quick reference#

Mental model#

Two retrievers, one fuser, one reranker. BM25 covers rare tokens (acronyms, codes, names). Dense covers paraphrases. RRF merges by rank. Cross-encoder reranks to top-5 for the LLM.

Pipeline#

  1. Query in.
  2. Fan out to BM25 and dense ANN in parallel under a shared deadline.
  3. Each returns top-50.
  4. RRF merges to top-20.
  5. Cross-encoder rerank to top-5.
  6. Prompt the LLM with top-5 as context.

RRF math#

RRF_score(d) = sum over retriever i where d appears: 1 / (k + rank_i(d))
  • k = 60 is the textbook default.
  • Rank starts at 1.
  • No score normalization needed.
  • Half the score for single-list presence vs both-list presence is the right ratio.

Linear interpolation (alternative)#

final = alpha * normalize(dense) + (1 - alpha) * normalize(bm25)
  • alpha = 0.5 to 0.7 typical.
  • Requires min-max normalization within candidate pool.
  • Needs a labeled tuning set per corpus.
  • Use RRF unless you already have the labels.

Latency budget (per RAG turn)#

Stage Budget
BM25 lookup 5-20ms
Dense embed + ANN 30-80ms
RRF merge < 1ms
Cross-encoder rerank (top-20) 50-150ms
LLM generation 800-2000ms

Parallel fan-out makes the slower retriever set the wall clock, not the sum.

Deadline propagation#

  • Push a hard deadline (e.g. 120ms) into both retrievers.
  • If one path misses, treat its list as empty.
  • The fuser still returns a degraded ranking.
  • Never block the LLM on a slow retriever.

When hybrid wins#

  • Mixed user queries: paraphrases plus acronyms plus identifiers.
  • Corpora with version numbers, error codes, internal jargon, product SKUs.
  • General-purpose RAG over a heterogeneous knowledge base.

When hybrid does not help#

  • Corpora under a few thousand chunks (lists overlap heavily).
  • Highly homogeneous domain text (medical, legal) where BM25 saturates.
  • Pure code search (use SPLADE or BM25-heavy stack).
  • Hard latency caps under 50ms end-to-end.
  • Multilingual mixed-script corpora without per-language analyzers.

Vector DB hybrid surface#

Vendor Default fusion
Pinecone Linear blend with alpha
Weaviate rankedFusion (RRF) or relativeScoreFusion
Vespa Rank-profile, linear or weighted
Elastic 8.9+ RRF native
Qdrant RRF via prefetch + fusion
OpenSearch Linear via normalization-processor

Production failure modes#

  • Tokenizer mismatch between BM25 and embedder silently halves recall.
  • Stale BM25 index after a dense reindex skews fusion.
  • Alpha drift as corpus changes; re-sweep quarterly or switch to RRF.
  • Over-aggressive stemmer wipes out exact-token wins.
  • Dual storage cost on huge corpora; budget both indexes.

Evaluation#

  • Recall@20 on labeled set: hybrid's job.
  • nDCG@10: rerank's job.
  • End-to-end answer quality: what users see.

Typical lift on BEIR-style benchmarks:

Pipeline Recall@20 nDCG@10
BM25 only 0.55 0.35
Dense only 0.68 0.42
Hybrid (RRF) 0.78 0.50
Hybrid + rerank 0.78 0.62

BM25 knobs#

  • k1 between 1.2 and 2.0: term-frequency saturation.
  • b = 0.75: document length normalization.
  • IDF: why rare tokens dominate; do not log-cap aggressively.

Dense knobs#

  • HNSW M = 16-32, efConstruction = 200, ef = 64-256 at query time.
  • Embedding dimension: 384 (small) to 1536 (OpenAI text-embedding-3-large).
  • Re-encode the whole corpus on embedder swap; dual-index during cutover.

RRF quick code#

def rrf_merge(lists, k=60, top_k=20):
    fused = {}
    for hits in lists:
        for hit in hits:
            fused[hit["id"]] = fused.get(hit["id"], 0.0) + 1.0 / (k + hit["rank"])
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)[:top_k]

Default stack to ship#

  • Embedder: bge-small-en-v1.5 or OpenAI text-embedding-3-small.
  • ANN: HNSW in Pinecone, Weaviate, Qdrant, or pgvector for small loads.
  • BM25: Elastic, OpenSearch, or rank-bm25 in-process for under 1M chunks.
  • Fusion: RRF with k = 60.
  • Reranker: Cohere Rerank 3 or BAAI/bge-reranker-v2-m3 self-hosted.

Hybrid + cross-encoder rerank is the production baseline. Ship that first; layer HyDE, query rewriting, or GraphRAG only if a labeled eval set proves they help.

Refs#

  • Robertson and Zaragoza, "The Probabilistic Relevance Framework: BM25 and Beyond" (2009)
  • Cormack, Clarke, Buettcher, "Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods" (SIGIR 2009)
  • Pinecone blog: hybrid search with sparse-dense vectors
  • Weaviate docs: hybrid search and fusion algorithms
  • Elastic blog: RRF in Elasticsearch 8.9
  • BEIR benchmark (Thakur et al., 2021)