Skip to main content
ANVISoftware Solutions
Lesson 14 of 22Intermediate17 min

Chunking and Retrieval Quality

By the end of this lesson

Split documents so retrieved context is actually relevant.

Teams spend weeks rewording prompts and an afternoon on chunking. It is usually the wrong way round. How you split your documents decides what the search can possibly find, and no prompt recovers an answer from a passage that was never retrievable.

The reason is mechanical. A chunk is the unit that gets embedded, the unit that gets ranked, and the unit that lands in the prompt. If the sentence that answers a question is spread across two chunks, neither chunk fully answers it and neither may rank highly. The information is in your corpus and out of reach.

A fixed-size split that makes one chunk worse than useless
Text
SOURCE PARAGRAPH
----------------
Standard-class rail travel is reimbursable when the ticket is booked seven
or more days before travel. First-class travel is reimbursable only where
the journey exceeds four hours and the employee has written approval from a
director. Receipts are required for any single item over 75 pounds.


SPLIT AT 240 CHARACTERS
-----------------------
CHUNK 118
Standard-class rail travel is reimbursable when the ticket is booked seven
or more days before travel. First-class travel is reimbursable only

CHUNK 119
where the journey exceeds four hours and the employee has written approval
from a director. Receipts are required for any single item over 75 pounds.
  • Read chunk 118 on its own, as the model will. It says first-class travel is reimbursable. The two conditions that restrict it are in the next chunk.
  • This is not a chunk that is merely unhelpful. It states the opposite of the policy. Retrieve it for "can I claim a first-class ticket?" and a correctly grounded, correctly cited answer will be wrong, and the citation will point at a real document.
  • Chunk 119 is broken the other way. It opens with "where the journey exceeds four hours" and never says what that condition applies to. Embedded on its own it is about journey length and approvals, not about travel class, so it will not rank well for the question it half-answers.
  • The split point was chosen by a character counter that has no idea a sentence was in progress. Nothing errored. Ingestion reported two chunks and moved on.
  • Notice also that neither chunk carries the heading it sat under. "Travel > Rail and air > Class of travel" is the context that tells you these sentences are about rail at all.

The test to apply to every chunk is simple and worth being strict about: could a colleague who has read nothing else answer a question from this text alone, and would they know what it is about? A chunk that fails either half of that will fail in a prompt too, because the prompt gives the model exactly that much and nothing more.

The decisions that make up a chunking strategy:

Split on structure first
Headings, sections, list boundaries, table rows, paragraph breaks. Documents are already divided into units of meaning by the person who wrote them. Use their divisions before you invent your own.
Size, as a ceiling rather than a target
Somewhere around 500 to 1,500 characters suits policy prose. Too small and a chunk loses the context that makes it interpretable. Too large and its embedding averages several subjects, so it ranks moderately for many questions and strongly for none.
Overlap, to survive the boundaries you still have to make
Repeat the last one or two sentences of each chunk at the start of the next, roughly 10 to 20 percent. It costs storage and some duplicate text in results, and it means a sentence near a boundary appears whole somewhere. Overlap is insurance on bad splits, not a substitute for good ones.
Keep the heading path attached
Prefix each chunk with its document title and heading trail before embedding it. A paragraph under "Parental leave > Notice required" is about parental leave even when those words never appear in the paragraph, and prefixing is what puts that into the vector.
Never split mid-sentence
This is the one hard rule. If a unit exceeds your ceiling, split at the nearest sentence end, or accept a slightly oversized chunk. An oversized chunk is a minor cost; a truncated clause can invert your policy.
Treat tables and lists as units
A grade band table split across chunks produces rows with no header and a header with no rows. Keep the header with the rows, and if the table is large, repeat the header in each piece.
A structure-aware splitter that keeps the heading trail
Python
import re
from dataclasses import dataclass

HEADING = re.compile(r"^(#{1,4}) +(.+)$")
MAX_CHARS = 1_200
SENTENCE_END = re.compile(r"(?<=[.!?]) +")

@dataclass
class Chunk:
    chunk_id: str
    heading_path: str
    text: str

    @property
    def text_for_embedding(self) -> str:
        # The heading trail goes into the vector, not just into the citation
        return f"{self.heading_path}\n{self.text}"

def split_markdown(body: str, document_id: str, title: str) -> list[Chunk]:
    chunks: list[Chunk] = []
    trail: list[str] = [title]
    paragraph_buffer: list[str] = []

    def flush() -> None:
        joined = "\n\n".join(paragraph_buffer).strip()
        paragraph_buffer.clear()
        if not joined:
            return
        for piece in pack_sentences(joined, MAX_CHARS):
            chunks.append(
                Chunk(
                    chunk_id=f"{document_id}#c{len(chunks):04d}",
                    heading_path=" > ".join(trail),
                    text=piece,
                )
            )

    for block in body.split("\n\n"):
        match = HEADING.match(block.strip())
        if match:
            flush()                                   # a heading always ends the current chunk
            depth = len(match.group(1))
            trail = trail[:depth] + [match.group(2).strip()]
        else:
            paragraph_buffer.append(block)

    flush()
    return chunks

