All posts

RAG Accuracy: The Chunking Problem You're Missing

Introduction & hook

Imagine a P0 incident. Your shiny new RAG system, built to answer questions from your company’s sprawling internal knowledge base, just told a senior exec that their project was cancelled last quarter. The only problem? It wasn’t. It’s thriving. You trace the hallucination back, not to the LLM’s intelligence, but to the chunks of information it was given. Somewhere, a critical context sentence was separated from its explanatory paragraph, leaving the LLM to fill in the blanks with creative fiction.

This isn’t a hypothetical. It’s a silent killer of RAG performance, and it’s called the Chunking Problem. In Retrieval Augmented Generation (RAG), your language model’s accuracy is directly capped by the quality and relevance of the information it receives. The process of splitting large documents into smaller, digestible pieces, or chunks, before embedding them and storing them in a vector database, is foundational. Get it wrong, and your RAG pipeline becomes a sophisticated garbage-in, garbage-out machine.

This post isn’t just about splitting text. It’s about intelligently preparing your knowledge base so your LLM can actually understand it, delivering precise, accurate answers that drive real business value instead of triggering urgent Slack messages.

How it works (the visual example)

Let’s ground this with a concrete scenario. Picture a dense 100-page API specification for your core backend service. A junior developer asks, “How do I implement idempotent POST requests for the /users endpoint?”

Now, imagine how your RAG pipeline processes this massive document:

  1. Naive Chunking (Fixed-size Token Split): This is the default many developers start with. You might cut the document into 256-token chunks, often with a small overlap. It’s like taking a book and chopping it into arbitrary pages without caring about chapters, paragraphs, or even whole sentences.

    • The Problem: The explanation for idempotent POSTs might span a sentence at the end of chunk A and a sentence at the start of chunk B. When the developer’s query is embedded and matched against these chunks, neither A nor B might contain enough complete context to be highly relevant. The key information is split, diluted, and potentially missed entirely. Your LLM receives half a story, and you get a polite “I’m sorry, I don’t have enough information…” or worse, a confident but incorrect answer.
  2. Sentence Chunking: A step up. You split the document into individual sentences.

    • The Improvement: Now, at least, you’re not chopping sentences in half. But individual sentences often lack sufficient context. The sentence “Idempotency is crucial for reliable API design” isn’t enough to explain how to implement it. You need the surrounding sentences that elaborate on headers, unique keys, and retry logic.
  3. Semantic Chunking: Here’s where the magic begins. Instead of arbitrary cuts or even just sentence boundaries, you group sentences or short paragraphs based on their meaning. You run a small embedding model over adjacent sentences and group them until the semantic similarity between the current sentence and the growing “chunk” drops below a certain threshold.

    • The Mental Model: Think of it like reading the API spec and highlighting entire, self-contained concepts or explanations. One chunk might be “Explanation of Idempotent POST,” another “Error Handling Best Practices,” and another “Authentication Flow.” Each chunk is a complete thought.
    • The Improvement: When the developer asks about “idempotent POST requests,” your system is much more likely to retrieve a chunk that semantically captures that entire concept. The LLM gets a coherent block of information.
  4. Overlap Strategies: Regardless of how you chunk, adding overlap is crucial. This means including a few sentences from the previous chunk at the beginning of the next, and vice-versa.

    • The Reason: Information often flows across boundaries. Overlap ensures that if a key point is mentioned at the very end of one chunk, the next chunk still has enough context to be relevant, preventing those crucial split-context scenarios. It’s like ensuring each page in your book starts with the last sentence or two from the previous page, just in case.
  5. Parent-Child Retrieval: This is the game-changer for production RAG. You create two sets of chunks:

    • Child Chunks: Small, semantically dense, optimized for retrieval. These are the ones you embed and store in your vector database. They are designed to be highly specific and easily matched to a query. (e.g., 2-3 semantically related sentences).
    • Parent Chunks: Larger, context-rich chunks that contain the full surrounding information for the child chunks. These are not embedded for retrieval directly. Instead, they are stored in a simple document store (like S3 or a database) and linked to their respective child chunks via metadata. (e.g., a full paragraph, or even a section).
    • The Workflow: When the developer asks a question, you query the vector database using the child chunks. Once you retrieve the top N relevant child chunks, you then use their associated metadata to fetch their corresponding, larger parent chunks. These parent chunks are what you pass to the LLM for generation.
    • The Analogy: You don’t read every word in the index of a book. You use the index (the small, keyword-rich child chunks) to find the relevant page numbers, then you turn to those pages and read the whole paragraph or section (the larger, context-rich parent chunks). This balances precise retrieval with rich generation context.

