What chunking strategies exist and how does chunk size affect RAG quality?
615 views
37 of 83
1
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
2
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.
3
Solution
Target Audience: Engineers building their first RAG index, and anyone who needs to name and compare chunking strategies precisely in an interview.
Difficulty: Medium
Verified with: Node 22.22.0 and Python 3.12.3 (six chunkers and a TF-IDF retriever on one handbook; TF-IDF stands in for an embedding model, and no model is called). Library and paper facts were checked in October 2026.
How to read this doc: Every idea is explained in plain language first, then tagged with π Interview term: so you also learn the words an interviewer expects. All program output shown was produced by actually running the example; the Verified section quotes it in full.
1. Why This Even Matters β A Story First
You are turning a cookbook into index cards so a friend can find recipes fast. If you cut the book every 60 characters, one card says "bake for 20 min" and the next says "ut
The solution is hidden so you can try first
14 min read Β· 9 sections
4
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).constDOC = `# 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.`;
constQA = [
["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."],
];
constSTOP = newSet("a an the is are do does of to on for what how long much can be get".split(" "));
consttoks = (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 }]constfixed = (size) => { const out = []; for (let i = 0; i < DOC.length; i += size) out.push(DOC.slice(i, i + size)); return out; };
constsentences = (t) => t.split("\n").filter((l) => l && !l.startsWith("#")).flatMap((l) => l.match(/[^.]+\./g)).map((s) => s.trim());
functionrecursive(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 limitfor (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;
}
constsections = () => DOC.split("\n## ").slice(1).map((s) => { const [h, ...body] = s.split("\n"); return { h, body: body.join(" ") }; });
constSTRATEGIES = {
"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
};
functionsearch(chunks, query) {
const docsT = chunks.map((c) =>toks(c.embed));
const df = newMap();
for (const t of docsT) for (const w ofnewSet(t)) df.set(w, (df.get(w) || 0) + 1);
constidf = (w) => Math.log(chunks.length / (df.get(w) || chunks.length)) + 1;
constvec = (t) => { const v = newMap(); for (const w of t) v.set(w, (v.get(w) || 0) + idf(w)); return v; };
constnorm = (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] ofObject.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")}"`);
5
Discussion
Other approaches, follow-ups and how it went in real interviews.