Back to all posts
Guide
9 min read

RAG Chunking Strategies, Measured

DevToolLab Team

DevToolLab Team

September 14, 2026

RAG Chunking Strategies, Measured

Retrieval quality in a RAG pipeline is usually blamed on the embedding model, and the embedding model is usually not the problem. If the chunk you retrieved ends halfway through the sentence that answered the question, no embedding model recovers it.

Chunking gets picked in a single line of config and never measured again. So we measured it: four strategies over RFC 9562, a 114,629-character specification, at a 1,000-character target. Fixed-size splitting landed mid-sentence at 98 percent of its boundaries. Paragraph-aware splitting did it at zero.

The Four Strategies

Fixed size cuts every N characters. It is one line of code, it produces perfectly even chunks, and it is completely blind to the text.

Fixed size with overlap does the same and repeats the last N characters of each chunk at the start of the next, so an idea cut in half appears whole in one of the two.

Recursive tries the largest separator first, paragraph breaks, then lines, then sentences, then spaces, falling back only when a piece still exceeds the budget. This is what most frameworks default to.

Paragraph aware never splits a paragraph. It packs whole paragraphs until the budget is spent and starts a new chunk.

Measuring the Damage

The metric that matters is not chunk size, it is where boundaries land. A boundary that falls on a sentence end or a paragraph break is free. A boundary anywhere else severs an idea. Node 18 or newer, no dependencies.

The RFC Editor page for RFC 9562, the Universally Unique IDentifiers specification, used here as the test corpus
The RFC Editor page for RFC 9562, the Universally Unique IDentifiers specification, used here as the test corpus
js
// chunking.mjs - Node 18+, no dependencies. Run: node chunking.mjs
const SOURCE = "https://www.rfc-editor.org/rfc/rfc9562.txt"
const SIZE = 1000     // target characters per chunk
const OVERLAP = 200

const raw = await fetch(SOURCE).then((r) => r.text())
const doc = raw.split("\n")
  .filter((l) => !/^\s*(Internet-Engineering|RFC 9562\s|Davis, et al\.)/.test(l) && !l.includes("\f"))
  .join("\n").replace(/\n{3,}/g, "\n\n")

