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.

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:
textdocument: 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
- 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.
- Use paragraph-aware for structured documents. Specifications, legal text, API references and anything with real paragraph markup can go to zero mid-sentence cuts.
- 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.
- 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.
- 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.
Related DevToolLab Tools
- 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 Guides
- Best RAG Platforms and Tools - the systems that run this pipeline once you have settled the chunking question.
- Best PDF Parsers for RAG - the step before chunking, where document structure is either preserved or destroyed.
- How LLM Tokenization Actually Works - why a character budget and a token budget are not the same thing.
- Best Embedding Models and APIs - what each chunk turns into, and what it costs.
