Skip to content

Semantic Chunking

Fixed-size chunking is fast and predictable, but it does not read your documents. It will slice through the middle of a thought as cheerfully as it slices through whitespace. Semantic chunking respects what the text actually says: it splits on sentence and paragraph boundaries, and when you raise the sophistication bar, it uses embeddings to find where a document's topic shifts and cuts there.

Owl mascot

What you'll learn

  • Semantic chunking splits text at natural linguistic boundaries: sentences, paragraphs, and topic shifts
  • Sentence-boundary splitting is the simplest semantic strategy; it requires a sentence tokenizer but produces readable, self-contained chunks
  • Embedding-based semantic chunking compares adjacent sentences and cuts where the cosine similarity drops, detecting topic changes without an LLM

The problem

Fixed-size chunking does not know what a sentence is. It splits text at character N, even if N lands in the middle of a word. The result is chunks that read like "p99 latency of 12ms, down f" -- broken mid-thought, useless for retrieval, and confusing for the model that receives them.

Semantic chunking solves this by respecting the document's own structure. At minimum, it splits on sentence boundaries so every chunk contains complete thoughts. At the higher end, it uses the same embedding model you use for retrieval to detect topic shifts -- cutting where the semantic content changes, not where the character counter says to.

Options & when to use each

StrategyWhat it is good forWhat it costs youWhen to pick it
Sentence-boundary splittingEvery chunk is a readable, complete thought; zero broken sentencesRequires a sentence tokenizer; chunks can vary in lengthNarrative text with clear sentence structure: articles, documentation, books
Paragraph-boundary splittingChunks map to the author's intended grouping of ideasParagraphs can be very long or very short; uneven chunk sizesWell-structured documents with consistent paragraph lengths
Embedding-based splittingDetects topic changes the author did not mark with whitespace; cuts between ideas, not between sentencesExtra embedding calls per document; slower ingestion pipelineLong, unstructured documents where you want the model's semantic understanding to drive splitting
Hybrid: sentence + size capReadable chunks that also respect a maximum token limitAdds complexity; still breaks sentences when the cap is hitProduction systems that need semantic quality with hard model limits

Build it

Sentence-boundary splitting with spaCy

Here is a concrete example demonstrating the implementation details.

python
import spacy

nlp = spacy.load("en_core_web_sm")

def split_by_sentences(text: str, max_sentences: int = 5) -> list[str]:
    """Split text into chunks of up to N sentences each."""
    doc = nlp(text)
    sentences = [sent.text.strip() for sent in doc.sents]

    chunks = []
    for i in range(0, len(sentences), max_sentences):
        chunk = " ".join(sentences[i : i + max_sentences])
        chunks.append(chunk)

    return chunks

text = (
    "RAG systems need chunking. The simplest approach splits by character count. "
    "But that breaks sentences. Semantic chunking avoids this. "
    "It uses sentence boundaries instead. The result is readable chunks. "
    "Models understand them better. Retrieval quality improves."
)

chunks = split_by_sentences(text, max_sentences=3)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i}: {chunk}")

Token-aware sentence splitting

Here is a concrete example demonstrating the implementation details.

python
import tiktoken

def split_sentences_with_token_cap(
    text: str, max_tokens: int = 256, overlap_sentences: int = 1
) -> list[str]:
    """Split at sentence boundaries, respecting a token cap per chunk."""
    enc = tiktoken.get_encoding("cl100k_base")
    doc = nlp(text)
    sentences = [sent.text.strip() for sent in doc.sents]

    chunks = []
    current_chunk: list[str] = []
    current_tokens = 0

    for sentence in sentences:
        sentence_tokens = len(enc.encode(sentence))

        if current_tokens + sentence_tokens > max_tokens and current_chunk:
            # Emit the current chunk
            chunks.append(" ".join(current_chunk))
            # Start new chunk with overlap: keep the last N sentences
            current_chunk = current_chunk[-overlap_sentences:] if overlap_sentences else []
            current_tokens = sum(len(enc.encode(s)) for s in current_chunk)

        current_chunk.append(sentence)
        current_tokens += sentence_tokens

    if current_chunk:
        chunks.append(" ".join(current_chunk))

    return chunks

Embedding-based semantic chunking

This is the most sophisticated automatic strategy: embed every sentence, compute cosine similarity between adjacent sentences, and split where the similarity drops below a threshold. The intuition: when an author changes topic, the embedding vectors of consecutive sentences will be further apart.

python
import numpy as np