Real-world use cases

This isn’t academic theory; these strategies are a lifesaver in real systems.

Where chunking strategies like parent-child excel

  • Complex Technical Documentation: API specs, engineering whitepapers, medical research. These documents often have deeply intertwined concepts where a single sentence isn’t enough, but a whole document is too much.
  • Legal & Compliance Documents: Contracts, regulations, policy manuals. Precision is paramount. You need the exact clause, but also its surrounding context to interpret it correctly.
  • Long-form Content: Books, research papers, extensive articles. Users are often looking for specific insights, not broad summaries.
  • Customer Support Knowledge Bases: When customers ask very specific product questions, providing the exact solution steps within their context is critical for resolution.

Where these approaches can be an anti-pattern

  • Atomic Data Points: If your “documents” are already very short, self-contained FAQs (e.g., “What is your return policy?”), then elaborate chunking might add unnecessary overhead. The entire FAQ entry is the optimal chunk.
  • High-Level Summarization: If your primary goal is to summarize an entire, relatively short document (e.g., a one-page executive brief), then you likely want to pass the full document to the LLM if it fits the context window. Chunking would break up the flow needed for a coherent summary.
  • Computational Overhead: Semantic and parent-child chunking are more computationally intensive during ingestion. For extremely large volumes of low-value, frequently changing content, the added complexity might not justify the accuracy gains.

Implementation & code

Let’s illustrate with Python. First, the naive approach that will undoubtedly lead to trouble.

import tiktoken

def naive_fixed_size_chunking(text: str, chunk_size_tokens: int = 256, overlap_tokens: int = 50) -> list[str]:
    """
    Naive fixed-size token chunking.
    Breaks context, often leading to poor RAG results.
    """
    tokenizer = tiktoken.get_encoding("cl100k_base")
    tokens = tokenizer.encode(text)
    
    chunks = []
    for i in range(0, len(tokens), chunk_size_tokens - overlap_tokens):
        chunk_tokens = tokens[i : i + chunk_size_tokens]
        chunks.append(tokenizer.decode(chunk_tokens))
        if i + chunk_size_tokens >= len(tokens): # Ensure we don't go past the end
            break
            
    return chunks

# Example: Imagine a sentence split across two chunks
long_text = "The quick brown fox jumps over the lazy dog. However, the dog was not actually lazy, but rather quite exhausted from a long day of chasing squirrels in the park. This particular park, known for its ancient oak trees, often attracted many playful squirrels."

# Using a very small chunk size to demonstrate the problem clearly
naive_chunks = naive_fixed_size_chunking(long_text, chunk_size_tokens=10, overlap_tokens=2)

print("--- Naive Chunking (DO NOT USE THIS IN PRODUCTION) ---")
for i, chunk in enumerate(naive_chunks):
    print(f"Chunk {i+1}: '{chunk}'")
# You'll quickly see sentences cut mid-word or mid-phrase, destroying coherence.
# A query about "chasing squirrels" might retrieve a chunk with only "chasing" and miss "squirrels".

Now, let’s look at a robust, parent-child with semantic chunking strategy. This is a conceptual example; in a real system, you’d use libraries like LangChain or LlamaIndex which abstract much of this, but understanding the underlying mechanics is key.

