Skip to content
intermediate Phase 8 · Chunking and Retrieval Strategies

Semantic and Structural Chunking

Split on headings, paragraphs, or embedding breakpoints instead of raw counts.

50m
0 problems
Topic Progress 0%

Documents Already Mark Their Own Boundaries

A returns policy written in Markdown already says where its sections begin. ## Eligibility and ## Refunds are boundaries a human reader would use to look something up, and reading them costs one regex pass — no model, no similarity, no tuning. That is the premise of structural chunking: split where the document says to split.

import re

HEADING = re.compile(r"^(#{1,6})\s+(.+)$", re.M)

def sections(md: str) -> list[dict]:
    marks = list(HEADING.finditer(md))
    out, stack = [], []
    if marks and marks[0].start() > 0:
        out.append({"path": "(preamble)", "text": md[:marks[0].start()].strip()})
    for i, m in enumerate(marks):
        level = len(m.group(1))
        end = marks[i + 1].start() if i + 1 < len(marks) else len(md)
        stack = [h for h in stack if h[0] < level]          # drop deeper headings when a sibling starts
        stack.append((level, m.group(2)))
        out.append({"path": " > ".join(t for _, t in stack), "text": md[m.start():end].strip()})
    return [s for s in out if s["text"]]

Input: a policy with one #, two ##, and one ###.

Output:

'Returns Policy'                            16 chars
'Returns Policy > Eligibility'              85
'Returns Policy > Refunds'                  55
'Returns Policy > Refunds > Exceptions'     48

Three lines decide the behavior. The stack filter pops Eligibility before Refunds is pushed, so paths reflect the tree rather than the order headings appear — without it, every later section inherits a heading it does not belong to. Slicing from m.start() keeps the heading line inside the chunk, which means the embedding sees the section name and the citation can quote the path without a second lookup. Filtering empty sections removes the blank runs that pure layout leaves behind.

# Returns Policy
├── ## Eligibility         -> "Returns Policy > Eligibility"
├── ## Refunds             -> "Returns Policy > Refunds"
│   └── ### Exceptions     -> "Returns Policy > Refunds > Exceptions"
└── ## Shipping            -> "Returns Policy > Shipping"   (Exceptions popped)

The path is the payload: it travels with the chunk as metadata, becomes the citation line in the UI, and gives Metadata Filtering in Retrieval a key to filter on. Fixed windows (Fixed-Size Chunking) cannot produce it — a character offset says nothing about which section a passage came from.

Cutting Where Adjacent Sentences Stop Agreeing

Most corpora arrive as PDF text, email bodies, or wiki exports with no heading markup at all. Structural chunking then falls back to the other half of its name: embed each sentence once at ingest, compare each sentence with its neighbor, and cut where agreement collapses (Cosine Similarity).

def breaks(sims: list[float], floor: float) -> list[int]:
    """sims[i] = similarity between sentence i and sentence i+1 (0-based)."""
    return [i + 1 for i, s in enumerate(sims) if s < floor]

sims = [0.81, 0.78, 0.34, 0.77, 0.29, 0.83]
print(breaks(sims, 0.50))

Output: [3, 5]

sentences    s1 ----- s2 ----- s3 // s4 ----- s5 // s6
sim( i, i+1)   .81     .78     .34    .77     .29    .83
                             ^cut                ^cut

0.34 says sentences 3 and 4 stopped talking about the same thing, so the chunk ending with sentence 3 closes there and a new one opens at index 3. The detector is pure arithmetic over numbers you already computed; every sentence was embedded exactly once, and only at ingest — a query never pays for this.

A fixed floor is a starting point, not a policy. Corpora differ in baseline similarity: the same 0.50 shatters a FAQ where every answer resembles every question, and never cuts a Q&A set whose sentences are all loosely related. Production code picks the cut relative to the document's own distribution — cut where similarity falls below, say, the 20th percentile of sims — so the threshold adapts to the corpus instead of being tuned once and forgotten.

Two practical constraints ride along. Sentence segmentation must run before similarity, and it is wrong often enough to matter: e.g., 3.5, and quoted strings split "sentences" mid-thought, placing dips inside statements rather than between topics. And embedding every sentence is a real ingest cost — budget it as part of re-indexing, not as part of the query path.

The Document Type Decides the Rule

The generic failure is applying one splitter to everything. Each format marks its units differently, and a rule that ignores the format does not merely chunk badly — it chunks at places the document never intended.

Document Split on Why a generic rule fails
Markdown / HTML heading levels fixed windows cut lists and tables away from their headers
Contract / policy clause and article numbers paragraphs run for pages; ## never appears
Source file top-level definitions and classes a mid-function chunk loses the signature it explains
Transcript / chat speaker turns splitting a turn changes who said what
Log export one event per line the structure is row-based, not prose

