Trail content
8 minEnglish narration, Portuguese subtitlesPodcast (RSS)
Chapters
  1. 0:00Opening
  2. 0:53The idea
  3. 1:44How it works
  4. 2:17The same chunk
  5. 3:31A challenge
  6. 4:15When not to use
  7. 5:41In production
  8. 6:47The code, by intent
  9. 7:31Wrap-up
Transcript

Opening

Eduardo maintains the search over a health insurer's internal manual. Somebody cut the documents every five hundred tokens, ignoring the structure. And one of the chunks says only this.

The deadline is twenty one business days from the request, and it may be extended once, with justification. In the cases covered by item three point two.

Deadline for what? For whom? From which version of the manual? Search finds this passage for the authorization deadline for surgery. But also for the reimbursement deadline, because the passage never says which topic it covers. And the model answers with confidence, and sometimes gets the topic wrong.

In this lesson, you'll learn to cut documents so that each chunk can be understood, and found, on its own.

The idea

Think of a file card. A good one doesn't just say what it contains. It says which folder, which drawer, and which year it came from. The pattern makes two changes, both at indexing time.

First, split by structure. Headings, sections, clauses, list items and tables are the boundaries. A chunk never starts mid sentence, and never separates a table from its header. There's still a maximum size, but it's the limit, not the rule.

Second, attach context. Each chunk gets a header with its path, document, chapter and section, its version, and optionally one sentence, written by a model, that situates the passage.

That header goes into the text you index, for the search. And into the text you send to the model, for the answer and the citation.

How it works

The document arrives, as a PDF, a web page or plain text.

A parser finds the headings, sections, lists and tables.

The split follows that structure, within the maximum size.

A small model reads the document and the passage, and writes one sentence that says where it belongs.

Each chunk becomes a header, with path, version and that sentence, plus the original text.

And the full chunk is indexed, for keyword search and for vector search.

The same chunk

Now the same passage, after the pattern. Three things changed, and each one teaches something.

The path. Authorizations, then elective surgery, now goes into the embedding and into keyword search. A reimbursement question no longer looks just as similar.

The context sentence. It translates the vague passage into the vocabulary of the question. Authorization, elective surgery, individual plan.

And the neighboring clause. If the section fits the limit, item three point two travels with it. If it doesn't, the header still points at section four point three, and the citation doesn't pretend the passage is complete.

What does it cost? Say eight hundred tokens of body and eighty of header. Five chunks per question go from four thousand tokens to four thousand four hundred. The real cost sits in ingestion, one small call per chunk, with the whole document cached. Not in the question.

And putting the path at the start helps twice. The search finds the right topic. And the model, which reads the start of a context better than the middle, sees first where the passage came from.

A challenge

Here's one for you. You turned on the context sentence, but you generated it by reading only the chunk, not the document. In the eval, the reimbursement question still retrieves the authorization passage. Why?

Because the sentence is as vague as the chunk. The model writing it never saw the chapter either. So it writes something like, this passage discusses a deadline.

The sentence exists to inject the context the chunk lost. Without the document in that call, there's no context to inject. That's why the call carries the whole document, cached, and reuses it across every chunk.

When not to use

Short documents that fit in a single chunk. A two page remote work policy doesn't need to be cut. A title and a version at the start are enough. Splitting it only invents boundaries, and can cut the rule in half.

Unstructured content, like conversation transcripts. There are no headings to follow. Split by topic, or by overlapping windows.

And material that fits whole in the context. That's the smell from two lessons ago. Don't chunk what the model could read.

Now the common traps. Cutting by characters and calling it structural. If the boundary ignores the table title, you only changed the size.

Which is exactly what happened at the insurer. The rows with the deadline numbers landed in one chunk. The header, with the columns, authorization, reimbursement, appeal, stayed in the previous one. So the bot started citing twenty one, without saying which deadline. A table becomes its own chunk, with its title and its columns.

And leaving repealed versions in the index. A header that says version six doesn't stop the search from returning version six. Filter by effective date at query time, and remove the old document when you reindex. That's solved in the index, not in the prompt.