import spacy
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
import uuid # For generating unique IDs for parent chunks

# Load NLP models (expensive, do this once in production)
# 'en_core_web_sm' is a small, fast English model for sentence splitting
nlp = spacy.load("en_core_web_sm") 
# 'all-MiniLM-L6-v2' is a good balance of speed and quality for embeddings
sentence_model = SentenceTransformer('all-MiniLM-L6-v2') 

def split_into_sentences(text: str) -> list[str]:
    """Uses SpaCy to robustly split text into sentences."""
    doc = nlp(text)
    return [sent.text.strip() for sent in doc.sents if sent.text.strip()]

def create_semantic_child_chunks(sentences: list[str], similarity_threshold: float = 0.7, 
                                 min_chunk_sentences: int = 2, max_chunk_sentences: int = 5,
                                 overlap_sentences: int = 1) -> list[str]:
    """
    Groups sentences into semantic chunks based on similarity, with overlap.
    These are the small, retrieval-optimized chunks.
    """
    if not sentences:
        return []

    child_chunks = []
    
    # Process with overlap for semantic grouping
    overlapped_sentences = []
    for i, sentence in enumerate(sentences):
        overlapped_sentences.append(sentence)
        if i < len(sentences) - overlap_sentences:
            overlapped_sentences.extend(sentences[i+1 : i+1+overlap_sentences]) # Add subsequent sentences as overlap
    
    embeddings = sentence_model.encode(overlapped_sentences)
    current_chunk_sentences = []
    current_chunk_embeddings = []

    for i in range(len(overlapped_sentences)):
        sentence = overlapped_sentences[i]
        sentence_embedding = embeddings[i]

        if not current_chunk_sentences: # Start new chunk
            current_chunk_sentences.append(sentence)
            current_chunk_embeddings.append(sentence_embedding)
            continue
        
        # Calculate similarity with the average embedding of the current chunk
        avg_chunk_embedding = np.mean(current_chunk_embeddings, axis=0).reshape(1, -1)
        similarity = cosine_similarity(sentence_embedding.reshape(1, -1), avg_chunk_embedding)[0][0]

        # Add to current chunk if similar and within max size
        if similarity > similarity_threshold and len(current_chunk_sentences) < max_chunk_sentences:
            current_chunk_sentences.append(sentence)
            current_chunk_embeddings.append(sentence_embedding)
        else: # End current chunk and start a new one
            if len(current_chunk_sentences) >= min_chunk_sentences:
                child_chunks.append(" ".join(current_chunk_sentences))
            
            # Reset for the new chunk, ensuring overlap is handled by the `overlapped_sentences` list itself
            current_chunk_sentences = [sentence] 
            current_chunk_embeddings = [sentence_embedding]

    # Add the last chunk if it meets min size criteria
    if len(current_chunk_sentences) >= min_chunk_sentences:
        child_chunks.append(" ".join(current_chunk_sentences))

    return child_chunks

