Almost every pipeline I read splits documents into fixed windows of about five
hundred tokens with a little overlap, because that is what the first tutorial
did. Nobody chose it. It is the int of retrieval: fine until the day it
isn’t, and invisible until then.
The day it isn’t usually looks like this. Support asks why the assistant can’t
find policy RF-2014, which is sitting in a table in a document the system
definitely ingested. The chunk containing that row got split down the middle of
the table, so the identifier lives in one chunk and the meaning lives in the
next. Neither chunk is retrievable by the question a human would ask — and the
ticket that follows will say the model is
hallucinating.
The rule this post argues for. A chunk is not a piece of a document. It is the unit you are asking the retriever to rank, and it should be the smallest thing that can answer a question on its own.
The question a chunk has to answer
Before you touch a splitter, write down five real questions your users asked this month. Not paraphrases — the actual strings, typos included. For each one, open the source document and highlight the smallest span of text that answers it.
You will find three shapes, and they want different boundaries:
- A sentence or two of prose. Anything from a paragraph splitter works.
- A row, a definition, a code block or a config key. Structure carries the meaning, and a character-count splitter destroys it.
- An argument that runs over several paragraphs. No single chunk answers it; you need neighbouring chunks stitched back together at retrieval time.
That exercise takes an afternoon and it decides your chunking strategy more reliably than any blog post, including this one.
Chunk for the retriever, not for the reader
The second failure is subtler. A chunk that reads beautifully to a human can be invisible to an embedding model, because everything that made it findable — the heading, the document title, the table’s column names — lives outside the span you stored.
Whatever splitter you use, carry the context down into the chunk:
from dataclasses import dataclass, field
from typing import Iterator
@dataclass(slots=True)
class Chunk:
"""One retrievable unit, plus everything needed to rank and cite it."""
doc_id: str
text: str
heading_path: list[str] = field(default_factory=list)
kind: str = "prose" # prose | table | code | definition
start: int = 0
end: int = 0
@property
def embedding_text(self) -> str:
"""What the embedder sees: the chunk under its own headings."""
trail = " > ".join(self.heading_path)
return f"{trail}\n\n{self.text}" if trail else self.text
def split_markdown(doc_id: str, source: str) -> Iterator[Chunk]:
"""Split on structure first, size second.
Headings open a new chunk. Tables and fenced code are never split, however
long they run — a half table retrieves for nothing.
"""
heading_path: list[str] = []
buffer: list[str] = []
kind = "prose"
start = 0
def flush(end: int) -> Iterator[Chunk]:
body = "\n".join(buffer).strip()
if body:
yield Chunk(doc_id, body, list(heading_path), kind, start, end)
for offset, line in enumerate(source.splitlines()):
if line.startswith("#"):
yield from flush(offset)
buffer, kind, start = [], "prose", offset
level = len(line) - len(line.lstrip("#"))
heading_path = heading_path[: level - 1] + [line.lstrip("# ").strip()]
continue
if line.startswith("```"):
kind = "prose" if kind == "code" else "code"
if line.startswith("|") and kind == "prose":
kind = "table"
buffer.append(line)
yield from flush(len(source.splitlines()))
Two things in there matter more than the splitting itself. heading_path puts
the document’s structure inside the text you embed, so “what is the refund
window” can match a chunk whose own sentence never says refund. And kind
gives you a column to filter and to debug on — when identifier lookups fail,
you want to know immediately whether your tables survived ingestion intact.
Measure it on your own questions
Now the part that separates a decision from a preference. Take the questions you collected, label which document span answers each one, and score every candidate strategy against that set. (How to build that set so it stays useful is its own post: fifty labelled failures.)
from dataclasses import dataclass
from statistics import mean
from typing import Callable, Sequence
Retriever = Callable[[str, int], Sequence[str]]
@dataclass(frozen=True, slots=True)
class LabelledQuery:
"""One real question, and the chunk ids that genuinely answer it."""
question: str
gold_chunk_ids: frozenset[str]
def recall_at_k(retriever: Retriever, queries: Sequence[LabelledQuery], k: int) -> float:
"""Share of questions whose answer appears anywhere in the top k."""
hits = 0
for query in queries:
retrieved = set(retriever(query.question, k))
if retrieved & query.gold_chunk_ids:
hits += 1
return hits / len(queries)
def mrr(retriever: Retriever, queries: Sequence[LabelledQuery], k: int) -> float:
"""How far down the list the first correct chunk sits, averaged."""
scores = []
for query in queries:
rank = next(
(i for i, chunk_id in enumerate(retriever(query.question, k), start=1)
if chunk_id in query.gold_chunk_ids),
None,
)
scores.append(1 / rank if rank else 0.0)
return mean(scores)
def compare(strategies: dict[str, Retriever], queries: Sequence[LabelledQuery], k: int = 10) -> None:
width = max(len(name) for name in strategies)
for name, retriever in strategies.items():
print(
f"{name:<{width}} "
f"recall@{k} {recall_at_k(retriever, queries, k):.2f} "
f"mrr@{k} {mrr(retriever, queries, k):.2f}"
)
Run it per question class, not just in aggregate. The aggregate number moves slowly and hides the thing you care about: a strategy that improves prose questions while quietly destroying identifier lookups will look like a small win overall and feel like a regression to every user in support.
question class strategy recall@10 mrr@10
prose fixed-512 .... ....
prose structural .... ....
identifier fixed-512 .... ....
identifier structural .... ....
Fill that table with your own numbers before you argue about chunk size in a planning meeting. Whoever has the table wins the argument, and should.
Keep the ingest path honest
The splitter has to run the same way in the notebook where you tuned it and in the service that ingests documents at 3am. If those two drift, the evaluation above is measuring a pipeline you don’t actually run.
import { z } from "zod";
export const ChunkSchema = z.object({
docId: z.string(),
text: z.string().min(1),
headingPath: z.array(z.string()),
kind: z.enum(["prose", "table", "code", "definition"]),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
});
export type Chunk = z.infer<typeof ChunkSchema>;
/** Never let a table or a fenced block be cut in half by a size limit. */
export function enforceBoundaries(chunks: Chunk[], maxChars: number): Chunk[] {
return chunks.flatMap((chunk) => {
if (chunk.text.length <= maxChars || chunk.kind !== "prose") {
return [chunk];
}
const paragraphs = chunk.text.split(/\n{2,}/);
const packed: Chunk[] = [];
for (const paragraph of paragraphs) {
const last = packed.at(-1);
if (last && last.text.length + paragraph.length <= maxChars) {
last.text = `${last.text}\n\n${paragraph}`;
continue;
}
packed.push({ ...chunk, text: paragraph });
}
return packed;
});
}
Note what the size limit is doing here: it packs prose and it refuses to touch anything structural. Size is the last constraint applied, not the first.
What to log in production
Three fields, written next to every retrieval, will tell you within a week whether your boundaries are right:
- the
kindof every chunk returned, so you can see whether tables ever win; - the rank at which the cited chunk appeared, so you can watch it drift;
- the query string, so next quarter’s labelled set writes itself.
That last one is the compounding one. The set of questions you evaluate against should be growing out of production every week, not out of a synthetic generator that has never met your users.
The order to do this in
- Collect twenty real questions and label the span that answers each.
- Run your current pipeline against them and write down the number.
- Change one thing — structure-aware boundaries, or heading context in the embedding text — and run it again.
- Only then argue about chunk size.
Most teams start at step four and never do the first three. That is why chunking feels like folklore instead of engineering: everyone is tuning a parameter nobody has measured.