Skip to the solution
HardSystem Design19 min read

How would you design a production RAG pipeline end-to-end in 2025-26?

859 views

The problem

Question presented to candidate: "Design the retrieval-augmented question answering system for our product: about two hundred thousand help-centre articles, contracts and internal docs, used by customers and support agents. Walk me from raw documents to a cited answer on screen, and tell me how you would know it is working."

What a strong answer should cover:

  • Two paths: an offline ingestion path (connectors, parsing, chunking with context, embedding, metadata and permissions, incremental updates by content hash, deletes, versioned indexes with blue-green re-indexing) and an online query path (query rewrite and routing, scoped hybrid retrieval with fusion, reranking, context packing under a token budget, generation with citations, abstain when nothing is relevant)
  • Back-of-envelope numbers stated as assumptions: chunk count, vector memory, embedding cost for a full build and daily changes, queries per second at peak, prompt tokens and generation cost per query, and a per-stage latency budget with streaming
  • Security and scope: tenant and permission filters built from the session, retrieved text treated as untrusted (prompt injection), caches and logs scoped the same way
  • Quality loop: a labelled eval set with retrieval metrics and answer-level checks (faithfulness, citation validity), traces of every stage per request, and regression runs before changing models, chunking or prompts
  • Current practice and its limits: hybrid search plus a reranker as the default retriever, contextual chunk headers, agentic or multi-step retrieval for hard questions, and long context windows as a complement to retrieval rather than a replacement

Clarifying questions expected:

  • Who are the users, what are the latency and freshness requirements, and how often do documents change?
  • Are there per-customer or per-document permissions, and can data be sent to external model APIs?
  • What does a good answer look like: short factual replies with citations, or long synthesized reports?

Code / implementation expected: Optional โ€” an architecture diagram with numbers is the core; a sketch of incremental ingestion or the query pipeline is a strong addition.

  • rag
  • architecture
  • retrieval

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

19 min read ยท 13 sections

Read the code

Miniature RAG pipeline: incremental ingest, hybrid retrieval, rerank, packing, citations
// A miniature production RAG pipeline: incremental ingest, hybrid retrieval, rerank, pack, cite, check.
const STOP = new Set(["a", "the", "is", "are", "of", "to", "do", "i", "have", "we", "by", "in", "from", "go", "how", "many", "get", "only", "has", "not"]);
const terms = (t) => t.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w && !STOP.has(w)).map((w) => (w.endsWith("s") && w.length > 3 ? w.slice(0, -1) : w));
function hash(s) { let h = 5381; for (const c of s) h = (h * 33 + c.charCodeAt(0)) % 4294967296; return h.toString(16).padStart(8, "0"); }
const DIM = 16;
let embedCalls = 0;
function embed(t) { // stub embedding model: hashing trick, unit length
  embedCalls++;
  const v = new Array(DIM).fill(0);
  for (const w of terms(t)) { let h = 0; for (const c of w) h = (h * 31 + c.charCodeAt(0)) % 1000003; v[h % DIM] += 1; }
  const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
  return v.map((x) => x / n);
}
const f3 = (x) => (Math.floor(x * 1000 + 0.5) / 1000).toFixed(3);

const index = { version: 0, chunks: new Map() }; // chunk id -> { doc, text, hash, v }
const vectorCache = new Map(); // content hash -> vector, so unchanged text is never re-embedded
function ingest(docs) {
  const before = embedCalls, seen = new Set();
  let added = 0, kept = 0, removed = 0;
  for (const [doc, body] of Object.entries(docs)) {
    body.split(". ").map((s) => s.replace(/\.$/, "")).forEach((text, n) => {
      const id = `${doc}#${n}`, h = hash(text);
      seen.add(id);
      if (index.chunks.get(id)?.hash === h) { kept++; return; }
      if (!vectorCache.has(h)) vectorCache.set(h, embed(text));
      index.chunks.set(id, { doc, text, hash: h, v: vectorCache.get(h) });
      added++;
    });
  }
  for (const id of [...index.chunks.keys()]) if (!seen.has(id)) { index.chunks.delete(id); removed++; }
  index.version++;
  console.log(`   index v${index.version}: upserted ${added}, unchanged ${kept}, deleted ${removed}, embed calls ${embedCalls - before}, chunks ${index.chunks.size}`);
}

