Chunking Strategies for Better Retrieval

Ma Mahalakshmi V Updated 16 Sep 2026
9 min read ·Lesson 122 of 224

Chunking Strategies for Better Retrieval

Chunking — splitting a long document into smaller pieces before embedding each piece separately — was mentioned briefly in Lesson 3 as the correct alternative to truncating long text. This lesson treats chunking as its own subject, because the specific strategy used has a direct, often large, effect on retrieval quality. Two pipelines that use the identical embedding model and the identical similarity search code can produce very different search results purely because of how they split documents into chunks.

Why Chunking Strategy Matters This Much

A retrieval system can only return what it has as a discrete, embedded unit. If an entire 20-page document is embedded as one vector, that vector represents an average of everything in the document — a query about one specific paragraph on page 14 has to compete, in that single vector, with the meaning of the other nineteen and a half pages. The result: a highly relevant document can score lower than it should, because its single embedding is diluted by unrelated content elsewhere in the same document.

Chunking fixes this by embedding smaller, more focused pieces — but it introduces a new problem: how the split is made determines whether each chunk is a coherent, self-contained unit of meaning, or an arbitrary fragment that cuts a sentence, an idea, or a code example in half. A badly chunked document can retrieve worse than one not chunked at all, because half-sentences and orphaned fragments embed poorly and read poorly even when they are retrieved correctly.

Strategy 1: Fixed-Size Chunking

The simplest approach: split text into chunks of a fixed length, measured in characters or tokens.

