Guide
Why your RAG chatbot gives wrong answers, and how to find the cause
By Aman Singh · Published · 10 min read
Your RAG chatbot answers confidently, and the answer is wrong. The usual reaction is to rewrite the prompt or move to a bigger model. Several write-ups on RAG failures, such as this one from Redgate, argue that most wrong answers start earlier, in what was retrieved. The model can only work with the text it is handed.
This guide gives you a way to find out which part is failing before you change anything: three checks, in order, and a script that runs the first two with no model and no API key. Everything below was run in October 2026 on Node.js 20.
Want a chatbot that answers from your documents correctly? Custom RAG chatbot development →
The three places an answer goes wrong
- 1The answer is not intact in any chunk. The text that answers the question was cut in half by the splitter, or lost when a PDF table was turned into text, or never indexed at all.
- 2The right chunk exists but is not retrieved. It is in the index, but the search ranks other chunks above it, or the number of chunks you pass on (k) is too small.
- 3The right chunk is retrieved and the model still answers wrong. It ignored it, mixed it with other text, or filled a gap from its own memory.
The order matters. A failure at 1 makes 2 and 3 meaningless, and a failure at 2 means the model never saw the answer. Tuning the prompt (check 3) when the fault is in 1 or 2 changes nothing.
Build a small golden set
A golden set is a list of real questions, each with the exact words in your documents that the correct answer depends on. Twenty to fifty is enough to start. Take the questions from support tickets, chat logs or sales emails, not from your imagination, because real users phrase things differently from the documents.
Here is the small document used in the test below, a refund policy. Note that the annual-plan rule and its exception sit in one sentence:
# Refund policy
## Monthly plans
Monthly plans can be cancelled at any time. We do not refund the current billing month once it has started.
## Annual plans
Annual plans can be refunded in full within 30 days of purchase. After 30 days, annual plans are not refundable, except where required by law or when a service outage lasted more than 24 hours in a row. In those cases we refund the unused months.
## Add-ons
Add-ons such as extra seats are refundable within 14 days of purchase. Usage-based charges are never refundable.
## How to request a refund
Email support with your invoice number and the email on the account. Approved refunds take 5 to 10 business days to reach your card.Checks 1 and 2: a script, no model needed
The script splits the document three ways, then asks two questions for each golden item. Is the evidence text intact inside at least one chunk? And does a search put that chunk in the top 1 or top 3? It uses a small keyword scorer so it has no dependencies; swap the buildSearch function for your real vector search and the checks stay the same.
// Checks the first two RAG failure points without calling any model:
// 1. Is the answer intact in at least one chunk of the index?
// 2. Does retrieval put that chunk in the top k?
// The retriever here is a small TF-IDF scorer so the script has no dependencies.
// Swap `search` for your real vector search; the checks stay the same.
import { readFileSync } from "node:fs";
type Chunk = { id: number; text: string };
// ---- chunking strategies ----------------------------------------------------
function fixedSize(text: string, size: number, overlap = 0): Chunk[] {
const chunks: Chunk[] = [];
const step = size - overlap;
for (let start = 0; start < text.length; start += step) {
chunks.push({ id: chunks.length, text: text.slice(start, start + size).trim() });
if (start + size >= text.length) break;
}
return chunks;
}
// Split on headings so a section stays whole, and keep the heading with it.
function bySection(text: string): Chunk[] {
const parts = text.split(/\n(?=## )/);
return parts.map((part, id) => ({ id, text: part.trim() }));
}
// ---- a tiny TF-IDF retriever --------------------------------------------------
const STOP = new Set(["the", "a", "an", "of", "to", "is", "are", "can", "i", "my", "on", "in", "do", "for", "and", "or", "get", "what", "how", "after", "need"]);
const tokens = (s: string) =>
s.toLowerCase().match(/[a-z0-9]+/g)?.filter((t) => !STOP.has(t)) ?? [];
function buildSearch(chunks: Chunk[]) {
const docs = chunks.map((c) => tokens(c.text));
const df = new Map<string, number>();
for (const doc of docs) for (const t of new Set(doc)) df.set(t, (df.get(t) ?? 0) + 1);
const idf = (t: string) => Math.log(1 + chunks.length / (df.get(t) ?? 1));
return (query: string, k: number): Chunk[] => {
const q = tokens(query);
return chunks
.map((chunk, i) => {
const counts = new Map<string, number>();
for (const t of docs[i]) counts.set(t, (counts.get(t) ?? 0) + 1);
const score = q.reduce((sum, t) => sum + (counts.get(t) ?? 0) * idf(t), 0);
return { chunk, score: score / Math.sqrt(docs[i].length || 1) };
})
.sort((a, b) => b.score - a.score)
.slice(0, k)
.map((r) => r.chunk);
};
}
// ---- the golden set: a question and the exact text the answer depends on ------
const golden = [
{ q: "Can I get a refund on my annual plan after 60 days?", evidence: "except where required by law or when a service outage lasted more than 24 hours" },
{ q: "How long do refunds take to reach my card?", evidence: "5 to 10 business days" },
{ q: "Can I get my money back for the current month on a monthly plan?", evidence: "do not refund the current billing month" },
{ q: "Are add-ons refundable?", evidence: "within 14 days of purchase" },
{ q: "What do I need to send support to request a refund?", evidence: "invoice number" },
];
const norm = (s: string) => s.replace(/\s+/g, " ").toLowerCase();
const has = (c: Chunk, evidence: string) => norm(c.text).includes(norm(evidence));
const doc = readFileSync(new URL("./policy.md", import.meta.url), "utf8");
const strategies: Record<string, Chunk[]> = {
"fixed 200 chars": fixedSize(doc, 200),
"fixed 200 + 60 overlap": fixedSize(doc, 200, 60),
"by section": bySection(doc),
};
for (const [name, chunks] of Object.entries(strategies)) {
const search = buildSearch(chunks);
console.log(`\n${name} (${chunks.length} chunks)`);
let intact = 0, top1 = 0, top3 = 0;
for (const { q, evidence } of golden) {
const inIndex = chunks.some((c) => has(c, evidence));
const hit1 = search(q, 1).some((c) => has(c, evidence));
const hit3 = search(q, 3).some((c) => has(c, evidence));
intact += +inIndex; top1 += +hit1; top3 += +hit3;
const verdict = !inIndex ? "NOT INTACT IN ANY CHUNK" : hit1 ? "found at k=1" : hit3 ? "found at k=3" : "NOT RETRIEVED";
console.log(` ${verdict.padEnd(24)} ${q}`);
}
console.log(` intact ${intact}/${golden.length} top-1 ${top1}/${golden.length} top-3 ${top3}/${golden.length}`);
}npm install -D tsx typescript @types/node
npx tsx debug-retrieval.tsWhat it showed
fixed 200 chars (4 chunks)
found at k=3 Can I get a refund on my annual plan after 60 days?
found at k=1 How long do refunds take to reach my card?
...
intact 5/5 top-1 4/5 top-3 5/5
fixed 200 + 60 overlap (5 chunks)
NOT INTACT IN ANY CHUNK Can I get a refund on my annual plan after 60 days?
...
intact 4/5 top-1 3/5 top-3 4/5
by section (5 chunks)
found at k=1 (all five questions)
intact 5/5 top-1 5/5 top-3 5/5| Splitting | Chunks | Answer intact | Top 1 | Top 3 |
|---|---|---|---|---|
| Fixed 200 characters | 4 | 5 of 5 | 4 of 5 | 5 of 5 |
| Fixed 200 + 60 overlap | 5 | 4 of 5 | 3 of 5 | 4 of 5 |
| By section (headings) | 5 | 5 of 5 | 5 of 5 | 5 of 5 |
Three things to take from it:
- Fixed-size splitting separated the rule from its exception. For the annual-plan question the chunk with the 30-day rule ranked first, and the chunk with the exception only appeared at k=3. A chatbot that passes just the top chunk would say there are no refunds after 30 days, and be wrong.
- Overlap made it worse here. Shifting the boundaries cut straight through the exception sentence, so it was intact in no chunk at all. Overlap moves the cut; it does not remove it.
- Splitting on structure fixed all five. Each heading's section stayed whole, with its heading, so the question and the answer shared words.
Check 3: did the model use it?
Only after checks 1 and 2 pass for a question does it make sense to look at the model. Take a case where the evidence is intact and retrieved but the answer is still wrong. Print the exact prompt your code sent, with the retrieved text in it, and read it as if you were the model. The problem is usually visible: the key chunk is buried among five irrelevant ones, two chunks contradict each other, or the instructions never said what to do when the answer is missing.
There is no script for this one, because it needs a model call. The useful habit is the same: keep the failing cases and re-run them after each change to the prompt.
Fix the layer that failed
| Failed check | Common fixes |
|---|---|
| 1 Not intact | Split on headings and paragraphs, not a character count. Keep a rule together with its exceptions. Add the section title to each chunk. Check the parsed text of PDFs and tables by eye. |
| 2 Not retrieved | Combine keyword and vector search. Rewrite the user's question before searching. Add a re-ranking step. Raise k, and filter by document type or date when you can. |
| 3 Not used | Tell the model to answer only from the provided text and to say so when it is missing. Ask for the source with each claim. Pass fewer, better chunks. Put the best chunk first. |
These are common approaches, not a recipe. Which one helps depends on your documents, which is the reason for measuring first.
Make it a habit
Put the golden set in your repository and run it whenever you change the splitter, the embedding model, k or the prompt. Track how many items pass checks 1 and 2. A change that raises that number is helping; one that lowers it, or leaves it flat while you tweak the prompt, is not. When a real user reports a wrong answer, add it to the set so it cannot quietly come back.
Common questions
What is the best chunk size for RAG?+
Should I switch to a bigger model when answers are wrong?+
How many test questions do I need?+
Why does my chatbot refuse when I rephrase a question?+
Want a RAG chatbot that is measured, not guessed?
If you would rather have a chatbot built and tested against your own documents, see custom AI chatbot development (RAG), or how AI and LLM integration projects are scoped. The same measure-first approach applies when you build an MCP server for your API.
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call