Search Tech Journey

Find topics, journeys and posts

6-month learning plan117 / 130
back to blog
llmadvanced 50m read

S117 · RAG I — Chunking Strategies & Indexing

The 80% of RAG performance you get before you even do retrieval — chunk sizes, overlap, semantic vs fixed splitting, metadata, and the indexing choices that make or break your bot.

LLMsM14 · LLMs & Applications· Session 117 of 130 90 min

🎯 Chunk a real corpus (PDF, code, wiki) into an embedding-friendly index and know why each decision — chunk size, overlap, delimiters, metadata — matters.

Why this session exists

Every RAG failure post-mortem lands on one of two culprits: bad chunking or bad reranking. This session is the first culprit. Chunk too small and the model gets fragmented context; too big and you retrieve noise. Split by fixed characters and you cut sentences in half; split by semantics and you may lose determinism. There are five decisions you make before a single vector gets stored, and getting them right buys you more than switching from GPT-4-mini to GPT-4o.

You will be able to
  • Pick a chunking strategy (fixed / recursive / semantic / structural) for a new corpus in under 30 seconds.
  • Explain why 512-token chunks with 15% overlap is the industry default and when to deviate.
  • Attach the right metadata (source, section, page, timestamp) so retrieval can filter before ranking.
  • Diagnose 'the bot is answering with wrong info' bugs by category: bad chunk, missing metadata, or retrieval miss.
  • Build an ingestion pipeline that is idempotent, incremental, and rebuildable from source.

Prerequisites

  • S111 · Tokenization — chunk sizes are in tokens; you need the tokenizer model.
  • S116 · Prompting — retrieved chunks become part of a zero-shot prompt.
  • Familiarity with embeddings from a prior session or self-study.


(a) Intuition · 5 min

Slicing a book for a librarian who reads only 3 pages at a time
🌍 Real world

Imagine hiring a librarian who can only read three pages before giving you an answer. You must cut the library into 3-page slices before shelving. If you cut mid-sentence, your reader loses context. If your slices are wildly different lengths, the shelf catalogue breaks. If you don't label each slice with book title + chapter, the reader can't cite sources.

Now imagine the librarian is smart but has no memory between questions — every question means fetching 3–5 slices from the shelf. The quality of each answer is entirely a function of how well you sliced.

💻 Code world

RAG is exactly this. The LLM's context window is the 3-page limit. The vector store is the shelf. Chunking is the slicing. Metadata is the labels.

Get chunking right and you get citations, freshness, source diversity, and answers that quote real text. Get it wrong and you get 'hallucinations' that are actually just the model working from ripped-up scraps.

The five decisions before you index a single document
  • Chunk size — usually 256–1024 tokens. Default: 512. Depends on embedding model context and downstream LLM context.
  • Overlap — usually 10–20% of chunk size. Prevents information at chunk boundaries from being lost.
  • Splitting strategy — fixed / recursive / semantic / structural (Markdown, code). Recursive is the sane default.
  • Metadata — source URL, section, page, updated_at, permissions. Retrieval will filter by these before ranking.
  • Storage — vector DB choice affects speed, filtering, and hybrid search. Ingestion must be idempotent + incremental.

Where RAG came from

  1. 2020
    RAG paper · Lewis et al.
    'Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks'. Original architecture — encoder+decoder + a dense retriever.
  2. 2022
    LangChain + LlamaIndex
    Open-source RAG orchestration explodes. Chunking becomes a first-class concern.
  3. 2023
    Pinecone / Weaviate / Chroma
    Vector-DB market matures. Metadata filtering + hybrid search become standard features.
  4. 2024
    Long-context LLMs
    Gemini 1.5 (1M ctx), Claude 3 (200K). 'Just put it all in the prompt' becomes viable for medium corpora.
  5. 2024
    Semantic + structural chunking
    Better splitters that respect Markdown headings, code blocks, and semantic breaks (embedding-based). ChromaResearch eval confirms recursive+moderate size still wins.

(b) Visual walkthrough · 15 min

The RAG pipeline (chunking + indexing is the left half)

The four splitting strategies

Fixed-size

Every N chars, no regard for structure

  • Simplest — one line of code
  • Breaks sentences and words
  • Only use for logs, tweets, or already-atomic units
  • Rarely the right default
Recursive character

Try paragraph → sentence → word → char, in order

  • The default in LangChain and LlamaIndex
  • Respects natural boundaries
  • Deterministic and fast
  • Works for 90% of prose corpora
Structural (Markdown / code / HTML)

