The Chunking Problem Nobody Talks About Honestly
Every RAG tutorial shows you how to split text into chunks. Few explain why certain approaches work better than others, and almost none provide empirical data.
I spent three weeks running a systematic benchmark across 50 documents from real production use cases. The results surprised me - and contradicted several "best practices" I'd been following for years.
This post shares the complete methodology, raw results, and the nuanced conclusions that emerged. No hand-waving, no "it depends" without data to back it up.
The Test Corpus
To make this benchmark meaningful, I needed documents that represent real-world RAG use cases:
| Document Type | Count | Avg Length | Characteristics |
|---|---|---|---|
| Technical documentation | 12 | 8,400 words | Code blocks, hierarchical headers, tables |
| Legal contracts | 8 | 12,200 words | Dense paragraphs, cross-references, defined terms |
| Research papers | 10 | 6,800 words | Abstract, methodology, citations, figures |
| Support articles | 10 | 1,200 words | Short, focused, step-by-step |
| Financial reports | 5 | 15,600 words | Tables, numbers, regulatory language |
| Product manuals | 5 | 4,200 words | Procedures, warnings, specifications |
For each document, I created 25 test queries with known "gold standard" relevant passages - passages that a domain expert identified as containing the answer.
The 6 Chunking Strategies Tested
Strategy 1: Fixed-Size Chunking
The simplest approach - split text every N tokens regardless of content.
function fixedSizeChunk(text: string, chunkSize: number = 512): string[] {
const tokens = tokenize(text);
const chunks: string[] = [];
for (let i = 0; i < tokens.length; i += chunkSize) {
chunks.push(detokenize(tokens.slice(i, i + chunkSize)));
}
return chunks;
}
Strategy 2: Fixed-Size with Overlap
Same as above, but chunks overlap to preserve context at boundaries.
function fixedSizeOverlapChunk(
text: string,
chunkSize: number = 512,
overlap: number = 50
): string[] {
const tokens = tokenize(text);
const chunks: string[] = [];
const step = chunkSize - overlap;
for (let i = 0; i < tokens.length; i += step) {
chunks.push(detokenize(tokens.slice(i, i + chunkSize)));
if (i + chunkSize >= tokens.length) break;
}
return chunks;
}
Strategy 3: Sentence-Based Chunking
Group complete sentences together, respecting natural language boundaries.
function sentenceChunk(text: string, sentencesPerChunk: number = 6): string[] {
const sentences = splitIntoSentences(text);
const chunks: string[] = [];
for (let i = 0; i < sentences.length; i += sentencesPerChunk) {
chunks.push(sentences.slice(i, i + sentencesPerChunk).join(' '));
}
return chunks;
}
function splitIntoSentences(text: string): string[] {
// Handle edge cases: abbreviations, decimals, etc.
return text
.replace(/([.?!])\s+(?=[A-Z])/g, '$1|SPLIT|')
.replace(/([.?!])\s*$/g, '$1|SPLIT|')
.split('|SPLIT|')
.map(s => s.trim())
.filter(s => s.length > 0);
}
Strategy 4: Semantic Chunking
Use embedding similarity to find natural breakpoints - split when the semantic meaning shifts.
async function semanticChunk(
text: string,
similarityThreshold: number = 0.75
): Promise<string[]> {
const sentences = splitIntoSentences(text);
const embeddings = await embedBatch(sentences);
const chunks: string[] = [];
let currentChunk: string[] = [sentences[0]];
for (let i = 1; i < sentences.length; i++) {
const similarity = cosineSimilarity(
embeddings[i],
embeddings[i - 1]
);
if (similarity < similarityThreshold) {
// Semantic shift detected - start new chunk
chunks.push(currentChunk.join(' '));
currentChunk = [sentences[i]];
} else {
currentChunk.push(sentences[i]);
}
// Also split if chunk is getting too long
if (currentChunk.length > 10) {
chunks.push(currentChunk.join(' '));
currentChunk = [];
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk.join(' '));
}
return chunks;
}
Strategy 5: Recursive/Hierarchical Chunking
Split by document structure - headers first, then paragraphs, then sentences.
function recursiveChunk(
text: string,
maxChunkSize: number = 512
): string[] {
const separators = [
'\\n## ', // H2 headers
'\\n### ', // H3 headers
'\\n\\n', // Paragraphs
'\\n', // Lines
'. ', // Sentences
' ', // Words (last resort)
];
return recursiveSplit(text, separators, maxChunkSize);
}
function recursiveSplit(
text: string,
separators: string[],
maxSize: number
): string[] {
if (tokenCount(text) <= maxSize) {
return [text];
}
const separator = separators[0];
const nextSeparators = separators.slice(1);
if (!text.includes(separator)) {
if (nextSeparators.length === 0) {
// Force split at maxSize
return fixedSizeChunk(text, maxSize);
}
return recursiveSplit(text, nextSeparators, maxSize);
}
const parts = text.split(separator);
const chunks: string[] = [];
let currentChunk = '';
for (const part of parts) {
const candidate = currentChunk
? currentChunk + separator + part
: part;
if (tokenCount(candidate) <= maxSize) {
currentChunk = candidate;
} else {
if (currentChunk) {
chunks.push(currentChunk);
}
if (tokenCount(part) > maxSize) {
// Part itself is too big, recurse with finer separators
chunks.push(...recursiveSplit(part, nextSeparators, maxSize));
currentChunk = '';
} else {
currentChunk = part;
}
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
Strategy 6: Document-Aware Chunking
The most sophisticated approach - understand document structure and preserve semantic units.
interface DocumentSection {
type: 'header' | 'paragraph' | 'list' | 'code' | 'table';
content: string;
level?: number;
parent?: string;
}
async function documentAwareChunk(text: string): Promise<string[]> {
// Step 1: Parse document structure
const sections = parseDocumentStructure(text);
// Step 2: Build section hierarchy
const hierarchy = buildHierarchy(sections);
// Step 3: Create chunks that preserve context
const chunks: string[] = [];
for (const section of hierarchy) {
const chunk = buildContextualChunk(section);
if (tokenCount(chunk) > 1000) {
// Section too large, split while preserving header context
const subChunks = splitLargeSection(section);
chunks.push(...subChunks);
} else if (tokenCount(chunk) < 100) {
// Section too small, merge with siblings
// (handled in post-processing)
chunks.push(chunk);
} else {
chunks.push(chunk);
}
}
// Step 4: Merge tiny chunks
return mergeSmallChunks(chunks, 150);
}
function buildContextualChunk(section: DocumentSection): string {
const parts: string[] = [];
// Include parent headers for context
if (section.parent) {
parts.push('[Context: ' + section.parent + ']');
}
// Include the section content
parts.push(section.content);
return parts.join('\\n\\n');
}
function parseDocumentStructure(text: string): DocumentSection[] {
const sections: DocumentSection[] = [];
const lines = text.split('\\n');
let currentSection: DocumentSection | null = null;
let currentContent: string[] = [];
let lastHeader = '';
for (const line of lines) {
// Detect headers
const headerMatch = line.match(/^(#{1,6})\\s+(.+)$/);
if (headerMatch) {
// Save previous section
if (currentSection) {
currentSection.content = currentContent.join('\\n').trim();
if (currentSection.content) {
sections.push(currentSection);
}
}
const level = headerMatch[1].length;
lastHeader = headerMatch[2];
currentSection = {
type: 'header',
content: '',
level,
parent: level > 1 ? lastHeader : undefined
};
currentContent = [line];
continue;
}
// Detect code blocks (checking for code fence markers)
const isCodeFence = line.trim().match(/^'''|^"""/);
if (isCodeFence) {
if (currentSection?.type === 'code') {
currentContent.push(line);
currentSection.content = currentContent.join('\\n');
sections.push(currentSection);
currentSection = null;
currentContent = [];
} else {
if (currentSection) {
currentSection.content = currentContent.join('\\n').trim();
if (currentSection.content) sections.push(currentSection);
}
currentSection = { type: 'code', content: '', parent: lastHeader };
currentContent = [line];
}
continue;
}
// Detect tables
if (line.includes('|') && line.trim().startsWith('|')) {
if (currentSection?.type !== 'table') {
if (currentSection) {
currentSection.content = currentContent.join('\\n').trim();
if (currentSection.content) sections.push(currentSection);
}
currentSection = { type: 'table', content: '', parent: lastHeader };
currentContent = [];
}
currentContent.push(line);
continue;
}
// Default: paragraph
if (!currentSection || currentSection.type === 'table') {
if (currentSection) {
currentSection.content = currentContent.join('\\n').trim();
if (currentSection.content) sections.push(currentSection);
}
currentSection = { type: 'paragraph', content: '', parent: lastHeader };
currentContent = [];
}
currentContent.push(line);
}
// Don't forget the last section
if (currentSection) {
currentSection.content = currentContent.join('\\n').trim();
if (currentSection.content) sections.push(currentSection);
}
return sections;
}
The Evaluation Methodology
For each strategy, I measured four metrics:
1. Retrieval Precision@5 Of the top 5 retrieved chunks, how many contained relevant information?
2. Retrieval Recall@10 Of all relevant passages in the document, what percentage appeared in the top 10 retrieved chunks?
3. Answer Quality (LLM-judged) Using the retrieved chunks as context, how good was the LLM's answer? Scored 1-5 by GPT-4 as judge.
4. Indexing Latency Time to chunk and embed the full corpus.
interface BenchmarkResult {
strategy: string;
documentType: string;
precisionAt5: number;
recallAt10: number;
answerQuality: number;
indexingLatencyMs: number;
avgChunkSize: number;
totalChunks: number;
}
async function evaluateStrategy(
strategy: ChunkingStrategy,
documents: Document[],
queries: Query[]
): Promise<BenchmarkResult[]> {
const results: BenchmarkResult[] = [];
for (const doc of documents) {
// Chunk the document
const startTime = Date.now();
const chunks = await strategy.chunk(doc.content);
const chunkingTime = Date.now() - startTime;
// Embed chunks
const embedStart = Date.now();
const embeddings = await embedBatch(chunks);
const embeddingTime = Date.now() - embedStart;
// Index chunks
await vectorStore.upsert(
chunks.map((chunk, i) => ({
id: doc.id + '-' + i,
embedding: embeddings[i],
metadata: { docId: doc.id, content: chunk }
}))
);
// Evaluate queries for this document
const docQueries = queries.filter(q => q.documentId === doc.id);
for (const query of docQueries) {
const retrieved = await vectorStore.search(query.text, { topK: 10 });
const precisionAt5 = calculatePrecision(
retrieved.slice(0, 5),
query.relevantPassages
);
const recallAt10 = calculateRecall(
retrieved,
query.relevantPassages
);
const answer = await generateAnswer(query.text, retrieved.slice(0, 5));
const answerQuality = await judgeAnswer(answer, query.goldAnswer);
results.push({
strategy: strategy.name,
documentType: doc.type,
precisionAt5,
recallAt10,
answerQuality,
indexingLatencyMs: chunkingTime + embeddingTime,
avgChunkSize: chunks.reduce((a, c) => a + tokenCount(c), 0) / chunks.length,
totalChunks: chunks.length
});
}
}
return results;
}
The Results
Overall Performance
| Strategy | Precision@5 | Recall@10 | Answer Quality | Chunks/Doc |
|---|---|---|---|---|
| Fixed (512) | 0.62 | 0.71 | 3.4 | 47 |
| Fixed + Overlap | 0.68 | 0.78 | 3.6 | 52 |
| Sentence-Based | 0.71 | 0.74 | 3.7 | 38 |
| Semantic | 0.74 | 0.76 | 3.8 | 41 |
| Recursive | 0.76 | 0.81 | 3.9 | 44 |
| Document-Aware | 0.82 | 0.86 | 4.2 | 36 |
But the averages hide important nuances. Let's break it down by document type.
Results by Document Type
| Document Type | Best Strategy | Precision@5 | Why It Won |
|---|---|---|---|
| Technical docs | Recursive | 0.84 | Respects header hierarchy, keeps code blocks intact |
| Legal contracts | Document-Aware | 0.88 | Preserves cross-references and defined terms |
| Research papers | Document-Aware | 0.79 | Keeps methodology with results, citations in context |
| Support articles | Sentence-Based | 0.86 | Short docs, natural sentence boundaries work well |
| Financial reports | Document-Aware | 0.85 | Tables and figures stay with explanatory text |
| Product manuals | Recursive | 0.80 | Step-by-step procedures remain intact |
Insight 2: For complex documents with cross-references (legal, financial), document-aware chunking is dramatically better.
Insight 3: Recursive chunking is a solid "default" choice - it performed in the top 2 for 4 of 6 document types.
The Surprising Failures
Semantic Chunking Underperformed
I expected semantic chunking to be a top performer. It wasn't. Here's why:
Problem 1: Embedding models don't capture all semantic shifts
Original text:
"The system processes 10,000 requests per second.
Memory usage is approximately 4GB.
To install, run pip install mypackage."
Semantic chunking kept these together (high embedding similarity),
but they're answering completely different questions!
Problem 2: Computational overhead
Semantic chunking requires embedding every sentence before chunking. For our 387K word corpus:
- •Fixed chunking: 2.3 seconds
- •Semantic chunking: 847 seconds (14 minutes!)
Problem 3: Inconsistent chunk sizes
Semantic boundaries don't align with ideal retrieval sizes. We got chunks ranging from 12 tokens to 2,400 tokens.
Fixed-Size Chunking's Hidden Strength
Fixed-size chunking is often dismissed as "naive," but it has advantages:
- 1Predictable retrieval behavior - You know exactly how much context you're retrieving
- 2Consistent embedding quality - Embedding models perform best on similar-length texts
- 3Simple debugging - Easy to understand why something was or wasn't retrieved
For support articles (short, focused documents), fixed-size chunking with overlap performed within 5% of the best strategy.
The Chunk Size Question
I tested chunk sizes from 128 to 2048 tokens across all strategies:
| Chunk Size | Precision@5 | Recall@10 | Answer Quality |
|---|---|---|---|
| 128 | 0.81 | 0.69 | 3.5 |
| 256 | 0.79 | 0.75 | 3.8 |
| 512 | 0.76 | 0.81 | 4.0 |
| 768 | 0.72 | 0.83 | 3.9 |
| 1024 | 0.68 | 0.84 | 3.7 |
| 2048 | 0.59 | 0.86 | 3.4 |
- •Smaller chunks → Higher precision, lower recall
- •Larger chunks → Lower precision, higher recall
The 512-token sweet spot balances both, but the optimal size depends on your use case:
- •Factoid questions ("What is the API rate limit?") → Smaller chunks (256-384)
- •Conceptual questions ("Explain how the authentication system works") → Larger chunks (768-1024)
The Overlap Question
Does overlap help? I tested overlap sizes from 0% to 30%:
| Overlap | Precision@5 | Recall@10 | Storage Overhead |
|---|---|---|---|
| 0% | 0.71 | 0.75 | 1.0x |
| 10% | 0.74 | 0.79 | 1.11x |
| 20% | 0.76 | 0.81 | 1.25x |
| 30% | 0.76 | 0.82 | 1.43x |
Implementation Recommendations
Based on 50 documents and 1,250 queries, here's my decision framework:
Recommendation by Use Case
1. General-purpose RAG (mixed document types)
Strategy: Recursive with 512 tokens, 10% overlap
Expected Precision@5: ~0.76
2. Technical documentation
Strategy: Recursive with code-block awareness
Chunk size: 512-768 tokens
Keep code blocks intact, even if they exceed chunk size
3. Legal/financial documents
Strategy: Document-aware with cross-reference preservation
Include defined terms in chunk metadata
Link related chunks (e.g., clause and its amendment)
4. Customer support knowledge base
Strategy: Sentence-based (6-8 sentences)
Simple is better for short, focused articles
Production Implementation
Here's the chunking pipeline we now use in production:
interface ChunkingPipeline {
detectDocumentType(content: string): DocumentType;
selectStrategy(type: DocumentType): ChunkingStrategy;
chunk(content: string): Promise<Chunk[]>;
validate(chunks: Chunk[]): ValidationResult;
}
class ProductionChunker implements ChunkingPipeline {
private strategies: Map<DocumentType, ChunkingStrategy>;
constructor() {
this.strategies = new Map([
['technical', new RecursiveChunker({ maxSize: 600, overlap: 0.1 })],
['legal', new DocumentAwareChunker({ preserveReferences: true })],
['support', new SentenceChunker({ sentencesPerChunk: 6 })],
['default', new RecursiveChunker({ maxSize: 512, overlap: 0.1 })],
]);
}
detectDocumentType(content: string): DocumentType {
// Simple heuristics - could be ML-based for better accuracy
if (content.includes('
'),
hasTable: chunk.content.includes('|'),
}
};
}
private isValidChunk(chunk: Chunk): boolean {
const tokens = chunk.metadata.tokenCount;
// Filter out chunks that are too small or too large
return tokens >= 50 && tokens <= 1500;
}
validate(chunks: Chunk[]): ValidationResult {
const issues: string[] = [];
// Check for orphaned code blocks
for (const chunk of chunks) {
const opens = (chunk.content.match(/
Key Takeaways
1. Document type matters more than chunking strategy
A mediocre strategy matched to document type outperforms an "optimal" strategy applied blindly.
2. Recursive chunking is the best default
If you can only implement one strategy, make it recursive with 512 tokens and 10% overlap.
3. Semantic chunking is overrated
The computational cost isn't justified by the retrieval improvement, at least for the document types I tested.
4. Document-aware chunking is worth it for complex documents
For legal, financial, or heavily cross-referenced documents, the investment in proper parsing pays off.
5. Chunk size depends on query type
Factoid queries need smaller chunks. Conceptual queries need larger chunks. If you have both, default to 512 and use query classification.
6. Always validate your chunks
Orphaned code blocks, split sentences, and tiny chunks hurt retrieval quality. Build validation into your pipeline.
What I'd Do Differently
If I were starting this benchmark again:
- 1Test more embedding models - I only used text-embedding-3-small. Different embeddings may favor different chunking strategies.
- 2Include multi-lingual documents - All my documents were English. Sentence boundaries differ across languages.
- 3Test retrieval + generation together - I evaluated retrieval and generation separately. In practice, they interact in complex ways.
- 4Measure user satisfaction - Precision and recall don't capture whether users actually got useful answers.
The benchmark took three weeks, but it permanently changed how I approach RAG systems. I hope it saves you some time.

