Chunking Strategies That Actually Work: A 50-Document Benchmark
Back to all articles
AI Engineering
25 min read16 min read

Chunking Strategies That Actually Work: A 50-Document Benchmark

I tested 6 chunking strategies across 50 real-world documents. The results challenged everything I thought I knew about text splitting for RAG systems.

Debasish Maji
Debasish Maji
AI Engineering Lead
March 22, 2026
RAGChunkingEmbeddingsInformation RetrievalBenchmarks

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 TypeCountAvg LengthCharacteristics
Technical documentation128,400 wordsCode blocks, hierarchical headers, tables
Legal contracts812,200 wordsDense paragraphs, cross-references, defined terms
Research papers106,800 wordsAbstract, methodology, citations, figures
Support articles101,200 wordsShort, focused, step-by-step
Financial reports515,600 wordsTables, numbers, regulatory language
Product manuals54,200 wordsProcedures, warnings, specifications
Total: 50 documents, 387,000 words

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

Diagram
flowchart TB subgraph Strategies["Chunking Strategies"] A[Fixed Size<br/>512 tokens] B[Fixed + Overlap<br/>512 tokens, 50 overlap] C[Sentence-Based<br/>5-7 sentences] D[Semantic<br/>Embedding similarity] E[Recursive<br/>Headers → Paragraphs] F[Document-Aware<br/>Structure-preserving] end A --> G[Evaluation] B --> G C --> G D --> G E --> G F --> G G --> H[Retrieval Precision] G --> I[Retrieval Recall] G --> J[Answer Quality] G --> K[Latency]

Strategy 1: Fixed-Size Chunking

The simplest approach - split text every N tokens regardless of content.

Typescript
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.

Typescript
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.

Typescript
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.

Typescript
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.

Typescript
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.

Typescript
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.

Typescript
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

StrategyPrecision@5Recall@10Answer QualityChunks/Doc
Fixed (512)0.620.713.447
Fixed + Overlap0.680.783.652
Sentence-Based0.710.743.738
Semantic0.740.763.841
Recursive0.760.813.944
Document-Aware0.820.864.236
Key finding: Document-aware chunking outperformed all other strategies by a significant margin.

But the averages hide important nuances. Let's break it down by document type.

•••

Results by Document Type

Diagram
flowchart TB subgraph Tech["Technical Docs"] T1["Recursive: 0.84"] T2["Doc-Aware: 0.81"] T3["Semantic: 0.72"] end subgraph Legal["Legal Contracts"] L1["Doc-Aware: 0.88"] L2["Sentence: 0.71"] L3["Fixed: 0.58"] end subgraph Research["Research Papers"] R1["Doc-Aware: 0.79"] R2["Recursive: 0.77"] R3["Semantic: 0.74"] end subgraph Support["Support Articles"] S1["Sentence: 0.86"] S2["Fixed: 0.82"] S3["Doc-Aware: 0.81"] end
Document TypeBest StrategyPrecision@5Why It Won
Technical docsRecursive0.84Respects header hierarchy, keeps code blocks intact
Legal contractsDocument-Aware0.88Preserves cross-references and defined terms
Research papersDocument-Aware0.79Keeps methodology with results, citations in context
Support articlesSentence-Based0.86Short docs, natural sentence boundaries work well
Financial reportsDocument-Aware0.85Tables and figures stay with explanatory text
Product manualsRecursive0.80Step-by-step procedures remain intact
Insight 1: For short, well-structured documents (support articles), simple strategies work fine. The overhead of document-aware chunking isn't justified.

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

Code
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:

  1. 1Predictable retrieval behavior - You know exactly how much context you're retrieving
  2. 2Consistent embedding quality - Embedding models perform best on similar-length texts
  3. 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 SizePrecision@5Recall@10Answer Quality
1280.810.693.5
2560.790.753.8
5120.760.814.0
7680.720.833.9
10240.680.843.7
20480.590.863.4
The trade-off is clear:
  • 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%:

OverlapPrecision@5Recall@10Storage Overhead
0%0.710.751.0x
10%0.740.791.11x
20%0.760.811.25x
30%0.760.821.43x
Finding: 10-20% overlap provides most of the benefit. Beyond 20%, you're paying storage costs without proportional retrieval gains.

•••

Implementation Recommendations

Based on 50 documents and 1,250 queries, here's my decision framework:

Diagram
flowchart TD A[Document Type?] --> B{Short & Structured?} B -->|Yes| C[Sentence-Based<br/>5-7 sentences] B -->|No| D{Has Complex Structure?} D -->|Yes| E{Cross-References?} D -->|No| F[Recursive<br/>512 tokens, 10% overlap] E -->|Yes| G[Document-Aware<br/>Full parsing] E -->|No| F style C fill:#22c55e,color:#fff style F fill:#0ea5e9,color:#fff style G fill:#8b5cf6,color:#fff

Recommendation by Use Case

1. General-purpose RAG (mixed document types)

Code
Strategy: Recursive with 512 tokens, 10% overlap
Expected Precision@5: ~0.76

2. Technical documentation

Code
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

Code
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

Code
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:

Typescript
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('
') && content.includes('function')) { return 'technical'; } if (/\\b(whereas|hereinafter|pursuant)\\b/i.test(content)) { return 'legal'; } if (content.split('\\n').length < 50 && content.includes('Step')) { return 'support'; } return 'default'; } selectStrategy(type: DocumentType): ChunkingStrategy { return this.strategies.get(type) || this.strategies.get('default')!; } async chunk(content: string): Promise { const type = this.detectDocumentType(content); const strategy = this.selectStrategy(type); const chunks = await strategy.chunk(content); // Post-processing return chunks .map(this.enrichChunk) .filter(this.isValidChunk); } private enrichChunk(chunk: Chunk, index: number): Chunk { return { ...chunk, metadata: { ...chunk.metadata, index, tokenCount: tokenCount(chunk.content), hasCode: chunk.content.includes('
Code
'),
        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(/
/g) || []).length; if (opens % 2 !== 0) { issues.push('Chunk ' + chunk.metadata.index + ' has unclosed code block'); } } // Check for split sentences for (let i = 0; i < chunks.length - 1; i++) { const current = chunks[i].content; const next = chunks[i + 1].content; if (!current.match(/[.!?]\s*$/) && next.match(/^[a-z]/)) { issues.push('Possible split sentence between chunks ' + i + ' and ' + (i + 1)); } } return { valid: issues.length === 0, issues, stats: { totalChunks: chunks.length, avgTokens: chunks.reduce((a, c) => a + c.metadata.tokenCount, 0) / chunks.length, minTokens: Math.min(...chunks.map(c => c.metadata.tokenCount)), maxTokens: Math.max(...chunks.map(c => c.metadata.tokenCount)), } }; } } ```

•••

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:

  1. 1Test more embedding models - I only used text-embedding-3-small. Different embeddings may favor different chunking strategies.
  1. 2Include multi-lingual documents - All my documents were English. Sentence boundaries differ across languages.
  1. 3Test retrieval + generation together - I evaluated retrieval and generation separately. In practice, they interact in complex ways.
  1. 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.

Found this helpful?

Share it with others who might benefit

TweetShare

Related articles