Whatever rule runs, measure the result before trusting it. Token counts per chunk, printed as a distribution, expose the problem that averages hide:

import statistics

sizes = [210, 225, 190, 240, 1900]                      # tokens per chunk
print(statistics.median(sizes), max(sizes),
      round(max(sizes) / statistics.median(sizes), 1))

Output: 225 1900 8.4

One chunk is 8.4x the median. At k=10 it consumes a fifth of a 10,000-token retrieval budget by itself, and because it contains more terms it matches more queries — the oversized chunk is over-represented in results, not only in storage. The fix is a bound in both directions: sub-split anything above a max token count with the fixed-window pass, and merge anything below a minimum into its predecessor.

Failure modes worth checking on every new corpus:

  • Uncapped sections — a single heading spanning 6,000 tokens bypasses every chunk-size rule you configured.
  • One rule for all formats — clause numbering and speaker turns are invisible to a heading regex.
  • Sentence splitting on code — e.g. and string literals land dips inside statements.
  • Ingest cost ignored — embedding every sentence at re-indexing is a batch job, not a query-path cost.
  • A floor tuned on one corpus — the 20th-percentile dip threshold that works for FAQs shatters legal text.

Quiz

1. What does a heading-derived path give you that a character window cannot?

Question 1 options

2. sims = [0.81, 0.78, 0.34, 0.77, 0.29, 0.83] with floor 0.50. Where does the splitter cut?

Question 2 options

3. Chunk sizes are [210, 225, 190, 240, 1900] tokens. What is the actual risk of the 1,900-token chunk?

Question 3 options

4. Which splitting rule fits a 90-page supplier contract?

Question 4 options

Flashcards

Question

What does structural chunking split on?

Answer

Boundaries the document already marks: heading levels in Markdown and HTML, clause and article numbers in contracts, top-level definitions in code, speaker turns in transcripts.

Question

How does embedding-based semantic chunking locate cut points?

Answer

Embed each sentence once at ingest, compute cosine similarity between adjacent sentences, and cut where a score dips below a threshold — or below a percentile of the document's own scores.

Question

Why keep the heading line inside the chunk text?

Answer

The chunk becomes self-describing: the embedding sees the section name, and the citation can quote the path without a second lookup or an offset calculation.

Question

What do you measure immediately after splitting?

Answer

The token-count distribution — median, max, and their ratio. A single chunk several times the median dominates the prompt budget and over-matches queries; fix it with a max size and a merge floor.

Question

Why does semantic chunking not replace token sizing?

Answer

Sections vary wildly in length: a 6,000-token heading still needs an inner split and a three-line section should merge with its neighbor. Structure chooses boundaries; token limits police them.

Revision Notes

Key Takeaways

  • 1. Documents declare their own boundaries; heading walks and clause numbers give correct cuts for one regex pass and zero model calls
  • 2. With no markup, cut where adjacent-sentence similarity dips, using a threshold relative to the document's own distribution
  • 3. Chunking rules are per-format: contracts, code, transcripts, and logs each mark units a prose splitter cannot see
  • 4. Measure the size distribution after splitting — a single chunk at 8x the median distorts both the budget and the results

Interview Tips

  • • Walk the heading walker concretely: how the level stack keeps `Exceptions` under `Refunds` and drops it when the next `##` arrives
  • • Give the dip detector with numbers — say what sims[i] indexes before you say where the cut lands, because the off-by-one is the question
  • • Defend a per-format table: name the unit for contracts, code, and transcripts, and say what a generic rule does to each
  • • Show you measure: quote median, max, and their ratio, then the max-size and merge-floor fixes that follow from them

Cheat Sheet

Semantic and Structural Chunking — Cheat Sheet

Structural pass (Markdown/HTML): regex the heading marks, walk them with a level stack, slice each heading through the next mark, keep the heading line inside the chunk.

stack = [h for h in stack if h[0] < level]     # pop deeper headings when a sibling starts
path  = " > ".join(title for _, title in stack)

Embedding pass (no markup):

sims[i] = cos(embed(s_i), embed(s_i+1))
cuts    = [i + 1 for i, s in enumerate(sims) if s < threshold]
# production: threshold = percentile(sims, 20) — relative to the document, not a fixed constant
Document Split on
Markdown / HTML heading levels
Contract / policy clause and article numbers
Source file top-level definitions and classes
Transcript / chat speaker turns
Log export one event per line

Size guard: sub-split sections over a max token count (Fixed-Size), merge under a minimum, then print median, max, max/median — a ratio above ~3 means one chunk is eating the budget.

Failure modes: uncapped sections · one rule for every format · sentence splitting on code or abbreviations · ingest embedding cost ignored · a floor tuned on a single corpus.

Next: which of these chunks a query may see — Metadata Filtering in Retrieval.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading