RAG Chunking Strategies: How to Test Their Impact on Quality

RAG Chunking Strategies: How to Test Their Impact on Quality

Chunking is one of the most consequential decisions in any RAG system, and one of the least tested. Most teams pick a strategy, set a chunk size, and ship. Then they wonder why retrieval quality degrades on longer documents, or why the system can never answer questions that span multiple sections.

The problem is not that teams make the wrong choice — it is that they have no framework for measuring whether their choice is correct. This guide gives you that framework.

Why Chunking Matters More Than You Think

When a user asks a question, your vector database searches for chunks similar to the query — not full documents. The chunk is the atomic unit of retrieval. If the answer to a question spans a chunk boundary, neither chunk may score high enough to be retrieved. If chunks are too large, they dilute the signal and cause lower similarity scores across the board. If they are too small, answers get split across many chunks and the context becomes fragmented.

The chunking strategy you choose affects every downstream metric: retrieval recall, context noise ratio, answer faithfulness, and ultimately user satisfaction.

The Four Main Strategies

1. Fixed-Size Chunking

Split text into fixed-length chunks of N tokens, with optional overlap:

from langchain.text_splitter import TokenTextSplitter

def fixed_size_chunker(text: str, chunk_size: int = 512, overlap: int = 50) -> list[str]:
    splitter = TokenTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=overlap
    )
    return splitter.split_text(text)

When it works well: Homogeneous documents (news articles, forum posts, product descriptions) where content density is relatively uniform.

When it breaks: Technical documentation, legal contracts, anything with section structure. A 512-token chunk might cut a code example in half, or include the end of one section and the start of another, creating noise.

2. Recursive Character Splitting

Split on natural boundaries (paragraphs, sentences, words) falling back to smaller units when needed:

from langchain.text_splitter import RecursiveCharacterTextSplitter

def recursive_chunker(text: str, chunk_size: int = 1000, overlap: int = 100) -> list[str]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    return splitter.split_text(text)

This is the default in LangChain and works well for most prose. It respects natural text boundaries while still enforcing a size limit.

3. Semantic Chunking

Group sentences by semantic similarity — when the topic shifts, start a new chunk:

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

def semantic_chunker(text: str, threshold: float = 0.5, model_name: str = "all-MiniLM-L6-v2") -> list[str]:
    model = SentenceTransformer(model_name)
    sentences = text.split(". ")
    embeddings = model.encode(sentences)
    
    chunks = []
    current_chunk = [sentences[0]]
    
    for i in range(1, len(sentences)):
        similarity = cosine_similarity(
            embeddings[i-1].reshape(1, -1),
            embeddings[i].reshape(1, -1)
        )[0][0]
        
        if similarity < threshold:
            chunks.append(". ".join(current_chunk))
            current_chunk = [sentences[i]]
        else:
            current_chunk.append(sentences[i])
    
    if current_chunk:
        chunks.append(". ".join(current_chunk))
    
    return chunks

When it works well: Documents with distinct topic sections, long-form content, mixed-topic documents.

When it breaks: Dense technical text where every sentence is about the same narrow topic — you can get enormous single chunks. Also slower and more compute-intensive than other strategies.

4. Sliding Window Chunking

Overlapping windows that ensure no information falls through the cracks:

def sliding_window_chunker(
    text: str, 
    window_size: int = 400, 
    step_size: int = 200
) -> list[str]:
    tokens = text.split()  # Simplified; use proper tokenizer in production
    chunks = []
    
    for i in range(0, len(tokens) - window_size + 1, step_size):
        chunk = " ".join(tokens[i:i + window_size])
        chunks.append(chunk)
    
    return chunks

When it works well: Questions that might span section boundaries, ensuring high recall at the cost of more redundant chunks.

When it breaks: Creates many duplicate or near-duplicate chunks, which can confuse the retriever and inflate your vector store.

Building a Chunk Quality Benchmark

To compare strategies objectively, you need a benchmark. Here is a complete framework:

Step 1: Prepare Your Test Corpus

Gather a representative sample of your actual documents — at least 20-30 documents covering different types (short, long, dense technical, narrative prose). Do not use toy examples; test on real content.

import json
from pathlib import Path

# Load your test corpus
test_corpus = []
for path in Path("test_documents/").glob("*.txt"):
    test_corpus.append({
        "id": path.stem,
        "content": path.read_text(),
        "doc_type": classify_document(path.stem)  # Your classification logic
    })

Step 2: Create a Question-Answer-Document Golden Dataset

For each test document (or a representative sample), create questions where you know which chunk should be retrieved:

GOLDEN_DATASET = [
    {
        "doc_id": "product-manual-v2",
        "question": "What is the default timeout for API calls?",
        "answer": "30 seconds",
        "answer_location": "section_3_2",  # Human-labeled section
        "answer_start_char": 4821,          # Character offset in source doc
        "answer_end_char": 4890,
    },
    {
        "doc_id": "legal-terms-2024",
        "question": "Can users export their data after account deletion?",
        "answer": "Within 30 days of account deletion",
        "answer_location": "section_12",
        "answer_start_char": 18234,
        "answer_end_char": 18312,
    }
]

Step 3: Chunk Overlap Score

The most important metric for a chunking strategy is whether the answer lands fully within a single chunk. Compute the answer containment rate:

def answer_containment_rate(
    chunks: list[str], 
    source_doc: str,
    golden_cases: list[dict]
) -> dict:
    """
    For each golden case, check if the answer text appears fully in at least one chunk.
    Returns containment rate and cases where answer was split.
    """
    results = {
        "total": len(golden_cases),
        "contained": 0,
        "split": 0,
        "split_cases": []
    }
    
    for case in golden_cases:
        answer = case["answer"].lower()
        found_in_chunk = any(answer in chunk.lower() for chunk in chunks)
        
        if found_in_chunk:
            results["contained"] += 1
        else:
            results["split"] += 1
            results["split_cases"].append({
                "question": case["question"],
                "answer": case["answer"]
            })
    
    results["containment_rate"] = results["contained"] / results["total"]
    return results

Step 4: Chunk Size Distribution

Healthy chunking produces a predictable size distribution. Runaway large chunks or many tiny useless chunks both indicate problems:

import statistics

def analyze_chunk_distribution(chunks: list[str]) -> dict:
    sizes = [len(chunk.split()) for chunk in chunks]
    return {
        "count": len(chunks),
        "mean_size": statistics.mean(sizes),
        "median_size": statistics.median(sizes),
        "std_dev": statistics.stdev(sizes) if len(sizes) > 1 else 0,
        "min_size": min(sizes),
        "max_size": max(sizes),
        "chunks_under_50_words": sum(1 for s in sizes if s < 50),
        "chunks_over_1000_words": sum(1 for s in sizes if s > 1000),
    }

Step 5: End-to-End Retrieval Recall

Plug each chunking strategy into your actual retriever and measure recall against your golden dataset. This is the ground truth metric — everything else is diagnostic:

import pytest
from your_vector_store import VectorStore
from your_embedder import Embedder

STRATEGIES = {
    "fixed_512": lambda doc: fixed_size_chunker(doc, chunk_size=512, overlap=50),
    "fixed_256": lambda doc: fixed_size_chunker(doc, chunk_size=256, overlap=25),
    "recursive_1000": lambda doc: recursive_chunker(doc, chunk_size=1000, overlap=100),
    "semantic_05": lambda doc: semantic_chunker(doc, threshold=0.5),
    "sliding_400": lambda doc: sliding_window_chunker(doc, window_size=400, step_size=200),
}

@pytest.mark.parametrize("strategy_name,chunker", STRATEGIES.items())
def test_retrieval_recall_by_strategy(strategy_name, chunker, test_corpus, golden_dataset):
    embedder = Embedder()
    
    # Build a fresh vector store for this strategy
    store = VectorStore(namespace=f"test_{strategy_name}")
    
    for doc in test_corpus:
        chunks = chunker(doc["content"])
        for i, chunk in enumerate(chunks):
            embedding = embedder.embed(chunk)
            store.upsert(
                id=f"{doc['id']}_{i}",
                embedding=embedding,
                metadata={"doc_id": doc["id"], "chunk_index": i, "text": chunk}
            )
    
    # Measure recall
    recalls = []
    for case in golden_dataset:
        query_embedding = embedder.embed(case["question"])
        results = store.search(query_embedding, k=5)
        
        retrieved_texts = [r.metadata["text"].lower() for r in results]
        answer_found = any(case["answer"].lower() in text for text in retrieved_texts)
        recalls.append(1.0 if answer_found else 0.0)
    
    recall = sum(recalls) / len(recalls)
    print(f"\nStrategy {strategy_name}: recall@5 = {recall:.3f}")
    
    # Assert minimum acceptable recall
    assert recall >= 0.70, f"Strategy {strategy_name} recall too low: {recall:.3f}"
    
    # Clean up
    store.delete_namespace(f"test_{strategy_name}")

