Skip to the solution
MediumAI Engineering14 min read

What chunking strategies exist and how does chunk size affect RAG quality?

615 views

The problem

Question presented to candidate: "Before documents go into a RAG index they get split into chunks. What chunking strategies are there, how do they differ, and what actually happens to retrieval quality when chunks are too small or too large?"

What a strong answer should cover:

  • Why chunking exists at all: the chunk is the unit that is embedded, ranked and pasted into the prompt, so one vector has to represent one chunk and the model only sees whole chunks
  • The main families: fixed-size windows (characters or tokens, with overlap), sentence and paragraph splits, recursive splitting on a separator hierarchy, structure-aware splitting (headings, HTML, code, tables), semantic splitting by embedding similarity, and parent-child (embed small, return large)
  • Context-preserving add-ons: prefixing each chunk with its heading path or a generated summary of where it sits (contextual chunk headers, contextual retrieval) and late chunking, which embeds the whole document before splitting
  • The size trade-off: small chunks match precisely but lose context (a sentence that says "the limit is 20" without saying which plan) and split facts across boundaries; large chunks keep context but blur the embedding, send more tokens, and bury the answer in noise
  • That there is no universal best size; it depends on the documents, the questions and the embedding model, and is chosen by measuring retrieval

Code / implementation expected: Optional β€” a fixed-size or recursive splitter, or a comparison of strategies on a small corpus, is a strong signal.

  • chunking
  • rag
  • indexing

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 Β· 9 sections

Read the code

Six chunking strategies on one handbook: hit@1 and context size per strategy
// Six chunking strategies on one handbook, scored by a TF-IDF retriever (stands in for embeddings).
const DOC = `# Acme Cloud Handbook

## Free plan
The Free plan costs nothing. The rate limit is 20 requests per minute. Data is kept for 7 days.

## Pro plan
The Pro plan costs 49 dollars per month. The rate limit is 300 requests per minute. Data is kept for 90 days.

## Enterprise plan
Pricing is negotiated per contract. The rate limit is 5000 requests per minute. Data is kept for 3 years.

## Refunds
Refunds go back to the original card. A refund takes 5 to 10 business days to appear on the statement. Annual plans can be refunded within 30 days of purchase.

## Support
Free users get community support only. Pro users get email support with a 24 hour response target. Enterprise customers get a named engineer and a 1 hour response target for outages.`;

const QA = [
  ["What is the rate limit on the Enterprise plan?", "The rate limit is 5000 requests per minute."],
  ["How long is data kept on the Pro plan?", "Data is kept for 90 days."],
  ["How long does a refund take to appear on the statement?", "A refund takes 5 to 10 business days to appear on the statement."],
  ["What response target do Enterprise customers get for outages?", "a named engineer and a 1 hour response target for outages"],
  ["How much does the Pro plan cost?", "The Pro plan costs 49 dollars per month."],
  ["Can annual plans be refunded?", "Annual plans can be refunded within 30 days of purchase."],
];

const STOP = new Set("a an the is are do does of to on for what how long much can be get".split(" "));
const toks = (s) => s.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/)
  .filter((w) => w && !STOP.has(w)).map((w) => (w.length > 3 && w.endsWith("s") ? w.slice(0, -1) : w));

// --- strategies: each returns [{ embed: text that is indexed, ret: text handed to the LLM }]
const fixed = (size) => { const out = []; for (let i = 0; i < DOC.length; i += size) out.push(DOC.slice(i, i + size)); return out; };
const sentences = (t) => t.split("\n").filter((l) => l && !l.startsWith("#")).flatMap((l) => l.match(/[^.]+\./g)).map((s) => s.trim());
function recursive(text, size, seps = ["\n\n", "\n", ". ", " "]) {
  if (text.length <= size || !seps.length) return [text];
  const [sep, ...rest] = seps;
  const pieces = text.split(sep).flatMap((p) => recursive(p, size, rest));
  const out = []; // greedily merge neighbours back up to the size limit
  for (const p of pieces) {
    if (out.length && out[out.length - 1].length + sep.length + p.length <= size) out[out.length - 1] += sep + p;
    else out.push(p);
  }
  return out;
}
const sections = () => DOC.split("\n## ").slice(1).map((s) => { const [h, ...body] = s.split("\n"); return { h, body: body.join(" ") }; });

const STRATEGIES = {
  "fixed 60 chars": () => fixed(60).map((c) => ({ embed: c, ret: c })),
  "fixed 600 chars": () => fixed(600).map((c) => ({ embed: c, ret: c })),
  "one sentence": () => sentences(DOC).map((c) => ({ embed: c, ret: c })),
  "recursive 200": () => recursive(DOC, 200).map((c) => ({ embed: c, ret: c })),
  "heading section": () => sections().map(({ h, body }) => ({ embed: `${h}: ${body}`, ret: `${h}: ${body}` })),
  "parent-child": () => sections().flatMap(({ h, body }) =>
    sentences(body).map((s) => ({ embed: `${h}: ${s}`, ret: `${h}: ${body}` }))), // embed small, return the section
};

function search(chunks, query) {
  const docsT = chunks.map((c) => toks(c.embed));
  const df = new Map();
  for (const t of docsT) for (const w of new Set(t)) df.set(w, (df.get(w) || 0) + 1);
  const idf = (w) => Math.log(chunks.length / (df.get(w) || chunks.length)) + 1;
  const vec = (t) => { const v = new Map(); for (const w of t) v.set(w, (v.get(w) || 0) + idf(w)); return v; };
  const norm = (v) => Math.sqrt([...v.values()].reduce((a, x) => a + x * x, 0));
  const qv = vec(toks(query));
  let best = -1, bestS = -1;
  docsT.forEach((t, i) => {
    const dv = vec(t); let dot = 0;
    for (const [w, x] of qv) dot += x * (dv.get(w) || 0);
    const s = dot / (norm(qv) * norm(dv) || 1);
    if (s > bestS + 1e-12) { bestS = s; best = i; }
  });
  return chunks[best];
}

console.log("strategy          chunks  avg chars  hit@1  chars sent/answer");
const misses = {};
for (const [name, make] of Object.entries(STRATEGIES)) {
  const chunks = make();
  const avg = chunks.reduce((a, c) => a + c.embed.length, 0) / chunks.length;
  let hits = 0, sent = 0; misses[name] = [];
  QA.forEach(([q, gold], i) => {
    const top = search(chunks, q);
    sent += top.ret.length;
    if (top.ret.includes(gold)) hits++; else misses[name].push(`Q${i + 1} got "${top.ret.slice(0, 44).replace(/\n/g, " ")}"`);
  });
  console.log(`${name.padEnd(17)} ${String(chunks.length).padStart(6)} ${avg.toFixed(1).padStart(10)} ${(hits + "/" + QA.length).padStart(6)} ${(sent / QA.length).toFixed(1).padStart(18)}`);
}
console.log("\nwhy the misses happen:");
for (const name of ["fixed 60 chars", "one sentence"]) {
  console.log(`  ${name}:`);
  for (const m of misses[name]) console.log(`    ${m}`);
}
console.log("\nfixed 60 cuts mid-sentence, e.g. chunk 3:");
console.log(`  "${fixed(60)[2].replace(/\n/g, "\\n")}"`);

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 37 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 nextMedium

What is prompt caching and how does it reduce cost and latency?

Continue