Chunking Strategies
How you split documents determines retrieval quality. Master fixed, recursive, semantic, and document-aware chunking to optimize your RAG pipeline.
Why Chunking Matters
The Chunking Dilemma
Embedding models have token limits (256–8192 tokens). Even if they didn't, embedding an entire 50-page document into one vector loses granularity — searching returns the whole document or nothing. Chunking splits documents into retrievable units that balance specificity (finding exact relevant info) with context (having enough surrounding info to be useful).
The wrong chunk size is the #1 silent killer of RAG quality:
- Too small (50 tokens): Loses context, retrieves fragments without meaning
- Too large (2000 tokens): Dilutes relevance, wastes context window, reduces precision
- Sweet spot (200–500 tokens): Usually optimal for most use cases
Same Document, Three Strategies
Consider this markdown document and how different strategies split it:
sample_doc = """
# Introduction to Machine Learning
Machine learning is a subset of artificial intelligence that enables
systems to learn from data. It has transformed industries from healthcare
to finance.
## Supervised Learning
In supervised learning, models learn from labeled examples. Common
algorithms include linear regression, decision trees, and neural networks.
The key challenge is overfitting — when a model memorizes training data
rather than learning general patterns.
## Unsupervised Learning
Unsupervised learning finds hidden patterns in unlabeled data. Clustering
algorithms like K-means group similar data points. Dimensionality reduction
techniques like PCA compress high-dimensional data.
## Deep Learning
Deep learning uses neural networks with many layers. Transformers
revolutionized NLP with attention mechanisms. GPT and BERT are
transformer-based architectures that achieve state-of-the-art results.
"""
| Strategy | Chunk 1 | Chunk 2 | Chunk 3 |
|---|---|---|---|
| Fixed (200 chars) | "# Introduction to Machine Learning\n\nMachine learning is a subset of artificial intelligence that enables systems to learn from data. It has transformed industries from health..." | "...care to finance.\n\n## Supervised Learning\n\nIn supervised learning, models learn from labeled examples. Common algorithms include linear regression, deci..." | "...sion trees, and neural networks. The key challenge is overfitting — when a model memorizes training data rather than learning general patterns.\n\n##..." |
| Recursive (by section) | "Introduction to Machine Learning\n\nMachine learning is a subset of artificial intelligence..." | "Supervised Learning\n\nIn supervised learning, models learn from labeled examples..." | "Unsupervised Learning\n\nUnsupervised learning finds hidden patterns..." |
| Semantic | "Machine learning is a subset of AI that enables systems to learn from data. It has transformed industries." | "In supervised learning, models learn from labeled examples. Common algorithms include linear regression, decision trees, and neural networks. The key challenge is overfitting." | "Deep learning uses neural networks with many layers. Transformers revolutionized NLP. GPT and BERT are transformer-based architectures." |
Strategy 1: Fixed-Size Chunking
The simplest approach — split every N characters/tokens with optional overlap.
from langchain.text_splitter import CharacterTextSplitter
# Fixed-size by character count
splitter = CharacterTextSplitter(
separator="", # Split anywhere
chunk_size=300, # Characters per chunk
chunk_overlap=50, # Overlap between chunks
length_function=len
)
chunks = splitter.split_text(sample_doc)
print(f"Fixed chunks: {len(chunks)}")
for i, chunk in enumerate(chunks):
print(f" Chunk {i}: {len(chunk)} chars — '{chunk[:60]}...'")
When to Use Fixed-Size
- ✅ Uniform-length documents (tweets, product descriptions)
- ✅ When you need predictable chunk counts for cost estimation
- ❌ Structured documents (breaks headers, code blocks, tables)
- ❌ When semantic coherence matters
Strategy 2: Recursive Character Splitting
LangChain's most popular splitter. Tries to split at natural boundaries in priority order: paragraphs → sentences → words → characters.
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Recursive splitting with hierarchy of separators
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=[
"\n\n", # First try: split on double newlines (paragraphs)
"\n", # Then: single newlines
". ", # Then: sentences
", ", # Then: clauses
" ", # Then: words
"" # Last resort: characters
],
length_function=len
)
chunks = splitter.split_text(sample_doc)
print(f"Recursive chunks: {len(chunks)}")
for i, chunk in enumerate(chunks):
print(f" Chunk {i} ({len(chunk)} chars): '{chunk[:80]}...'")
# --- Token-based splitting (more accurate for LLMs) ---
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Use tiktoken for accurate token counting
splitter_tokens = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base", # GPT-4 tokenizer
chunk_size=256, # Tokens, not characters
chunk_overlap=30
)
chunks_tok = splitter_tokens.split_text(sample_doc)
print(f"\nToken-based chunks: {len(chunks_tok)}")
Strategy 3: Semantic Chunking
Groups sentences by embedding similarity. When consecutive sentences are semantically similar, they stay together. When similarity drops (topic shift), a new chunk starts.
import numpy as np
from openai import OpenAI
import re
client = OpenAI()
def semantic_chunk(text: str, threshold: float = 0.75, min_chunk: int = 2):
"""Split text into semantically coherent chunks.
Algorithm:
1. Split into sentences
2. Embed each sentence
3. Compute cosine similarity between consecutive sentences
4. Split where similarity drops below threshold
"""
# Split into sentences
sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if s.strip()]
if len(sentences) <= min_chunk:
return [text]
# Embed all sentences
response = client.embeddings.create(
input=sentences, model="text-embedding-3-small"
)
embeddings = [d.embedding for d in response.data]
# Calculate consecutive similarities
similarities = []
for i in range(len(embeddings) - 1):
a, b = np.array(embeddings[i]), np.array(embeddings[i + 1])
sim = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
similarities.append(sim)
# Find split points (where similarity drops)
chunks = []
current_chunk = [sentences[0]]
for i, sim in enumerate(similarities):
if sim < threshold and len(current_chunk) >= min_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i + 1]]
else:
current_chunk.append(sentences[i + 1])
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
# Usage
chunks = semantic_chunk(sample_doc, threshold=0.72)
print(f"Semantic chunks: {len(chunks)}")
for i, chunk in enumerate(chunks):
print(f"\n--- Chunk {i} ({len(chunk)} chars) ---")
print(chunk[:150] + "...")
Semantic Chunking Tradeoffs
- ✅ Best coherence — each chunk is about ONE topic
- ✅ Adapts to document structure automatically
- ❌ Expensive — requires embedding every sentence
- ❌ Threshold tuning — 0.7 might be too aggressive or too conservative
- ❌ Variable chunk sizes — harder to predict costs
Strategy 4: Document-Aware Chunking
Uses document structure (headers, code blocks, tables) as natural boundaries.
from langchain.text_splitter import (
MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter,
Language
)
# --- Markdown-aware splitting ---
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
md_chunks = md_splitter.split_text(sample_doc)
for chunk in md_chunks:
print(f"Headers: {chunk.metadata}")
print(f"Content: {chunk.page_content[:80]}...\n")
# Each chunk knows its heading hierarchy!
# Headers: {'h1': 'Introduction to Machine Learning', 'h2': 'Supervised Learning'}
# Content: In supervised learning, models learn from labeled examples...
# --- Two-stage: structural split then size limit ---
# First: split by headers
md_chunks = md_splitter.split_text(sample_doc)
# Second: if any chunk is too long, split it further
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50
)
final_chunks = []
for chunk in md_chunks:
if len(chunk.page_content) > 500:
sub_chunks = char_splitter.split_text(chunk.page_content)
for sc in sub_chunks:
final_chunks.append({"content": sc, "metadata": chunk.metadata})
else:
final_chunks.append({
"content": chunk.page_content,
"metadata": chunk.metadata
})
# --- Code-aware splitting ---
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=500,
chunk_overlap=50
)
code = '''
class DataProcessor:
def __init__(self, config):
self.config = config
def process(self, data):
"""Process raw data into features."""
cleaned = self.clean(data)
features = self.extract_features(cleaned)
return features
def clean(self, data):
"""Remove nulls and normalize."""
return [d for d in data if d is not None]
'''
code_chunks = python_splitter.split_text(code)
# Splits at class/function boundaries, not mid-function!
Chunk Size vs Retrieval Quality
Empirical results show a sweet spot that varies by use case:
| Chunk Size | Precision | Recall | Best For |
|---|---|---|---|
| 128 tokens | High ⬆️ | Low ⬇️ | FAQ, factoid QA, definitions |
| 256 tokens | High ⬆️ | Medium ➡️ | General-purpose RAG (recommended start) |
| 512 tokens | Medium ➡️ | High ⬆️ | Technical docs, tutorials, reasoning-heavy |
| 1024 tokens | Low ⬇️ | High ⬆️ | Long-form analysis, legal documents |
Choosing a Strategy: Decision Framework
🔑 Quick Decision Guide
- Structured docs (Markdown, HTML, code)? → Document-aware chunking
- Homogeneous text (articles, emails)? → Recursive character splitting
- Mixed topics in single docs? → Semantic chunking
- Tight budget, simple data? → Fixed-size with overlap
- Maximum quality, any cost? → Semantic + parent-child chunks (see Lesson 13)
🛠️ Mini-Project: Chunking Benchmark
Compare chunking strategies on real documents and measure retrieval quality.
Steps:
- Select 5 markdown/text documents with known questions and answers
- Implement all 4 strategies (fixed, recursive, semantic, markdown-aware)
- Chunk each document with each strategy and embed all chunks
- For 10 test queries, retrieve top-3 chunks from each strategy
- Score: Does the correct answer appear in the top-3? (Precision@3)
- Compare chunk counts, average chunk size, and retrieval accuracy
- Bonus: Plot chunk size distribution for each strategy
"""Chunking Benchmark - Starter Code"""
from langchain.text_splitter import (
CharacterTextSplitter,
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter
)
import chromadb
import json
# Test documents and ground-truth QA pairs
test_cases = [
{
"doc": open("docs/ml_intro.md").read(),
"questions": [
{"q": "What is overfitting?", "answer_in": "memorizes training data"},
{"q": "What did transformers revolutionize?", "answer_in": "NLP"},
]
},
# ... add more
]
strategies = {
"fixed_300": CharacterTextSplitter(chunk_size=300, chunk_overlap=50),
"recursive_500": RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50),
"recursive_256_tokens": RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=256, chunk_overlap=30
),
}
def evaluate_strategy(name, splitter, test_cases):
"""Score a chunking strategy on retrieval accuracy."""
client = chromadb.Client()
collection = client.create_collection(f"bench_{name}")
all_chunks = []
for doc_case in test_cases:
chunks = splitter.split_text(doc_case["doc"])
all_chunks.extend(chunks)
collection.add(
documents=all_chunks,
ids=[f"chunk_{i}" for i in range(len(all_chunks))]
)
# Score retrieval
hits = 0
total = 0
for doc_case in test_cases:
for qa in doc_case["questions"]:
results = collection.query(query_texts=[qa["q"]], n_results=3)
top_3_text = " ".join(results["documents"][0])
if qa["answer_in"].lower() in top_3_text.lower():
hits += 1
total += 1
accuracy = hits / total
print(f"{name}: {accuracy:.0%} ({hits}/{total}) | {len(all_chunks)} chunks")
return accuracy
# Run benchmark
for name, splitter in strategies.items():
evaluate_strategy(name, splitter, test_cases)
📋 Key Takeaways
- Chunking is the #1 lever for RAG quality — more impactful than model choice or prompt tuning
- Recursive character splitting is the best default for most text documents
- Use document-aware splitting for structured content (Markdown, HTML, code)
- Semantic chunking produces best coherence but costs more (requires embedding every sentence)
- 256–512 tokens with 10-20% overlap is the sweet spot for most RAG applications
- Always benchmark on YOUR data with YOUR queries — there is no universal best strategy