The Cut That Lost the Answer
A support bot answered "within 30" to the question how long do I have to return opened headphones? The sentence existed in the index: Opened headphones are returnable within 30 days of delivery if the seal is intact. Ingest had cut it in half, retrieval returned the half without its unit, and the model completed the sentence from its own priors.
Fixed-size chunking is the ingest rule that produced the cut: take size units of text, advance step units, repeat. The boundaries come from arithmetic, not from the document.
def naive(text: str, size: int = 60) -> list[str]:
return [text[i:i + size] for i in range(0, len(text), size)]
Input:
policy = ("Refund window. Opened headphones are returnable within 30 days of "
"delivery if the seal is intact. Shipping labels are issued after "
"the return is approved.")
naive(policy)
Output:
['Refund window. Opened headphones are returnable within 30 da',
'ys of delivery if the seal is intact. Shipping labels are is',
'sued after the return is approved.']
Chunk 0 stops at 30 da; chunk 1 starts at ys. The query lands on chunk 0, the prompt receives a fragment with no unit, and nothing downstream — embedding, search, or generation — can tell that anything is missing.
|------------ size = 60 ------------|
|------------ size = 60 ------------|
<- no shared region: every character lives in exactly one chunk
Fixed windows earn their popularity honestly: one linear pass, no model calls, no grammar to get wrong, and identical input producing identical chunks, which keeps an index reproducible across re-runs. What they replace is regex sentence splitting, which falls over on 30 days. Mr. Smith, on numbered lists, and on any table exported to text. What they still get wrong is the boundary above — and the fix is a second parameter.
Size in Tokens, Then Overlap
A character limit is a proxy for the thing that actually counts. The context window is measured in tokens, the bill is measured in tokens, and 4,000 characters are roughly 1,000 tokens in English but considerably more in Chinese or in a string full of emoji (How Tokenizers Work). Size chunks with the encoding the target model uses and the index cannot hand you a chunk that busts the retrieval budget (Context Window Budgeting).
Overlap is the second parameter: each window repeats the tail of the previous one, so a fact sitting on a cut is present in full somewhere.
import tiktoken
enc = tiktoken.get_encoding("o200k_base") # pin this to the model you call
def chunk(text: str, size: int = 40, overlap: int = 8) -> list[str]:
if overlap >= size:
raise ValueError("overlap must be smaller than size")
ids = enc.encode(text)
step = size - overlap # a stride, not an offset: this creates the shared region
return [enc.decode(ids[i:i + size]) for i in range(0, len(ids), step)]
Input: the 77-token policy from the previous chapter, chunk(policy, 40, 8).
Output:
0 | Refund window. ... the buyer pays return shipping on non-defective
1 | buyer pays return shipping on non-defective items. Refunds post ... missing a serial number are
2 | , and items missing a serial number are refused at the counter.
The same text cut with overlap=0 splits non-defective items. across two chunks. At step = 32 the second window opens eight tokens early, so the whole clause lands inside chunk 1 — and chunk 0 still ends mid-clause, which is harmless because the sentence exists elsewhere.
| Sizing unit | Counts | Failure it avoids | Failure it invites |
|---|---|---|---|
| Characters | len(text) |
dependency on a tokenizer | CJK and emoji overshoot the token budget |
| Tokens | len(enc.encode(text)) |
budget overruns on any script | needs the right encoding pinned |
| Sentences | parsed boundaries | mid-clause cuts | a parser that breaks on e.g. and 3.5 |
The table is the decision: characters are fine for throwaway scripts over ASCII, tokens are the only unit that predicts what the provider will accept, and sentences belong to the structure-aware pass covered in Semantic and Structural Chunking.
Paying for Overlap, Carrying Metadata
Overlap is not free. With size=40 and overlap=8, a 77-token document produces three windows storing 40 + 40 + 13 = 93 tokens — 21% more text than the source, re-embedded at ingest and re-billed whenever a chunk enters a prompt. Push overlap to 50% and the same document stores more than half again as much, while two copies of one sentence compete for top-k slots and can occupy rank 1 and rank 2 together.
overlap 0 |----A----|----B----|----C----| cuts land wherever arithmetic puts them
overlap 20% |---A---|---B---|---C---| tail repeated once; boundary fact survives whole
overlap 50% |-A-|-B-|-C-| index roughly doubles; near-duplicates crowd top-k
The second half of the fix happens when the chunk is created, not when it is queried. Every chunk should leave the chunker already knowing where it came from:
chunk = {
"text": "Opened headphones are returnable within 30 days of delivery "
"if the seal is intact.",
"heading": "Returns Policy > Eligibility",
"source": "returns-policy.md",
"index": 0,
}
Heading and source become citation text, filter keys (Metadata Filtering in Retrieval), and the section label in the UI. A passage that reached the index without them cannot gain them later: the words alone do not say which section they came from, and re-deriving position from character offsets breaks the moment the source file is edited.
Failure modes to check on every chunking deployment:
- Overlap of zero — boundary sentences are only ever retrievable as fragments.
- Character sizing with no token check — English staging passes, production 400s on CJK traffic.
- Overlap past 30% — index cost climbs while top-k fills with copies of one paragraph.
- Chunks stored bare — nothing to cite, nothing to filter on, no section label to show.
- A 13-token tail — half a sentence that ranks on two content words and pollutes results.
Quiz
1. Retrieval returns a chunk ending in "within 30 da" and the answer ships without a unit. Which ingest change addresses the cause?
2. Why size chunks with a tokenizer instead of len(text)?
3. size=40, overlap=8, document=77 tokens. How many chunks come out and how many tokens are stored?
4. When should a chunk's heading and source fields be attached?
Flashcards
Question
What two parameters define a fixed-size chunker?
Click to reveal answer
Answer
size (units per chunk) and overlap (units repeated from the previous chunk). The stride is size - overlap, and overlap must stay below size or the loop never finishes.
Question
What does 10-20% overlap recover, and what does it cost?
Click to reveal answer
Answer
A fact straddling a cut is complete inside the next window, so retrieval matches a whole clause instead of a fragment. The cost is duplicated text: roughly 20% more tokens stored and re-embedded, and duplicated passages competing for top-k slots if overlap grows past about 30%.
Question
Why count chunk sizes with the model's tokenizer?
Click to reveal answer
Answer
The context window and the invoice are both token-counted. len(enc.encode(chunk)) with the encoding pinned to the target model is the only count that predicts what the provider will accept; character counts pass English tests and overshoot on CJK and emoji.
Question
What metadata should each chunk carry at ingest?
Click to reveal answer
Answer
Heading path, source file, chunk index, plus any filterable fields such as tenant, topic, and timestamp. Search can add scores later; it cannot reconstruct provenance from bare text.
Question
What is the fundamental limitation of fixed-size chunking?
Click to reveal answer
Answer
Cut points come from arithmetic, so a clause, table row, or code block can be split mid-structure. Overlap hides damage at boundaries; only splitting on the document's own structure removes the cause.
Revision Notes
Key Takeaways
- 1. Fixed windows are arithmetic: cut points ignore sentence and section boundaries, which is both their speed and their failure mode
- 2. Size in tokens with the model's pinned encoding and overlap 10-20%, so a straddling fact appears whole in the next window
- 3. Overlap is paid twice — a larger index at ingest and longer prompts on every retrieval — so treat it as a budget number, not a default
- 4. Heading and source metadata must be written when the chunk is created; retrieval cannot reconstruct provenance from bare text
Interview Tips
- • Anchor the explanation in a concrete fragment: a boundary split "within 30 days" and the model answered from priors — then name overlap as the fix and state the 10-20% range
- • Do the arithmetic out loud: stride = size - overlap, chunk count = ceil((n - size) / stride) + 1, and be ready to quote the stored-token overhead
- • Justify tokens over characters with the CJK or emoji density example rather than saying tokens are what models use
- • Name when fixed windows are still the right call — uniform records, log lines, a cheap baseline before paying for structure-aware splitting
Cheat Sheet
Fixed-Size Chunking — Cheat Sheet
Chunker
ids = enc.encode(text)
step = size - overlap # overlap < size; 10-20% of size
chunks = [enc.decode(ids[i:i+size]) for i in range(0, len(ids), step)]
Worked: 77 tokens, size 40, overlap 8 → stride 32 → windows at 0 / 32 / 64 → sizes 40 + 40 + 13 = 93 stored tokens (source 77, +21%).
| Sizing unit | Counts | Reach for it when |
|---|---|---|
| characters | len(text) |
throwaway scripts over ASCII text |
| tokens | len(enc.encode(text)) |
anything that reaches a model — window and cost are token-counted |
| sentences | parsed boundaries | you need structure and can afford a parser |
Overlap tradeoff: 0% → mid-clause cuts · 10–20% → boundary context recovered for ~20% more stored tokens · 50% → index roughly doubles, duplicates compete for top-k.
Chunk record at ingest: text, heading, source, index — plus tenant / topic / updated_at if anything will ever filter on them (Metadata Filtering).
Failure modes: character sizing on CJK traffic · zero overlap · overlap past 30% · bare chunks with no provenance · 13-token tail chunks · an encoding that does not match the model you call.
Next: structure-aware splitting in Semantic and Structural Chunking.