RAG Retrieval Returns the Wrong Chunks: A Production Debugging Guide
Your RAG demo worked and production doesn't. Before you swap the embedding model, check the four things that actually break retrieval: recall settings, chunk boundaries, keyword blindness, and where the answer sits in the prompt.
When a RAG system that demoed well starts returning irrelevant chunks in production, the cause is almost never the embedding model. In order of how often I've found them: the approximate index is tuned for speed and quietly dropping the right result, the chunking splits answers across boundaries, the query depends on an exact token that dense vectors handle poorly, or the right passage is retrieved and then buried in the middle of a long prompt. Work through those four before you touch the model.
Key takeaways
- Log the retrieved chunks next to every answer. Without that log you cannot tell a retrieval failure from a generation failure, and you will tune the wrong half of the system.
- An approximate index changes your results. pgvector's own docs say so — check hnsw.ef_search or ivfflat.probes before blaming the retriever.
- Chunking is a retrieval decision, not a preprocessing detail. If the answer spans a boundary, no k is large enough.
- Dense vectors are weak at exact-token matching. Add BM25 and merge with reciprocal rank fusion when your corpus has identifiers.
- Position matters: models use information at the start and end of a long context better than information in the middle.
First: prove it is actually retrieval
The single most valuable thing you can add to a production RAG system is a log line containing the retrieved chunk IDs, their scores, and the final answer — stored together, queryable, for every request.
Without it, every debugging session is guesswork. With it, triage takes thirty seconds:
- The correct passage is in the retrieved set, and the answer is still wrong → generation or prompt problem. Skip to the section on position.
- The correct passage is not in the retrieved set → retrieval problem. Continue below.
Build a small labelled set while you're at it — fifty real user queries with the document that should answer each one. You do not need a formal eval harness on day one. You need to be able to answer "did the right chunk come back, yes or no" on a fixed set of queries, so that when you change something you know whether it helped.
Cause 1: your approximate index is trading recall for speed
This is the one that explains "it worked in my notebook." In a notebook you probably had a few thousand vectors and did an exact scan. In production you added an index, because sequential scans over millions of rows are not viable.
pgvector's README is unusually direct about the consequence: "Unlike typical indexes, you will see different results for queries after adding an approximate index." Both of its index types expose a recall knob, and both default conservatively:
| Index type | Search parameter | Default | Effect of raising it |
|---|---|---|---|
| HNSW | hnsw.ef_search | 40 | Larger dynamic candidate list — better recall, slower queries |
| IVFFlat | ivfflat.probes | 1 | More lists probed — better recall, slower queries |
An ivfflat.probes of 1 means you are searching a single partition of your vector space. If the answer lives in a neighbouring list, it is not merely ranked low — it is never considered.
Measure it directly. Run your labelled queries with the index, then again with index scans disabled to get ground truth, and compare:
-- Ground truth: force an exact scan for this session.
-- Plain SET, not SET LOCAL — SET LOCAL only applies inside an explicit
-- transaction block, and outside one Postgres warns and discards it,
-- which would silently give you an index scan and a perfect-looking recall.
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT id, content, embedding <=> :query_vec AS distance
FROM documents
ORDER BY embedding <=> :query_vec
LIMIT 10;
-- Approximate: the path production actually takes
SET enable_indexscan = on;
SET enable_bitmapscan = on;
SET hnsw.ef_search = 40; -- the default
SELECT id, content, embedding <=> :query_vec AS distance
FROM documents
ORDER BY embedding <=> :query_vec
LIMIT 10;If the two lists differ meaningfully, raise ef_search until recall on your labelled set is acceptable, and treat the latency cost as the price of correctness. A retriever that answers in 20ms and misses the document is not fast — it is wrong, quickly.
The metric has to match the index
One quiet variant of this bug: an index built for one distance metric and queried with another. In pgvector, vector_cosine_ops and vector_l2_ops build different indexes, and using the mismatched operator in your ORDER BY means the planner won't select that index at all. You fall back to a sequential scan, which is slow but correct — so this one shows up as a latency problem long before anyone suspects retrieval. Check that the operator in the query matches the operator class in the index definition.
Cause 2: your chunk boundaries split the answer
If retrieval quality is uneven — great on some questions, hopeless on others — look at the chunks, not the index.
The classic failure is a fixed-size splitter cutting mid-explanation, so that the sentence defining a term lands in chunk 7 and the sentence applying it lands in chunk 8. Each chunk on its own is a weak match for the user's question. Neither ranks in the top k. Raising k doesn't help, because the problem is that no single chunk contains the answer.
Three fixes, in increasing order of effort:
- Overlap. A modest overlap between adjacent chunks (10–20% of chunk size) means boundary-spanning facts appear intact in at least one chunk. Cheap, and often enough.
- Split on structure, not character count. Markdown headings, HTML sections, function boundaries in code. A chunk that corresponds to a real semantic unit retrieves far better than one that corresponds to 512 characters.
- Small-to-big retrieval. Embed and search over small units for precision, but return the enclosing section to the model for context. You get the ranking behaviour of small chunks and the completeness of large ones.
A tell for this failure mode: your retrieved chunks are topically right but truncated — they discuss the correct subject and stop just before the useful part. That is a boundary problem, every time.
Cause 3: dense vectors are bad at exact tokens
Embeddings encode meaning. They are, by construction, not great at "find the document containing exactly ERR_CONN_4021" or getUserByEmail or a ticket number. A rare identifier contributes very little to a dense vector, so a semantically similar but incorrect document can easily outrank the exact match.
PresenceZero is a good illustration of the shape of corpus this bites hardest: almost all of its useful signal is exact — domain strings, RDAP status values, category names. Semantic similarity is simply the wrong tool for "does this exact token appear."
The fix is hybrid search: run a lexical query (BM25, or Postgres full-text search) alongside the vector query and merge the two ranked lists. The merge step is where people over-engineer. Reciprocal rank fusion avoids the tuning problem entirely — it scores by rank position rather than by raw relevance score, so you never have to normalise a BM25 score against a cosine distance:
def reciprocal_rank_fusion(result_lists, k=60):
"""Merge ranked ID lists. `k` is the rank constant; 60 is the common default."""
scores = {}
for results in result_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
merged = reciprocal_rank_fusion([vector_results, bm25_results])That is the whole algorithm — Elasticsearch implements the same 1 / (k + rank) formula with a rank_constant defaulting to 60, and its documentation makes the point plainly: "Not only does this remove the need to figure out what the appropriate weighting is using linear combination, but RRF is also shown to give improved relevance over either query individually."
Two practical notes. First, run both queries concurrently; done sequentially, hybrid search doubles your retrieval latency for no reason. Second, a document appearing in both lists gets both contributions, which is exactly the behaviour you want — agreement between two independent signals should outrank a strong showing in one.
Cause 4: you retrieved the right chunk and then buried it
If your log shows the correct passage in the retrieved set and the answer is still wrong, retrieval is done and the problem moved downstream.
The finding to know here is from Lost in the Middle: How Language Models Use Long Contexts (Liu et al.): performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle." The paper found this even in models built for long contexts.
The operational consequences are concrete:
- Retrieving more is not free. Going from k=5 to k=20 to be safe pushes your best chunk toward the middle of the prompt, where it is used least effectively. It can measurably reduce answer quality while improving retrieval recall — the two metrics move in opposite directions, which is why you must track both.
- Order deliberately. After ranking, put the highest-scoring chunks at the start and end of the retrieved block rather than in descending order. It is a few lines of code and costs nothing.
- Rerank instead of padding. A cross-encoder reranker over the top 30 candidates, keeping the best 5, is a better use of budget than passing 30 chunks to the model and hoping.
Long context windows changed the economics of this, but they did not repeal it. "It all fits" is not the same as "it is all used."
The order I actually work through it
- Check the log. Is the right chunk being retrieved at all? This partitions the problem.
- If not retrieved: raise the recall knob (
hnsw.ef_search/ivfflat.probes) and re-measure. Cheapest possible fix, and often the whole bug. - Still not retrieved, and the near-misses look truncated? Fix chunking — overlap first, then structural splitting.
- Still not retrieved, and the query hinges on an identifier? Add BM25 and fuse with RRF.
- Retrieved but the answer is wrong? Reduce k, reorder so the best chunks sit at the edges, add a reranker.
- Only now consider a different embedding model — and re-run the labelled set before and after, because a full re-embed is a real cost and you should be able to prove it bought something.
The theme across all of this is the same one that makes agent tooling work well: be deliberate about what enters the context window and in what order. I make the same argument about instruction files and MCP tool schemas in Claude Code vs Cursor — different surface, identical discipline.
Frequently asked questions
Should I fix retrieval by switching to a bigger embedding model?
Almost never first. Swapping models invalidates your whole index and costs a full re-embed, and it does nothing for the three most common failure modes: an approximate index tuned for speed over recall, chunk boundaries that split the answer, and queries that hinge on an exact identifier no embedding captures well. Measure recall at k first, then decide.
How do I tell whether the problem is retrieval or generation?
Log the retrieved chunks alongside every answer, then read them. If the correct passage is in the retrieved set and the answer is still wrong, it is a generation or prompt-ordering problem. If the passage never arrives, it is retrieval. Without that log you are guessing at which half of the system to tune.
Why does the same query work in my notebook but fail in production?
The usual cause is that the notebook did an exact scan over a few hundred vectors while production queries an approximate index. pgvector's docs are explicit that you will see different results after adding an approximate index. Raise hnsw.ef_search or ivfflat.probes and re-measure before concluding the retriever is broken.
Is hybrid search worth the complexity?
If your corpus contains identifiers — error codes, SKUs, function names, ticket numbers — yes. Dense vectors are weak on exact-token matching by design. Running BM25 alongside vector search and merging with reciprocal rank fusion is a well-trodden fix and needs no weight tuning.
References
- Lost in the Middle: How Language Models Use Long Contextsarxiv.org · accessed 2026-08-09
- pgvector — open-source vector similarity search for Postgresgithub.com · accessed 2026-08-09
- Reciprocal rank fusion — Elasticsearch Referenceelastic.co · accessed 2026-08-09
Last reviewed August 9, 2026
Tahir Nazir
Senior AI Engineer & Full-Stack Lead
5+ years shipping AI-powered products — RAG pipelines, agentic workflows, and MCP tooling. Top Rated on Upwork with a 100% job success score.
More about Tahir →Keep reading
New posts land here first. Follow along by RSS, or get in touch if you are building something similar.
Related articles
Claude Code vs Cursor: How Each One Decides What Your Agent Knows
Both tools read project instructions from disk, but they disagree on file names, load order, and scoping. Here is the concrete difference — and how to keep one repo working well in both.ComparisonAI Engineering8 min readHow to Build an MCP Server (TypeScript, End to End)
A working Model Context Protocol server in TypeScript — project setup, tool registration with Zod, stdio transport, and wiring it into Claude Code and Cursor without breaking the JSON-RPC stream.How-toAI Engineering11 min read