def split_by_semantic_boundaries(
    text: str, threshold: float = 0.5, min_chunk_sentences: int = 2
) -> list[str]:
    """Split text where the semantic similarity between adjacent sentences drops."""
    doc = nlp(text)
    sentences = [sent.text.strip() for sent in doc.sents]

    if len(sentences) <= min_chunk_sentences:
        return [" ".join(sentences)]

    # Embed every sentence
    embeddings = []
    for sent in sentences:
        emb = openai.embeddings.create(
            model="text-embedding-3-small", input=sent
        ).data[0].embedding
        embeddings.append(np.array(emb))

    # Find split points where similarity drops
    split_indices = [0]
    for i in range(1, len(sentences)):
        similarity = np.dot(embeddings[i - 1], embeddings[i]) / (
            np.linalg.norm(embeddings[i - 1]) * np.linalg.norm(embeddings[i])
        )
        if similarity < threshold:
            split_indices.append(i)

    split_indices.append(len(sentences))

    # Build chunks, enforcing minimum size
    chunks = []
    i = 0
    while i < len(split_indices) - 1:
        start = split_indices[i]
        end = split_indices[i + 1]
        # If the chunk is too small, merge it with the next one
        if end - start < min_chunk_sentences and i + 2 < len(split_indices):
            end = split_indices[i + 2]
            i += 1
        chunks.append(" ".join(sentences[start:end]))
        i += 1

    return chunks

Putting it together in pgvector

Here is a concrete example demonstrating the implementation details.

python
def ingest_semantic(conn, document_id: str, text: str, strategy: str = "sentence"):
    """Ingest a document using semantic chunking and store in pgvector."""

    if strategy == "sentence":
        chunks = split_sentences_with_token_cap(text, max_tokens=256)
    elif strategy == "embedding":
        chunks = split_by_semantic_boundaries(text, threshold=0.5)
    else:
        raise ValueError(f"Unknown strategy: {strategy}")

    cur = conn.cursor()
    for i, chunk in enumerate(chunks):
        embedding = openai.embeddings.create(
            model="text-embedding-3-small", input=chunk
        ).data[0].embedding

        cur.execute("""
            INSERT INTO chunks (document_id, chunk_index, content, embedding,
                                chunking_strategy, token_count)
            VALUES (%s, %s, %s, %s, %s, %s)
        """, (
            document_id, i, chunk, embedding, strategy,
            len(tiktoken.get_encoding("cl100k_base").encode(chunk))
        ))

    conn.commit()
    print(f"Ingested {len(chunks)} chunks using {strategy} strategy")

What goes wrong

MistakeHow you notice itThe fix
Threshold is too high for embedding-based splittingEvery sentence becomes its own chunk; too many tiny embeddingsLower the threshold. Start at 0.3 and raise it until chunks average 3-5 sentences
Threshold is too lowThe entire document becomes one chunk; no splitting happensRaise the threshold. If similarity between adjacent sentences is always above 0.8, your threshold is too low
Sentence tokenizer fails on code or listsChunks contain fragments of bullet points or code blocks that read like gibberishPre-process: extract code blocks and lists, chunk them separately with fixed-size, and treat narrative text with semantic chunking
spaCy sentence segmentation is slow on long documentsIngestion takes minutes per document; pipeline is bottleneckedBatch sentences for embedding: embed all sentences in one API call, then split locally. Or use a faster sentence segmenter like nltk.sent_tokenize
Embedding-based chunking is too expensiveYour OpenAI bill spikes because you are embedding every sentence twice -- once for splitting and once for storageCache sentence embeddings during splitting and reuse them for chunk embeddings. Combine adjacent sentence embeddings to approximate chunk embeddings

Confirm it worked

Here is a concrete example demonstrating the implementation details.

python
# Verify semantic chunking produces better retrieval than fixed-size
test_doc = (
    "Chapter 1: Introduction to databases. A database is an organized collection of data. "
    "Relational databases use tables with rows and columns. SQL is the standard query language. "
    "Chapter 2: Vector databases. Vector databases store embeddings. "
    "They use approximate nearest neighbor search. pgvector adds vector support to PostgreSQL. "
    "Chapter 3: Hybrid search combines vector and keyword retrieval."
)

# Semantic chunks should separate the three chapters
semantic_chunks = split_by_semantic_boundaries(test_doc, threshold=0.4)
print(f"Semantic chunks: {len(semantic_chunks)}")
for i, chunk in enumerate(semantic_chunks):
    print(f"  Chunk {i}: {chunk[:80]}...")

# Each chunk should be about one topic (database types, vector DBs, hybrid search)
# and should contain at least 2 sentences.
for chunk in semantic_chunks:
    sentences_in_chunk = len(list(nlp(chunk).sents))
    assert sentences_in_chunk >= 2, f"Chunk has only {sentences_in_chunk} sentence(s)"

# Verify retrieval: a query about vector databases should match chunk 1 most closely
embedding = openai.embeddings.create(
    model="text-embedding-3-small", input="What is a vector database?"
).data[0].embedding

similarities = []
for i, chunk in enumerate(semantic_chunks):
    chunk_emb = openai.embeddings.create(
        model="text-embedding-3-small", input=chunk
    ).data[0].embedding
    sim = np.dot(embedding, chunk_emb) / (
        np.linalg.norm(embedding) * np.linalg.norm(chunk_emb)
    )
    similarities.append((i, sim))
    print(f"  Chunk {i} similarity: {sim:.3f}")

best = max(similarities, key=lambda x: x[1])
print(f"Best match: chunk {best[0]} (similarity {best[1]:.3f})")
assert best[0] == 1, "Vector database query should match the vector DB chunk"

Next: Recursive Chunking