def production_ready_parent_child_chunking(document_text: str) -> tuple[list[dict], list[dict]]:
    """
    Orchestrates parent-child chunking for robust RAG.
    Returns child chunks for vector DB and parent chunks for context retrieval.
    """
    # 1. Create larger "parent" chunks. These should be coherent blocks (paragraphs, sections).
    # We use double newlines as a simple heuristic for paragraph splitting.
    # For very complex docs, you'd use HTML/Markdown parsers or more advanced text splitters.
    raw_parent_chunks = [p.strip() for p in document_text.split('\n\n') if p.strip()]
    
    parent_documents = []
    child_documents_for_vector_db = []

    for parent_text in raw_parent_chunks:
        parent_id = str(uuid.uuid4()) # Unique ID for each parent chunk
        
        parent_documents.append({
            "id": parent_id,
            "text": parent_text,
            "metadata": {"source": "api_spec_v2", "type": "paragraph"} # Add useful metadata
        })

        # 2. For each parent chunk, create smaller, semantically grouped "child" chunks.
        # These are what we'll embed and search against.
        sentences_in_parent = split_into_sentences(parent_text)
        semantic_child_chunks = create_semantic_child_chunks(
            sentences_in_parent, 
            similarity_threshold=0.75, # Tunable threshold
            min_chunk_sentences=1,    # Allow single sentence children if semantically distinct
            max_chunk_sentences=3,    # Keep children short for dense embeddings
            overlap_sentences=1       # Small overlap within child chunks for context
        )

        for child_text in semantic_child_chunks:
            child_documents_for_vector_db.append({
                "text": child_text,
                "embedding": sentence_model.encode(child_text).tolist(), # Embed for storage
                "metadata": {
                    "parent_id": parent_id, # Crucial link to the parent
                    "source": "api_spec_v2", 
                    "type": "semantic_snippet"
                }
            })
    
    print(f"Created {len(parent_documents)} parent chunks.")
    print(f"Created {len(child_documents_for_vector_db)} child chunks for vector DB.")
    print("\n--- Example Retrieval Flow ---")
    if child_documents_for_vector_db:
        # Simulate retrieval: Find top-k child chunks (based on a query embedding)
        # For simplicity, let's just pick the first child chunk
        retrieved_child_doc = child_documents_for_vector_db[0]
        retrieved_parent_id = retrieved_child_doc["metadata"]["parent_id"]

        # Fetch the full parent chunk using its ID
        actual_parent_context = next(
            (p["text"] for p in parent_documents if p["id"] == retrieved_parent_id), 
            "Parent not found"
        )

        print(f"Query matched child: '{retrieved_child_doc['text']}'")
        print(f"Full parent context for LLM: '{actual_parent_context}'")

    return child_documents_for_vector_db, parent_documents

# Example document text
document_example = """
Section 1: Introduction to Project Phoenix

Project Phoenix is an ambitious initiative to modernize our legacy banking platform. This project aims to enhance scalability, improve security, and reduce operational costs by migrating to a cloud-native microservices architecture. The target completion date for Phase 1 is Q4 2024.

Section 2: API Idempotency Guidelines

All external-facing POST and PUT API endpoints **MUST** implement idempotency. This prevents duplicate transactions and ensures data consistency, especially in distributed systems with retries. Clients should provide a unique `X-Request-ID` header for each request. The server will use this ID to identify and safely ignore duplicate requests within a 24-hour window. Failure to implement this will result in data corruption under high load.

Section 3: Deployment Strategy

Our deployment strategy utilizes Kubernetes with a CI/CD pipeline managed by Argo CD. Blue/Green deployments are preferred for critical services to minimize downtime.
"""

child_docs, parent_docs = production_ready_parent_child_chunking(document_example)

Why this code structure?

  • split_into_sentences: Robust sentence splitting is crucial. Naive text.split('.') fails on abbreviations, decimals, etc. SpaCy handles these nuances.
  • create_semantic_child_chunks: This function is the core of semantic grouping. By encoding sentences and comparing their embeddings, it intelligently groups related ideas. The similarity_threshold, min_chunk_sentences, max_chunk_sentences, and overlap_sentences are critical hyperparameters you’ll tune. Small chunks are better for precise retrieval.
  • production_ready_parent_child_chunking: This orchestrates the main strategy.
    • It first creates larger parent_documents (e.g., paragraphs or logical sections) and assigns them unique IDs. These are the rich contexts you want your LLM to see.
    • Then, for each parent, it generates smaller, semantic child_documents_for_vector_db. These child chunks are what get embedded and stored in your vector database.
    • The parent_id in the child chunk’s metadata is the critical link. When a child chunk is retrieved, this parent_id allows you to fetch the full, richer context from the parent_documents store.

This separation ensures your vector search is highly precise (using small, dense child embeddings) while providing the LLM with ample, coherent context for accurate generation.

Senior-level insights & gotchas