Measuring Impact on Retrieval Recall

When you run the benchmark above, you will typically see patterns like these (actual numbers vary by domain):

Strategy Answer Containment Recall@5 Avg Chunk Size Notes
fixed_256 71% 0.68 256 tokens Splits answers frequently
fixed_512 82% 0.77 512 tokens Better but still misses cross-boundary answers
recursive_1000 89% 0.83 ~600 tokens Good default for most prose
semantic_0.5 91% 0.86 Variable (100-800) Best containment, slower indexing
sliding_400 95% 0.89 400 tokens Highest recall, 2x chunk count, more noise

The sliding window consistently achieves the highest recall because overlap ensures no answer falls through a boundary. But it creates a noisier context — you retrieve more semi-relevant chunks alongside the relevant ones. Test context noise ratio separately.

Testing Chunk Quality Regressions

Once you have selected a strategy, lock it down with regression tests. Changes to chunk size, overlap, or separator characters can silently degrade quality:

# tests/test_chunking_regression.py
import pytest
from your_chunkers import recursive_chunker

REGRESSION_BASELINES = {
    "product_docs": {
        "recall_at_5": 0.83,
        "containment_rate": 0.89,
        "mean_chunk_size": 612,
    }
}

def test_chunking_regression_product_docs(product_docs_corpus, product_docs_golden):
    all_chunks = []
    for doc in product_docs_corpus:
        chunks = recursive_chunker(doc["content"], chunk_size=1000, overlap=100)
        all_chunks.extend([(doc["id"], i, c) for i, c in enumerate(chunks)])
    
    containment = answer_containment_rate(
        [c for _, _, c in all_chunks],
        product_docs_corpus,
        product_docs_golden
    )
    
    baseline = REGRESSION_BASELINES["product_docs"]
    
    assert containment["containment_rate"] >= baseline["containment_rate"] - 0.03, (
        f"Containment rate regressed: {containment['containment_rate']:.3f} "
        f"vs baseline {baseline['containment_rate']:.3f}"
    )

Run this in CI whenever anyone changes the chunking configuration. A 3-point drop in containment rate is a red flag.

Domain-Specific Considerations

Code documentation: Use a code-aware splitter that keeps functions and classes intact. LangChain's Language.PYTHON splitter does this. Never split on character count alone when chunking code.

from langchain.text_splitter import RecursiveCharacterTextSplitter, Language

code_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1000,
    chunk_overlap=100
)

Legal and regulatory documents: Semantic chunking works best — legal sections are semantically distinct. Test that contract clauses never get split across chunks; a partial clause retrieved without its condition is dangerous.

Product manuals with numbered steps: Recursive splitting with "\n\n" and "\n" as primary separators usually keeps numbered lists intact. Verify by checking that no chunk contains step 3 but not step 2 for a sequential process.

Practical Recommendations

Start with recursive character splitting at 1000 tokens with 100 token overlap. This is the safe default that works reasonably well across most document types without extra compute overhead.

Then measure. Run your benchmark. If answer containment is below 85%, try semantic chunking or increase overlap. If you are seeing context noise (many irrelevant chunks retrieved), reduce chunk size or add a reranking step rather than changing the chunking strategy.

Never change chunking configuration without running the full benchmark. What feels like a minor tuning can silently drop recall by 10+ points on specific document types.

HelpMeTest can help you automate the end-user validation side of this — running regression tests against your live RAG application after each knowledge base or configuration change, verifying that answers your users rely on are still correct.

Summary

The right chunking strategy depends on your document corpus and query patterns. The process is:

  1. Build a golden dataset with questions and expected answer locations
  2. Compute answer containment rate for each strategy candidate
  3. Measure end-to-end retrieval recall@k in your actual vector store
  4. Lock in the winner with regression tests
  5. Run regressions in CI on every configuration change

Chunking is not set-and-forget. As your document corpus grows and your query patterns shift, revisit the benchmark. A strategy that worked well at launch may not be optimal six months later.

Read more

Start now free