A chunk is the smallest thing search can return, so the cut decides what can ever be found. Cut on the document’s own structure, size each chunk to the embedding model’s window in its own tokens, and choose between chunkers by recall@k on real questions, not by a library default.
The example from Chunking: Acme’s refund policy, page 4. The size cut is a toy. Teal marks the heading path. On a phone the figure pans sideways.
A bad boundary raises no error. It produces a confident answer built from half a passage, so it looks like hallucination three layers downstream. Before you touch the prompt or the model, check whether the right chunk came back at all: if it did not, nothing after retrieval can fix it (RAG Architecture calls these gates 1 and 2).
Choose what to cut on
- Cuts on a token or character count, nudged to a paragraph, line or space
- Right for unstructured text, or as the baseline
- Breaks mid-idea and mid-table
- Cuts on headings, paragraphs, table rows, functions
- Right when layout carries meaning: policies, manuals, code
- Breaks when the parse is wrong or a section outgrows the window
- Cuts where neighbouring sentences stop matching
- Right for long text that changes topic without headings
- About 4× the embedding tokens; gains vary by corpus
Rule of thumb: structure first, then size inside each section. Pay for a meaning-based cut only when your golden set shows a gap the other two leave.
Semantic chunking sounds principled, which is why teams reach for it. A semantic splitter embeds every sentence with its neighbours to find the cuts, then embeds the chunks again: on Acme’s 20 million tokens that is $10.40 instead of $2.60 at $0.13 per million. The one systematic comparison, Qu et al., “Is Semantic Chunking Worth the Computational Cost?”, found no consistent gain over fixed-size chunks. LLM-guided chunkers report gains on their own benchmarks; adopt one only if it wins on yours.
Let the content pick the unit
| Content | Cut on | Never |
|---|---|---|
| Prose, policies | heading, then paragraph | strand a heading from its text |
| Tables | the whole table; by row if too big | split rows without the header row |
| Code | a function or class | cut a function in two |
| Lists | the stem with its items | leave an orphaned bullet |
| Transcripts | a window of 6 to 10 turns | split a question from its answer |
| Slides, scanned PDFs | one page | trust the file’s text order |
Half a table is worse than none: the model reads the rows it got as the complete table. When a table must be split, cut between rows and repeat the header row in each piece; Docling’s HybridChunker and Unstructured’s chunk_by_title do that by default. Getting typed elements (heading, table, list item) out of a PDF is the job of Document Processing, and it is where most of the weeks go on a real corpus.
Size the chunk for the embedder, not for Claude
Interactive: slide overlap and new text against a 512-token embedder window. At the defaults 24 + 64 + 400 = 488 leaves 24 spare.
The embedding model reads a fixed window, counted by its own tokenizer, and it reads the heading path, the overlap and the new text together. Past the window a truncating embedder drops the tail without an error: search can never find those sentences, yet the model still reads them when the chunk is retrieved. Hosted windows run from 2,048 tokens (Google gemini-embedding-001) to 128,000 (Cohere embed-v4.0), and some small local models stop at 512 (Embeddings).
| Default | Overlap | Tokens |
|---|---|---|
| 4,000 characters LangChain recursive splitter | 200 (5%) | about 1,000 |
| 1,024 tokens LlamaIndex sentence splitter | 20 (2%) | 1,024 |
| 800 tokens OpenAI vector store | 400 (50%) | 800 |
Defaults as read in the library sources on 2026-09-28 (Chunking, step 2). Tokens at about 4 characters each.
The defaults disagree on size and on overlap (2% to 50%), so a default is a convention, not a finding. Start near 512 tokens in all with 10 to 15% overlap for prose, and let the measurement in the last step move you. Overlap is not free: with chunk text C that includes overlap O, the index grows by C ÷ (C − O), so 464 ÷ 400 = 1.16× here and 2× at OpenAI’s 800/400.
Cut in order: structure, size, overlap
- StructureSplit on headings into sections; keep each section’s heading path.
- SizeInside one section, split to the window minus the path: 488 tokens.
- OverlapRepeat 64 tokens between neighbours, never across a heading.
from langchain_text_splitters import (
MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter)
WINDOW, PATH_BUDGET, OVERLAP = 512, 24, 64 # embedder tokens
by_head = MarkdownHeaderTextSplitter(
[("#", "h1"), ("##", "h2"), ("###", "h3")])
by_size = RecursiveCharacterTextSplitter(
chunk_size=WINDOW - PATH_BUDGET, # 488, overlap included
chunk_overlap=OVERLAP,
length_function=count_tokens) # the EMBEDDER's tokenizer
def chunk(markdown: str) -> list[dict]:
out = []
for sec in by_head.split_text(markdown): # 1 structure
path = " > ".join(sec.metadata.values())
for body in by_size.split_text(sec.page_content): # 2 size, 3 overlap
out.append({"heading": path, "text": f"{path}\n{body}"})
return out
Runs on langchain-text-splitters 1.1.3 (checked 2026-10-21). count_tokens is your embedding model’s tokenizer; the splitter’s chunk_size includes the overlap, so it is the window minus the path.
Keep the path in its own field as well as in the text: the field is what Metadata & Filters filters and displays, and the text copy is what the embedding sees.
Search small, return big
- Index childrenSplit each 500-token parent into four 125-token children; embed only the children.
- Search childrenSmall chunks make sharp vectors: the top 3 hits are children.
- Send parentsLook up each hit’s parent by id, once each, in rank order.
Small chunks rank well and read badly; big ones read well and blur the vector. Parent-child retrieval gets both: 3 hits in 2 parents send 1,000 tokens where the children alone were 375 (2.7×), and the index holds 4 vectors per parent. A fetch by id skips the search filter, so apply the tenant and permission filter again on the parents (Chunking, step 4). LlamaIndex’s sentence-window and auto-merging retrievers are variants of the same idea.
Fix the chunk that cannot stand alone
What do I do if E-4502 keeps happening?
If it keeps happening, restart the worker, then check the queue.
The error code is in an earlier chunk, so a search for E-4502 misses this one, and “it” is a guess.From the payments runbook, Troubleshooting: what to do when error E-4502 persists. If it keeps happening, restart the worker, then check the queue.
Written once at ingest, from the whole document, and put before the chunk in both indexes.| Fix | Helps | Costs |
|---|---|---|
| Heading path | search, if a heading names the subject | a few tokens per chunk |
| Parent or window | reading, not search | a bigger prompt |
| Context per chunk | search and reading | a model call per chunk |
Context per chunk is Anthropic’s Contextual Retrieval: on its own 2024 data it cut top-20 retrieval misses by 35% with contextual embeddings, 49% with contextual BM25 added and 67% with a reranker on top. With the document cached it costs about 38 cents per million document tokens on Claude Haiku 5.5, the smallest current model. Take the heading path first; add context where your golden set shows misses the path cannot fix.
Stamp every chunk
- Source and page
- What a citation points at, and what a reader clicks to check it.
- Heading path
- Embedded with the text and kept as a field for display and filters.
- Tenant and permissions
- Applied inside the search, never in the prompt. A chunk without them cannot be filtered: that is a data leak, not a quality bug.
- Chunker and embedder version
"chunker": "headings-v3"and the embedding model id, so a re-chunk or a mixed index is visible (Vector Indexes & Stores).
A new chunker is a new index: every chunk id changes and everything is re-embedded. Build it beside the old one and cut over only when the golden set and the permission tests pass (RAG Best Practices).
Measure the cut before you upgrade it
def recall_at_k(golden, search, k=5):
"""golden: (question, gold excerpt) pairs from real user queries."""
norm = lambda s: " ".join(s.split())
def hit(q, gold):
return any(norm(gold) in norm(c["text"]) for c in search(q, k))
return sum(hit(q, g) for q, g in golden) / len(golden)
# one search function per candidate index, same questions, same k
for name, search in {"recursive-488": a, "headings-v3": b}.items():
print(f"{name:>14} recall@5 = {recall_at_k(golden, search):.2f}")
95% margins for a pass rate of 0.85; RAG Evaluation owns the table and the paired comparison.
Score gold excerpts, the passage that answers each question, not chunk ids, which change with every chunker. Take the questions from real user queries: questions written while reading a passage reuse its words and retrieve it far too easily. Bigger chunks hold more, so compare chunkers at equal prompt tokens, not only equal k. And when recall@k is already high but answers are still wrong, stop tuning chunking: the loss is downstream, at the reranker cut or in generation (Retrieval & Reranking, LLM evaluation metrics).
- Trusting
chunk_size. LangChain counts characters by default: 512 is about 128 English tokens. Pass the embedder’s tokenizer aslength_function. - Counting words with
len(text.split()). Japanese and Chinese have no spaces, so a long paragraph is one “word”. Count tokens. - Sizing for Claude’s window. The embedder is the bottleneck; a 1M-token model does not make a 2,000-token chunk searchable.
- Re-embedding part of the corpus with a new chunker or model: two vector spaces, no error.
“What chunk size and overlap, and why?” Answer with a method, not a number: cut on the document’s units, size to the embedder’s window in its own tokens with the path and overlap included, start near 512 with 10 to 15% overlap, and compare two or three chunkers by recall@k on about 50 real questions. Expect the follow-up “why chunk at all with a 1M-token window?” (RAG Architecture prices it). Model answer: RAG pipeline and chunking strategy.
Chunks are sized at 512 tokens with overlap included, then a 24-token heading path is prepended. The embedder’s window is 512. What happens?
536 tokens go in and the last 24 are dropped without an error. Gold excerpts in chunk tails miss at recall@k. Size the body at window minus path, 488, and check token counts at ingest.
Recall@5 goes from 0.84 to 0.88 on 50 questions after a chunker change. Ship it?
Not on that alone. Four points is 2 questions (42 to 44 of 50), inside the ±9.9-point noise. Add questions, read which ones flipped, and price the full re-embed first.
Recall@5 is 0.95 and users still get wrong answers. Do you try semantic chunking?
No. The gold passage is reaching the top 5, so chunking is not where the answer is lost. Check whether it survives the reranker cut and reaches the prompt whole, then whether the model uses and cites it.
- Cut on structure first, then size inside a section, then overlap that never crosses a heading.
- Path, overlap and text must fit the embedder’s window, counted in its tokens.
- Semantic chunking costs about 4× the embedding tokens; use it only if your golden set shows the gap.
- Search small, return big; give orphan chunks a heading path, then context if the misses persist.
- Compare chunkers by recall@k on real questions; 50 questions leave ±9.9 points.