Skip to the solution
HardAI Engineering14 min read

How do you evaluate a RAG pipeline end to end?

1.0k views

The problem

Question presented to candidate: "We have a RAG assistant over our internal handbook and an end-to-end accuracy of about 64% on our test questions. Leadership wants it higher, but nobody can say whether the retriever or the model is the problem. Design the evaluation you would build for this pipeline, end to end."

What a strong answer should cover:

  • Evaluate stages separately as well as end to end: a labelled retrieval set scored with recall@k, MRR or nDCG; the generator scored with the correct context supplied (oracle context); then the full pipeline against reference answers
  • A golden set with evidence labels (which passage answers each question), slices by topic and difficulty, and deliberately unanswerable questions to test abstention
  • Failure attribution per question: retrieval miss, right chunk retrieved but wrong one used, over-abstention, answering the unanswerable; the counts tell you which stage to invest in
  • Generation-side scoring: answer correctness against a reference, faithfulness to the retrieved context, citation correctness, using code checks where possible and a calibrated LLM judge where not
  • Operational metrics alongside quality (latency, tokens and cost per query), CI gating on every chunking, embedding, prompt or model change, and online signals after release

Clarifying questions expected:

  • How big and how fast-changing is the corpus, and is there a per-user permission model on documents?
  • Do we have real user questions to build the test set from, and who can label the correct evidence?
  • What does a wrong answer cost compared with "I could not find that"?

Code / implementation expected: Optional โ€” a small harness that computes stage metrics and attributes each failure to a stage.

  • rag-evaluation
  • recall
  • faithfulness
  • evals

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

14 min read ยท 10 sections

Read the code

End-to-end RAG eval harness: stage metrics plus failure attribution
// End-to-end RAG eval harness on a tiny handbook. Retriever = word-overlap
// ranking (a stand-in for BM25 or vectors); generator = deterministic stub that
// answers from the first retrieved chunk sharing >= 2 words with the question,
// else says "not found". The harness scores each stage separately, then attributes failures.
const STOP = new Set("a an the is are be can do does i to of in on for and or with have has it you my what how many when long often each get".split(" "));
const stem = (w) => (w.length > 4 && w.endsWith("ed") ? w.slice(0, -2) : w.length > 3 && w.endsWith("s") ? w.slice(0, -1) : w);
const words = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((w) => w && !STOP.has(w)).map(stem));
const overlap = (a, b) => { let n = 0; for (const w of a) if (b.has(w)) n++; return n; };

const DOCS = [
  ["d1", "Employees accrue 20 vacation days per year."],
  ["d2", "Up to 5 unused vacation days roll over into the next year."],
  ["d3", "Parental leave is 16 weeks paid for all employees."],
  ["d4", "Sick leave needs a doctor note after 3 consecutive sick days."],
  ["d5", "The office opens at 8 am and closes at 7 pm."],
  ["d6", "Remote work is allowed up to 3 days per week with manager approval."],
  ["d7", "Expense reports must be filed within 30 days of purchase."],
  ["d8", "Laptops are refreshed every 3 years by the IT team."],
].map(([id, text]) => ({ id, text, w: words(text) }));

// gold = the chunk that answers it; null = not answerable from the corpus.
const QUERIES = [
  ["q01", "How many vacation days do employees get per year?", "d1"],
  ["q02", "Can unused vacation roll over to next year?", "d2"],
  ["q03", "How long is paid parental leave?", "d3"],
  ["q04", "When do I need a doctor note for sick days?", "d4"],
  ["q05", "What time does the office close?", "d5"],
  ["q06", "How many days per week can I work remotely?", "d6"],
  ["q07", "What is the deadline to submit receipts?", "d7"],
  ["q08", "How often are laptops replaced?", "d8"],
  ["q09", "How many vacation days can I carry forward each year?", "d2"],
  ["q10", "How many sick days per year do employees get?", null],
  ["q11", "What is the dress code?", null],
].map(([id, q, gold]) => ({ id, q, gold, w: words(q) }));

function retrieve(qw, k) {
  return DOCS.map((d, i) => ({ d, s: overlap(qw, d.w), i }))
    .filter((x) => x.s > 0)
    .sort((a, b) => b.s - a.s || a.i - b.i)
    .slice(0, k).map((x) => x.d);
}
function generate(qw, ctx) {
  for (const d of ctx) if (overlap(qw, d.w) >= 2) return d.id; // answers from this chunk
  return null; // "not found in the handbook"
}

const K = 3;
const answerable = QUERIES.filter((q) => q.gold);
let r1 = 0, r3 = 0, mrr = 0, oracleOk = 0, e2eOk = 0;
const buckets = {};
const rows = [];
for (const q of QUERIES) {
  const ctx = retrieve(q.w, K);
  const ids = ctx.map((d) => d.id);
  const rank = q.gold ? ids.indexOf(q.gold) + 1 : 0;
  if (q.gold) { if (rank === 1) r1++; if (rank >= 1) { r3++; mrr += 1 / rank; } }
  // Stage 2 in isolation: give the generator the gold chunk only (oracle context).
  const oracle = q.gold ? generate(q.w, DOCS.filter((d) => d.id === q.gold)) : null;
  if (q.gold && oracle === q.gold) oracleOk++;
  const ans = generate(q.w, ctx);
  let label;
  if (!q.gold) label = ans ? "answered unanswerable" : "correct abstain";
  else if (ans === q.gold) label = "correct";
  else if (rank === 0) label = "retrieval miss";
  else if (ans === null) label = "over-abstain (gold retrieved)";
  else label = "wrong chunk used (gold retrieved)";
  if (label === "correct" || label === "correct abstain") e2eOk++;
  buckets[label] = (buckets[label] || 0) + 1;
  rows.push(`${q.id} top${K}=[${ids.join(",")}] gold=${q.gold || "-"} answer=${ans || "not found"} -> ${label}`);
}
rows.forEach((r) => console.log(r));
const n = answerable.length, f = (x) => x.toFixed(3);
console.log(`retrieval (${n} answerable): recall@1=${f(r1 / n)} recall@${K}=${f(r3 / n)} MRR@${K}=${f(mrr / n)}`);
console.log(`generation with oracle context: ${oracleOk}/${n} = ${f(oracleOk / n)}`);
console.log(`end to end (${QUERIES.length} queries incl. unanswerable): ${e2eOk}/${QUERIES.length} = ${f(e2eOk / QUERIES.length)}`);
console.log("failure attribution:");
for (const k of Object.keys(buckets).sort()) console.log(`  ${k.padEnd(34)} ${buckets[k]}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 64 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

Guardrails for LLM apps: how do you implement input/output safety in 2025?

Continue