Blog

Fixed-size vs variable-size chunking on real documents

4 Sep 2026 · 8 min read


Not all chunking strategies are equal. Pick the wrong one and your RAG pipeline silently returns garbage — embeddings that don't match, context windows that overflow, chunks that split mid-table. The choice between fixed-size and variable-size chunking is the single biggest lever you have for retrieval quality.

We benchmarked 10 chunking strategies across 500 real-world documents (PDFs, DOCX files, PPTX slide decks, EPUB books) to answer one question: which strategy maximizes retrieval precision for RAG?

This post presents the results, with production-ready code in Python and Node.js.

What we tested

Every document was first converted to GitHub-Flavored Markdown via the AnyMD API. Then we applied 10 chunking strategies:

#StrategyTypeChunk sizeOverlap
1Fixed 256 charsFixed256 chars0
2Fixed 512 charsFixed512 chars0
3Fixed 256 tokensFixed256 tokens0
4Fixed 512 tokensFixed512 tokens0
5Fixed 1,024 tokensFixed1,024 tokens0
6Heading-based (variable)VariablePer-section0
7Recursive character (1,000)Variable~1,000 chars200 chars
8Recursive character (2,000)Variable~2,000 chars200 chars
9Semantic (heading + paragraph)VariablePer-sectionHeading context
10Hybrid (heading + token limit)VariableMax 512 tokHeading context

Overall results

RankStrategyPrecision@5Recall@5F1Avg chunks/doc
🥇Hybrid (heading + token limit)0.9120.8940.90314
🥈Heading-based (variable)0.8870.8620.87411
🥉Semantic (heading + paragraph)0.8750.8580.86613
4Recursive character 2,0000.8410.8230.8329
5Recursive character 1,0000.8190.7950.80717
6Fixed 1,024 tokens0.7820.7610.77116
7Fixed 512 tokens0.7350.7080.72131
8Fixed 256 tokens0.6640.6320.64861
9Fixed 512 chars0.6180.5890.60335
10Fixed 256 chars0.5410.5070.52371

Hybrid chunking — using heading boundaries as semantic breakpoints capped by a token limit — outperforms every fixed-size strategy by at least 13% F1. The worst fixed strategy (256 chars) scores 0.523 F1, meaning nearly half of retrievals are useless for downstream generation.

Why variable-size chunking wins

Fixed-size chunking has a fundamental problem: it ignores document structure. A chunk boundary that lands in the middle of a paragraph, table, or list produces an embedding with no coherent semantic center.

Here's a 512-token fixed chunk of a real document converted via AnyMD:

consumer. Under these conditions, the system must
handle at least 10,000 requests per second with a P99
latency under 200ms."

### 3.2 Security Requirements

All API endpoints must be authenticated via OAuth 2.0.
TLS 1.3 is mandatory in transit; AES-256-GCM at rest.

### 3.3 Storage Requirements

The database must support horizontal sharding across at
least 3 availability zones. Backup frequency: hourly
incremental, daily full. Recovery time objective (RTO):
15 minutes. Recovery point objective (RPO): 1 minute.

### 4. Architecture Overview

Figure 1 shows the high-level system architecture.
[Diagram: load balancer → API gateway → service mesh →
microservices → database cluster]

### 4.1 Frontend Layer

The frontend is built with React 19 and communicates
with the API gateway via WebSocket for real-time
updates and REST for CRUD operations. Static assets
are served through a CDN with a 90% cache hit target.

### 4.2 API Gateway Layer

The gateway is implemented as a Rust service using
Axum. It handles rate limiting (1,000 req/min per
client), authentication (JWT validation), and request
routing to the appropriate microservice.

| Service     | Language | Replicas | Resource Limit       |
|-------------|----------|----------|----------------------|
| Auth        | Rust     | 3        | 2 vCPU, 4 GB RAM     |

That's four ### subsections and the start of a table in a single chunk. An embedding model trying to represent that will produce a blur — part security, part storage, part frontend, part architecture. A query about "OAuth 2.0 authentication" competes with "horizontal sharding" and "React 19".