Split on headings, functions, tags

  • Preserves semantic units (chapter, function, section)
  • Attach heading path as metadata (breadcrumb)
  • Perfect for docs sites, code repos
  • MarkdownHeaderTextSplitter / CodeSplitter
Semantic (embedding-based)

Split where consecutive sentences drift apart

  • Best quality on prose that lacks headings
  • Slower and costs embedding calls at ingest
  • Non-deterministic across re-ingests
  • Newer; still worth trying on legal / research docs

The chunk-size × overlap heatmap (intuition)

How chunk size interacts with quality

128 tokens
Too small — fragments. Retrieval finds relevant scraps but LLM can't stitch them. Good for very short factual snippets (FAQ). Bad default.
128
256 tokens
Small chunks — great precision (each embedding tightly focused), but you need to retrieve MANY (K=10-20) to give the LLM enough context.
256
512 tokens
The industry default. Balances precision and recall. Fits ~3-5 chunks easily in most LLM prompts. Start here unless you have a reason not to.
512 ✅
1024 tokens
Bigger chunks — great recall (a single chunk often contains the whole answer), but embeddings are diluted (average of many topics). Use for narrative content.
1024
2048+ tokens
Approaching 'just don't chunk'. Only viable when your embedding model supports long context and your LLM has huge context.
2048+

Overlap: why 15% is the magic number

1risky
0% overlap

Cheap. Fast to index. Information at chunk boundaries is often lost — a definition + example may be split.

2default
10–20% overlap

Sweet spot. Same information appears in two adjacent chunks, so retrieval catches it either way. Small storage cost (~15% more chunks).

3wasteful
50%+ overlap

Wasteful. Storage doubles, embedding call cost doubles, marginal quality gain. Rarely worth it.

Metadata: the retrieval super-filter

Metadata every chunk should carry

source
URL, file path, ticket ID. Used for citations back to the user ('according to https://…').
cite
section / heading path
Breadcrumb like 'API Docs > Auth > OAuth2 > Refresh Tokens'. Used both for filtering and for prefixing the chunk text before embedding.
structure
updated_at
Timestamp. Filter to 'only docs updated in last 90 days' or bias ranking toward fresher.
freshness
permissions / owner
In multi-tenant systems — the filter that keeps user A's data out of user B's answers. Get this wrong and you have a data breach.
security
content_type
code / docs / FAQ / policy. Route different types to different LLM prompts (code needs a code-aware model).
type
chunk_index + doc_id
Reconstructable ordering, so you can pull chunk N-1 and N+1 for extra context ('expand window') at query time.
expand

Common misconception
✗ What most people think

"Chunking is a preprocessing detail. Pick 512 tokens with 50 overlap, move on — the retrieval quality comes from the embedding model."

✓ What is actually true

The chunk is the atomic unit of retrieval, so chunking decides what can ever be retrieved. A fact split across a boundary is unreachable by any embedding model, and a chunk mixing three topics gets an embedding that is the average of three directions — close to none of them. Chunking sets the ceiling; the embedding model only determines how close you get to it.

Why the myth is so sticky

Because the default works on the corpus everyone tests first: clean prose articles, where 512 tokens happens to be about a coherent section and topic drift within a chunk is mild. The failure appears on tables, code, transcripts and structured documents — where a fixed token window slices through a table header, a function signature, or a speaker turn, and the retrieval failure looks like a model problem because the query is obviously relevant and nothing comes back.

Prove it to yourself

Measure boundary damage directly on your own corpus rather than trusting the default:

# For each known question/answer pair in your eval set:
#   1. find the char span in the source doc that actually answers it
#   2. chunk the doc with your current settings
#   3. check whether ANY single chunk fully contains that span
#
# containment_rate = fraction fully contained in one chunk
#
# This is a hard ceiling on recall@1: a span split across two
# chunks cannot be retrieved intact by any embedding model.
# Sweep chunk_size and overlap against containment_rate before
# you touch the embedding model.
From first principles
Start with the question

