[控场AI]
· 15 分钟阅读· 7,708 字

RAG分块策略实战:5种可运行Python方案

RAG分块策略实战:5种可运行Python方案

梳理5种生产级RAG分块策略及选型规则,附可运行Python代码。

文章指出分块是RAG教程最常被忽略却直接决定检索质量的一步,块过大、过小、跨语义边界切分是三种主要失效模式。作者给出覆盖约95%生产流水线的5种策略:固定大小、递归字符、句子边界、文档结构与语义分块,每种都配有可直接运行的Python代码、适用与不适用场景说明。核心结论是分块策略应取决于源文档结构与查询形态,而非某个默认token数;带Markdown分隔符的递归分块可作为多数生产语料的默认选择,其余四种更适合特定场景,最终决定前应在留出评估集上对两三种候选方案做基准对比。

分块(Chunking)是RAG教程中最常被一笔带过的步骤——多数教程只丢下一句“把文档切成500个token的块”,而检索效果差往往就出在这里。正确的分块策略取决于源文档的结构和查询形态,而非某篇博客的默认数字。本文梳理了覆盖约95%生产级RAG流水线的5种分块策略,每种都配有可直接运行的Python代码。

前置阅读:如果你还没搭好完整流水线,可以先看“从零构建RAG流水线”,本文对应其中第1步。

RAG chunking — 5 strategies with runnable Python

为什么分块如此关键

块是检索的基本单位。用户提问后,检索器返回与问题embedding最接近的top-K块。有三种问题都由块的形态直接导致:

  • 块太大:embedding把具体性平均掉了。块会被太多弱相关的查询召回,把真正相关的块挤出top-K。
  • 块太小:块丢失上下文。检索到的段落单独读没问题,但LLM无法回答,因为答案的落脚点落在没被召回的其他块里。
  • 块跨语义边界切分:定义在一个块里,例子在另一个块里,只召回其中一个,LLM只拿到半个答案。

下面5种策略以不同方式应对这三种失效模式。

Embedding(嵌入) 是把一段文本映射成一个高维向量的过程——语义相近的文本,其向量在空间中的方向也相近。检索时,用户问题同样被编码为向量,系统通过余弦相似度等度量找出与问题向量最接近的top-K个块。这解释了一个反直觉的现象:块越大,向量覆盖的主题越多,其方向就越“居中”,与任何具体问题的相似度都不够突出;块越小,向量越聚焦,但承载的语义信息也越少。因此分块本质上是在“检索精度”和“上下文完整性”之间做权衡,这个权衡没有普适最优解,取决于你的查询是偏细节型还是偏概括型。

Top-K召回 指检索阶段返回相似度最高的前K个块,K通常取3到10。它的关键约束是:K一旦固定,块的数量越多,单个块被选中的概率就越低;块越大,挤占名额的风险越高。这正是文中“块太大把真正相关的块挤出top-K”的机制来源。

通用准备工作

所有策略共用同一套脚手架。先安装依赖:

pip install langchain-text-splitters sentence-transformers numpy

定义一个用于展示分块结果的辅助数据类:

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}...")

策略1:固定大小分块

按N个字符(或token)切分,相邻块之间保留M个字符重叠。不感知结构、句子或语义。速度快、可复现,语料均匀时往往是合理默认。

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

适用场景:均匀散文、日志、聊天记录、行边界不承载语义的源码。

不适用场景:结构化文档中任意切断会把标题和内容分离,或把代码块从函数中间劈开。

策略2:递归字符分块

按优先级列表遍历分隔符——段落、换行、句子、词、字符——在保证块不超限的前提下,用最高优先级的分隔符切分。这是LangChain的RecursiveCharacterTextSplitter,也是混合结构内容的合理默认。

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,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    raw = splitter.split_text(text)
    return [
        Chunk(text=c, index=i, strategy="recursive", metadata={})
        for i, c in enumerate(raw)
    ]

适用场景:结构化程度不一的散文——文档、文章、Notion页面、带列表和代码块的Markdown。通常也是团队文档的默认选择。

不适用场景:内部没有断行的超长段落(会退化为字符级切分);分隔符列表与文档格式不匹配(如源码的层级是class → method → statement)。

