Your RAG bot has the right document and still answers wrong. The problem is often the query, not the index.
Intro
The Right Document, Wrong Answer
Your RAG bot has the right document sitting in its index, and it still gives a wrong answer. You check the embeddings, the chunking, the vector database, and everything looks fine. So what went wrong? Very often the problem is the query itself. Today we fix that.
- Index is fine
- Query is the problem
Where We Are
In the last episode we split documents into chunks that are worth retrieving. Now we look at the other side of the match: the question. We will cover the knobs on retrieval, a diversity trick called maximal marginal relevance, and a family of techniques that transform the query before searching. We will also implement the key pieces in plain Python.
- Episode 17
- Retrieval and query rewriting
The Plan
Here is the plan. First, why user questions make poor search queries. Then the retrieval knobs: top k, thresholds, filters, and diversity. Then the transformations: rewriting, multi query, hypothetical documents, step back, decomposition, and routing. We finish with cost, code, and the mistakes that bite in production.
- Knobs and MMR
- Query transformations
- Code, cost, mistakes
Why Questions Are Bad Queries
Questions Are Not Documents
A user question and the document that answers it often look nothing alike. The user asks, how do I get my money back. The document says, refunds are issued within fourteen days of cancellation. The question is short, informal, and phrased as a request. The passage is formal and states facts. Embeddings bridge some of that gap, but not all of it.
- Questions ask
- Documents state
Three Ways Queries Fail
Queries fail in three common ways. They can be vague, like a single word. They can depend on context, like, what about the second one, which means nothing alone. Or they can bundle several questions into one, so the embedding lands between topics and matches none of them well. Each failure needs a different fix.
- Vague or short
- Missing context
- Several questions in one
Embedding Space Picture
Picture the embedding space from episode eleven. Documents are dots, and the query is one more dot. Retrieval returns the nearest dots. If the query dot lands in the wrong neighborhood, because it is vague or mixed, the nearest neighbors are wrong, no matter how good the index is. Our job is to move that query dot to the right place.
- Query dot vs document dots
- Nearest is not always right
Retrieval Knobs
Top-k: How Many to Fetch
The first knob is top k, how many chunks you fetch. Suppose the best chunk sits at rank six. With k equal to three, you never see it, and the model has no chance. With k equal to eight, it is in the context. But a larger k adds noise, more tokens to pay for, and more distractors for the model to be confused by.
- Too small: miss the answer
- Too large: noise and cost
Fetch Wide, Then Narrow
A good habit is to separate two numbers: how many candidates you retrieve, and how many you finally put in the prompt. Retrieve generously, say twenty or thirty, then narrow down to the handful that go to the model. Recall is cheap at the retrieval stage. The narrowing step is where precision comes from, and we will improve it a lot in the next episode.
- Retrieve 20 to 50
- Keep 3 to 5 for the prompt
Similarity Thresholds
The second knob is a similarity threshold. Instead of always returning the top k, you drop anything below a minimum score. If the scores are zero point eight two, zero point seven nine, then zero point six one, and zero point five five, a threshold of zero point six keeps three. The big benefit: if nothing passes, you know the corpus probably has no answer, and you can say so.
- Drop weak matches
- Enables 'no answer'
Thresholds Are Model-Specific
A warning: a score of zero point six means different things for different embedding models. There is no universal cutoff. Calibrate it. Take a few dozen real questions, label which chunks are relevant, and look at the score distributions of relevant and irrelevant chunks. Pick the threshold that balances missing good chunks against letting bad ones through.
- Scores depend on the embedder
- Calibrate on your data
Metadata Filters
The third knob is metadata filters. Chunks carry labels like product, year, language, or which team may read them. Filtering on those fields before or during the search shrinks the candidate set to what is actually eligible. If the user asks about the twenty twenty four pricing for product B, no similarity score should ever surface a twenty nineteen page for product A.
- Filter before similarity
- Year, product, language, access
Diversity with MMR
The Redundancy Problem
Pure similarity ranking has a flaw. If your corpus contains the same fact in five places, say a policy copied into several documents, the top five results can be near duplicates. They fill your context window with one idea, and the different chunk that holds the missing detail sits at rank six. We want relevance and diversity together.
- Top results say the same thing
- Wasted context
Deriving MMR
Maximal marginal relevance builds the result list greedily. Let q be the query, S the set already selected, and d a candidate. Score each candidate as lambda times its similarity to the query, minus one minus lambda times its highest similarity to anything already selected. The first term rewards relevance. The second punishes redundancy. Pick the best score, add it to S, and repeat.
- Reward relevance to query
- Penalize similarity to chosen
What Lambda Does
Lambda is the dial. At one, the second term vanishes and you get ordinary top k. At zero you ignore the query after the first pick and chase diversity alone, which returns irrelevant material. In practice, values between about zero point five and zero point eight are common starting points, but tune it on your own questions.
- Lambda 1: plain top-k
- Lambda 0: pure diversity
- Typical: 0.5 to 0.8
Worked Example: Step One
Let us run it with lambda equal to zero point seven. Three candidates. A has similarity zero point nine to the query, B zero point eight eight, C zero point seven. A and B are near duplicates, with similarity zero point nine five between them. A and C have only zero point three. Step one, nothing is selected yet, so the score is just zero point seven times the query similarity: A scores zero point six three, B zero point six one six, C zero point four nine. A wins.
- Lambda = 0.7
- A: 0.90, B: 0.88, C: 0.70
Worked Example: Step Two
Step two, with A selected. For B, the relevance part is zero point six one six, and the redundancy penalty is zero point three times zero point nine five, which is zero point two eight five. The final score is zero point three three one. For C, relevance is zero point four nine, the penalty is zero point three times zero point three, which is zero point zero nine, giving zero point four. C beats B, even though B was more similar to the query.
- B: 0.616 - 0.285 = 0.331
- C: 0.49 - 0.09 = 0.40
- C wins
MMR in Practice
In practice you run MMR as a second step. Fetch a larger pool, say thirty candidates with their embeddings, compute the pairwise similarities between them, and pick five greedily. That is a few hundred dot products, which is negligible next to any model call. Many vector stores and frameworks offer MMR as a built in search mode.
- Fetch a larger pool first
- Run MMR over candidates
- Cost: small extra compute
Rewriting with History
The Follow-Up Problem
Now the transformations. The most important one is fixing follow up questions. A user asks about the Pro plan, gets an answer, and then asks, and how much is it for students? Embedded alone, that sentence has no mention of the Pro plan, so retrieval has no idea what it refers to. The query depends on the conversation.
- 'What about the second one?'
- Needs the conversation
Standalone Query Rewriting
The fix is to ask a model to rewrite the follow up into a standalone query, given the recent history. The output should be something like, Pro plan pricing for students. Now it contains every term the retriever needs and nothing it does not. Note that you use the rewritten query for retrieval, but usually the original wording for the final answer.
- LLM sees history plus follow-up
- Outputs a self-contained query
Python: History-Aware Rewrite
In Python, this is a small function. It takes the history, the latest question, and a function that calls your model. The prompt says: rewrite the last question so it can be understood without the conversation, do not add new facts, return only the query. Use temperature zero, and if the call fails or returns empty text, fall back to the original question. Keep only the last few turns of history to control cost.
- Small prompt, low temperature
- Fall back to original
Multi-Query Expansion
Next, multi query expansion. A single phrasing may miss a document that uses different vocabulary. So you ask a model for three to five alternative phrasings of the question, perhaps emphasizing different words or angles, and run retrieval for each. Each paraphrase lands in a slightly different spot, and together they cover more ground.
- Generate 3 to 5 paraphrases
- Cover different wordings
Merging and Deduplicating
The same chunk will often come back from several paraphrases, so you must deduplicate by chunk ID. Two simple merge rules work well: keep the best score each chunk achieved, or count how many query variants retrieved it, since agreement is a useful signal. In code, a dictionary keyed by chunk ID does the job, and then you sort and cut to your final k.
- Same chunk, many hits
- Keep best score or count votes
Python: Multi-Query Retrieval
The code follows that recipe. Generate n variants with the model, always add the original question to the list, search each one, and fold the results into a dictionary that keeps the maximum score per chunk ID. Finally sort by score and return the top results. Always including the original question is a cheap safeguard against a bad paraphrase.
- Generate variants
- Search each, merge by ID
- Include the original query
HyDE and Step-Back
HyDE: Search With a Fake Answer
Here is a clever idea called HyDE, hypothetical document embeddings. Instead of embedding the question, ask the model to write a plausible answer passage, even without any knowledge of your data. Then embed that passage and search with it. An answer looks much more like a document than a question does, so its embedding tends to land closer to the real thing.
- Hypothetical Document Embeddings
- Answers look like documents
Why HyDE Works, and Its Risk
The generated passage can contain wrong facts, and that is fine, because it is only a search key. What matters is that its vocabulary and structure resemble real documents. The real retrieved chunks then supply the true facts. The risk is that for niche topics the model may invent a passage that drifts to the wrong subject, so test it. It also adds one full generation call before retrieval.
- Facts may be wrong
- Style and vocabulary matter
- Real documents ground the answer
Step-Back Queries
Step back prompting goes the other way. A very specific question may match nothing, while the answer depends on a general concept. So the model first produces a broader version, for instance, turning why was I charged twice on March third into, how does billing handle duplicate charges. You retrieve for both the specific and the broad query, and give the model principles along with details.
- Ask the broader question first
- Retrieve principles, then specifics
Decomposition and Routing
Multi-Hop Questions
Some questions need a chain of facts. Who manages the team that built the billing service? One document says which team built billing. Another says who manages that team. No single chunk contains both, so one search cannot find the answer. These are called multi hop questions.
- Answer needs chained facts
- One search cannot do it
Decomposition
Decomposition asks a model to split the question into sub questions. If they are independent, like comparing the refund rules of two plans, you retrieve for each in parallel and merge the context. If they depend on each other, as in the manager example, you run them in sequence, feeding the first answer into the next query. Sequential steps add latency, one round trip each.
- Split into sub-questions
- Sequential or parallel
- Combine at the end
Query Routing
Query routing decides where a query should go. You may have separate indexes for product docs and HR policies, a SQL database for numbers, or no need to retrieve at all for a greeting or a simple arithmetic question. A small classifier or a cheap model call picks the route. Routing saves cost and avoids searching the wrong corpus.
- Choose the right source
- Or skip retrieval entirely
Routing Pitfalls
Routers fail when routes overlap or descriptions are vague. Keep the routes few, give each a clear description and a couple of example queries, and consider a fallback: if the router is unsure, search everything. A wrong route is worse than a slower search, because the right document is never even looked at.
- Keep routes few and distinct
- Allow multi-route fallback
Cost and Latency
What Each Technique Costs
Every transformation has a price. A history rewrite is one short model call. Multi query is one call plus several searches, and searches run in parallel so they are cheap in time. HyDE needs a longer generation, which is slower. Step back adds a call and a second search. Decomposition can cost several sequential calls. Routing is one small call. Exact timings depend on your model and hardware, so measure.
- Rewrite: one short LLM call
- Multi-query: one call, N searches
- HyDE: one long generation
- Decomposition: several calls
Use the Cheapest Tool That Works
The practical rule is to use the cheapest tool that fixes your observed failure. Run a small, fast model for rewriting. Apply techniques conditionally: rewrite only when there is conversation history, decompose only when the question has several parts. And measure. If a technique does not raise the share of questions where the right chunk is retrieved, drop it.
- Small model for rewriting
- Apply only when needed
- Measure recall gain
Python: MMR From Scratch
MMR in Plain Python
Here is MMR in plain Python. A helper computes cosine similarity. The main function keeps a list of selected indexes and a list of remaining ones. In each round it loops over the remaining candidates, computes lambda times the query similarity, subtracts one minus lambda times the maximum similarity to the selected items, and moves the best one across. Handle the first round by treating the redundancy term as zero.
- Cosine similarity helper
- Greedy selection loop
Checking With Our Numbers
Always test with the numbers you derived by hand. Build three small vectors whose similarities match the example, run the function with lambda zero point seven and k equal to two, and confirm that it returns A and then C, not A and B. If your code disagrees with your arithmetic, one of them is wrong, and it is better to find that now.
- Expect order A, C, B
- Unit-test your logic
Common Mistakes
Mistake: Over-Rewriting
The first mistake is over rewriting. A rewriter can silently drop a constraint, like thirty days, or add an assumption the user never made. The query then drifts and retrieves a nicely matching but wrong chunk. Constrain the prompt, forbid new facts, log original and rewritten queries side by side, and search with both when in doubt.
- Rewriter adds or drops meaning
- Constrain the prompt
- Keep the original too
Mistake: Top-k Too Small
The second mistake is a top k that is too small. Teams pick three because prompts feel tidy, then wonder why answers are incomplete. Build a small set of questions with known relevant chunks and plot recall as k grows. You will usually see a curve that flattens, and the right place to fetch is near the start of the flat part.
- Right chunk at rank 7
- Measure recall at k
Mistake: Ignoring No-Answer
The third mistake is ignoring the no answer case. Retrieval always returns something, even for a question the corpus cannot answer. Those weak chunks get stuffed into the prompt, and the model, wanting to help, builds a confident answer from nothing. Use a similarity threshold, instruct the model to say it does not know when the context lacks the answer, and test with questions that have no answer.
- Always returning k chunks
- Model fabricates from weak context
- Threshold plus 'I don't know'
Recap and Next Episode
Recap
Let us recap. User questions are poor queries, so we reshape them. We tune top k, thresholds, and metadata filters, and use MMR to trade relevance against redundancy. We rewrite follow ups into standalone queries, expand with multiple phrasings, use HyDE and step back, decompose multi hop questions, and route to the right source. And we watch for drift, small k, and missing answers.
- Fix the query, not just the index
- Knobs: k, threshold, filters, MMR
- Transform: rewrite, multi-query, HyDE, step-back, decompose, route
Next: Reranking
We now fetch a wide, diverse set of candidates. But the order inside that set is still decided by a fairly rough similarity score. In the next episode we will fix that with reranking, using cross encoders that read the query and each chunk together.
- Retrieve wide, rerank precisely
- Cross-encoders