Why does chunk size have an interior optimum? Bigger chunks contain more information — why is bigger not simply better?

  1. 1
    A chunk is compressed to one fixed-length vector, typically 384–1536 dimensions, regardless of whether it holds 50 tokens or 5,000.
    forced by · the embedding model outputs a fixed-size representation; that is the entire premise of vector search
  2. 2
    Encoders pool token representations into that single vector — mean pooling, or a CLS token. Either way the result is roughly an average over the chunk's content directions.
    forced by · pooling is the only way to get a fixed size from a variable-length sequence
  3. 3
    Averaging k distinct topic directions produces a vector whose cosine similarity to each individual topic is reduced by roughly a factor related to k. The chunk becomes mediocre-matching for everything it contains.
    forced by · the mean of several roughly-orthogonal unit vectors has norm well below 1 and is not close to any of them
  4. 4
    So as chunks grow and accumulate topics, retrieval precision falls — the correct chunk ranks below a smaller, more focused chunk that is only tangentially relevant. This is semantic dilution.
    forced by · ranking is by similarity, and dilution lowers the true positive's score while leaving competitors' scores intact
  5. 5
    Shrink chunks instead and dilution disappears, but now a single fact's supporting context is spread across several chunks, and any one of them read alone is ambiguous or missing the referent ("it increased by 12%" — what did?).
    forced by · context needed to interpret a passage often lies outside a small window
⇒ Therefore

Therefore chunk size trades precision of the embedding against sufficiency of the context, and both degrade monotonically in opposite directions — which forces an interior optimum that depends on your document structure, not on a universal number.

And note what this predicts: the optimum should disappear if you decouple the two. Embed a small, focused unit but return a larger surrounding window, and you get precise matching with sufficient context simultaneously. That is exactly the small-to-big / parent-document retrieval pattern, and this derivation is why it works rather than being just another trick.

Mental modelWhat you embed is not what you return

Two separate decisions hide inside "chunking". The index unit is what gets embedded and matched — it should be small and single-topic so its vector points in one clear direction. The context unit is what gets handed to the model — it should be large enough that the passage is self-contained and interpretable.

Nothing requires them to be the same object. Once you separate them, most chunking dilemmas dissolve.

  • Chunk on structure first (headings, sections, function boundaries, speaker turns), then on size. Structural boundaries are semantic boundaries you get for free.
  • Overlap is insurance against boundary damage, not a quality knob. It costs index size and creates near-duplicate results you must dedupe.
  • Enrich each chunk with its context — document title, section path, date — before embedding. A chunk that says "it increased 12%" is unretrievable; one prefixed with "Q3 Revenue > APAC" is not.
  • Tables, code and lists must never be split mid-structure. Route them to a different chunker rather than forcing one strategy over the whole corpus.
🔔 Fires when you see

Fire this model the moment you see: retrieval that misses obviously-relevant documents · answers that cite the right document but the wrong passage · a corpus of PDFs, tables or code · someone tuning the embedding model before measuring containment · chunks whose text is ambiguous when read alone.

The tradeoff

How do you segment a heterogeneous corpus — fixed-size windows, structure-aware splitting, or semantic/embedding-based splitting?

Fixed-size tokens with overlap
+ you gain trivially simple, perfectly predictable cost and index size, works on any input including scanned junk, and re-indexing is fast and deterministic
− you pay cuts through tables, code blocks and sentences with no regard for meaning, producing chunks that are individually uninterpretable; and it wastes index space on overlap
pick when a first version, or a corpus with no reliable structure at all — OCR output, chat logs, scraped text
Structure-aware (headings, sections, code AST)
+ you gain boundaries coincide with meaning, so chunks are self-contained and the document hierarchy becomes available as metadata for filtering and for context enrichment
− you pay requires a parser per format and breaks on malformed documents; chunk sizes become wildly variable, which complicates cost estimation and can exceed the embedding model's window
pick when your corpus has real structure you control or can parse reliably — markdown docs, wikis, source code, well-formed HTML
Semantic / embedding-based splitting
+ you gain finds topic boundaries even in unstructured prose by detecting where consecutive sentence embeddings diverge, giving coherent chunks without any format knowledge
− you pay an embedding call per sentence at index time makes ingestion far more expensive; the threshold is a fiddly hyperparameter; and it is non-deterministic across model versions, so re-indexing can silently change your chunks
pick when long unstructured prose where structure-aware splitting has nothing to work with and quality matters more than ingestion cost
What a senior engineer actually does

Structure first, size as a fallback: split on the strongest structural signal the document offers, then apply size limits within each unit. That single rule handles most real corpora, and it degrades gracefully to fixed-size when structure is absent.

The higher-leverage move, though, is decoupling index unit from context unit — embed a sentence or a small window, return the parent section. It removes the chunk-size tradeoff instead of tuning it, and it usually beats any amount of chunker sophistication.


(c) Hands-on · 25 min

Build a small ingestion pipeline that chunks a Markdown corpus with the recursive splitter, attaches structural metadata, and prepares vectors for a real store. Uses only the CPU.