function bm25(q) {
  const ids = [...index.chunks.keys()], N = ids.length;
  const toks = new Map(ids.map((id) => [id, terms(index.chunks.get(id).text)]));
  const avg = ids.reduce((s, id) => s + toks.get(id).length, 0) / N;
  return ids.map((id) => {
    const t = toks.get(id); let s = 0;
    for (const w of new Set(terms(q))) {
      const df = ids.filter((x) => toks.get(x).includes(w)).length, tf = t.filter((x) => x === w).length;
      if (tf) s += Math.log(1 + (N - df + 0.5) / (df + 0.5)) * (tf * 2.2) / (tf + 1.2 * (0.25 + 0.75 * t.length / avg));
    }
    return { id, s };
  }).filter((x) => x.s > 0).sort((a, b) => b.s - a.s || (a.id < b.id ? -1 : 1));
}
function dense(q) {
  const qv = embed(q);
  return [...index.chunks].map(([id, c]) => ({ id, s: c.v.reduce((s, x, i) => s + x * qv[i], 0) })).sort((a, b) => b.s - a.s || (a.id < b.id ? -1 : 1));
}
function rrf(lists, k = 60) {
  const score = new Map();
  for (const l of lists) l.slice(0, 5).forEach((x, r) => score.set(x.id, (score.get(x.id) || 0) + 1 / (k + r + 1)));
  return [...score].map(([id, s]) => ({ id, s })).sort((a, b) => b.s - a.s || (a.id < b.id ? -1 : 1));
}
const rerank = (q, cands) => cands.map((c) => { const qt = terms(q), dt = new Set(terms(index.chunks.get(c.id).text)); return { id: c.id, s: qt.filter((w) => dt.has(w)).length / qt.length }; }).sort((a, b) => b.s - a.s || (a.id < b.id ? -1 : 1));
const fakeLlm = (context) => context.length ? `${context[0].text} [${context[0].id}]` : "I do not know based on the documents.";

