Chunk overlap is the single most misunderstood knob in RAG pipelines. Most teams either set it to zero and wonder why retrieval misses context boundaries, or crank it to 20% and watch their index bloat without measurable gains. The thesis is simple: chunk overlap RAG tuning follows a sharp diminishing-returns curve where 10-15% overlap captures nearly all boundary-crossing context, while higher values primarily increase storage, latency, and duplicate noise in the retrieved set.
What chunk overlap actually does
When you split a document into fixed-size chunks with overlap, you create sliding windows. A 512-token chunk with 15% overlap (77 tokens) means chunk N ends where chunk N+1 begins minus 77 tokens. The retriever sees each window as an independent document. If a query-relevant span straddles the boundary between two windows, at least one window contains the full span — provided the overlap exceeds the span length.
def chunk_with_overlap(text: str, chunk_size: int, overlap_pct: float) -> list[str]:
tokens = tokenizer.encode(text)
overlap = int(chunk_size * overlap_pct)
stride = chunk_size - overlap
chunks = []
for i in range(0, len(tokens), stride):
chunk_tokens = tokens[i:i + chunk_size]
if len(chunk_tokens) < chunk_size * 0.5: # drop tiny tail chunks
break
chunks.append(tokenizer.decode(chunk_tokens))
return chunks
The retriever doesn’t know these chunks came from the same source. It scores each independently. Overlap ensures that no single sentence gets cleaved in half across two vectors where neither half retrieves well alone.
The retrieval quality tradeoff curve
Empirically, retrieval recall improves steeply from 0% to roughly 10% overlap, then flattens. Precision often degrades past 15% because the same semantic content appears in multiple chunks, increasing the chance that near-duplicate chunks crowd out diverse relevant chunks in the top-k results.
Consider a 2000-token document split into 512-token chunks:
| Overlap | Chunks | Unique tokens indexed | Boundary coverage for 100-token spans |
|---|---|---|---|
| 0% | 4 | 2048 | 0% (spans crossing boundaries lost) |
| 10% | 5 | 2560 | 100% |
| 20% | 6 | 3072 | 100% |
| 50% | 11 | 5632 | 100% |
The jump from 0% to 10% adds one chunk but guarantees any 100-token span fits entirely in at least one chunk. Going to 20% adds another chunk with zero additional boundary coverage for spans under 154 tokens. At 50% you’ve nearly tripled index size for no retrieval benefit on typical query spans.
Concrete failure modes at zero overlap
Zero overlap breaks retrieval in predictable ways. A legal contract might have “The parties agree that” at the end of chunk 3 and “the arbitration clause survives termination” at the start of chunk 4. A query for “arbitration clause survives termination” matches chunk 4 weakly because the subject (“The parties agree that”) is missing. The embedding for chunk 4 represents only the predicate, not the full proposition.
# Zero overlap: semantic units get severed
chunks_0 = chunk_with_overlap(contract_text, 512, 0.0)
# Chunk 3 ends: "...The parties agree that"
# Chunk 4 starts: "the arbitration clause survives termination."
# 10% overlap: the unit stays intact in at least one chunk
chunks_10 = chunk_with_overlap(contract_text, 512, 0.10)
# Chunk 3 ends: "...The parties agree that the arbitration clause survives termination."
# Chunk 4 starts: "the arbitration clause survives termination. Section 12 provides..."
The same problem appears in code: a function signature at the end of one chunk, the docstring at the start of the next. Zero overlap guarantees the retriever never sees them together.
When overlap hurts: duplicate crowding and latency
High overlap creates near-duplicate vectors. At 50% overlap, consecutive chunks share half their tokens. Their embeddings land close in vector space. When you retrieve top-5, you often get 3 chunks from the same document region and miss a distinct relevant section elsewhere.
def demonstrate_crowding(query_vec, index, k=5):
results = index.search(query_vec, k=10) # fetch extra to see duplicates
# Group by source document and position
by_source = {}
for r in results:
key = (r.metadata['doc_id'], r.metadata['chunk_idx'] // 2) # coarse region
by_source.setdefault(key, []).append(r)
# If one region dominates top-k, crowding occurred
top_regions = sorted(by_source.values(), key=len, reverse=True)
return top_regions[0] if top_regions else []
This crowding effect is worst when:
- The query matches a high-frequency pattern (boilerplate, headers, common imports)
- The document has repetitive structure (API references, legal definitions, changelogs)
- Your top-k is small relative to the number of overlapping chunks per semantic unit
Latency scales linearly with index size. Doubling overlap from 10% to 50% roughly doubles the number of vectors, doubling ANN search time and memory footprint. For a 10M chunk index, that’s the difference between a 4GB and 8GB HNSW graph — or between 20ms and 40ms p99 latency on the same hardware.
Practical guidelines for tuning
Start with 10% overlap for general-purpose text (docs, articles, contracts). This handles most sentence-level boundary crossings. Increase to 15% only if you have evidence of longer semantic units that routinely cross boundaries: multi-paragraph arguments, long code functions, or tables split across chunks.
# Recommended defaults by content type
DEFAULTS = {
"general_text": {"chunk_size": 512, "overlap_pct": 0.10},
"code": {"chunk_size": 512, "overlap_pct": 0.15}, # functions span more tokens
"legal": {"chunk_size": 512, "overlap_pct": 0.12}, # defined terms, cross-refs
"api_docs": {"chunk_size": 256, "overlap_pct": 0.0}, # each endpoint standalone
"chat_logs": {"chunk_size": 256, "overlap_pct": 0.20}, # conversation continuity
}
Measure before you tune. Run a retrieval eval set with 0%, 10%, 15%, 20% overlap and plot recall@k vs. index size. The knee of the curve is your operating point. If recall@5 jumps from 0.62 to 0.78 between 0% and 10% but only reaches 0.80 at 20%, stop at 10%.
# Quick eval script skeleton
for overlap in 0.0 0.10 0.15 0.20; do
python build_index.py --overlap $overlap --out idx_${overlap}
python eval_retrieval.py --index idx_${overlap} --queries eval_set.jsonl \
--metrics recall@5,ndcg@5,latency_p99 --out results_${overlap}.json
done
python plot_tradeoff.py results_*.json
Watch for the duplicate crowding signal: if increasing overlap reduces recall@k for diverse queries, you’ve passed the optimum. This happens when the same document region occupies multiple top-k slots that could have covered different relevant sections.
The decisive takeaway
Set chunk overlap to 10% for most RAG workloads. Treat 15% as a ceiling for content with demonstrably long cross-boundary dependencies (code, legal). Never exceed 20% — the index bloat and duplicate crowding outweigh any marginal boundary coverage. Zero overlap is only acceptable for highly structured content where every chunk is semantically self-contained (API reference pages, enum definitions, flashcard-style QA pairs).
The retriever sees chunks, not documents. Overlap is the only mechanism that lets a semantic unit survive the chunking boundary intact. Give it just enough room to breathe, then stop.