In production

What to measure. Search recall and precision, before and after, on the same eval.

Build twin questions on purpose. The reimbursement deadline and the authorization deadline. If the reimbursement passage stays out of the top five for the authorization question, the header is doing its job.

Check the citations. The section cited in the answer has to be the right one.

And alert on the empty-path rate at ingestion. If thirty percent of the chunks come out with no path, the parser broke on a new kind of PDF. Don't paper over it with the generated sentence. Fix the conversion, and reindex.

One security point. A chunk can carry personal data, a member's name, a card number. Under data protection law, the index is one more place where that data lives. If your policy forbids sending those documents to the model that writes the context sentence, stay on the structural header, built only from title and section.

The code, by intent

On the page, there are two tabs. The TypeScript one does only the structural part. It splits the document by its headings, keeps the path as it goes down, and puts title, path and version at the top of each chunk. No model call at all.

The Python one adds the context sentence. It sends the whole document as a cached block, and asks a small model, for each chunk, which topic and case that passage covers.

And that's the order to adopt it. Start with the structural header. It's cheap, and it already solves much of the problem.

Turn on the generated sentence only if the topic confusion eval still fails.

Wrap-up

So here's the idea to take with you. A chunk cut blindly is a sentence without an address. Cut along the document's own structure, and give each piece its path, its version and, when it helps, a sentence that says what it's about. Then both the search and the model know where they are.

That path in the header is made of exact words, and exact words are what keyword search loves. In the next lesson, we'll combine keyword search with embeddings: hybrid search. See you there.

PatternContext and knowledge

Contextual Chunking

Chunking com Contexto

Contextual Chunking is an AI design pattern for LLM apps and agents, in the Context and knowledge group. Split documents along their structure and attach to each chunk the context it came from.

Element
17/70
Level
Intermediate
Read
8 min
Updated
On this page 18
  1. 01Intent
  2. 02Problem
  3. 03Solution
  4. 04Structure
  5. 05Pseudocode
  6. 06Real-world scenarios
  7. 07Applicability
  8. 08When not to use
  9. 09How to implement
  10. 10Variations
  11. 11Cost, latency, and failure modes
  12. 12How to evaluate
  13. 13In production
  14. 14Security
  15. 15Pros and cons
  16. 16Relations
  17. 17Code
  18. 18Check your understanding
  19. ··Quick answers

Intent

Split documents along their structure (sections, clauses, tables) and attach to each chunk the context it came from: document, section, date, and a sentence that situates the passage. It is for RAG systems where chunks lose their meaning on their own.

In one sentence
A file card that says not only what it contains, but which folder, drawer, and year it came from.

Problem

Eduardo maintains search over a health insurer’s internal manual (a Brazilian private plan, overseen by ANS, the national regulator). The documents were cut every 500 tokens, ignoring structure. One of the chunks says only:

...the deadline is 21 business days from the request, and it may be
extended once with justification. In the cases covered by item 3.2...

Deadline for what? For whom? From which version of the manual? Search finds this passage for “what is the authorization deadline for elective surgery?”, but also for “what is the reimbursement deadline?”, because the passage does not say which topic it covers. The model answers confidently and sometimes gets the topic wrong.

The blind cut also separated a table from its title. The rows with deadline numbers landed in one chunk; the header with the columns (authorization, reimbursement, appeal) stayed in the previous one. The bot started citing “21” without saying which deadline.

Then there is item 3.2. It lives in another chunk. Anyone who reads only the passage above cannot tell what the item allows. In this insurer, that mixes ANS rules with the plan’s own rules.

The question this pattern answers: how do you split documents so that each chunk is understandable and findable on its own?

Solution

Two changes at indexing time:

  • Split by structure: use headings, sections, clauses, list items, and tables as boundaries. A chunk never starts mid-sentence or separates a table from its header. A maximum size exists, but it is the limit, not the rule.
  • Attach context: each chunk gets a header with its path (document, chapter, section), its date or version, and, optionally, a model-generated sentence that situates it (“This passage covers the authorization deadline for elective surgery on individual plans”).

