跳到正文
DEV Community· aiunplugged·· 2 小時前AI 評分60

RAG 分塊:5 種策略及可執行 Python 範例

RAG chunking — 5 strategies with runnable Python

AI 導讀

這篇教程介紹 RAG 的 5 種分塊策略,並為每種策略提供可執行的 Python 範例。策略包括固定大小、遞迴、句子邊界、文件結構及語義分塊,適用於不同類型的文件。文章建議先以留出評估集比較兩三種方案,並稱帶 Markdown 分隔符的遞迴分塊可作為 2026 年約 70% 生產語料的預設方案。

正文

Originally published on aiunplugged.in — cross-posting for the Dev.to community.

Chunking is the step that most RAG tutorials gloss over — "just split your documents into 500-token chunks" — and most retrieval problems trace back to. The right chunk strategy depends on the shape of the source documents and the shape of the queries, not on a blog post's default. This article walks the five strategies that cover ~95% of production RAG pipelines in 2026, with runnable Python for each.

Prerequisite: the RAG pipeline walkthrough ships the four-step loop in one file. This article drops into step 1 of that loop.

Why chunking matters

A chunk is the unit of retrieval. When a user asks a question, the retriever returns the top-K chunks whose embeddings sit closest to the question's embedding. Three things can go wrong, all driven by chunk shape:

  1. Chunks too large — the embedding averages away specificity. The chunk gets retrieved for too many loosely-related queries, pushing truly relevant chunks out of the top-K.
  2. Chunks too small — the chunk loses context. The retrieved passage reads fine on its own but the LLM can't answer the question because the resolution of the answer sits in neighbouring passages that weren't retrieved.
  3. Chunks split across semantic boundaries — a definition is in one chunk, the example is in another, and only one gets retrieved. The LLM gets half the answer.

The five strategies below address those three failure modes in different ways.

Setup — shared across all strategies

Every strategy below uses this scaffolding. Install once:

pip install langchain-text-splitters sentence-transformers numpy

Shared helper for displaying chunks:

from dataclasses import dataclass
from typing import Callable


@dataclass
class Chunk:
    text: str
    index: int
    strategy: str
    metadata: dict


def show(chunks: list[Chunk]) -> None:
    for c in chunks:
        preview = c.text[:80].replace("\n", " ")
        print(f"[{c.strategy} #{c.index:02d}] ({len(c.text)} chars) {preview}...")

Sample input used in every example below:

SAMPLE = """
# Production RAG — chunking

Chunking is the step that determines retrieval quality more than any other.
A chunk too large averages away specificity. A chunk too small loses context.

## Fixed-size chunking

The simplest strategy. Split the document into pieces of exactly N characters
or N tokens, with M units of overlap between adjacent chunks. Works when the
corpus is uniform prose with no strong structural cues.

## Recursive splitting

Recursive splitting walks a hierarchy of separators — paragraphs, then
sentences, then words — and splits at the highest level that keeps chunks
within the size limit. Preserves structure better than fixed-size.
""".strip()

Strategy 1 — Fixed-size chunking

Split at N characters (or tokens) with M characters of overlap. No awareness of structure, sentences, or semantics. Fast, deterministic, and often the right default when the corpus is uniform.

def fixed_size_chunk(text: str, size: int = 400, overlap: int = 50) -> list[Chunk]:
    chunks = []
    start = 0
    i = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunks.append(Chunk(
            text=text[start:end],
            index=i,
            strategy="fixed",
            metadata={"start": start, "end": end},
        ))
        start = end - overlap
        i += 1
    return chunks


show(fixed_size_chunk(SAMPLE, size=200, overlap=30))

When it wins: uniform prose, logs, chat transcripts, source code where line boundaries don't carry meaning. Also the right default when the corpus is small enough that chunking strategy doesn't move retrieval quality much.

When it loses: structured documents where cutting at an arbitrary character boundary breaks a heading away from its content, or splits a code block mid-function.

Strategy 2 — Recursive character splitting

