Should you fuse BM25 with vector search, and what weight should each side get? I ran both retrievers separately and fused them with Reciprocal Rank Fusion (RRF) across three different query styles, sweeping the fusion weight from pure vector to pure BM25.
Result: no single retriever wins everywhere. Pure BM25 beat pure vectors by 49 points of MRR on exact code identifiers (0.772 vs 0.279). Pure vectors beat pure BM25 by 24 points on hand-written paraphrase questions (0.768 vs 0.566). An RRF blend with 80% weight on BM25 rank was the only setting that stayed within 9 points of the best score on every query type.
The setup
- Corpus: the English FastAPI docs, 151 Markdown files, chunked with the heading-aware-plus-heading-path chunker from my chunking benchmark, the strategy that scored best there. 2,713 chunks.
- BM25: the same from-scratch implementation as the chunking post (k1=1.5, b=0.75), reused unchanged.
-
Vectors:
sentence-transformers/all-MiniLM-L6-v2, 384 dimensions, cosine similarity via normalized dot product. A small, CPU-friendly model (model card). -
Fusion:
score(d) = alpha / (60 + rank_bm25(d)) + (1 - alpha) / (60 + rank_vec(d)). Standard RRF withk=60, the constant from the original Cormack, Clarke and Buettcher paper, with a weightalphainstead of a flat 50/50 sum.alpha=1.0is pure BM25,alpha=0.0is pure vector search. Each retriever contributes its own top 50 candidates; a document missing from one side gets rank 200 for that side's term. -
Three query sets, on purpose:
- Heading queries (773): section headings used verbatim. They overlap with the indexed heading-path prefix by construction, so this is an easy benchmark, kept for continuity, not as the main result.
-
Identifier queries (305): inline-code spans like
response_modelorBackgroundTasksthat appear in exactly one chunk. This tests exact-token recall, the case hybrid search is usually sold on. - Paraphrase queries (30): questions I wrote by hand, like "How do I run code after sending the response to the client?" for the background-tasks page, avoiding the target page's own vocabulary. A hit counts any chunk from the target file. Small sample, so treat the percentages as approximate.
Results
MRR by alpha (0.0 = pure vector, 1.0 = pure BM25):
| alpha | heading MRR | identifier MRR | paraphrase MRR |
|---|---|---|---|
| 0.0 | 0.491 | 0.279 | 0.768 |
| 0.2 | 0.581 | 0.471 | 0.794 |
| 0.4 | 0.642 | 0.569 | 0.806 |
| 0.5 | 0.679 | 0.696 | 0.751 |
| 0.6 | 0.710 | 0.735 | 0.731 |
| 0.8 | 0.782 | 0.753 | 0.722 |
| 0.9 | 0.813 | 0.768 | 0.678 |
| 1.0 | 0.859 | 0.772 | 0.566 |
hit@1 / hit@5 for the identifier and paraphrase sets:
| alpha | identifier hit@1 | identifier hit@5 | paraphrase hit@1 | paraphrase hit@5 |
|---|---|---|---|---|
| 0.0 | 0.207 | 0.397 | 0.667 | 0.933 |
| 0.4 | 0.515 | 0.656 | 0.667 | 0.967 |
| 0.8 | 0.662 | 0.889 | 0.533 | 0.933 |
| 1.0 | 0.692 | 0.889 | 0.367 | 0.867 |
What the numbers mean
BM25 wins big on identifiers, vectors win big on paraphrases, by more than I guessed. MiniLM's hit@1 on identifier queries was 0.207. A 384-dim sentence model wasn't trained to preserve rare exact tokens like BackgroundTasks; it embeds the gist of the sentence around it, and one API reference page looks a lot like another. BM25's IDF weighting does the opposite: a rare token dominates the score.
Pure BM25 also won the heading benchmark, and that number is misleading. The chunker prepends each chunk's heading path to the indexed text, so querying with the heading is close to a substring search, BM25's best case. Real users don't type section titles. The 30 paraphrase questions are the fairer read on "does semantic search help", and there pure vectors beat pure BM25 by 24 MRR points.
No fixed alpha is best for all three. alpha=0.4 is best for paraphrases, alpha=1.0 for identifiers and headings. Taking the worst-case regret across the three query types, the minimum is at alpha=0.8: 1.9 points below the best identifier score, 8.4 below the best paraphrase score, 7.7 below the best heading score. alpha=0.5, the "obvious" 50/50 choice, loses 18 points on headings.
Honest caveats
- The paraphrase set is small and I wrote it. 30 questions is enough to see a 24-point gap, not enough to trust the third decimal place.
- The heading benchmark favors whatever puts the query text into the index, BM25's easiest case.
- One small local embedding model. Larger or API-hosted embedding models could close some of the identifier gap. I didn't test that.
- RRF's k=60 wasn't swept here. Only alpha was. (I did sweep k in a follow-up.)
- Single corpus. FastAPI's docs are unusually well structured. A messier corpus would likely show a smaller BM25 edge on identifiers.
The fusion code
K_RRF = 60
def rrf_fuse(bm25_ranked_ids, vec_ranked_ids, alpha, top_k=5):
"""bm25_ranked_ids, vec_ranked_ids: lists of doc ids, best first.
alpha=1.0 is pure BM25, alpha=0.0 is pure vector."""
rank_bm = {doc_id: r for r, doc_id in enumerate(bm25_ranked_ids)}
rank_vec = {doc_id: r for r, doc_id in enumerate(vec_ranked_ids)}
fallback = max(len(bm25_ranked_ids), len(vec_ranked_ids)) * 4
candidates = set(rank_bm) | set(rank_vec)
scored = [
(
alpha / (K_RRF + rank_bm.get(d, fallback) + 1)
+ (1 - alpha) / (K_RRF + rank_vec.get(d, fallback) + 1),
d,
)
for d in candidates
]
scored.sort(reverse=True)
return [d for _, d in scored[:top_k]]
Run BM25 and your vector index independently, each returning its own top-N (50 was enough here), then fuse. No score normalization needed: that's the point of using ranks instead of raw scores, which differ in scale between BM25 and cosine similarity.
Latency on 2 vCPUs over 2,713 chunks: BM25 1.82 ms median, vector encode+search 23.99 ms. A hybrid query costs roughly what the vector half costs.
What I'd do in your pipeline
- Don't default to alpha=0.5. On this corpus it lost more than any other tested value on at least one query type. Start from 0.7-0.8 (BM25-leaning) and tune on a real query log.
- If your users type API names, error codes or config keys, weight BM25 heavily. Small embedding models are bad at exact tokens; that's structural, not a tuning problem.
- If your users type full questions, don't skip vectors. BM25 alone lost 24 MRR points on plain paraphrases.
- Measure your own alpha with a golden-set eval in CI built from real traffic instead of a guess.
The full script (bench_hybrid.py), raw results and corpus fetch script are on the original post.
I run experiments like this every week at RecallRun. If you want to build RAG systems end to end, the AI Engineering course starts free: https://recallrun.dev/learn/ai-engineering/?utm_source=devto&utm_campaign=hybrid
Top comments (3)
Assigning an absent document rank 200 changes the fusion rule in a way worth isolating. Standard missing-list handling gives that document no contribution from that leg; your fallback gives it a small positive contribution. That can affect close rankings and means even the alpha endpoints can include candidates never retrieved by the active leg.
I would rerun the sweep with zero contribution for missing entries and build the candidate union only from enabled retrievers. Comparing those curves would separate the benefit of the 0.8 blend from the behavior of the synthetic fallback rank.
Fair catch. Rank 200 means a one-sided document still gets (1-alpha)/260 from the leg that never saw it. Next to a rank-1 term of 1/61 that's about 23%, so it's not noise in the middle of the sweep, and it does change how one-sided hits compare with documents both legs found.
At the endpoints it matters less than it looks: at alpha=1.0 a fallback-only document scores 1/260, below every real BM25 candidate (1/110 or better), so it can only land at rank 51+. But it can still put a target there instead of "not found", which nudges MRR up a little.
I'll rerun the sweep your way (zero for missing entries, candidates only from enabled legs) and post both curves here. My guess is the 0.8 blend survives but the paraphrase numbers in the middle move.
Reran it as you described: zero contribution for a missing entry, and candidates only from legs with non-zero weight. Same corpus for both versions (the FastAPI docs are now 152 files / 2,849 chunks, so absolute numbers moved by up to a point from the post). MRR, rank-200 fallback / zero:
Endpoints identical, and nothing in the sweep moved more than 3.7 points. But you were right that the fallback shaped the recommendation: with zero, the minimax-regret alpha moves from 0.8 to 0.9 (worst case 8.7 points vs 8.3). That flip comes from the 30-question paraphrase set, where one question is worth 3.3 points of hit@1, so I'd call it "0.8 to 0.9" rather than a new single answer. No blend beats pure BM25 on headings or identifiers in either version.
I'm adding this as an update section on the original post with the rerun script, and changing the advice to use zero for missing entries. Thanks for pushing on it.