Chunking in RAG: how do you pick size, overlap, and strategy?
291 views
51 of 83
1
The problem
Question presented to candidate:
"You are setting up the index for a RAG assistant over our engineering wiki. Walk me through how you would decide the chunk size, the overlap and the splitting strategy, and how you would know you got them right."
What a strong answer should cover:
Strategy first, from the documents: follow their structure (headings, code units, table rows) where it exists, recursive splitting where it does not, and attach the heading path to each chunk
Size from the questions and the models: short factual questions favour smaller chunks, explanatory questions favour larger ones or parent-child; chunks must fit the embedding model input limit, and top-k times chunk size must fit the context budget
Overlap as boundary insurance with a cost: with fixed windows, an overlap of at least L-1 tokens guarantees any span of L tokens is whole in some chunk, while overlap multiplies storage and embedding cost by size / (size - overlap) and returns near-duplicate chunks
Decide by measurement: a labelled question set with known answer passages, a sweep over a small grid of size and overlap, scored on recall@k and tokens sent per query, then the cheapest configuration that meets the recall target
Operations: chunking is versioned with the index, any change means re-chunking and re-embedding everything, and the eval is re-run
Clarifying questions expected:
What do the documents look like (structured pages, code, tables, PDFs) and what do typical questions ask (one fact, or an explanation)?
Which embedding model, and what are its input limit and price?
How many tokens of context and how much latency can each answer afford?
Code / implementation expected: Optional โ a sliding-window chunker with overlap and a small parameter sweep with recall@k is a strong signal.
rag
chunking
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 who know what chunking is and now have to choose real parameters for a RAG index, and defend the choice with numbers in an interview.
Difficulty: Medium
Verified with: Node 22.22.0 and Python 3.12.3 (a sliding-window chunker swept over 12 size and overlap settings, scored by a TF-IDF retriever on 8 labelled questions; no model is called). Capacity arithmetic was run in Python 3.12.
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
A fisher choosing a net has three dials. The mesh size decides which fish get caught: too fine and the net fills with weed, too coarse and the fish
The solution is hidden so you can try first
15 min read ยท 11 sections
4
Read the code
Sweep chunk size and overlap, score recall@2 and context tokens, then pick a configuration
// Sweep chunk size x overlap on one document with 8 labelled questions, then pick a config.// Tokens are words here; a real pipeline counts with the embedding model's tokenizer.constFACTS = [
["The backup job for the billing database runs every night at 02:00 UTC.", "When does the billing database backup run?"],
["Customer passwords are hashed with argon2id using a per user salt.", "How are customer passwords hashed?"],
["Invoices older than seven years are archived to cold storage and then deleted.", "What happens to invoices older than seven years?"],
["The public API rejects payloads larger than 2 megabytes with status 413.", "What is the maximum API payload size?"],
["On call engineers must acknowledge a paging alert within fifteen minutes.", "How fast must on call engineers acknowledge a page?"],
["Feature flags are evaluated at the edge and cached for thirty seconds.", "How long are feature flags cached?"],
["Refund requests above 500 euros need approval from a finance manager.", "Who approves large refund requests?"],
["Staging data is refreshed from an anonymised production snapshot every Monday.", "How is staging data refreshed?"],
];
constFILLER = [
"This section was reviewed by the platform group last quarter.",
"Teams should read the related runbook before making changes.",
"Questions about this topic go to the internal help channel.",
"Older versions of this page are kept in the wiki history.",
];
// Build the document: 2 filler sentences, then a fact, repeated.const tokens = [], spans = [];
FACTS.forEach(([fact], i) => {
for (const f of [FILLER[i % 4], FILLER[(i + 1) % 4]]) tokens.push(...f.split(" "));
spans.push([tokens.length, tokens.length + fact.split(" ").length]);
tokens.push(...fact.split(" "));
});
const N = tokens.length;
constnorm = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "");
constSTOP = newSet("the a an is are and for of to at with by from in on be this how what when who must".split(" "));
constterms = (ws) => ws.map(norm).filter((w) => w && !STOP.has(w));
functionchunk(size, overlap) {
const out = [], stride = size - overlap;
for (let s = 0; ; s += stride) { out.push([s, Math.min(s + size, N)]); if (s + size >= N) break; }
return out;
}
functiontopK(chunks, query, k) {
const docs = chunks.map(([a, b]) =>terms(tokens.slice(a, b)));
const df = newMap();
for (const d of docs) for (const w ofnewSet(d)) df.set(w, (df.get(w) || 0) + 1);
constvec = (t) => { const v = newMap(); for (const w of t) v.set(w, (v.get(w) || 0) + Math.log(1 + docs.length / (df.get(w) || docs.length))); return v; };
constlen = (v) => Math.sqrt([...v.values()].reduce((a, x) => a + x * x, 0)) || 1;
const q = vec(terms(query.split(" ")));
return docs.map((d, i) => {
const v = vec(d); let dot = 0;
for (const [w, x] of q) dot += x * (v.get(w) || 0);
return { i, s: Math.round((dot / (len(q) * len(v))) * 1e9) };
}).sort((a, b) => b.s - a.s || a.i - b.i).slice(0, k).map((h) => chunks[h.i]);
}
const K = 2, rows = [];
console.log(`document: ${N} tokens, ${FACTS.length} facts, longest fact ${Math.max(...spans.map(([a, b]) => b - a))} tokens, top-k = ${K}`);
console.log("size overlap chunks stored dup facts whole in a chunk recall@2 ctx tok/query");
for (const size of [12, 24, 48, 96]) {
for (const pct of [0, 25, 50]) {
const overlap = Math.floor((size * pct) / 100);
const chunks = chunk(size, overlap);
const stored = chunks.reduce((a, [x, y]) => a + y - x, 0);
const whole = spans.filter(([a, b]) => chunks.some(([x, y]) => x <= a && b <= y)).length;
let hit = 0, ctx = 0;
FACTS.forEach(([, q], i) => {
const got = topK(chunks, q, K);
ctx += got.reduce((a, [x, y]) => a + y - x, 0);
if (got.some(([x, y]) => x <= spans[i][0] && spans[i][1] <= y)) hit++;
});
rows.push({ size, overlap, pct, recall: hit / FACTS.length, ctx: ctx / FACTS.length, stored });
console.log(`${String(size).padStart(4)}${(overlap + " (" + pct + "%)").padStart(8)}${String(chunks.length).padStart(6)}${String(stored).padStart(6)}${(stored / N).toFixed(2)}${(whole + "/" + FACTS.length).padStart(22)}${(hit + "/" + FACTS.length).padStart(8)}${(ctx / FACTS.length).toFixed(1).padStart(13)}`);
}
}
const ok = rows.filter((r) => r.recall === 1).sort((a, b) => a.ctx - b.ctx || a.stored - b.stored);
const p = ok[0];
console.log(`\npick: the cheapest config with recall@2 = 100% is size ${p.size}, overlap ${p.overlap} (${p.pct}%): ${p.ctx.toFixed(1)} context tokens per query`);
const big = rows.find((r) => r.size === 96 && r.pct === 0);
console.log(`size 96 also reaches ${Math.round(big.recall * 100)}% but sends ${big.ctx.toFixed(1)} tokens per query (${(big.ctx / p.ctx).toFixed(1)}x)`);
console.log("\nrule: a fact of L tokens always fits whole in some window when overlap >= L - 1");
for (const [size, overlap] of [[24, 6], [24, 12], [24, 13]]) {
const chunks = chunk(size, overlap);
let worst = 0; // longest span that is guaranteed to fit, found by brute force over all positionsfor (let L = 1; L <= size; L++) {
let all = true;
for (let a = 0; a + L <= N && all; a++) all = chunks.some(([x, y]) => x <= a && a + L <= y);
if (all) worst = L; elsebreak;
}
console.log(` size ${size}, overlap ${String(overlap).padStart(2)}: every span up to ${worst} tokens fits whole (overlap + 1 = ${overlap + 1})`);
}
5
Discussion
Other approaches, follow-ups and how it went in real interviews.