The header goes into the indexed text (for search) and into the text sent to the model (for the answer and the citation).

Anthropic described this idea in Contextual Retrieval (September 2024). Before indexing, a model reads the document and the chunk and writes a sentence that situates the passage. That sentence goes into the text used for the embedding and for keyword search.

Worked example

Take the deadline passage and the document it came from. After the blind cut, the index stores only the middle. After this pattern, the same middle gets a header:

[Member Handbook | Authorizations | 4.1 Elective surgery | v7, effective 2026-03]
This passage covers the authorization deadline for elective surgery on individual plans.
The deadline is 21 business days from the request, and it may be
extended once with justification. In the cases covered by item 3.2
(urgency and emergency, see section 4.3), the deadline does not apply.

Three things changed, and each one teaches a lesson.

  1. The path. “Authorizations > 4.1 Elective surgery” enters the embedding and BM25. A reimbursement question no longer competes with the same similarity.
  2. The context sentence. It translates the vague passage into the question’s vocabulary (“authorization”, “elective surgery”, “individual plan”).
  3. The neighboring clause. If section 4.1 fits the limit, item 3.2 travels with it. If it does not, the header still points at 4.3, and the citation does not pretend the passage is complete.

A token account, with illustrative numbers. Suppose 800 tokens of body and 80 of header. Across 2,000 chunks, the index grows by about 160,000 tokens. At query time, five chunks go from 4,000 to 4,400 tokens. The extra cost sits in ingestion (one small call per chunk, with the document cached), not in the question.

The paper “Lost in the Middle” (Liu et al., 2023) shows that models use the start and end of a context better than the middle. Putting the path at the start of the chunk helps twice: search finds the right topic, and the model reads first where the passage came from.

Check +20 XP on first trySearch returned the 21-day deadline passage for a reimbursement question. The passage came from the authorizations chapter. Which fix attacks the cause?

Structure

flowchart LR
  D(["1. Document"]):::io --> P["2. Parse the structure"]:::tool
  P --> S["3. Split by section and clause"]:::tool
  S --> C(["4. Generate a context sentence"]):::llm
  C --> H["5. Build header and passage"]:::tool
  H --> I[("6. Index")]:::store
  1. The document arrives as PDF, HTML, or text.
  2. A parser identifies headings, sections, lists, and tables.
  3. The split follows that structure, within a maximum size.
  4. A small model reads the document and the passage and writes a sentence that situates it.
  5. Each chunk becomes a header (path, version, context sentence) plus the original text.
  6. The full chunk is indexed for lexical and vector search.

Pseudocode

schema ChunkContext { summary: string /* one sentence */ }

function index_document(doc):
    tree = parse_structure(doc)                         # 2. headings, sections, tables
    for section in tree.leaves(max_tokens=800):         # 3. by structure
        path = " > ".join(section.ancestors_titles())
        ctx = llm(CONTEXT_PROMPT, doc.summary, section.text, model="small") -> ChunkContext   # 4
        header = f"[{doc.title} | {path} | version {doc.version}] {ctx.summary}"
        index.add(id=section.id, text=header + "\n" + section.text,
                  metadata={doc: doc.id, path: path, version: doc.version})               # 5 and 6

Real-world scenarios

  • Internal knowledge base chat. Policies split by section, with the path and version on every passage. A search for “reimbursement deadline” no longer brings back the authorization deadline.
  • Legal assistant. Contracts split by clause and item, with the clause number in the header, for exact citation. In Mata v. Avianca (2023), lawyers filed a brief with decisions invented by ChatGPT. An index that stores the clause number cannot invent a clause, but it only helps if search returns the right passage.
  • Tax and health-plan manuals. Brazilian rules cite each other (“under art. 12”). Without the path on the chunk, the model treats the cross-reference as if it were the rule.

Applicability

  • Long, structured documents (manuals, policies, contracts, regulations).
  • Passages that, on their own, use vague terms (“the deadline”, “in this case”).
  • Several versions or several similar documents in the same index.
  • Answers that need to cite the exact section.