策略3:句子边界分块

先把文档切成句子,再贪心地把连续句子组成块直到触及大小预算。块永远不会在句子中间切断,embedding更干净。

import re

def sentence_chunk(text: str, size: int = 400) -> list[Chunk]:
    # 简化的句子切分。生产中建议用spaCy或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

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

适用场景:问答语料、FAQ文档、访谈转录、对话。任何以一两句话为自然语义单位的语料。

不适用场景:代码(分号终止语句,但函数才是意义单位);大量缩写会干扰句子分词器的文本;没有明确句末标点的非英文语种。

策略4:文档结构分块

用文档自身的结构标记——Markdown标题、HTML的<h1>/<h2>、docstring小节——来定义块边界。每个块是一个自包含的小节。块大小会不均匀,只要单节不超过embedding模型的上下文窗口就没问题。

def markdown_section_chunk(text: str, min_size: int = 100) -> list[Chunk]:
    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

适用场景:技术文档、Markdown知识库、结构化报告、政策文件、教材章节。作者刻意用标题组织思想的语料。

不适用场景:无结构散文(小说、邮件、聊天);单节太大无法作为单块embedding的文档;章节标题过于泛化(“概述”“简介”)无法帮embedding携带上下文时。

策略5:语义分块

对每个句子做embedding,然后观察相邻句子embedding之间的余弦相似度。相似度低于阈值处即为主题边界,在该处切分。块按语义连贯性分组,而不是按字符数。

代价比其他四种都高(建索引时每句一次embedding调用),但当语料密集、主题在章节内平滑漂移时,能产出最干净的块。

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)
    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:
            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

相似度阈值是可调参数:值越低,块越大越少(更容忍主题漂移);值越高,块越小越多(对主题连贯要求更严)。可以从0.5起步,用评估集微调。

适用场景:长论文、学术文章、主题渐进漂移但无明确结构标记的密集技术写作。新闻存档、播客转录、会议记录。

不适用场景:短文档(句子太少无法聚类);已有良好结构的内容(文档结构分块更便宜且更贴合现实);异构语料中没有任何单一阈值能跨文档通用。

余弦相似度 衡量两个向量方向的接近程度,取值在-1到1之间,与向量长度无关,因此适合比较不同长度文本的embedding。计算方式是两向量点积除以各自模长的乘积。本文semantic_chunk函数中的np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))正是这一公式的直接实现,对已归一化的sentence-transformers输出而言,点积本身已近似等于余弦相似度。

all-MiniLM-L6-v2 是sentence-transformers库中一个约22MB的轻量级模型,输出384维向量,在速度和语义质量之间取得了不错的平衡,常用于本地原型。它的上下文窗口有限(约256个token),因此语义分块通常按“句子”而非“段落”逐一编码。需要注意,该模型是为英文语料训练的,多语言场景应换用paraphrase-multilingual-MiniLM等模型。

如何选策略:粗略规则

把策略匹配到源文档的主要形态:

  • 均匀散文、日志、聊天记录 → 固定大小
  • 混合Markdown / HTML / Notion风格文档 → 递归
  • 问答、FAQ、对话、访谈 → 句子边界
  • 带标题的技术文档、教材、政策、规范 → 文档结构
  • 无结构标记的长论文、学术文章、会议记录 → 语义

在做最终决定前,拿两三种候选方案在留出的评估集上做基准对比。实践中,带Markdown分隔符的递归分块可以作为多数生产语料的默认选择,其余四种更适合特定场景。

本文未覆盖的内容

生产环境中的分块长尾远不止这五种策略。未涉及的部分包括:从语料形态到策略的30秒决策表;多语言混合脚本、表格被粗暴提取器破坏的PDF、嵌套条款结构的法律合同等边缘情况;块元数据schema;分块器版本管理;以及增量重建索引策略——检测源文档编辑后真正变化的块。

相关阅读:从零构建RAG流水线、向量数据库对比(pgvector、Qdrant、Pinecone、Chroma)、何时不该用RAG,以及RAG生产环境FAQ。

#RAG分块策略#chunking策略#RAG chunking#文本分块Python#RecursiveCharacterTextSplitter#语义分块#RAG检索优化#LangChain分块
分享:

相关推荐