Skip to content

The Chunking Problem

You have a corpus of documents. You generate embeddings for every document and store them in pgvector. You query, and the results are wrong -- the model gets context that is too broad to be useful or too narrow to answer the question. The root cause is not your embedding model. It is the decision you made before embedding: what counts as a "document" in your vector store.

Owl mascot

What you'll learn

  • Embedding an entire document produces a vector that averages all its topics together, making it match everything poorly and nothing well
  • Chunks that are too small lose the context needed to answer the question; chunks that are too large dilute the signal with unrelated content
  • The right chunk size depends on your embedding model's context window, your documents' structure, and the kinds of questions users ask

The problem

When you embed a full document -- say, a 20-page whitepaper -- the resulting vector is an average of every paragraph, every table, every footnote. A user searches for "latency numbers for the RPC layer" and the closest match is... the whole whitepaper. The model gets 20 pages of context, 19 of which are irrelevant. It wastes tokens, increases latency, and confuses the model with competing signals.

On the other end: if you split every sentence into its own chunk, a sentence like "It reduced latency by 40%" has no referent. Forty percent of what? The chunk has no context because you severed it from the paragraph that named the thing being measured.

This is the chunking problem. It is the single most important design decision in a RAG system, and it is not one you can tune after the fact without re-embedding your entire corpus.

Options & when to use each

StrategyWhat it is good forWhat it costs youWhen to pick it
Fixed-size chunkingPredictable, fast, easy to reason aboutSplits sentences mid-word; loses semantic boundariesDocuments with no natural structure (transcripts, logs) or when you need consistency above all else
Semantic chunkingPreserves sentence and paragraph boundaries; chunks read like coherent unitsRequires a sentence splitter or an extra embedding pass; slowerNarrative text: articles, books, documentation, anything with paragraphs
Recursive chunkingCombines small retrieval units with larger context units; handles long documents wellMore complex pipeline; stores parent-child metadata; extra storageLong documents where you need both precision (small chunk) and context (large chunk)
Agentic chunkingAn LLM decides where to split based on contentExpensive, slow, non-deterministicOnly when automatic strategies fail and you need human-quality splits on a small corpus

Build it

Before you reach for a splitting strategy, measure the problem. The following script loads a document, embeds it whole versus chunked, and compares retrieval quality on a known set of questions.

python
import psycopg2
from pgvector.psycopg2 import register_vector
import openai

# Assume embeddings table from Day 1
conn = psycopg2.connect("postgresql://user:pass@localhost/rag_demo")
register_vector(conn)
cur = conn.cursor()

def embed(text: str) -> list[float]:
    return openai.embeddings.create(
        model="text-embedding-3-small", input=text
    ).data[0].embedding

def compare_whole_vs_chunked(document: str, chunks: list[str], test_questions: list[str]):
    """Embed the whole document and each chunk, then compare retrieval quality."""

    # Whole-document approach
    whole_embedding = embed(document)
    cur.execute("INSERT INTO documents (content, embedding) VALUES (%s, %s) RETURNING id",
                (document, whole_embedding))
    whole_id = cur.fetchone()[0]

    # Chunked approach
    chunk_ids = []
    for chunk in chunks:
        chunk_embedding = embed(chunk)
        cur.execute("INSERT INTO documents (content, embedding) VALUES (%s, %s) RETURNING id",
                    (chunk, chunk_embedding))
        chunk_ids.append(cur.fetchone()[0])

    conn.commit()

    for question in test_questions:
        q_embedding = embed(question)

        # Whole-document retrieval
        cur.execute("""
            SELECT content, 1 - (embedding <=> %s::vector) AS similarity
            FROM documents WHERE id = %s
        """, (q_embedding, whole_id))
        whole_result = cur.fetchone()
        print(f"Q: {question}")
        print(f"  Whole doc similarity: {whole_result[1]:.4f}")
        print(f"  Whole doc content starts: {whole_result[0][:120]}...")

        # Chunked retrieval
        cur.execute("""
            SELECT content, 1 - (embedding <=> %s::vector) AS similarity
            FROM documents WHERE id = ANY(%s)
            ORDER BY embedding <=> %s::vector LIMIT 1
        """, (q_embedding, chunk_ids, q_embedding))
        chunk_result = cur.fetchone()
        print(f"  Best chunk similarity: {chunk_result[1]:.4f}")
        print(f"  Best chunk content: {chunk_result[0][:120]}...")
        print()

    # Cleanup
    cur.execute("DELETE FROM documents WHERE id = %s OR id = ANY(%s)",
                (whole_id, chunk_ids))
    conn.commit()

# Example: a paragraph about latency buried in a long document
doc = (
    "Introduction: This document describes the architecture of the new payment system. "
    "Chapter 1 covers the data model, Chapter 2 covers API design, "
    "Chapter 3 covers performance benchmarks. Our RPC layer handles 50,000 requests per second "
    "at p99 latency of 12ms, down from 85ms in the previous version. This was achieved by switching "
    "from JSON serialization to protobuf and adding connection pooling. "
    "Chapter 4 covers deployment and Chapter 5 covers monitoring."
)

chunks = [
    "Introduction: This document describes the architecture of the new payment system.",
    "Chapter 1 covers the data model, Chapter 2 covers API design.",
    "Chapter 3 covers performance benchmarks. Our RPC layer handles 50,000 requests per second "
    "at p99 latency of 12ms, down from 85ms in the previous version. This was achieved by switching "
    "from JSON serialization to protobuf and adding connection pooling.",
    "Chapter 4 covers deployment and Chapter 5 covers monitoring.",
]

compare_whole_vs_chunked(doc, chunks, [
    "What is the p99 latency of the RPC layer?",
    "How was the latency improvement achieved?",
])

Run it. The whole-document embedding returns moderate similarity for every question -- it matches everything a little. The chunked embedding returns high similarity for the one chunk that actually contains the answer and low similarity for the rest. The difference is the entire case for chunking.

What goes wrong

MistakeHow you notice itThe fix
Chunk size is your embedding model's max token limitChunks fit but retrieval quality is poor because each chunk covers too many topicsAim for 256-512 tokens per chunk. Smaller than the model's max gives each chunk room to be about one thing
Chunks are all the same size regardless of contentSome chunks end mid-sentence, some are single wordsUse a strategy that respects sentence boundaries, or at minimum use overlap so partial sentences reconnect
No overlap between chunksA query that matches a concept split across two chunks gets neitherAdd 10-20% overlap between adjacent chunks. The exact number depends on your content; start at 10%
All documents use the same chunk sizeLegal contracts and Slack messages get the same treatmentTune chunk size per document type. Short messages get small chunks; long articles get medium chunks

Confirm it worked

Here is a concrete example demonstrating the implementation details.

python
# After running the comparison above, verify:
# 1. The chunk containing the latency number has significantly higher similarity
#    for the latency question than the whole document does.
# 2. The chunk similarity for the latency question is above 0.7 (cosine similarity
#    with text-embedding-3-small).
# 3. The whole-document similarity is roughly the same for all three questions,
#    confirming it averages all content.

cur.execute("""
    SELECT question, embedding <=> %s::vector AS distance
    FROM queries ORDER BY distance LIMIT 5
""", (embed("RPC latency p99 12ms"),))
for row in cur.fetchall():
    print(f"  {row[0]} -> distance: {row[1]:.4f}")
# The correct chunk should be the closest match by a visible margin.

Next: Fixed-Size Chunking