Variable-size chunking avoids this by respecting document structure. Each heading becomes a natural semantic boundary:

Chunk 1: ### 3.1 Performance Requirements → 298 tokens
Chunk 2: ### 3.2 Security Requirements → 187 tokens
Chunk 3: ### 3.3 Storage Requirements → 256 tokens
Chunk 4: ### 4. Architecture Overview → 124 tokens
Chunk 5: ### 4.1 Frontend Layer → 312 tokens
Chunk 6: ### 4.2 API Gateway Layer → 408 tokens  (includes table, kept whole)

Each chunk has a single semantic focus. The embedding for "Security Requirements" cleanly represents authentication, TLS, and encryption — and will match a query about "OAuth 2.0" with high precision.

When fixed-size chunking still makes sense

Fixed-size isn't useless. Three scenarios where it's acceptable:

  1. Uniform content — technical manuals or log files with no headings, where all text is roughly the same density. Fixed 512 tokens works fine.
  2. Very short documents — a single-paragraph memo or a one-slide deck. Just embed the whole thing.
  3. Real-time streaming — when you need chunk boundaries on a clock (e.g., 5-second audio transcription segments), fixed windows are simpler to implement.

For everything else — reports, whitepapers, invoices, contracts, academic papers, slide decks, spreadsheets — variable-size chunking on clean Markdown is strictly superior.

Python: hybrid chunker (heading + token limit)

import re
import tiktoken
from pathlib import Path

def hybrid_chunk(
    markdown: str,
    model: str = "text-embedding-3-small",
    max_tokens: int = 512,
    min_tokens: int = 50,
) -> list[dict]:
    """
    Split Markdown into chunks at heading boundaries, capped by a
    hard token limit. Sections smaller than min_tokens are merged
    into the previous chunk.
    """
    enc = tiktoken.get_encoding(tiktoken.encoding_name_for_model(model))
    lines = markdown.split("\n")
    chunks = []
    current = {"heading": "", "content": "", "tokens": 0}

    i = 0
    while i < len(lines):
        line = lines[i]
        heading_match = re.match(r"^(#{1,6})\s+(.+)$", line)

        if heading_match:
            # Start a new section
            if current["content"].strip():
                if current["tokens"] >= min_tokens or not heading_match:
                    chunks.append(current)
                else:
                    # Merge small section into previous
                    if chunks:
                        chunks[-1]["content"] += "\n" + current["content"]
                        chunks[-1]["tokens"] = len(enc.encode(chunks[-1]["content"]))

            current = {
                "heading": line.strip("# ").strip(),
                "content": line + "\n",
                "tokens": len(enc.encode(line + "\n")),
            }
            i += 1
            continue

        # Check token limit
        line_tokens = len(enc.encode(line + "\n"))
        if current["tokens"] + line_tokens > max_tokens and current["content"].strip():
            # Flush current chunk, keep heading context
            chunks.append(current)
            current = {
                "heading": current["heading"],
                "content": line + "\n",
                "tokens": line_tokens,
            }
            i += 1
            continue

        current["content"] += line + "\n"
        current["tokens"] += line_tokens
        i += 1

    if current["content"].strip():
        chunks.append(current)

    # Add metadata
    for idx, c in enumerate(chunks):
        c["chunk_id"] = idx
        c["source"] = "heading"

    return chunks

# Usage with AnyMD output
md = Path("converted_report.md").read_text()
chunks = hybrid_chunk(md)
print(f"Created {len(chunks)} chunks, "
      f"avg {sum(c['tokens'] for c in chunks) // len(chunks)} tokens")
for c in chunks[:3]:
    print(f"  [{c['heading']}] {c['tokens']} tok")

Node.js: fixed-size vs variable-size comparison

import { getEncoding } from "js-tiktoken";

interface Chunk {
  heading: string;
  content: string;
  tokens: number;
}

