Chunking Documents for Embedding Quality
On any corpus of long documents, how text is split into embeddable units affects retrieval more than which embedding model produced the vectors — and it is the parameter teams tune last, after spending weeks comparing models. This page sets chunk boundaries, size and overlap from the structure of the content, and measures the result. It sits under vector search integration strategies in search engine selection and architecture.
Prerequisites
- Documents with some structure to split on — headings, paragraphs, list items. Splitting unstructured walls of text is a harder problem with worse outcomes.
- An embedding pipeline that can re-run over the whole corpus, because changing chunking means re-embedding everything.
- A small evaluation set: queries with the passages that should answer them.
- A decision about what a search result represents — the passage or the document it came from.
Diagnosis: a vector is a summary, and summaries of mixed content are useless
An embedding represents one coherent idea reasonably well and several unrelated ideas badly, because the vector lands somewhere between them and is close to none. That is why chunk size matters: it decides how many ideas each vector has to summarise.
The failure at both extremes is easy to demonstrate. Embed a whole 40-page manual and query for a specific installation step: the document vector is an average over installation, safety, troubleshooting and warranty, and it matches the query weakly. Embed each sentence and query the same way: the matching sentence is retrieved, and it reads “Then tighten the bracket” with no indication of what is being installed.
query: "how do I mount the bracket on a brick wall"
whole document (12,000 tokens) similarity 0.61 returned 8th
single sentence (14 tokens) similarity 0.79 returned 1st, but unusable alone
passage with heading (340 tokens) similarity 0.84 returned 1st, and answers the question
The middle row is what you are aiming for: enough context to be interpretable, narrow enough to be about one thing.
Solution Steps
1. Split on structure, never on character count
A fixed character split cuts mid-sentence, producing chunks that begin with a dangling clause and embed poorly. Paragraph and heading boundaries are already semantic boundaries in the source, and respecting them costs a few lines.
# chunk.py — accumulate whole paragraphs up to a token target
def chunk_document(doc, target=350, overlap=60):
chunks, buf, count = [], [], 0
for para in doc["paragraphs"]:
n = len(para.split())
if count + n > target and buf:
chunks.append(buf[:])
keep = []
total = 0
for p in reversed(buf): # carry the tail forward as overlap
total += len(p.split())
keep.insert(0, p)
if total >= overlap:
break
buf, count = keep, total
buf.append(para); count += n
if buf:
chunks.append(buf)
return chunks
2. Prepend the heading path to every chunk
A chunk about “installation” is far more retrievable when the vector also knows what is being installed. The heading path costs a few tokens and adds the context the passage assumes but does not state.
def contextualise(doc, chunk_paras: list[str]) -> str:
heading_path = " > ".join(doc["heading_stack"]) # e.g. "Mounting > Brick walls"
return f"{doc['title']} — {heading_path}\n\n" + "\n\n".join(chunk_paras)
This single change is frequently worth more than any model comparison, because it fixes the failure where every chunk in a manual looks alike to the embedding.
3. Keep a pointer to the parent document
Users want the page, not the fragment. Retrieval matches a chunk; the result should present the document, optionally scrolled to the matching passage.
{
"chunk_id": "doc-8812#c4",
"parent_id": "doc-8812",
"heading_path": "Mounting > Brick walls",
"text": "…",
"embedding": [...],
}
4. Deduplicate at the parent level when returning results
Without this, a query that matches four chunks of the same document returns that document four times and crowds out everything else.
def collapse_to_parents(hits, size=10):
seen, out = set(), []
for h in hits:
pid = h["parent_id"]
if pid in seen:
continue
seen.add(pid)
out.append(h) # keep the best-scoring chunk per document
if len(out) == size:
break
return out
5. Size overlap from the boundary risk, not from a rule of thumb
Overlap exists so that an answer spanning a boundary is not lost. Ten to twenty per cent of the chunk size is the usual band; more inflates the corpus without improving recall, and none risks losing exactly the passages that straddle two sections.
chunk 350 tokens, overlap 60 -> 17% overlap, corpus grows ~20%
chunk 350 tokens, overlap 175 -> 50% overlap, corpus grows ~100% for marginal recall
Verification
Confirm chunks respect boundaries and carry context:
python3 chunk.py --doc doc-8812 --show 2
# --- chunk 3 ---
# Installation Guide — Mounting > Brick walls
#
# When mounting on brick, use the supplied wall plugs...
# --- chunk 4 ---
# Installation Guide — Mounting > Brick walls
#
# ...use the supplied wall plugs. Drill to a depth of... <- overlap present
Confirm retrieval improves against the evaluation set:
python3 evaluate_chunking.py --variants whole,fixed512,structural350
# whole recall@10 0.52
# fixed512 recall@10 0.71
# structural350 recall@10 0.86
Confirm parent collapsing is working:
python3 search.py --query "mount bracket brick wall" --show-parents
# 1 doc-8812 (chunk 4)
# 2 doc-9104 (chunk 1)
# 3 doc-7731 (chunk 12)
# => no parent appears twice
Measuring chunking without a labelled set
The evaluation above assumes queries paired with the passages that answer them, and many teams do not have that. A cheaper proxy gets most of the way there and can be built in an afternoon.
Take fifty real queries from the logs. For each, run retrieval under two chunking variants and record whether the top three chunks come from the document a human would consider correct — which is usually obvious at a glance even without formal grading. That produces a comparable number for each variant without constructing a judgment set, and it is sufficient to choose between chunking strategies, which is a coarse decision.
python3 compare_chunking.py --queries real-50.txt --variants structural350,fixed512
# structural350 correct parent in top 3: 44/50
# fixed512 correct parent in top 3: 33/50
The formal judgment set is worth building eventually, for the finer decisions described in relevance evaluation. For chunking specifically, the differences are large enough that a rough measure decides it correctly.
Common Pitfalls
Changing chunking without re-embedding
Chunk boundaries and embeddings are inseparable: a new chunking strategy produces different text, which produces different vectors. Applying new chunking to newly ingested documents while leaving old ones as they were creates a corpus where retrieval quality depends on when a document happened to be indexed — a difference nobody will think to look for.
Chunking structured records as if they were prose
A product record with a title, twelve attributes and a short description is not a long document and should not be chunked at all. Chunking applies to long-form content; applying it to short structured records splits a coherent entity into fragments that are individually meaningless. The test is whether the whole record fits comfortably inside one chunk — if it does, embed it whole.
Overlap that duplicates the answer across chunks
Large overlaps mean the same passage appears in several chunks, so a query matching it retrieves three near-identical results. Parent collapsing hides this in the result list and it still consumes candidate slots. Keep overlap modest and rely on collapsing rather than using overlap to guarantee coverage.
Operational notes
Keep the chunker deterministic and versioned. Given the same document and the same parameters it must produce byte-identical chunks, or re-embedding produces gratuitous churn and comparing two runs becomes impossible. A unit test asserting stable output for a fixture document catches any accidental non-determinism introduced by a refactor.
Store the chunking parameters alongside the vectors, exactly as with the model version. A corpus embedded under one chunking strategy and queried alongside one embedded under another produces inconsistent retrieval, and without the parameters recorded there is no way to detect that state.
Measure chunking before comparing models. The variance between chunking strategies on a typical documentation corpus is larger than the variance between reasonable embedding models, so a model comparison run under bad chunking measures mostly noise. Fixing chunking first also makes the model comparison cheaper, because it narrows the differences you are trying to detect.
A last note on content that resists chunking altogether: tables, code blocks and reference lists carry meaning in their structure, and splitting them mid-row or mid-function produces fragments that embed as noise. Treat those as atomic — one chunk per table or per code block, regardless of the token target — and accept the occasional oversized chunk rather than the guaranteed nonsense of a split one.
Related
- Vector search integration strategies — the parent guide, including where vectors should live.
- Choosing an embedding model for search — the comparison that should follow this work rather than precede it.
- Hybrid search and fusion strategies — how these chunk-level matches combine with lexical retrieval.