function ask(q) {
  console.log(`   Q: ${q}`);
  const lex = bm25(q), vec = dense(q), fused = rrf([lex, vec]);
  console.log(`     bm25   ${lex.slice(0, 3).map((x) => x.id).join(" ") || "-"}`);
  console.log(`     vector ${vec.slice(0, 3).map((x) => x.id).join(" ")}`);
  console.log(`     rrf    ${fused.slice(0, 4).map((x) => `${x.id}=${f3(x.s * 100)}`).join(" ")}`);
  const ranked = rerank(q, fused).filter((x) => x.s >= 0.5);
  console.log(`     rerank ${ranked.map((x) => `${x.id}=${f3(x.s)}`).join(" ") || "nothing above floor 0.500"}`);
  const context = []; let used = 0;
  for (const x of ranked) { const c = index.chunks.get(x.id), cost = terms(c.text).length + 2; if (used + cost > 12) break; used += cost; context.push({ id: x.id, text: c.text }); }
  console.log(`     packed ${context.map((c) => c.id).join(" ") || "-"} (${used} of 12 budget units)`);
  const answer = fakeLlm(context), cited = [...answer.matchAll(/\[([a-z]+#\d+)\]/g)].map((m) => m[1]);
  const valid = cited.every((id) => context.some((c) => c.id === id));
  console.log(`     answer "${answer}"  citations valid: ${valid}  index v${index.version}`);
}

console.log("1) ingest, then re-ingest after edits");
ingest({ refund: "Refunds are available within 30 days of purchase. After 30 days we offer store credit only.", shipping: "Standard shipping takes 3 to 5 business days. Express shipping arrives next day.", warranty: "Hardware has a 1 year warranty. Water damage is not covered.", promo: "Free shipping on all orders in March 2024." });
ingest({ refund: "Refunds are available within 45 days of purchase. After 45 days we offer store credit only.", shipping: "Standard shipping takes 3 to 5 business days. Express shipping arrives next day.", warranty: "Hardware has a 1 year warranty. Water damage is not covered.", returns: "Start a return from the Orders page. Refunds go to the original card." });
console.log("");
console.log("2) queries against index v2");
ask("How many days do I have to get a refund?");
ask("Is water damage covered by the warranty?");
ask("Do you ship to Mars?");
Back-of-envelope sizing: index memory, embedding cost, query load and generation cost
// Back-of-envelope sizing for a RAG system. Every input is an assumption you replace with your own.
const A = {
  docs: 200000, tokensPerDoc: 2500, chunkTokens: 500, overlapTokens: 50,
  dims: 1024, bytesPerDim: 4, hnswM: 16, bytesPerToken: 4,
  dailyChangedShare: 0.01,
  queriesPerDay: 30000, peakFactor: 5, rerankDepth: 50,
  systemTokens: 500, chunksInPrompt: 6, questionTokens: 40, historyTokens: 400, outputTokens: 350,
  // Illustrative prices in dollars per million tokens, NOT a real price list.
  embedPrice: 0.02, inputPrice: 3, outputPrice: 15,
};
const comma = (n) => String(Math.floor(n + 0.5)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const fx = (x, d) => (Math.floor(x * 10 ** d + 0.5) / 10 ** d).toFixed(d);
const GB = 1e9;

console.log("1) index size");
const stride = A.chunkTokens - A.overlapTokens;
const chunksPerDoc = Math.ceil((A.tokensPerDoc - A.overlapTokens) / stride);
const chunks = A.docs * chunksPerDoc;
console.log(`   chunks per doc  ceil((${A.tokensPerDoc} - ${A.overlapTokens}) / ${stride}) = ${chunksPerDoc}`);
console.log(`   total chunks    ${comma(chunks)}`);
const vecGB = chunks * A.dims * A.bytesPerDim / GB;
const linkGB = chunks * 2 * A.hnswM * 4 / GB; // layer-0 neighbour ids, 4 bytes each
const textGB = chunks * A.chunkTokens * A.bytesPerToken / GB;
console.log(`   vectors         ${comma(chunks)} x ${A.dims} x ${A.bytesPerDim} B = ${fx(vecGB, 2)} GB`);
console.log(`   graph links     ${comma(chunks)} x ${2 * A.hnswM} x 4 B = ${fx(linkGB, 2)} GB`);
console.log(`   chunk text      ${comma(chunks)} x ${A.chunkTokens} x ${A.bytesPerToken} B = ${fx(textGB, 2)} GB`);

console.log("");
console.log("2) embedding cost");
const fullTokens = chunks * A.chunkTokens;
console.log(`   full build      ${comma(fullTokens)} tokens = $${fx(fullTokens / 1e6 * A.embedPrice, 2)}`);
const dailyTokens = fullTokens * A.dailyChangedShare;
console.log(`   daily changes   ${comma(dailyTokens)} tokens = $${fx(dailyTokens / 1e6 * A.embedPrice, 2)} per day`);

console.log("");
console.log("3) query load and generation cost");
const avgQps = A.queriesPerDay / 86400;
console.log(`   average ${fx(avgQps, 3)} qps, peak x${A.peakFactor} = ${fx(avgQps * A.peakFactor, 2)} qps`);
console.log(`   rerank pairs    ${comma(A.queriesPerDay * A.rerankDepth)} per day`);
const inTok = A.systemTokens + A.chunksInPrompt * A.chunkTokens + A.questionTokens + A.historyTokens;
const perQuery = (inTok * A.inputPrice + A.outputTokens * A.outputPrice) / 1e6;
console.log(`   prompt tokens   ${A.systemTokens} + ${A.chunksInPrompt} x ${A.chunkTokens} + ${A.questionTokens} + ${A.historyTokens} = ${comma(inTok)} in, ${A.outputTokens} out`);
console.log(`   per query       $${fx(perQuery, 5)}   per day $${fx(perQuery * A.queriesPerDay, 2)}   per 30 days $${comma(perQuery * A.queriesPerDay * 30)}`);
const lean = A.systemTokens + 3 * A.chunkTokens + A.questionTokens + A.historyTokens;
const leanCost = (lean * A.inputPrice + A.outputTokens * A.outputPrice) / 1e6;
console.log(`   with 3 chunks   ${comma(lean)} in -> per 30 days $${comma(leanCost * A.queriesPerDay * 30)} (${fx(100 * (1 - leanCost / perQuery), 1)}% less)`);

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 69 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

MCP authoring: how do you build a Model Context Protocol server in 2025-26?

Continue