nits.dev

Retrieval

Chunking is a retrieval decision, not a preprocessing step

Fixed-size chunking is a default, not a choice. Here is how to pick boundaries from your own failed queries, and the harness that tells you whether it worked.

27 August 2026 5 min read Explainer
A document drawn as a long band divided into seven chunks. A dashed terracotta boundary cuts straight through the one solid block in the middle, splitting it in half.

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:

retrieval/chunking.py
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.)

retrieval/evaluate.py
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.

shape
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.

src/ingest/chunk.ts
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 kind of 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

  1. Collect twenty real questions and label the span that answers each.
  2. Run your current pipeline against them and write down the number.
  3. Change one thing — structure-aware boundaries, or heading context in the embedding text — and run it again.
  4. 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.

Nitish Kumar

— Nitish Kumar

Production readiness consultant at Noetic