In this post
What this post covers
How this was tested
FastAPI English docs (152 Markdown files, 1.46M characters), 781 heading queries, BM25 (k1=1.5, b=0.75), fixed-size character windows only, Python 3, standard library only. No embeddings and no heading-aware chunkers were tested. Script and raw output are in the Experiment files box.
Does overlapping fixed-size chunks improve retrieval, and is it worth the bigger index? I swept overlap at 0, 10, 20 and 40% of the chunk size, at four chunk sizes, using the same harness as the chunk size sweep.
Short answer: yes, and more overlap kept helping up to 40%. But it is a trade: 40% overlap meant about 1.6x the chunks and characters to index, and the gain was biggest on the strict metric, which rewards “clean” chunks. On the lenient metric the gain was 1 to 7 points. Overlap patches bad cut points; it does not replace cutting at headings.
New to chunking? The free lesson on ingestion and chunking covers the basics.
Setup
- Corpus: 152 FastAPI doc files, 1.46M characters. (The docs change upstream, so the numbers differ slightly from older posts. Don’t compare cell by cell.)
- Queries: 781 h2/h3 headings with 2+ words and at least 300 characters of body.
- Retriever: BM25, top 5.
- Chunker: fixed windows of 200, 400, 800 or 1,200 characters, stepping by size minus overlap.
- Strict hit: a retrieved chunk lies at least 50% inside the target section. Lenient hit: a retrieved chunk shares at least 100 characters with it.
Results
| Size | Overlap | Chunks | Index size vs 0% | Strict hit@1 | Strict hit@5 | MRR | Lenient hit@1 | Lenient hit@5 |
|---|---|---|---|---|---|---|---|---|
| 200 | 0% | 7,388 | 1.00x | 0.543 | 0.786 | 0.641 | 0.543 | 0.786 |
| 200 | 10% | 8,192 | 1.11x | 0.592 | 0.821 | 0.683 | 0.592 | 0.821 |
| 200 | 20% | 9,182 | 1.25x | 0.608 | 0.867 | 0.713 | 0.608 | 0.867 |
| 200 | 40% | 12,167 | 1.66x | 0.648 | 0.939 | 0.776 | 0.648 | 0.939 |
| 400 | 0% | 3,730 | 1.00x | 0.466 | 0.702 | 0.559 | 0.656 | 0.873 |
| 400 | 10% | 4,126 | 1.11x | 0.498 | 0.755 | 0.600 | 0.695 | 0.908 |
| 400 | 20% | 4,607 | 1.24x | 0.517 | 0.786 | 0.630 | 0.729 | 0.942 |
| 400 | 40% | 6,070 | 1.65x | 0.554 | 0.900 | 0.705 | 0.726 | 0.936 |
| 800 | 0% | 1,903 | 1.00x | 0.392 | 0.581 | 0.467 | 0.711 | 0.922 |
| 800 | 10% | 2,090 | 1.11x | 0.387 | 0.602 | 0.472 | 0.729 | 0.950 |
| 800 | 20% | 2,331 | 1.24x | 0.419 | 0.671 | 0.526 | 0.722 | 0.939 |
| 800 | 40% | 3,028 | 1.63x | 0.449 | 0.775 | 0.589 | 0.738 | 0.942 |
| 1,200 | 0% | 1,292 | 1.00x | 0.279 | 0.417 | 0.338 | 0.699 | 0.921 |
| 1,200 | 10% | 1,418 | 1.10x | 0.298 | 0.455 | 0.362 | 0.745 | 0.944 |
| 1,200 | 20% | 1,571 | 1.23x | 0.311 | 0.499 | 0.391 | 0.729 | 0.935 |
| 1,200 | 40% | 2,024 | 1.57x | 0.318 | 0.549 | 0.415 | 0.732 | 0.937 |
“Index size” is total characters in all chunks divided by the 0% run at the same size, so it is what you pay in embedding calls and storage.
What the numbers say
Strict hit@5 rose steadily with overlap. At 40% overlap the gain over 0% was +15.3 points at 200 characters, +19.8 at 400, +19.4 at 800 and +13.2 at 1,200. Strict MRR rose by 0.077 to 0.146.
10% overlap, the common default, gave a small gain. Strict hit@5 went up 3.5, 5.3, 2.1 and 3.8 points across the four sizes. Strict hit@1 moved between -0.5 and +4.9 points. With 781 queries, a difference of about 2 points is within noise (roughly one standard error), so the 800-character hit@1 change of -0.5 points means “no effect”, not “overlap hurts”.
The lenient metric shows much smaller gains. Lenient hit@5 improved by 3.5 to 6.9 points at 400, 1.7 to 2.8 at 800 and 1.4 to 2.3 at 1,200 characters. That is the honest reading: the section was usually already found at 0% overlap (lenient hit@5 was 0.873 to 0.922 for sizes 400 and up). What overlap changed was whether the hit chunk was mostly about the target section. At 200 characters the two metrics are identical, since a 200-character chunk that shares 100 characters is also at least half inside.
It is not free. 40% overlap cost 1.57x to 1.66x the index. Compare it with the other lever: going from 400 to 200 characters with no overlap raised strict hit@5 from 0.702 to 0.786 (+8.4 points) at 1.0x the characters, though it doubled the chunk count (7,388 vs 3,730), and each chunk is a separate vector.
Caveats
- Strict metric favours overlap. Overlap adds more chunks that sit mostly inside the target section, so there are more chances to match. Part of the strict gain is that effect, not better retrieval of the same text.
- Duplicate text in the top 5. With high overlap, neighbouring chunks share text and can fill several of the five slots with near-copies. I did not measure how much this hurts answer diversity.
- BM25 and heading queries. Embedding retrieval or natural-language questions may behave differently. I did not test either.
- Fixed windows only. The heading-aware chunker in the earlier sweep had zero boundary crossings, so there is little for overlap to repair there. I did not test overlap on it.
- Retrieval, not answer quality. Check answers on your own golden set.
Copyable code
def fixed(text, size, pct):
step = size - size * pct // 100
out, i = [], 0
while i < len(text):
out.append((i, min(len(text), i + size)))
if out[-1][1] == len(text):
break
i += step
return out
The full script, with BM25 and scoring, is in the Experiment files box.
Rule of thumb
- If you must use fixed windows, use 10 to 20% overlap. It bought 2 to 9 points of strict hit@5 for 1.1x to 1.25x the index.
- Go to 40% only if recall matters more than index cost. It gave the largest gains here (up to 19.8 points of strict hit@5) at about 1.6x the cost.
- Prefer fixing the boundaries. Splitting at headings removed boundary crossings entirely in the size sweep, which overlap can only soften.
- Measure on your corpus. Gains of 2 points or less here were within noise.
Experiment files
Want the production version of this?
A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests. It runs offline in about 60 seconds.
See ShipRAG or get the free RAG checklist first.