const sentenceEnds = [...doc.matchAll(/[.!?]["')\]]?\s+(?=[A-Z0-9])/g)].map((m) => m.index + m[0].length)
const paragraphEnds = [...doc.matchAll(/\n\s*\n/g)].map((m) => m.index + m[0].length)

const fixed = (text) => {
  const out = []
  for (let i = 0; i < text.length; i += SIZE) out.push([i, Math.min(i + SIZE, text.length)])
  return out
}

const fixedOverlap = (text) => {
  const out = []
  for (let i = 0; i < text.length; i += SIZE - OVERLAP) {
    out.push([i, Math.min(i + SIZE, text.length)])
    if (i + SIZE >= text.length) break
  }
  return out
}

// Split on the largest separator that fits, then fall back. The "recursive" strategy.
const recursive = (text) => {
  const out = []
  const seps = ["\n\n", "\n", ". ", " "]
  const walk = (start, end, depth) => {
    if (end - start <= SIZE || depth >= seps.length) { out.push([start, end]); return }
    const sep = seps[depth]
    const parts = []
    let cur = start, idx
    while ((idx = text.indexOf(sep, cur)) !== -1 && idx < end) { parts.push([cur, idx + sep.length]); cur = idx + sep.length }
    parts.push([cur, end])
    let bufStart = start, bufEnd = start
    for (const [s, e] of parts) {
      if (e - bufStart > SIZE && bufEnd > bufStart) { walk(bufStart, bufEnd, depth + 1); bufStart = s }
      bufEnd = e
    }
    if (bufEnd > bufStart) walk(bufStart, bufEnd, depth + 1)
  }
  walk(0, text.length, 0)
  return out.filter(([s, e]) => e > s)
}

// Never split a paragraph; pack paragraphs until the budget is spent.
const byParagraph = (text) => {
  const out = []
  let start = 0, cur = 0
  for (const end of [...paragraphEnds, text.length]) {
    if (end - start > SIZE && cur > start) { out.push([start, cur]); start = cur }
    cur = end
  }
  if (cur > start) out.push([start, cur])
  return out
}

// A boundary is safe if it lands on a sentence end or a paragraph break.
const SAFE = new Set([...sentenceEnds, ...paragraphEnds, 0, doc.length])
const midSentence = (b) => b.slice(1).filter(([s]) => !SAFE.has(s)).length
const midPara = (b) => { const set = new Set(paragraphEnds); return b.slice(1).filter(([s]) => !set.has(s)).length }

const STRATEGIES = { "fixed size": fixed, "fixed + overlap": fixedOverlap, recursive, "paragraph aware": byParagraph }
for (const [name, fn] of Object.entries(STRATEGIES)) {
  const b = fn(doc)
  const total = b.reduce((a, [s, e]) => a + (e - s), 0)
  const sc = midSentence(b)
  console.log(
    `${name.padEnd(17)} chunks ${String(b.length).padStart(4)}  avg ${String(Math.round(total / b.length)).padStart(4)}` +
      `  mid-sentence ${`${sc} (${((sc / (b.length - 1)) * 100).toFixed(0)}%)`.padStart(11)}` +
      `  mid-para ${String(midPara(b)).padStart(4)}  chars ${total.toLocaleString("en-US").padStart(8)}`,
  )
}

Run on September 14, 2026 with Node 25.5.0:

text
document: RFC 9562, 114,629 chars, 616 sentences
target chunk size: 1000 chars (overlap 200 where used)

strategy           chunks  avg size   mid-sentence   mid-para  total chars
------------------------------------------------------------------------------
fixed size            115       997      112 (98%)        112      114,629
fixed + overlap       144       995      142 (99%)        142      143,229
recursive             147       780       15 (10%)         15      114,629
paragraph aware       132       868         0 (0%)          0      114,629

Reading the Numbers

Fixed-size chunking severs a sentence at 112 of its 114 boundaries. That is not an edge case, it is the normal operation of the strategy, and every one of those cuts produces two chunks that each contain half an idea.

Overlap does not fix it, it pays to work around it. The overlap run still cuts mid-sentence at 99 percent of boundaries; what it buys is a second copy of the severed region inside the neighboring chunk. The cost is visible in the last column: 143,229 characters stored and embedded versus 114,629, a 25 percent increase in vectors, storage and embedding spend, to compensate for a boundary problem you could have avoided.

Recursive splitting cuts the mid-sentence rate to 10 percent for free, at the price of more variable chunks (147 chunks averaging 780 characters rather than 115 averaging 997). Paragraph-aware never splits a paragraph at all, which on a structured document like an RFC is achievable because paragraphs are already below the budget.

The tradeoff is honest rather than free: strategies that respect structure produce uneven chunks, and very uneven chunks can skew similarity scores because embedding a 200-character chunk and a 900-character chunk gives them different effective specificity. Recursive is the reasonable middle, which is why frameworks default to it.

Choosing a Strategy

  1. Start with recursive, not fixed size. It is the same one line of config and it removes 90 percent of the boundary damage. There is no scenario where plain fixed-size splitting is the better choice for prose.
  2. Use paragraph-aware for structured documents. Specifications, legal text, API references and anything with real paragraph markup can go to zero mid-sentence cuts.
  3. Reach for overlap only after you have measured. It costs 25 percent more embeddings in this run and does not reduce the cut rate. If your text has no usable separators, such as OCR output or transcripts without punctuation, then it earns its cost.
  4. Measure boundaries, not chunk sizes. Every chunker reports its sizes. None of them report how many ideas they cut in half, and that is the number that predicts retrieval quality.
  5. Set the size in tokens, not characters. A 1,000-character budget is roughly 250 English tokens but far fewer in other languages, so a character budget silently shrinks your chunks for non-English corpora.

Conclusion

The default chunking configuration in most RAG pipelines splits a sentence at nearly every boundary, and the usual remedy, overlap, pays a 25 percent embedding tax without reducing the cut rate. Switching to recursive splitting costs nothing and eliminates most of it. Run the script above on one of your own documents before you spend another week tuning the retriever.

  • PDF Splitter - break a source PDF into sections before ingestion so chunking runs on coherent documents rather than one 300-page blob.
  • Markdown to Text - strip formatting before embedding, so link syntax and heading markers stop consuming chunk budget.
  • Invisible Character Remover - clear zero-width and non-breaking characters that survive a PDF or web scrape and quietly corrupt chunk boundaries.
  • Word Counter - size a source document before ingestion to estimate chunk counts and embedding spend.

Related Posts

Best Database Migration Tools in 2026

Flyway, Liquibase, Atlas, Bytebase, Prisma Migrate and Alembic compared on license, price and drift detection, after Liquibase left Apache 2.0.

By DevToolLab Team

Cybersecurity Lab Gear for Students 2026

Kali runs in 2GB of RAM. Security Onion standalone wants 24GB and refuses to run on ARM. What a security student actually needs to buy, and what to skip.

By DevToolLab Team

How LLM Tokenization Actually Works

A model never sees letters. We built a real BPE tokenizer on OpenAI's published vocabularies and measured why strawberry, numbers and Hindi all go wrong.

By DevToolLab Team