The RAG Retrieval Toolkit: BM25, Dense, HyDE, RRF, MMR and Reranking
The RAG retrieval toolkit: BM25, dense vectors, HyDE, RRF, MMR and reranking, when to use each, rough latency per stage, and how a hybrid pipeline fits them.
This article is published in English.
Sources: The Retrieval Toolkit That’s Quietly Deciding Whether Your RAG Pipeline Works.
After a few RAG pipelines, you tend to hit the same wall. The embeddings are good and the prompt is clean, yet the answers still miss. Often the model isn't to blame. What it was given is. Retrieval is the quiet bottleneck, and most failures trace back to a short list of techniques that teams skip or misuse.
Here is that toolkit: how each piece works, why it exists, when to use it, and how the pieces combine in a production pipeline, plus what they cost in time to answer.
1. BM25: keyword scoring that won't die
BM25 (Best Matching 25) is a sparse, lexical ranking function and an improvement on TF-IDF. It ranks documents by how many query terms they share, adjusted for term frequency and document length.
For each query term, a document earns credit when the term appears often, but with diminishing returns: twenty occurrences are nowhere near twenty times as relevant as one. Long documents are penalized so they can't rack up matches simply by being wordy.
from rank_bm25 import BM25Okapi
corpus = [doc.split() for doc in documents] # tokenized docs
bm25 = BM25Okapi(corpus)
query = "vector database indexing".split()
scores = bm25.get_scores(query)
top_k = sorted(range(len(scores)), key=lambda i: -scores[i])[:5]
BM25 cares about the literal token and not the meaning, which makes it strong for:
- Product codes, SKUs, error codes and IDs
- Named entities such as people, companies and drug names
- Rare or technical terms that embeddings tend to blur together
Use it where precision on specific terms outranks tolerance for paraphrase: legal, medical, code and log search. It's cheap and fast and needs no training or GPU. Its weakness is zero semantic understanding, so "car" and "automobile" look unrelated to it.
2. Dense vector retrieval: matching meaning
Dense retrieval turns queries and documents into embedding vectors, hundreds to thousands of dimensions with no zeros, produced by a neural encoder. Similarity comes from cosine or dot product, and an approximate nearest-neighbor index such as HNSW or IVF finds the closest ones.
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
doc_embeddings = model.encode(documents, normalize_embeddings=True)
query_embedding = model.encode([query], normalize_embeddings=True)
scores = np.dot(doc_embeddings, query_embedding.T).flatten()
top_k = np.argsort(-scores)[:5]
It captures semantic similarity: "executive resignation" finds "CEO stepped down" with no shared words. People often call this semantic search, though strictly the dense retrieval is the mechanism and semantic matching the outcome.
Reach for it with conversational queries, paraphrased questions, cross-lingual retrieval, or whenever users won't use your documents' exact vocabulary. It can drift on rare terms, exact codes and numbers, because the embedding space smooths over detail that BM25 keeps.
3. Query rewriting and HyDE: repairing the query first
Sometimes the retriever is fine and the query is the issue. Users type short, vague or oddly worded questions, while both BM25 and dense retrieval work best on well-formed, information-rich text.
Query rewriting has an LLM expand or clarify the question before retrieval, turning "how fast" into a precise question about how many documents per second the X indexing pipeline can handle at peak.
HyDE (Hypothetical Document Embeddings) goes further. Rather than embedding the query, an LLM is asked to invent a plausible answer, and that made-up answer is embedded and used to find real documents. The intuition is that a fabricated answer often sits closer in embedding space to genuine answers than the question does.
hypothetical_doc = llm.generate(f"Write a short passage answering: {query}")
hyde_embedding = model.encode([hypothetical_doc], normalize_embeddings=True)
scores = np.dot(doc_embeddings, hyde_embedding.T).flatten()
Short or ambiguous queries are among the commonest causes of poor recall, since the query simply doesn't carry enough signal. This helps most in chat-style interfaces and in domains where user vocabulary diverges from the corpus. The cost is one more LLM call before retrieval begins, with added latency and expense, and a poor hypothetical answer can pull retrieval the wrong way.
4. Hybrid search: using both
BM25 and dense retrieval fail at opposite ends. Hybrid search runs both in parallel and merges the result lists, catching exact terms and paraphrases in one pass. The catch is that BM25 scores and cosine similarities live on different scales, so averaging them doesn't work. That's the job of reciprocal rank fusion.
5. Reciprocal rank fusion: combine ranks, not scores
RRF avoids score normalization altogether by fusing on rank position and ignoring raw scores.
RRF_score(doc) = Σ 1 / (k + rank_r(doc)) for each ranker r
The constant k, commonly 60, damps the effect so a single first-place ranking can't dominate.
def reciprocal_rank_fusion(ranked_lists, k=60):
scores = {}
for ranked_list in ranked_lists:
for rank, doc_id in enumerate(ranked_list):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: -x[1])
bm25_ranked = ["doc3", "doc1", "doc5"]
dense_ranked = ["doc1", "doc4", "doc3"]fused = reciprocal_rank_fusion([bm25_ranked, dense_ranked])
Documents that several retrievers rank highly rise to the top naturally, with no calibration of score scales. It's the usual way to merge hybrid results, and it fits any case where two or more ranked lists from different retrievers are combined, such as BM25 plus dense, or several embedding models.
6. Maximal marginal relevance: cutting redundancy
A perfect ranking can still be useless if the top five chunks all say the same thing. MMR re-ranks for diversity as well as relevance.
MMR = λ · Sim(doc, query) − (1 − λ) · max(Sim(doc, selected))
The parameter λ sets the balance. At λ = 1 you get plain top-k, and at λ = 0 diversity is maximized regardless of relevance.
import numpy as np
def mmr(query_vec, doc_vecs, doc_ids, lambda_param=0.5, top_k=5):
selected, remaining = [], list(range(len(doc_ids)))
sim_to_query = doc_vecs @ query_vec
while len(selected) < top_k and remaining:
if not selected:
idx = remaining[np.argmax(sim_to_query[remaining])]
else:
mmr_scores = []
for i in remaining:
sim_to_selected = max(doc_vecs[i] @ doc_vecs[j] for j in selected)
mmr_scores.append(
lambda_param * sim_to_query[i] - (1 - lambda_param) * sim_to_selected
)
idx = remaining[np.argmax(mmr_scores)]
selected.append(idx)
remaining.remove(idx)
return [doc_ids[i] for i in selected]
Without MMR you can spend the whole context window on five near-duplicates instead of five chunks that cover different sides of the answer. It pays off on corpora with overlapping or repeated content, like FAQ pages, product docs with boilerplate, or any set of near-duplicate passages.
7. Cross-encoder reranking: the accuracy heavyweight
Everything so far belongs to the bi-encoder world, where queries and documents are embedded separately and then compared. A cross-encoder feeds query and candidate together into one transformer, so the model can attend across both texts. That gives much finer relevance judgments than comparing precomputed vectors.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, doc) for doc in candidate_docs]
scores = reranker.predict(pairs)
reranked = [doc for _, doc in sorted(zip(scores, candidate_docs), reverse=True)]
Cross-encoders consistently beat bi-encoder similarity at ranking relevance, because they reason jointly about query and document. Use one as a final polish on a small candidate set, typically the top 20 to 50 after fusion or MMR, and never over the full corpus, since each document pair needs its own forward pass. After the LLM call itself it's the biggest latency addition on this list, which is why it sits at the end of the pipeline and not at the start.
8. Time to answer: the bill for all of this
Every technique above raises retrieval quality, and none of them is free. Each adds latency, and the end-to-end time from submitting a query to receiving the final answer decides whether a carefully tuned pipeline is usable in production. Typical costs per stage:
- Query rewriting or HyDE, if used: roughly 200 ms to 1 s, an extra LLM call before retrieval starts.
- BM25 lookup: about 5 to 20 ms, from a cheap in-memory inverted index.
- Dense vector search (ANN): about 10 to 50 ms, depending on index size and type, HNSW or flat.
- RRF fusion: 1 to 5 ms, plain arithmetic on rank lists.
- MMR re-ranking: about 20 to 100 ms, growing with the candidate pool because it's iterative and pairwise.
- Cross-encoder reranking, if used: about 50 to 300 ms, often the largest single cost short of generation.
- LLM generation: from around 500 ms to several seconds, usually the dominant share.
To keep that total in check:
- Run BM25 and dense retrieval in parallel. They don't depend on each other.
- Apply MMR to a small pool, say the top 20 to 30 after RRF, since pairwise similarity checks scale badly.
- Cache embeddings for frequent or repeated queries.
- Add a cross-encoder only when the accuracy gain clearly justifies the delay.
- Track time to answer as a first-class metric next to answer quality, such as faithfulness or recall@k. A system two percent more accurate but three times slower may not be worth shipping.
How the pieces fit
A production hybrid retrieval pipeline commonly runs like this:
- Query rewriting or HyDE tidies a vague query before retrieval, optionally, at the price of latency.
- BM25 catches exact terms.
- Dense retrieval catches meaning.
- RRF merges both lists with no score-normalization fuss.
- MMR trims the merged list to a diverse, non-redundant candidate set.
- A cross-encoder makes a final high-precision pass over that smaller set.
- Time to answer is the report card that says whether all of it is shippable.
Quick decision guide
- Short, vague or off-vocabulary queries? Rewrite them or try HyDE.
- Exact-match precision for codes, IDs and rare terms? BM25.
- Tolerance for paraphrase? Dense retrieval.
- Both at once? Fuse with RRF.
- Redundant or duplicate chunks in the context? MMR.
- Highest relevance on a small final set? Cross-encoder reranking.
- Accurate but slow? Measure each stage before optimizing blindly.
No single technique is a silver bullet. The best production systems combine them, tuned against real query logs and not intuition, and the right mix still depends heavily on the corpus and the latency budget.