Module 3: RAG Done Properly · Lesson 2 of 5 · 35 min

Chunking: The Highest-Leverage Decision

Chunks are the unit of everything downstream — embedding, retrieval, citation, grounding. Cut them badly and no reranker, no fusion trick, no bigger model can repair the damage.

A chunk is what gets embedded, what gets retrieved, and what the model reads as evidence. That triple duty creates tension: small chunks embed crisply (one idea per vector) but may lack the surrounding context needed to actually answer; large chunks carry context but their embeddings blur into topic soup and they burn prompt budget. And if a fact straddles a chunk boundary — question in one chunk, answer in the next — neither chunk retrieves well. This is why chunking is the highest-leverage decision in the pipeline: errors here are unrecoverable downstream.

fixed-size (naive)splits mid-sentence, mid-thoughtrecursive / separator-awarerespects paragraphs & headerssemantic / structuralone coherent idea per chunk
The same document cut three ways: naive fixed-size splits mid-sentence; overlap heals boundaries; structural chunking follows the document's own seams.
1/3

Fixed-size vs. structural

StrategyHowStrengthsWeaknesses
Fixed-sizeEvery N tokens/words, hard cutTrivial, uniform, predictable budgetCuts mid-sentence/mid-thought; ignores document structure
Fixed-size + overlapWindows share 10–20% of contentFacts near boundaries appear intact in at least one chunkIndex bloat; near-duplicate retrievals
StructuralSplit on headings/paragraphs, then size-capChunks align with authors' units of meaning; heading path makes great citation metadataNeeds format-aware parsing; sections vary wildly in size
SemanticSplit where embedding similarity between consecutive sentences dropsAdapts to unstructured proseSlower, fussier, rarely beats structural on well-formatted docs
fixed-size chunker with overlap
# Colab cell 1 — pure Python: runs with no key and no installs.
def fixed_size_chunks(text: str, size: int = 350, overlap: int = 50) -> list[str]:
    """Split into word windows of ~size words, consecutive windows sharing
    'overlap' words so boundary-straddling facts survive in one piece."""
    words = text.split()
    chunks, start = [], 0
    while start < len(words):
        end = min(start + size, len(words))
        chunks.append(" ".join(words[start:end]))
        if end == len(words):
            break
        start = end - overlap          # step back to create the overlap
    return chunks

# demo: numbered words make the shared boundary regions visible
demo_text = " ".join(f"w{i}" for i in range(1, 121))   # w1 ... w120
for piece in fixed_size_chunks(demo_text, size=50, overlap=10):
    words = piece.split()
    print(f"{len(words):>3} words: {words[0]} ... {words[-1]}")
Word-based sizing is a fine proxy (a word is roughly 1.3 tokens in English); swap in a real tokenizer when you need exact budgets. The overlap is the load-bearing part: without it, any fact within a few sentences of a cut is fragmented across two chunks and retrieves poorly from both. The numbered-word demo makes it visible — each window starts ten words before the previous one ended.
Spot the bug
A teammate wants maximum boundary safety and calls fixed_size_chunks(text, size=200, overlap=200). The ingestion job pins a CPU at 100% and never finishes. What happened — and what's the guard the function is missing?

Two upgrades seniors are expected to know

Contextual retrieval attacks the core weakness of chunks: they're read out of context. A chunk saying "set this flag to true and restart the service" embeds — and reads — poorly because nothing says which flag or service. The heading + text trick above is the free version; the full version has an LLM write a 1–2 sentence situating blurb per chunk at index time ("This passage is from the Acme Gateway operations guide, section on retry configuration..."), prepended before embedding and indexing. It costs one cheap LLM call per chunk once, offline — the good side of the cost asymmetry, since the index is built once and queried forever — and it substantially cuts retrieval failures on corpora where chunks are elliptical. Batch API + prompt caching (the document rides in the cached prefix while each chunk varies) make it cheap at scale — Module 1's cost levers composing.