“Just use RecursiveCharacterTextSplitter” is a common first instinct, and it’s a good start, but it’s often not enough for production-grade RAG. It splits by characters, not meaning. You must move beyond simple character or token-based splitting if accuracy matters.

  • The Parent Chunk Size vs. Child Chunk Size Balancing Act: This isn’t a one-size-fits-all.
    • Child Chunks: Too small (e.g., single words) and their embeddings become ambiguous, leading to poor retrieval. Too large (e.g., whole paragraphs) and your retrieval becomes less precise. Aim for 1-5 semantically related sentences.
    • Parent Chunks: Too large, and you might exceed the LLM’s context window or dilute the query’s focus. Too small, and the LLM loses necessary surrounding context. A good starting point is usually 200-500 tokens, aligned with paragraphs or sub-sections.
  • Embedding Model Choice Matters Immensely: The quality of your chunking is only as good as the embedding model used for similarity comparisons (both for semantic chunking and for storing child embeddings). Small, fast, general-purpose models (like all-MiniLM-L6-v2) are great for a start, but for highly specialized domains (e.g., legal, medical), consider fine-tuning a model on your specific jargon. Benchmarking different embedding models is non-negotiable.
  • Late Chunking / Dynamic Context Expansion: This is an advanced technique. Instead of pre-chunking everything into parent-child pairs, you might retrieve a larger document or logical section (e.g., an entire chapter). Then, at query time, you dynamically chunk that retrieved larger piece to fit the LLM’s context window, perhaps prioritizing chunks semantically closest to the query within that section. This is powerful when a broad initial retrieval is necessary, but the specific answer is buried deep.
  • Re-ranking is Not Optional at Scale: Vector search alone is noisy. After retrieving your top K child chunks (and their corresponding parent chunks), implement a re-ranking step. This could be a small, fast cross-encoder model or a specialized re-ranking API (e.g., Cohere Rerank) that takes your query and the retrieved parent chunks and reorders them by actual relevance before passing them to the final LLM. This significantly boosts precision.
  • Benchmarks are Your Only North Star: Don’t guess. Build a robust evaluation suite for your RAG pipeline. Metrics like RAGAS can help evaluate answer relevance, faithfulness, and context recall. Quantify the improvement of parent-child vs. naive chunking. Without benchmarks, you’re flying blind, tuning parameters on intuition.
  • Data Preprocessing is Paramount: No chunking strategy, however sophisticated, can fix garbage data. Before you even think about chunking, ensure your documents are clean. Remove boilerplate (headers, footers), parse tables and figures into text, resolve OCR errors, and extract meaningful metadata. Tables, for instance, need to be converted into a structured text format (e.g., Markdown or JSON) that LLMs can easily consume.
  • Metadata is Your Best Friend: Don’t just store text. Store source, author, creation date, section name, page numbers, security tags, etc., alongside your chunks. This metadata allows for powerful metadata filtering during retrieval (e.g., “only search documents from Q4 2023 budget reports by author ‘Jane Doe’”). This dramatically narrows the search space and improves relevance.

Summary & production checklist

Getting chunking right is hard, but it directly unlocks peak RAG performance.

Production Chunking Checklist:

  • Abandon fixed-size token chunking as your default. It’s a context killer.
  • Prioritize semantic chunking or robust sentence-based chunking with intelligent overlap for creating your retrieval-optimized pieces.
  • Implement parent-child retrieval: small, dense chunks for vector search; larger, context-rich chunks for LLM generation.
  • Explore late chunking for scenarios requiring dynamic context adaptation from larger retrieved documents.
  • Benchmark your chunking strategy rigorously using appropriate RAG evaluation metrics to quantify improvements.
  • Preprocess documents meticulously before chunking: clean text, handle tables, remove boilerplate.
  • Integrate re-ranking after initial vector retrieval to improve precision and retrieve the most relevant chunks.
  • Store and utilize rich metadata with your chunks for enhanced filtering and retrieval accuracy.