When not to use

  • Short documents that fit in a single chunk. A title and version at the start are enough; splitting only invents artificial boundaries.
  • Unstructured content (conversation transcripts). Split by topic, or by overlapping windows.
  • The material fits whole in the context. See RAG for Small Data.
Check +20 XP on first tryYour work-from-home policy is 2 pages and fits in one chunk. A colleague wants to cut every 500 tokens and generate a context sentence. What do you do?

How to implement

  1. Look at real documents and identify their structural markers (numbered headings, clauses, tables).
  2. Write a parser per document type; convert PDFs to a structured format first.
  3. Define the maximum size and what to do with larger sections (split by paragraph, keeping the heading).
  4. Build the header with path and version.
  5. Generate the context sentence with a small model, caching the whole document in the prompt.
  6. Reindex and compare recall and precision in the search eval.
  7. Use the header’s path in the answer’s citations.

Common traps

  • Cutting by characters and calling it structural. If the boundary ignores the table title, you only changed the size.
  • Generating the sentence from the chunk alone. Without the document, the model writes “this passage discusses a deadline”, as vague as the body. The call needs to see the path and a document summary.
  • Leaving repealed versions in the index. A header that says “v6” does not stop search from returning v6. Filter by effective date at query time and remove the old document on reindex.
  • Trusting the sentence and relaxing the parser. A wrong sentence poisons the embedding. A sample of chunks at ingestion catches that before a user does.

Variations

  • Structural header only. Path and version, no generated sentence. Cheap, and it already solves much of the problem.
  • Generated context sentence. Improves search for vague passages; costs one small call per chunk at indexing time.
  • Hierarchical chunks. Small chunks for search, and the whole section (the parent) sent to the model when a small one is found.
  • Tables as their own chunks. Each table becomes a chunk, with title and caption, as text.

Start with the structural header. Turn on the generated sentence only if the topic-confusion eval still fails.

Cost, latency, and failure modes

Aspect What happens How it shows up
Cost At indexing: one small call per chunk, with the document cached ingestion cost per document
Latency None at query time; the work happens at indexing ingestion time
Size Headers make each chunk slightly bigger tokens per chunk
Failure: wrong parser Badly read structure produces meaningless chunks chunks with an empty or swapped path
Failure: wrong context sentence It places the passage under the wrong topic off-topic search results
Failure: old version Repealed documents stay indexed answers citing a previous version

How to evaluate

  • Search recall and precision before and after, on the same eval.
  • Topic confusion: similar questions about different topics (“reimbursement deadline” and “authorization deadline”).
  • Citations: the section cited in the answer is the right one.
  • Table and clause boundaries: cases where the blind cut used to split a block.
{ "question": "What is the authorization deadline for elective surgery?", "relevant": ["manual-v7#authorizations-4.1"], "not_relevant": ["manual-v7#reimbursement-2.3"] }

Build twin questions on purpose. If the relevant reimbursement passage is not in the top 5 for the authorization question, the header is doing its job.

Check +20 XP on first tryYou generated the context sentence by reading only the chunk, not the document. In the eval, the reimbursement question still retrieves authorization. Why?

In production

  • Record the path and version of the passages used in each answer.
  • A reindexing routine when a document changes, removing old versions.
  • A sample of chunks to check the parser and context sentences.
  • An alert on the empty-path rate at ingestion: a broken parser after a new kind of PDF.

Security

An indexed chunk can carry personal data (a member’s name, a card number). Under Brazil’s LGPD, the index is one more place that data lives. Do not send documents with personal data to the context-sentence model if company policy forbids it. In those cases, stay on the structural header, built only from title and section.

Pros and cons

Pros Cons
Each chunk is understandable on its own A parser per document type
Search finds the right topic Indexing cost for the generated sentence
Citations with the exact section Slightly larger chunks
Versions distinguishable in the index Reindexing needed when structure changes

