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.
🎯 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.
- 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
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.
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.
- 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
- 2020RAG paper · Lewis et al.'Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks'. Original architecture — encoder+decoder + a dense retriever.
- 2022LangChain + LlamaIndexOpen-source RAG orchestration explodes. Chunking becomes a first-class concern.
- 2023Pinecone / Weaviate / ChromaVector-DB market matures. Metadata filtering + hybrid search become standard features.
- 2024Long-context LLMsGemini 1.5 (1M ctx), Claude 3 (200K). 'Just put it all in the prompt' becomes viable for medium corpora.
- 2024Semantic + structural chunkingBetter 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
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
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
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
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
Overlap: why 15% is the magic number
Cheap. Fast to index. Information at chunk boundaries is often lost — a definition + example may be split.
Sweet spot. Same information appears in two adjacent chunks, so retrieval catches it either way. Small storage cost (~15% more chunks).
Wasteful. Storage doubles, embedding call cost doubles, marginal quality gain. Rarely worth it.
Metadata: the retrieval super-filter
Metadata every chunk should carry
"Chunking is a preprocessing detail. Pick 512 tokens with 50 overlap, move on — the retrieval quality comes from the embedding model."
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.
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.
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.Why does chunk size have an interior optimum? Bigger chunks contain more information — why is bigger not simply better?
- 1A 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
- 2Encoders 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
- 3Averaging 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
- 4So 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
- 5Shrink 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 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.
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.
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.
How do you segment a heterogeneous corpus — fixed-size windows, structure-aware splitting, or semantic/embedding-based splitting?
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
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.
(d) Production reality · 15 min
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.
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.
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 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.
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.
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.
Where this shows up next
(e) Recall + stretch · 10 min
Explain-out-loud test
- Why 512 tokens with 15% overlap as a default?
- When do you deviate — bigger or smaller chunks?
- 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.