"""rag_ingest.py — chunk a Markdown corpus with recursive + structural splitters.
 
Requires: pip install langchain-text-splitters sentence-transformers chromadb
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
)
from sentence_transformers import SentenceTransformer
import chromadb
 
# ---------- Config ----------
CORPUS_DIR = Path("./corpus")           # any folder of .md files
CHUNK_SIZE = 512                        # target tokens
CHUNK_OVERLAP = 80                      # ~15% overlap
EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"   # 22M, CPU-friendly
COLLECTION_NAME = "docs_v1"
 
 
@dataclass
class Chunk:
    id: str
    text: str
    metadata: dict = field(default_factory=dict)
 
 
def load_markdown(path: Path) -> list[dict]:
    """Split a Markdown file first by headings, then by size within each section."""
    md_text = path.read_text(encoding="utf-8")
    # Structural pass: split on H1/H2/H3 → sections with heading breadcrumb.
    md_splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
    )
    sections = md_splitter.split_text(md_text)
    # Size pass: recursive splitter inside each section.
    size_splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE * 4,        # rough chars ≈ 4× tokens for English
        chunk_overlap=CHUNK_OVERLAP * 4,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    docs = []
    for sec in sections:
        # Build a heading breadcrumb: 'H1 > H2 > H3'.
        breadcrumb = " > ".join(
            sec.metadata[k] for k in ("h1", "h2", "h3") if k in sec.metadata
        )
        for i, piece in enumerate(size_splitter.split_text(sec.page_content)):
            docs.append({
                "text": (f"{breadcrumb}\n\n{piece}" if breadcrumb else piece),
                "metadata": {
                    "source": str(path),
                    "breadcrumb": breadcrumb,
                    "chunk_index": i,
                },
            })
    return docs
 
 
def deterministic_id(source: str, text: str) -> str:
    """Stable id so re-ingesting the same content is a no-op (idempotent)."""
    h = hashlib.sha1(f"{source}\n{text}".encode()).hexdigest()
    return h[:16]
 
 
def build_index() -> None:
    files = sorted(CORPUS_DIR.rglob("*.md"))
    print(f"Loading {len(files)} markdown files from {CORPUS_DIR} ...")
 
    chunks: list[Chunk] = []
    for f in files:
        for d in load_markdown(f):
            chunks.append(Chunk(
                id=deterministic_id(d["metadata"]["source"], d["text"]),
                text=d["text"],
                metadata=d["metadata"],
            ))
    print(f"Produced {len(chunks)} chunks. Sample metadata:")
    for c in chunks[:3]:
        print(f"  [{c.id}] {c.metadata['breadcrumb'] or '(no heading)'}"
              f"  · {len(c.text)} chars")
 
    # Embed in batches. MiniLM outputs 384-dim vectors, ~100 chunks/sec on CPU.
    encoder = SentenceTransformer(EMBED_MODEL)
    texts = [c.text for c in chunks]
    print("\nEmbedding ...")
    vectors = encoder.encode(texts, show_progress_bar=True, batch_size=32)
 
    # Store in Chroma (persistent, on-disk).
    client = chromadb.PersistentClient(path="./chroma_store")
    coll = client.get_or_create_collection(name=COLLECTION_NAME)
    coll.upsert(
        ids=[c.id for c in chunks],
        documents=[c.text for c in chunks],
        metadatas=[c.metadata for c in chunks],
        embeddings=vectors.tolist(),
    )
    print(f"\nIndexed {len(chunks)} chunks into '{COLLECTION_NAME}'"
          f" ({coll.count()} total).")
 
    # Quick sanity query.
    demo_q = "How do I refresh an OAuth token?"
    q_vec = encoder.encode([demo_q])[0]
    hits = coll.query(query_embeddings=[q_vec.tolist()], n_results=3)
    print(f"\nSanity query: {demo_q!r}")
    for i, (doc, meta) in enumerate(zip(hits["documents"][0], hits["metadatas"][0])):
        print(f"  #{i+1}  {meta.get('breadcrumb','(no heading)')}")
        print(f"        {doc[:120].replace(chr(10),' ')}...")
 
 
if __name__ == "__main__":
    build_index()

Line-by-line

Anatomy of the ingest pipeline

MarkdownHeaderTextSplitter (structural pass)
First split on H1/H2/H3 to get semantically-coherent sections. Each section keeps its breadcrumb as metadata.
1
RecursiveCharacterTextSplitter (size pass)
Within each section, split into 512-token chunks with 15% overlap. Uses separators in order — paragraph, line, sentence, space — falling back to char only as last resort.
2
breadcrumb prefix
Prepend 'API > Auth > OAuth' to the chunk text before embedding. Gives the sentence-embedding model document-level context it would otherwise miss.
3
deterministic_id (SHA-1 of source+text)
Stable id per chunk means re-running ingest on unchanged files is a no-op (upsert). Idempotency is table stakes for production ingest.
4
encoder.encode(batch_size=32)
Batching keeps GPU/CPU busy. ~100 chunks/sec on CPU with MiniLM; ~5,000/sec on an A100 with bge-large.
5
coll.upsert
Chroma's atomic upsert — insert if new, replace if id exists. Combined with deterministic_id, this makes ingest fully incremental.
6
Try itCompare chunk sizes on your corpus

Change:

for size in [256, 512, 1024]:
    CHUNK_SIZE = size
    CHUNK_OVERLAP = int(size * 0.15)
    COLLECTION_NAME = f"docs_size_{size}"
    build_index()

Then query each with the same 5 questions and count hits. This 15-min experiment is the difference between guessing at chunk size and knowing.

💡 Hint · Re-run ingest with CHUNK_SIZE=256 vs 512 vs 1024. Store each in a differently-named collection. Then run 5 sample questions against each. Measure how often the top-3 hits contain the right passage. You'll usually find 512 wins for prose, 1024 wins for narrative, 256 wins for tightly-structured references.

(d) Production reality · 15 min

War story Notion · AI Q&A over workspacesmillions of workspaces
🔥 What broke

Early Notion AI often quoted the wrong page in a workspace — a doc titled 'Q1 Roadmap' might get 'Q3' facts mixed in. Users blamed hallucinations; the retrieval layer was actually fine, embeddings just weren't distinguishing 'Q1' from 'Q3' well in short chunks.

🧯 The fix

Prepend the full page-path breadcrumb ('Team A > Roadmaps > Q1 2024') to every chunk before embedding. Include page title + workspace as metadata for hard filters. Chunk-boundary drift dropped substantially.

🎓 Lesson to steal
Sentence embeddings lose document-level context. Prefixing chunks with their heading breadcrumb is a 5-minute fix worth 5–15 points of retrieval quality on structured corpora (docs, wikis).
War story Common enterprise RAG failure modeevery legal / medical / financial RAG
🔥 What broke

Team ships a RAG bot over legal contracts. Retrieval sometimes returns clauses from OLD versions of contracts, generating advice based on superseded law. Weeks of debugging point at retrieval quality; embedding tuning does nothing.

🧯 The fix

The chunks lacked updated_at and effective_date metadata. Adding them plus a hard filter (only chunks with effective_date within active range) cut wrong-version answers by ~90%. Freshness bias in ranking (a small bonus for newer docs) cleaned up the rest.

🎓 Lesson to steal
Metadata is not optional. Every chunk needs source, timestamp, and permission tags at minimum. Retrieval is filter-then-rank; without filters, ranking is fighting uphill.
War story GitHub Copilot Chat / Cursor · repo-scale RAGmillions of files across languages
🔥 What broke

Naive character-splitting broke code — function definitions were cut mid-body, imports separated from their uses, and embedding a chunk of half a function produced garbage similarity scores.

🧯 The fix

Use language-aware splitters (Tree-sitter-based) that split on function / class / method boundaries. Attach language + repo + file path as metadata. Retrieval quality on 'find the function that does X' jumped substantially.

LangChain, LlamaIndex, and every serious code-RAG tool now ships tree-sitter splitters.

🎓 Lesson to steal
For structured content (code, XML, JSON, Markdown), use a structural splitter. Character splitters treating code as prose produce embeddings of nonsense.

Where this shows up next

Chunking + indexing is the foundation for every RAG topic
S118 · RAG II — Retrieval + Reranking
The retrieve-rank step; only as good as the chunks it retrieves from.
S119 · Vector Databases
The storage layer — index type (HNSW, IVF) affects ingest cost and query speed.
S120 · LLM Agents
Agents often retrieve mid-conversation; the same chunking rules apply.
S122 · LLM Evaluation
Chunking is often the biggest single variable in a RAG eval sweep.
S127 · Streaming Analytics
Real-time ingest — chunks need to appear in the index seconds after being written.
S128 · Cost & Sustainability
Chunk size × overlap × embed cost = your ingest bill; small choices multiply.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. Why 512 tokens with 15% overlap as a default?
  2. When do you deviate — bigger or smaller chunks?
  3. What metadata does every chunk need?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.