def pack_sentences(text: str, limit: int) -> list[str]:
    """Fill up to limit characters, but only ever break between sentences."""
    pieces, current = [], ""
    for sentence in SENTENCE_END.split(text):
        if current and len(current) + len(sentence) + 1 > limit:
            pieces.append(current)
            current = sentence
        else:
            current = f"{current} {sentence}".strip()
    if current:
        pieces.append(current)
    return pieces
  • The splitter walks the document's own structure. A heading flushes whatever was accumulating, which means a chunk never spans two sections — the boundary the author already drew is respected instead of being overridden by a character count.
  • trail is maintained as a stack, so a level-three heading replaces the previous level three and keeps the level two above it. That produces "Expenses Handbook > Travel > Class of travel" rather than a flat label.
  • text_for_embedding is where the heading trail earns its place. What gets embedded is the trail plus the text, so a paragraph that never says the word "travel" still lands near travel questions. The stored text stays clean for the prompt and the citation.
  • pack_sentences enforces the size ceiling without ever cutting mid-sentence. It will return a chunk slightly over the limit when a single sentence exceeds it, and that is the right trade — compare it with chunk 118 above.
  • The sentence pattern here is deliberately simple, and it will mishandle abbreviations and decimal figures. For policy documents with amounts like 75.00 that matters, so check its output on your own text and reach for a proper sentence splitter if the mistakes are real.
  • No overlap is applied in this version. Because splits only ever land on sentence and section boundaries, there is less for overlap to rescue. Add it if your evaluation set shows answers falling between adjacent chunks, and measure rather than adding it by default.

The two approaches, judged on what they do to retrieval:

 Fixed character splitStructure-aware split
Boundaries landWherever the counter runs outAt sections, paragraphs and sentence ends
Chunk read aloneMay be a fragment, and may state something the full text qualifiesA complete unit the author already treated as one
Heading contextLost unless you add it back deliberatelyCarried, and embedded with the text
Chunk sizesUniform, which is convenient and means nothingUneven, because real sections are uneven
Effort to implementA few lines, working in minutesA parser per document format, plus reading the output
Where each belongsA first pass to get the pipeline running end to endAnything people will act on

Chunking is tuned against real questions, not chosen from a default. The loop that actually works:

  1. Collect twenty real questions and the passage that answers each

    Ask the people who will use the assistant, and write down which paragraph in which document holds each answer. This takes an afternoon and it is the only way to measure anything that follows. Without it you are adjusting numbers and reading one answer to judge the effect.

  2. Measure whether the right passage is retrieved at all

    For each question, run the search and check whether the chunk containing the known answer appears in the top k. That percentage is your retrieval ceiling — generation cannot exceed it, and a prompt change cannot move it.

  3. Read the chunks that came back instead of the right one

    This is where the diagnosis happens. Fragments mean your boundaries are wrong. Chunks covering four subjects mean they are too large. Near-miss chunks from the correct section usually mean the heading context is missing.

  4. Change one thing, re-index, measure again

    Size, or overlap, or the splitting rule. One at a time, because two changes at once tell you nothing about which helped. Re-indexing is required — a chunking change is not live until the corpus is rebuilt.

  5. Only then look at the prompt

    Once the right passage is reliably retrieved, remaining failures are generation problems and the prompt is the right place to work. Reordering that sequence is how teams spend a fortnight on prompt wording to fix a splitter.

Summary

  • Chunking sets the ceiling on retrieval quality, and no prompt change can lift an answer past it
  • Split on the document's own structure — headings, paragraphs, rows — and never mid-sentence
  • Size is a ceiling, not a target: too small loses context, too large averages several subjects into one vector
  • Embed the heading trail with the chunk text so a paragraph carries the subject it sits under
  • Tune against twenty real questions with known answers, change one thing at a time, and re-index after every change

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Split something of your own badly, then well

Take a two-page document you know well. Split it at a fixed 300 characters, print every chunk, and read each one as though it is all you have.

Mark the chunks that are incomplete, the chunks that would mislead someone, and the chunks you cannot tell the subject of. Then split it by heading and paragraph with the trail prefixed, and mark the same three categories again.

Show solution

In the fixed split, expect roughly a third of the chunks to be unusable alone. The misleading ones are the ones to sit with: a sentence truncated before its condition does not read as broken, it reads as a simpler rule than the document states.

The subject-unclear count is usually higher than people expect, and it is the one the heading prefix fixes outright. Pronouns and bare verbs are everywhere in policy prose — "it must be approved in advance" is three words from being useful and has none of them.

In the structure-aware split, the remaining problems are usually oversized chunks where a section is long, and tables. Both are fine to handle specifically rather than by adjusting the global size.

The reason this exercise is worth doing by hand: retrieval quality is invisible from the outside. Your pipeline returns five chunks and an answer either way. Reading the chunks is how you find out what the search had to work with.

Challenge

Chunk a document that is not prose

Your corpus is getting a new document: a per-grade expense allowance table with 14 rows and 5 columns, plus a page of footnotes that qualify specific cells.

Design the chunking. Say what one chunk is, what goes into the vector, what goes into the prompt, and how a footnote reaches the answer when it applies.

Show solution

A defensible unit is one row, rendered as text rather than as columns: "Grade 4: daily meal allowance 30 pounds, hotel cap 140 pounds, taxi limit 25 pounds per journey." Rows are what questions are about, and a row written this way is interpretable alone.

The column headers must travel with every row, which is why rendering to sentences beats storing a slice of the grid. A chunk containing 4, 30, 140, 25 and nothing else is numbers with no meaning, and its embedding is close to nothing anyone would ask.

Footnotes are the genuinely hard part, and the reason this is a challenge. A footnote qualifying the hotel cap for London belongs with the rows it modifies, otherwise a correct row produces a wrong answer for a London trip. Appending the applicable footnote text to each affected row duplicates it and keeps the qualification attached — duplication is much cheaper than a confidently incomplete answer.

Also worth storing: the whole table as one additional chunk, for questions about the shape of it rather than one cell. "Which grades get a hotel allowance?" is answered by the table, not by any row.

The general lesson is the one from the callout. The right chunk is the unit a question is about, and that unit is a property of the content. A splitter tuned on prose will do none of this, which is why new document types need the measurement run again.

Saved in this browser only.