4. Better retrieval¶
Intermediate · 14 min read
If the right chunk never reaches the prompt, the best LLM in the world can't answer. Retrieval is where most RAG quality is won or lost. The techniques on this page work on both sides of the search: before it (improve the query) and after it (improve the ranking).
flowchart LR
Q[User question] --> RW[Rewrite / expand<br/>4.2 · 4.3 · 4.4]
RW --> S[Hybrid search<br/>top 20–50]
S --> RR[Rerank<br/>4.5]
RR --> D[Diversify<br/>4.6]
D --> P[Top 3–8 into the prompt]
4.1 Why first-pass retrieval misses¶
| Cause | Example | Technique |
|---|---|---|
| Follow-up question with no subject | "And for laptops?" | Query rewriting (4.2) |
| User words ≠ document words | "money back" vs "refund" | Multi-query (4.3), HyDE (4.4), hybrid search |
| Question vs answer have different shapes | Short question, long declarative chunk | HyDE (4.4) |
| Right chunk retrieved but ranked 9th | Similar-looking chunks crowd it out | Reranking (4.5) |
| Top results are near-duplicates | Three copies of the same policy | MMR (4.6) |
| Exact codes and names missed | "error E-4012", "Laptop Pro X2" | Hybrid BM25 + vectors |
| Wrong product, year or tenant | Retrieves the 2023 policy | Metadata filters (4.7) |
The examples below use the same small corpus and a bag-of-words stand-in for embeddings:
import math
import re
from collections import Counter
CHUNKS = {
"c1": "Damaged items get a full refund including shipping.",
"c2": "Refunds are processed within 5 working days of approval.",
"c3": "Returns must be started within 30 days of delivery.",
"c4": "Express shipping takes 1 day and costs 9 dollars.",
"c5": "All laptops have a 1 year warranty covering hardware faults.",
"c6": "Accidental damage such as a cracked screen is not covered by the warranty.",
"c7": "To return an item, print the return label from your orders page.",
}
STOP = {"a", "an", "the", "is", "are", "to", "of", "and", "or", "in", "do", "does", "how", "what", "can", "i", "my",
"it", "if", "for", "get", "be", "by", "from", "your", "yes", "you", "within", "all", "have", "such", "as", "back"}
def embed(text: str) -> Counter:
words = re.findall(r"[a-z0-9]+", text.lower())
return Counter(w.rstrip("s") if len(w) > 3 else w for w in words if w not in STOP)
def cosine(a: Counter, b: Counter) -> float:
dot = sum(a[t] * b[t] for t in a)
norm = math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values()))
return dot / norm if norm else 0.0
VECTORS = {cid: embed(text) for cid, text in CHUNKS.items()}
def search(query: str, k: int = 3) -> list[tuple[str, float]]:
q = embed(query)
ranked = sorted(((cid, cosine(q, v)) for cid, v in VECTORS.items()), key=lambda x: -x[1])
return [(cid, round(s, 2)) for cid, s in ranked[:k]]
print(search("My screen cracked, can I send it back for my money?"))
Only c6 (the cracked screen isn't covered by the warranty) matches. The chunks the user also needs, c1 (damaged
items get a full refund) and c7 (how to return an item), score zero, because the user said "money" and "send back",
not "refund" and "return". The techniques below fix exactly this.
4.2 Query rewriting: make follow-ups standalone¶
In a chat, the user's latest message is often not a searchable query. "What about laptops?" only makes sense with the history. Before retrieving, ask a small, fast LLM to rewrite the latest message into a standalone search query:
# no-run — one cheap LLM call before retrieval
REWRITE_PROMPT = """Rewrite the user's last message as a standalone search query for a document search engine.
Use the conversation for context. Keep product names, codes and numbers exactly. Output only the query.
Conversation:
{history}
Last message: {question}"""
history = "User: How long is the warranty on phones?\nAssistant: Phones have a 2 year warranty [warranty.md#3]."
query = llm(REWRITE_PROMPT.format(history=history, question="What about laptops?"), model="small-fast-model")
# -> "How long is the warranty on laptops?"
Rules that keep rewriting safe:
- Search with the rewritten query, but show the LLM the original question and history when answering.
- Use a small model with
temperature=0. It adds one round trip, often 200–500 ms. - Skip the rewrite for a conversation's first message, if it's already a full question.
- Log both queries. A bad rewrite is a common hidden cause of bad answers.
4.3 Multi-query retrieval with RRF¶
One phrasing of a question finds some relevant chunks and misses others. Multi-query asks the LLM for three to five different phrasings, searches with each, and merges the result lists with reciprocal rank fusion (RRF), the same fusion used for hybrid search. A chunk that ranks well for several phrasings rises to the top.
question = "My screen cracked, can I send it back for my money?"
variants = [ # what an LLM returns when asked for 3 alternative search queries
"Is accidental damage like a cracked screen covered by the warranty?",
"Can I get a refund for a damaged item?",
"How do I return an item?",
]
def rrf(result_lists: list[list[tuple[str, float]]], k: int = 60) -> list[tuple[str, float]]:
scores: dict[str, float] = {}
for results in result_lists:
for rank, (cid, _) in enumerate(results, start=1):
scores[cid] = scores.get(cid, 0) + 1 / (k + rank)
return sorted(((cid, round(s, 4)) for cid, s in scores.items()), key=lambda x: -x[1])
single = search(question)
fused = rrf([search(q) for q in [question, *variants]])
print("single query:", [cid for cid, _ in single])
print("multi-query: ", [cid for cid, _ in fused[:4]])
for cid, score in fused[:4]:
print(f" {cid} {score} {CHUNKS[cid]}")
single query: ['c6', 'c1', 'c2']
multi-query: ['c1', 'c6', 'c7', 'c2']
c1 0.0645 Damaged items get a full refund including shipping.
c6 0.0328 Accidental damage such as a cracked screen is not covered by the warranty.
c7 0.0323 To return an item, print the return label from your orders page.
c2 0.032 Refunds are processed within 5 working days of approval.
The damaged-item refund c1 now ranks first, because two phrasings found it. The return instructions c7 appear
for the first time. The single query found neither.
The cost is one extra LLM call plus several searches, which can run in parallel. Multi-query helps most with vague or multi-part questions. For a precise question ("What is error E-4012?"), it adds noise and latency.
4.4 HyDE: search with a hypothetical answer¶
A question and the chunk that answers it look different. The question is short and asks; the chunk is a longer statement. HyDE (Hypothetical Document Embeddings) asks the LLM to write a fake answer first, then searches with the embedding of that answer. The fake answer may be wrong on details, but it looks like the real answer chunk, using the same kind of vocabulary, so it lands near it in vector space.
question = "Can I get my money back if it arrived broken?"
hypothetical = ("Yes. If an item arrives damaged or broken you get a full refund, "
"usually including the shipping cost.") # written by an LLM; details may be wrong
print("question :", search(question))
print("HyDE answer :", search(hypothetical))
question : [('c1', 0.0), ('c2', 0.0), ('c3', 0.0)]
HyDE answer : [('c1', 0.77), ('c4', 0.22), ('c2', 0.13)]
The question shares no words with any chunk, so every score is 0 and the "top 3" is arbitrary. The hypothetical
answer uses the documents' own vocabulary ("damaged", "full refund", "shipping") and finds c1 with a clear lead.
Real embedding models narrow this gap, but the effect is the same.
The fake answer is only a search key. Never show it to the user or put it in the final prompt. Trade-offs:
- It adds an LLM call (latency and cost) before every search.
- It helps most when users' wording differs from the documents' wording, and in zero-shot domains without a tuned embedding model.
- It can hurt when the LLM knows nothing about the topic and invents a misleading answer, for example with internal product names. Keep the original query in the mix: search with both and fuse with RRF.
4.5 Reranking with a cross-encoder¶
Embedding search uses a bi-encoder. The question and every chunk are embedded separately, so search over millions of chunks is fast, but the model never looks at the question and chunk together. A cross-encoder reranker reads the question and a chunk together and outputs a relevance score. It's far more accurate, and far too slow to run over the whole corpus. So use both, in two stages:
flowchart LR
Q[Question] --> B[Stage 1: bi-encoder / hybrid search<br/>millions of chunks → top 50<br/>fast, high recall]
B --> C[Stage 2: cross-encoder reranker<br/>50 → top 5<br/>slow, high precision]
C --> L[LLM prompt]
| Bi-encoder (embeddings) | Cross-encoder (reranker) | |
|---|---|---|
| Input | Question or chunk | Question and chunk together |
| Precompute chunk vectors | Yes, at indexing time | No, every pair is scored at query time |
| Speed | Milliseconds over millions (ANN index) | ~10–100 ms per batch of pairs |
| Accuracy | Good | Better: sees word-level interactions between question and chunk |
| Role | Recall: get the answer into the top 50 | Precision: get it into the top 5 |
# no-run — pip install sentence-transformers (open-source rerankers run on CPU for small batches)
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base") # or a hosted API: Cohere Rerank, Voyage, Jina
def retrieve_and_rerank(question: str, first_k: int = 50, final_k: int = 5) -> list[dict]:
candidates = hybrid_search(question, k=first_k) # stage 1: wide net
scores = reranker.predict([(question, c["text"]) for c in candidates])
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [{**c, "rerank_score": float(s)} for c, s in ranked[:final_k]]
Reranking is usually the single biggest quality gain you can add to a basic pipeline. Tips:
- Rerank 20–100 candidates. Too few and the right chunk isn't there to promote. Too many and latency grows.
- Reranker scores are comparable across questions, so a threshold ("drop chunks below 0.2") is a good way to send fewer chunks or answer "I don't know".
- An LLM can rerank too ("rank these 20 passages by relevance"). It's slower and costlier than a cross-encoder, but needs no extra model.
4.6 MMR: avoid five copies of the same chunk¶
Corpora are full of near-duplicates: the same policy in three versions, a paragraph copied into several pages. Plain top-k returns all of them, wasting prompt space. Maximal marginal relevance (MMR) picks results one at a time. Each pick balances relevance to the query against similarity to the chunks already chosen:
DUP_CHUNKS = {
"a": "Refunds are processed within 5 working days of approval.",
"b": "Refunds are processed within 5 working days after approval.", # near-duplicate (older version)
"c": "Refund processing takes 5 working days once approved.", # near-duplicate (FAQ copy)
"d": "Refunds go back to the original payment method.",
"e": "Refunds older than 30 days need a manager's approval.",
}
vecs = {cid: embed(t) for cid, t in DUP_CHUNKS.items()}
def mmr(query: str, k: int = 3, lam: float = 0.5) -> list[str]:
q = embed(query)
chosen, candidates = [], set(vecs)
while candidates and len(chosen) < k:
def score(cid):
redundancy = max((cosine(vecs[cid], vecs[s]) for s in chosen), default=0)
return lam * cosine(q, vecs[cid]) - (1 - lam) * redundancy
best = max(sorted(candidates), key=score)
chosen.append(best)
candidates.remove(best)
return chosen
query = "How are refunds processed?"
plain = sorted(vecs, key=lambda c: -cosine(embed(query), vecs[c]))[:3]
print("plain top-3:", plain, [DUP_CHUNKS[c][:45] for c in plain])
print("MMR top-3: ", mmr(query), [DUP_CHUNKS[c][:45] for c in mmr(query)])
plain top-3: ['a', 'b', 'd'] ['Refunds are processed within 5 working days o', 'Refunds are processed within 5 working days a', 'Refunds go back to the original payment metho']
MMR top-3: ['a', 'd', 'e'] ['Refunds are processed within 5 working days o', 'Refunds go back to the original payment metho', "Refunds older than 30 days need a manager's a"]
Plain top-3 spends two of its three slots on the same fact (a and its older copy b). MMR keeps a, skips the
copy, and uses the space for two different facts. lam=1 is plain relevance ranking and lam=0 is pure diversity. Values around 0.5–0.7 work well. Deduplicating at
indexing time (content hashes, near-duplicate detection) is even better, because then the copies never get stored.
4.7 Metadata filters and query routing¶
Some questions need a filter, not a better similarity score. "What was the refund policy in 2024?" should only search 2024 documents. Two common patterns:
- Self-query: an LLM extracts filters from the question with structured output,
for example
{"query": "refund policy", "year": 2024, "doc_type": "policy"}. Your code validates them against the allowed fields before searching. Never pass LLM output straight into a filter, and never let it set the tenant: that comes from the logged-in session. - Routing: classify the question and send it to the right source. Policies go to the vector index, order status goes to an API, revenue numbers go to text-to-SQL. Agentic RAG goes further and lets an agent decide what to search, and whether to search again.
4.8 Which technique, when?¶
| Technique | Fixes | Added latency | Added cost | Add it when |
|---|---|---|---|---|
| Hybrid BM25 + vectors | Exact terms, codes, names | Low | Low | Almost always |
| Query rewriting | Follow-up questions in chat | 1 small LLM call | Low | Any multi-turn chat |
| Reranking | Right chunk ranked too low | 50–300 ms | Low to medium | Almost always, once you have an eval set |
| Multi-query + RRF | Vague or multi-part questions | 1 LLM call + parallel searches | Medium | Recall is low on complex questions |
| HyDE | Vocabulary and shape mismatch | 1 LLM call | Medium | Zero-shot domains, short questions |
| MMR / dedup | Near-duplicate results | Negligible | None | Corpora with versions or copies |
| Self-query filters | Dates, products, doc types | 1 small LLM call | Low | Questions carry constraints |
Add them one at a time and keep each only if your eval set improves. Every technique adds latency, and stacking all of them blindly can make answers slower without making them better.
Interview questions¶
What is the difference between a bi-encoder and a cross-encoder?
A bi-encoder embeds the query and documents separately into vectors, so document vectors are precomputed and nearest-neighbour search scales to millions. A cross-encoder takes the query and a document together and outputs a relevance score. It's more accurate because it models their interactions, but it must run per pair at query time. Hence two-stage retrieval: the bi-encoder (plus BM25) retrieves the top 50, and the cross-encoder reranks them to the top 5.
Explain HyDE. When does it help and when does it hurt?
Hypothetical Document Embeddings: an LLM writes a plausible answer to the question, and you search with that answer's embedding, because an answer looks more like the target chunk than a question does. It helps with vocabulary or shape mismatch and zero-shot domains. It costs an LLM call per query, and it can mislead when the LLM knows nothing about the domain. The hypothetical answer is only a search key and is never shown to the user.
How do you handle follow-up questions in a RAG chatbot?
Rewrite the latest message into a standalone query using the conversation history, with a small LLM call. Retrieve with the rewritten query, but generate with the full history and the original question. Log both, because bad rewrites are a common hidden failure.
Your retriever returns the right chunk at rank 12, but you only send 5. What do you do?
Add a reranker: retrieve 30–50 candidates with hybrid search, rerank with a cross-encoder, and send the top 5. Also check chunking (is the chunk diluted?), contextual headers, and hybrid search for exact terms. Measure recall@5 before and after on the eval set.
Practice¶
- Add a fourth variant to the multi-query example and see how the fused ranking changes.
- Run HyDE with a deliberately wrong hypothetical answer ("Express shipping is free.") and watch retrieval go wrong.
- Change
laminmmrto 1.0 and 0.2 and compare the picks.
Next: 5. Parent–child retrieval & GraphRAG Back to: RAG overview · Notes overview