Relations

  • Destination pattern: RAG. Contextual chunking is the ingestion step that makes search return the right passage.
  • Neighbors: Hybrid Search combines keyword and embedding, which makes good use of the path in the header. Embeddings explains why a vague passage becomes a vague vector.
  • When it does not apply: RAG for Small Data, if the material fits whole.

Code

Both tabs split a document by headings and build the header with the path; the Python tab also generates the context sentence with the whole document cached.

Python. Use this when documents are in Markdown or were converted to a format with headings.

import re

import anthropic

client = anthropic.Anthropic()


def split_by_headings(markdown: str, max_chars: int = 3000) -> list[dict]:  # 2 and 3
    chunks, path = [], []
    for block in re.split(r"\n(?=#{1,4} )", markdown):
        m = re.match(r"(#{1,4}) (.+)", block)
        if m:
            level = len(m.group(1))
            path = path[: level - 1] + [m.group(2).strip()]
        for i in range(0, len(block), max_chars):  # large section: split, keeping the path
            chunks.append({"path": " > ".join(path), "text": block[i : i + max_chars]})
    return chunks


def situate(doc_text: str, chunk_text: str) -> str:  # 4
    reply = client.messages.create(
        model="claude-haiku-4-5", max_tokens=100,
        system=[{"type": "text", "text": f"<document>\n{doc_text}\n</document>",
                 "cache_control": {"type": "ephemeral"}}],  # document cached across chunks
        messages=[{"role": "user", "content": "In one sentence, say which topic and case this passage covers, "
                                              f"to help find it in a search.\n<passage>{chunk_text}</passage>"}],
    )
    return reply.content[0].text.strip()


def index_document(doc: dict) -> list[dict]:
    out = []
    for c in split_by_headings(doc["markdown"]):
        header = f"[{doc['title']} | {c['path']} | version {doc['version']}] {situate(doc['markdown'], c['text'])}"
        out.append({"id": f"{doc['id']}#{c['path']}", "text": f"{header}\n{c['text']}", "path": c["path"]})  # 5
    return out

What you gained: chunks with path, version, and a context sentence, with the document read from cache for each chunk. What you lost: heading-based parsing depends on the document having headings; unmarked PDFs need better conversion first.

TypeScript. Use this when you want to start with only the structural header, with no generated sentence.

type Chunk = { id: string; path: string; text: string };

export function chunkByHeadings(doc: { id: string; title: string; version: string; markdown: string }, maxChars = 3000): Chunk[] {
  const chunks: Chunk[] = [];
  let path: string[] = [];
  for (const block of doc.markdown.split(/\n(?=#{1,4} )/)) {
    const m = block.match(/^(#{1,4}) (.+)/);
    if (m) path = [...path.slice(0, m[1].length - 1), m[2].trim()];
    const p = path.join(" > ");
    const header = `[${doc.title} | ${p} | version ${doc.version}]`; // 5. structural header
    for (let i = 0; i < block.length; i += maxChars) {
      chunks.push({ id: `${doc.id}#${p}#${i}`, path: p, text: `${header}\n${block.slice(i, i + maxChars)}` });
    }
  }
  return chunks;
}

What you gained: an indexing step with no model cost that already solves most of the lost context. What you lost: very vague passages stay hard to find; add the generated sentence if the eval shows failures.

Check your understanding

Check +20 XP on first tryA deadlines table lost its header: the title stayed in the previous chunk and the rows in the next one. The bot cites numbers without saying which deadline. What is the diagnosis?

Check +20 XP on first tryThe index holds manual v6 (repealed) and v7. The headers carry the version. The answer still cites the old rule. What is missing?

Check +20 XP on first tryThe PDF parser returned an empty path on 30% of chunks. The search eval got worse after you turned on contextual chunking. What is the next step?

Quick answers

What is Contextual Chunking?

Contextual Chunking is an AI design pattern for LLM apps and agents, in the Context and knowledge group. Split documents along their structure and attach to each chunk the context it came from.

Where does Contextual Chunking show up in practice?

In the real-world scenarios Internal knowledge base chat and Legal assistant.

Go further

Relations

Part of the tracks 1
Search