/** Fixed-size chunking: every chunk is exactly `maxTokens`. */
function fixedChunk(text: string, maxTokens: number = 512): string[] {
  const enc = getEncoding("cl100k_base");
  const tokens = enc.encode(text);
  const chunks: string[] = [];
  for (let i = 0; i < tokens.length; i += maxTokens) {
    const slice = tokens.slice(i, i + maxTokens);
    chunks.push(new TextDecoder().decode(enc.decode(slice)));
  }
  return chunks;
}

/** Variable-size (heading-aware) chunking. */
function variableChunk(markdown: string, maxTokens: number = 512): Chunk[] {
  const enc = getEncoding("cl100k_base");
  const chunks: Chunk[] = [];
  let current: Chunk = { heading: "", content: "", tokens: 0 };

  for (const line of markdown.split("\n")) {
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
    const lineTokens = enc.encode(line + "\n").length;

    if (headingMatch) {
      if (current.content.trim()) chunks.push(current);
      current = {
        heading: line.replace(/^#+\s*/, ""),
        content: line + "\n",
        tokens: lineTokens,
      };
      continue;
    }

    if (current.tokens + lineTokens > maxTokens && current.content.trim()) {
      chunks.push(current);
      current = { heading: current.heading, content: "", tokens: 0 };
    }

    current.content += line + "\n";
    current.tokens += lineTokens;
  }

  if (current.content.trim()) chunks.push(current);
  return chunks;
}

// Benchmark on a real document
const md = await Bun.file("report.md").text();
const fixed = fixedChunk(md, 512);
const variable = variableChunk(md, 512);
console.log(`Fixed-size:    ${fixed.length} chunks`);
console.log(`Variable-size: ${variable.length} chunks`);

Benchmark methodology

We tested on a corpus of 500 documents:

  • 100 PDFs — academic papers, financial reports, legal contracts
  • 100 DOCX files — Word documents, business proposals, manuals
  • 100 PPTX files — slide decks with mixed text/table/bullet content
  • 100 EPUB files — books and long-form publications
  • 100 mixed — CSVs, RTFs, ODTs, XLSX exports

Each document was converted using the AnyMD API. Then each chunking strategy was evaluated on:

  • Precision@5 — of the top-5 retrieved chunks, what fraction are relevant to the query?
  • Recall@5 — of all relevant chunks, what fraction appear in the top-5?
  • Embedding quality — cosine similarity between chunk embeddings and query embeddings for 200 hand-curated query-document pairs.

Embeddings were generated with text-embedding-3-small (OpenAI), and retrieval was done via cosine similarity. The same queries and relevance judgments were used across all strategies.

The AnyMD advantage

All of these strategies depend on one critical input: clean Markdown. When you chunk raw PDF text (extracted by PyMuPDF or pdfminer), you get:

  • Broken tables — columns merged, headers missing
  • Missing list hierarchy — indentation lost, nesting flat
  • Orphaned headings — no visual distinction between # and ###
  • Garbled code blocks — indentation mangled, syntax lost

AnyMD converts 15+ document formats to GitHub-Flavored Markdown with full structural fidelity: headings, tables, lists, code blocks, and inline formatting are all preserved. The chunking strategies above work because the input is clean.

Recommendations by use case

Use caseRecommended strategyWhy
Document Q&A (reports, papers)Hybrid heading + tokenBest F1; sections stay coherent
Chatbot with structured dataHeading-based variablePreserves section context
Code documentation RAGHybrid + code block preservationCode blocks must stay whole
Semantic search (any doc)Recursive character (2,000)Good balance of size and precision
Real-time / streamingFixed 512 tokensPredictable chunk boundaries
High-throughput batchHeading-based variableFewer chunks = fewer embeddings

Get started

The best chunking strategy starts with the right conversion. Convert your documents to clean Markdown with AnyMD, then apply the hybrid chunker above.

Sign up for a free API key — 100 pages per month, no credit card required — and benchmark our output against your current pipeline: anymd.net/pricing →


← Read more →