Walks a priority list of separators — paragraph breaks, then line breaks, then sentences, then words, then characters — and splits at the highest-priority separator that keeps chunks under the size limit. This is LangChain's RecursiveCharacterTextSplitter, and it's the right default for mixed structured content.

from langchain_text_splitters import RecursiveCharacterTextSplitter


def recursive_chunk(text: str, size: int = 400, overlap: int = 50) -> list[Chunk]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=size,
        chunk_overlap=overlap,
        # Priority order — try to split at paragraph break first, then line, etc.
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    raw = splitter.split_text(text)
    return [
        Chunk(text=c, index=i, strategy="recursive", metadata={})
        for i, c in enumerate(raw)
    ]


show(recursive_chunk(SAMPLE, size=200, overlap=30))

When it wins: mixed-structure prose — documentation, articles, Notion pages, Markdown with occasional lists and code blocks. The default for most team docs.

When it loses: very long paragraphs without internal breaks (the splitter falls back to character-level and loses the benefit), or when the separators-list doesn't match the document format (for example, source code where the hierarchy is class → method → statement).

Strategy 3 — Sentence-boundary chunking

Split the document into sentences first, then greedily group consecutive sentences into chunks until a size budget is hit. Chunks never cut mid-sentence, which keeps embeddings cleaner.

import re


def sentence_chunk(text: str, size: int = 400) -> list[Chunk]:
    # Split on sentence terminators. Not perfect — abbreviations like "e.g."
    # and "Dr." will trip a naive regex. For production, use spaCy or NLTK.
    sentences = re.split(r"(?<=[.!?])\s+", text.strip())

    chunks: list[Chunk] = []
    current: list[str] = []
    current_len = 0
    i = 0

    for s in sentences:
        if current_len + len(s) > size and current:
            chunks.append(Chunk(
                text=" ".join(current),
                index=i,
                strategy="sentence",
                metadata={"n_sentences": len(current)},
            ))
            i += 1
            current = [s]
            current_len = len(s)
        else:
            current.append(s)
            current_len += len(s) + 1  # +1 for the joining space

    if current:
        chunks.append(Chunk(
            text=" ".join(current),
            index=i,
            strategy="sentence",
            metadata={"n_sentences": len(current)},
        ))

    return chunks


show(sentence_chunk(SAMPLE, size=200))

When it wins: Q&A corpora, FAQ documents, interview transcripts, dialogue. Any corpus where the natural unit of meaning is a sentence or two.

When it loses: code (semicolons terminate statements but the function is the meaningful unit), heavily-abbreviated text that confuses sentence tokenisers, non-English scripts without clear sentence terminators.

Strategy 4 — Document-structure chunking

Use the document's own structural markers — markdown headings, HTML <h1>/<h2>, docstring sections — to define chunk boundaries. Each chunk is a self-contained section. Chunk sizes end up uneven, which is fine as long as no single section blows past the embedding model's context window.

def markdown_section_chunk(text: str, min_size: int = 100) -> list[Chunk]:
    # Split at any level of markdown heading. A chunk starts at a heading and
    # ends at the next heading of equal or higher level.
    lines = text.split("\n")
    chunks: list[Chunk] = []
    buffer: list[str] = []
    current_heading = ""
    i = 0

    def flush():
        nonlocal i
        if not buffer:
            return
        section = "\n".join(buffer).strip()
        if len(section) >= min_size:
            chunks.append(Chunk(
                text=section,
                index=i,
                strategy="structure",
                metadata={"heading": current_heading},
            ))
            i += 1

    for line in lines:
        if re.match(r"^#{1,6}\s", line):
            flush()
            buffer = [line]
            current_heading = line.lstrip("# ").strip()
        else:
            buffer.append(line)
    flush()

    return chunks


show(markdown_section_chunk(SAMPLE))

When it wins: technical documentation, markdown knowledge bases, structured reports, policy documents, textbook chapters. Any corpus where the writer deliberately organised ideas under headings.

When it loses: unstructured prose (novels, email, chat), documents where sections are too large to embed as single chunks, documents where section titles are generic ("Overview", "Introduction") and don't help the embedding carry context.