def fixed_size_chunks(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    """Split text into fixed-size character chunks with overlap.

    `overlap` characters from the end of each chunk are repeated at the
    start of the next chunk.
    """
    if overlap >= chunk_size:
        raise ValueError("overlap must be smaller than chunk_size")

    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start = end - overlap
    return chunks

Why include overlap at all? Without overlap, a sentence or idea that happens to fall exactly on a chunk boundary is split between two chunks, and neither chunk alone contains the complete thought — a query about that specific idea may fail to match either half well. Overlap (here, overlap characters repeated at the start of the next chunk) means the region around a boundary appears intact in at least one of the two adjacent chunks, reducing the chance that a boundary destroys a specific piece of meaning. A typical overlap is 10-20% of the chunk size — enough to cover most sentence-boundary cases without doubling storage and embedding cost.

Why is fixed-size chunking's simplicity also its weakness? It splits purely by character count, with no awareness of sentence, paragraph, or section boundaries. A 500-character cutoff can and will land in the middle of a sentence, a code example, or a table row, producing chunks that are syntactically broken even if surrounding overlap helps semantically. It is fast, dependency-free, and works acceptably on unstructured, dense prose, but is rarely the best choice when better options are easy to implement.

Strategy 2: Sentence- and Paragraph-Aware Chunking

A better default respects natural text boundaries, only falling back to a hard cut when a single unit (a sentence, a paragraph) is itself too long.

import re


def split_into_sentences(text: str) -> list[str]:
    """A simple sentence splitter based on punctuation.

    Good enough for well-formed prose; a proper NLP sentence tokenizer
    (e.g. from spaCy or NLTK) handles more edge cases (abbreviations,
    decimal numbers) more reliably for production use on messy text.
    """
    sentences = re.split(r"(?<=[.!?])\s+", text.strip())
    return [s for s in sentences if s]


def sentence_aware_chunks(text: str, max_chunk_chars: int = 500) -> list[str]:
    """Group whole sentences into chunks up to max_chunk_chars long."""
    sentences = split_into_sentences(text)
    chunks = []
    current = ""

    for sentence in sentences:
        candidate = f"{current} {sentence}".strip()
        if len(candidate) <= max_chunk_chars:
            current = candidate
        else:
            if current:
                chunks.append(current)
            current = sentence  # a single sentence longer than the budget stands alone

    if current:
        chunks.append(current)
    return chunks

sentence_aware_chunks accumulates whole sentences into current until adding the next sentence would exceed max_chunk_chars, at which point it closes off the current chunk and starts a new one — this guarantees every chunk boundary falls between sentences, never in the middle of one. The one exception: a single sentence longer than max_chunk_chars is kept intact rather than cut mid-sentence, accepting an oversized chunk in the rare case rather than breaking a sentence, which would defeat the purpose of this approach entirely.

Why is this generally better than fixed-size chunking for prose? Every chunk is a coherent set of complete sentences, which both embeds more meaningfully (the model receives intact ideas, not fragments) and reads better when shown directly to a user or included in a generation prompt. The trade-off is implementation complexity and a dependency on reasonably reliable sentence boundaries — text with unusual formatting (bullet lists, code blocks, tables) does not split cleanly by sentence punctuation, which motivates the next strategy.

Strategy 3: Structure-Aware (Recursive) Chunking

Real documents have structure beyond plain sentences: headings, paragraphs, list items, code blocks. Structure-aware chunking splits along the largest natural boundary first (sections, then paragraphs, then sentences), only descending to a smaller unit when a larger one is still too big.

def recursive_chunks(text: str, max_chunk_chars: int = 500,
                      separators: list[str] | None = None) -> list[str]:
    """Split text by progressively finer separators, only descending
    to a finer separator when a piece is still too large.
    """
    separators = separators or ["\n\n", "\n", ". ", " "]

    def split_piece(piece: str, remaining_separators: list[str]) -> list[str]:
        if len(piece) <= max_chunk_chars:
            return [piece] if piece.strip() else []
        if not remaining_separators:
            # No separator left small enough to help; hard-cut as a last resort.
            return [piece[i:i + max_chunk_chars] for i in range(0, len(piece), max_chunk_chars)]

        sep, rest = remaining_separators[0], remaining_separators[1:]
        parts = piece.split(sep)
        result = []
        buffer = ""
        for part in parts:
            candidate = (buffer + sep + part) if buffer else part
            if len(candidate) <= max_chunk_chars:
                buffer = candidate
            else:
                if buffer:
                    result.extend(split_piece(buffer, rest))
                buffer = part
        if buffer:
            result.extend(split_piece(buffer, rest))
        return result

    return split_piece(text.strip(), separators)

How this works, step by step: the function tries the coarsest separator first ("\n\n", a paragraph break). It groups consecutive paragraphs into a buffer as long as the buffer stays within max_chunk_chars. When a buffer would grow too large, it is finalized — but only after being recursively re-split using the next separator in the list ("\n", then ". ", then " ") if it is still too big on its own. This means a single oversized paragraph gets broken down into lines, and a single oversized line gets broken into sentences, and so on — the split only gets finer where it actually needs to, preserving larger natural boundaries (like whole paragraphs) wherever they already fit the size budget. The final fallback — a hard character cut — only triggers if a piece has no more separators left to try and is still too large (an unbroken run of text longer than the entire budget), which is rare in normal prose.

This is conceptually the same idea used by widely adopted recursive text splitters in RAG frameworks, implemented here from first principles so the mechanism is fully visible rather than hidden behind a library call.

Choosing Chunk Size: The Core Trade-off

Chunk sizeRetrieval precisionContext completenessCost per document
Small (e.g., 100-200 tokens)Higher — a match is very specific to the queryLower — a chunk may lack surrounding context needed to make sense on its ownHigher — more chunks, more embedding calls, more stored vectors
Large (e.g., 800-1000+ tokens)Lower — a chunk's meaning is averaged over more content, diluting specific matchesHigher — a chunk carries more surrounding contextLower — fewer chunks per document

Why is there no single correct chunk size? The right size depends on the content and the query pattern. Short, focused FAQ entries or single-fact snippets retrieve well as small chunks because each one already represents one complete idea. Long-form technical documentation, where understanding one paragraph often depends on the paragraph before it, benefits from somewhat larger chunks (or from including a small amount of surrounding context alongside each chunk, sometimes called "parent document" retrieval) so a returned chunk is not missing information needed to make sense of it. This is precisely why Lesson 9's evaluation methodology matters — chunk size and strategy should be selected by measuring retrieval quality on real, representative queries, not by assumption.

Preserving Chunk-to-Document Relationships

Every chunk needs to retain a link back to its source document and its position, both for display and for deduplication logic.

from dataclasses import dataclass


@dataclass
class Chunk:
    parent_doc_id: str
    chunk_index: int
    text: str


def chunk_document(doc_id: str, text: str, max_chunk_chars: int = 500) -> list[Chunk]:
    pieces = recursive_chunks(text, max_chunk_chars=max_chunk_chars)
    return [Chunk(parent_doc_id=doc_id, chunk_index=i, text=piece) for i, piece in enumerate(pieces)]


def test_chunk_document_preserves_order_and_parent():
    text = "First paragraph here.\n\nSecond paragraph here.\n\nThird paragraph here."
    chunks = chunk_document("doc-1", text, max_chunk_chars=40)
    assert all(c.parent_doc_id == "doc-1" for c in chunks)
    assert [c.chunk_index for c in chunks] == list(range(len(chunks)))
    print(f"PASS: chunk_document produced {len(chunks)} ordered chunks for doc-1")


test_chunk_document_preserves_order_and_parent()

Storing parent_doc_id and chunk_index on every chunk (as columns in the documents table from Lesson 6, or a related table) makes it possible to show a user which original document a result came from, to fetch neighboring chunks for additional context when a result is used in a RAG prompt, and to delete or re-index all chunks belonging to one source document without hunting for them by content.

Common Mistakes

  • Splitting purely by character or token count with no regard for structure. As shown, this reliably cuts sentences and ideas in half, producing chunks that embed and read poorly.
  • Using no overlap at all between adjacent chunks. This maximizes the chance that content straddling a boundary is not fully represented in any single chunk.
  • Choosing one chunk size and never revisiting it. Chunk size is a tunable parameter with measurable effects on retrieval quality (Lesson 9); treating it as a fixed default rather than something to evaluate against real data leaves easy quality gains unclaimed.

Best Practices

  • Default to structure-aware, recursive splitting over naive fixed-size splitting for anything beyond a quick prototype — it produces chunks that are both semantically coherent and cheap to implement, as shown above.
  • Always store the parent document ID and chunk position alongside each chunk's text and embedding, so results can be traced back, displayed in context, and cleanly re-indexed.
  • Tune chunk size and overlap using measured retrieval quality on representative queries, not intuition — the next lesson provides the methodology to do this rigorously.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Chunking Strategies for Better Retrieval and get answers drawn from it.

Signed-in readers only.