Small-to-big (parent-document) retrieval resolves the size tension by refusing to choose: embed small, return big. Index sentence- or paragraph-sized units for crisp matching, but store a pointer from each to its parent section; at query time, match on the small unit, then hand the parent to the generator. You get precise vectors and sufficient evidence context. Costs to name: a two-level store, dedup when several small units share a parent, and a larger generation prompt. The umbrella idea behind both techniques — and the phrase worth saying in an interview — is decoupling the retrieval representation from the generation payload: what you match on and what the model reads no longer have to be the same bytes.

structural chunker for markdown, with citation metadata
# Colab cell 2 — run cell 1 first (it defines fixed_size_chunks).
import re

def structural_chunks(md: str, doc_id: str,
                      max_words: int = 350, overlap: int = 50) -> list[dict]:
    """Split on headings first; size-cap oversized sections with the
    fixed-size chunker. Keep the heading path for citations, and prepend
    it to the text we embed so section context reaches the vector."""
    parts = re.split(r"(?m)^(#{1,4}\s.*)$", md)
    chunks, heading = [], "(intro)"
    for part in parts:
        part = part.strip()
        if not part:
            continue
        if re.match(r"^#{1,4}\s", part):
            heading = part.lstrip("#").strip()
            continue
        for i, piece in enumerate(fixed_size_chunks(part, max_words, overlap)):
            chunks.append({
                "doc_id": doc_id,
                "heading": heading,
                "position": i,
                "text": piece,
                "embed_text": f"{heading}\n{piece}",   # heading rides into the embedding
            })
    return chunks

SAMPLE_MD = """# Acme Gateway runbook

## Retry configuration
Set retry_backoff_max to cap exponential backoff. Workers retry failed
jobs up to five times before dead-lettering.

## Connection errors
ERR_CONN_5031 means the gateway dropped a keep-alive connection.
Restart the connection pool or raise the idle timeout.
"""

for c in structural_chunks(SAMPLE_MD, doc_id="runbook"):
    print(f"[{c['doc_id']} / {c['heading']} / #{c['position']}] {c['text'][:48]}...")
Two tricks worth stealing: (1) the metadata (doc_id, heading, position) is what makes citations possible later — store it now or regret it; (2) embedding heading + text instead of bare text injects section context into the vector, so a chunk that just says "set this flag to true" still retrieves for queries about the feature its heading names.
  • Chunk size: start around 250–500 words for technical docs. Smaller for FAQ-like corpora (one Q&A per chunk), larger for narrative prose.
  • Overlap: 10–20% of chunk size. More than that mostly buys you duplicate retrievals.
  • Never mix units across the corpus without recording which chunker produced each chunk — you can't A/B what you can't attribute.
  • Chunking is an eval-set question, not a taste question: re-run retrieval metrics (Lesson 5) for each candidate strategy and let precision@5 decide.
Bad chunking cannot be fixed downstream
A reranker can only reorder the chunks that exist. Fusion can only merge rankings of the chunks that exist. If the answer was sliced in half at index time, every downstream stage is optimizing over damaged goods. When RAG quality disappoints, look at the actual chunks first — read twenty of them raw before touching any other dial.

Whiteboard drills

Check yourself
Drill: Your corpus is heterogeneous: 2,000 one-paragraph FAQs, 300 long narrative runbooks, and an API reference where each endpoint is a table-heavy half page. Design the chunking — and defend treating them differently.
Check yourself
Drill: "Retrieval quality is disappointing. How do you determine whether chunking — as opposed to embedding, retrieval mode, or ranking — is the culprit?" Give the procedure, not a guess.
Key takeaways
  • Chunks serve three masters at once: embedding quality, retrieval unit, and grounding evidence.
  • Small chunks embed crisply but lose context; large chunks blur; overlap heals boundary cuts.
  • Structural chunking (headings/paragraphs + size cap) beats naive fixed-size on formatted docs.
  • Contextual retrieval: an LLM-written situating blurb per chunk, paid once offline (Batch API + caching), read on every query.
  • Small-to-big: embed small units, hand the model their parent — decouple the retrieval representation from the generation payload.
  • Heterogeneous corpora need per-type chunkers and per-type metrics — a blended score hides the broken type.
  • Store doc_id, heading path, and position with every chunk — citations depend on it.
  • Chunking mistakes are unrecoverable downstream; choose by measured retrieval metrics, not vibes.