Strategy 5 — Semantic chunking

Embed every sentence, then look at the cosine similarity between adjacent sentence embeddings. Where the similarity drops below a threshold, that's a topic boundary — split there. Chunks end up grouped by semantic coherence rather than character count.

More expensive than the other four (one embedding call per sentence at index time), but produces the cleanest chunks when the corpus is dense and topics shift smoothly within sections.

import numpy as np
from sentence_transformers import SentenceTransformer


_model = None


def _embedder():
    global _model
    if _model is None:
        _model = SentenceTransformer("all-MiniLM-L6-v2")
    return _model


def semantic_chunk(
    text: str,
    similarity_threshold: float = 0.5,
) -> list[Chunk]:
    sentences = re.split(r"(?<=[.!?])\s+", text.strip())
    if len(sentences) < 2:
        return [Chunk(
            text=text,
            index=0,
            strategy="semantic",
            metadata={"n_sentences": len(sentences)},
        )]

    embeddings = _embedder().encode(sentences)
    # Cosine similarity between each pair of adjacent sentences
    sims = []
    for i in range(len(sentences) - 1):
        a, b = embeddings[i], embeddings[i + 1]
        sims.append(float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))))

    chunks: list[Chunk] = []
    buffer = [sentences[0]]
    i = 0
    for idx, sim in enumerate(sims):
        if sim < similarity_threshold:
            # Topic shift detected — close the current chunk
            chunks.append(Chunk(
                text=" ".join(buffer),
                index=i,
                strategy="semantic",
                metadata={"split_similarity": round(sim, 3)},
            ))
            i += 1
            buffer = [sentences[idx + 1]]
        else:
            buffer.append(sentences[idx + 1])

    if buffer:
        chunks.append(Chunk(
            text=" ".join(buffer),
            index=i,
            strategy="semantic",
            metadata={},
        ))
    return chunks


show(semantic_chunk(SAMPLE, similarity_threshold=0.5))

The similarity threshold is a tunable parameter — lower values produce fewer, larger chunks (more tolerant of topic drift); higher values produce more, smaller chunks (stricter about topic coherence). Start at 0.5 and adjust against an evaluation set.

When it wins: long essays, academic papers, dense technical writing where topics shift gradually without clear structural markers. News archives, podcast transcripts, meeting notes.

When it loses: short documents (not enough sentences to form clusters), already-structured content (document-structure chunking is cheaper and matches reality), heterogeneous corpora where no single similarity threshold works across documents.

Picking a strategy — the rough rule

Match the strategy to the dominant shape of the source:

  • Uniform prose, logs, chat transcripts → fixed-size.
  • Mixed Markdown / HTML / Notion-style docs → recursive.
  • Q&A, FAQs, dialogue, interviews → sentence-boundary.
  • Technical docs, textbooks, policy, specs with headings → document-structure.
  • Long essays, academic papers, meeting notes without structural markers → semantic.

Benchmark two or three candidates on a held-out evaluation set before committing. In practice, recursive-with-markdown-separators is a defensible default for 70% of production corpora in 2026, and the other four are specialised wins for the remaining 30%.

What this article deliberately does NOT cover

Chunking in production has a longer tail than the five strategies above. The parts not covered here, and where to find them:

  • A one-page decision table that maps corpus shape to strategy in 30 seconds.
  • Edge cases: multilingual text with mixed scripts, PDFs with tables that get destroyed by naive extractors, legal contracts with nested clause structures.
  • Chunk metadata schema — what fields to store alongside each chunk so retrieval can filter and the generator can cite.
  • Chunker versioning — rolling out a new strategy without re-embedding the entire corpus from scratch.
  • Delta reindex strategy — detecting which chunks actually changed when source documents are edited, so only those get re-embedded. Cuts embedding cost on small edits by ~99%.

Those live in Chapter 2 of The RAG Production Handbook — launching November 9, 2026. First 20 buyers at ₹699 (regular launch ₹999, rising to ₹1,499 on Dec 1).

Related reading:

來源:DEV Community · dev.to