AI 专题 OCT 08, 2025

RAG 文档切片策略:固定长度 vs 递归 vs 语义切分

#RAG#Python#文档切片

RAG 文档切片策略:固定长度 vs 递归 vs 语义切分

本文是【AI 专题精讲】系列第 02 篇。 上一篇:RAG 文件解析:PDF / Word / Excel / HTML 全格式文本提取 | 下一篇:RAG 大文件上传:分片上传、断点续传、进度追踪


这篇文章你会得到什么

上一篇我们把各种格式的文件解析成了纯文本。但你不能直接把一整篇文档丢给 AI——10 万字的技术文档,光 token 就超了,而且检索时一整篇文档的相关性评分根本没法用。

所以 RAG 的第二步是切片(Chunking):把长文本切成合适大小的段落,每段独立做 Embedding 和存储。

听起来简单对吧?text.slice(0, 1000) 不就行了?

不行。切得太粗暴,一句话被拦腰截断,AI 只看到半句话,回答质量直接崩掉。

今天的目标:搞懂三种主流切片策略(固定长度、递归、语义),知道它们各自的优劣,能根据不同场景选择最合适的方案。附带一个质量评估脚本,让你能量化地比较不同切片策略的效果。


为什么切片这么重要

先看一个直观的例子。假设你的知识库里有一段关于请假制度的文档:

第三条 年假制度
员工入职满一年后,享有 5 天带薪年假。入职满三年后,年假增至 10 天。
年假需提前 3 个工作日提交申请,经直属上级审批后生效。
未使用的年假不可跨年累积,但可在当年 12 月折算为加班工资。

第四条 病假制度
员工因病需请假时,须提供医院开具的诊断证明。
病假期间工资按基本工资的 80% 发放,连续病假超过 30 天的,按当地最低工资标准发放。

用户问:“年假可以跨年吗?

切得好:检索到的片段包含”未使用的年假不可跨年累积,但可在当年 12 月折算为加班工资”——AI 给出准确回答。

切得差:如果切片正好在”不可跨年”这句话中间断开,检索到的片段只有前半段或后半段,AI 回答就可能出错或说”信息不足”。

切片质量对 RAG 效果的影响:

切片质量检索命中率AI 回答质量用户体验
好(语义完整)准确、有据可查”AI 真聪明”
一般(偶有截断)大致正确,偶尔缺信息”还行吧”
差(频繁截断)答非所问或信息不全”这 AI 什么也不懂”

切片的三个核心参数

不管用哪种策略,切片都绑不开三个参数:

chunk_size(切片大小)

每个切片的最大长度。通常用字符数或 token 数衡量。

chunk_size特点适合场景
200-500 字符精细,检索精度高,但上下文少FAQ、条款、定义
500-1000 字符平衡,最常用通用文档
1000-2000 字符粗粒度,上下文丰富,但检索精度下降长篇叙述、技术文章

我的经验:通用场景先从 800 字符开始,根据效果再调。

chunk_overlap(重叠区域)

相邻切片之间重叠的字符数。用来避免关键信息被切断。

chunk 1: [...........|overlap|]
chunk 2:             [overlap|...........]

一般设为 chunk_size 的 10%-20%。比如 chunk_size=800,overlap 设 100-160。

overlap 太小:边界信息丢失。 overlap 太大:存储膨胀、检索到大量重复内容。

separators(分隔符)

按什么字符来分割文本。常见的分隔符优先级:

"\n\n"  →  段落分隔(最优先)
"\n"    →  换行
"。"    →  句号(中文)
"."     →  句号(英文)
" "     →  空格
""      →  逐字符(兜底)

策略一:固定长度切片

最简单的方式——每 N 个字符切一刀。

原理

文本总长 3000 字符,chunk_size=1000,overlap=200

chunk 1: 字符 0-999
chunk 2: 字符 800-1799
chunk 3: 字符 1600-2599
chunk 4: 字符 2400-2999

Python 实现

def split_by_fixed_length(
    text: str,
    chunk_size: int = 800,
    chunk_overlap: int = 100,
) -> list[str]:
    chunks = []
    start = 0

    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk.strip())
        start = end - chunk_overlap

    return [c for c in chunks if c]

用 LangChain 实现

from langchain_text_splitters import CharacterTextSplitter

splitter = CharacterTextSplitter(
    separator="",
    chunk_size=800,
    chunk_overlap=100,
    length_function=len,
)

chunks = splitter.split_text(long_text)

优缺点

优点缺点
实现极其简单完全无视语义边界
切片大小高度可控经常在句子中间截断
速度最快切片质量最差

适用场景:对切片质量要求不高的场景,或者作为其他策略的兜底。


策略二:递归切片(推荐默认方案)

递归切片是目前最主流的方案,LangChain 的默认实现就是它。

原理

核心思路:按分隔符优先级层层递归

  1. 先尝试用 \n\n(段落)分割
  2. 如果某段还是太长,再用 \n(换行)分割
  3. 还是太长,用 / .(句号)分割
  4. 最后兜底用空格或逐字符

这样切出来的片段尽可能保持在自然段落或完整句子的边界上。

Python 实现

def split_recursive(
    text: str,
    chunk_size: int = 800,
    chunk_overlap: int = 100,
    separators: list[str] = None,
) -> list[str]:
    if separators is None:
        separators = ["\n\n", "\n", "。", ".", "!", "?", " ", ""]

    chunks = []
    _split_recursive(text, chunk_size, chunk_overlap, separators, 0, chunks)
    return chunks


def _split_recursive(
    text: str,
    chunk_size: int,
    chunk_overlap: int,
    separators: list[str],
    sep_idx: int,
    result: list[str],
):
    if not text.strip():
        return

    # 文本已经足够短,直接作为一个 chunk
    if len(text) <= chunk_size:
        result.append(text.strip())
        return

    # 没有更多分隔符了,强制按长度切
    if sep_idx >= len(separators):
        for i in range(0, len(text), chunk_size - chunk_overlap):
            chunk = text[i:i + chunk_size]
            if chunk.strip():
                result.append(chunk.strip())
        return

    sep = separators[sep_idx]

    if sep == "":
        # 空字符串分隔符 = 逐字符,走固定长度
        for i in range(0, len(text), chunk_size - chunk_overlap):
            chunk = text[i:i + chunk_size]
            if chunk.strip():
                result.append(chunk.strip())
        return

    parts = text.split(sep)

    current = ""
    for part in parts:
        candidate = current + sep + part if current else part

        if len(candidate) <= chunk_size:
            current = candidate
        else:
            # 当前累积的内容可以作为一个 chunk
            if current.strip():
                result.append(current.strip())

            # 如果单个 part 就超长了,用下一级分隔符继续分
            if len(part) > chunk_size:
                _split_recursive(
                    part, chunk_size, chunk_overlap,
                    separators, sep_idx + 1, result,
                )
                current = ""
            else:
                current = part

    if current.strip():
        result.append(current.strip())

用 LangChain 实现(更简洁)

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", ".", "!", "?", " ", ""],
    length_function=len,
)

chunks = splitter.split_text(long_text)

中文场景建议加上中文标点分隔符 "。", "!", "?", ";",效果比默认的英文分隔符好很多。

优缺点

优点缺点
尽可能保留完整段落/句子对没有明确段落结构的文本效果一般
实现成熟,LangChain 默认方案仍然基于字符规则,不理解语义
速度快,适合大批量处理参数需要调优

适用场景:大多数通用文档,推荐作为默认方案。


策略三:语义切片

语义切片是最”智能”的方案——不按字符数切,而是按语义相似度切

原理

  1. 先把文本按句子分割
  2. 对每个句子生成 Embedding 向量
  3. 计算相邻句子之间的相似度
  4. 在相似度突然下降的地方(语义断裂点)切分
句子1: 相似度 0.92 → 句子2: 相似度 0.88 → 句子3: 相似度 0.35 → 句子4
                                            ↑ 这里语义断裂,切!

Python 实现

import numpy as np
from openai import OpenAI
import os
import re

client = OpenAI(
    base_url="https://api.deepseek.com",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
)


def get_embeddings(texts: list[str]) -> list[list[float]]:
    """批量获取 Embedding"""
    response = client.embeddings.create(
        model="text-embedding-v1",
        input=texts,
    )
    return [item.embedding for item in response.data]


def cosine_similarity(a: list[float], b: list[float]) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


def split_into_sentences(text: str) -> list[str]:
    """按句子分割(支持中英文)"""
    sentences = re.split(r'(?<=[。!?.!?\n])', text)
    return [s.strip() for s in sentences if s.strip()]


def split_by_semantic(
    text: str,
    threshold: float = 0.5,
    max_chunk_size: int = 1500,
    min_chunk_size: int = 100,
) -> list[str]:
    sentences = split_into_sentences(text)

    if len(sentences) <= 1:
        return [text.strip()] if text.strip() else []

    # 获取所有句子的 Embedding
    embeddings = get_embeddings(sentences)

    # 计算相邻句子的相似度
    similarities = []
    for i in range(len(embeddings) - 1):
        sim = cosine_similarity(embeddings[i], embeddings[i + 1])
        similarities.append(sim)

    # 在相似度低于阈值的地方切分
    chunks = []
    current_chunk = [sentences[0]]

    for i, sim in enumerate(similarities):
        current_text = "".join(current_chunk)

        if sim < threshold and len(current_text) >= min_chunk_size:
            chunks.append(current_text.strip())
            current_chunk = [sentences[i + 1]]
        elif len(current_text) > max_chunk_size:
            chunks.append(current_text.strip())
            current_chunk = [sentences[i + 1]]
        else:
            current_chunk.append(sentences[i + 1])

    # 最后一段
    if current_chunk:
        remaining = "".join(current_chunk).strip()
        if remaining:
            chunks.append(remaining)

    return chunks

优缺点

优点缺点
切片边界最自然,语义完整性最好需要调用 Embedding API,有成本
不依赖固定规则,适应各种文本结构速度最慢(要对每个句子做 Embedding)
检索效果通常最优threshold 参数需要调优

适用场景:高质量要求的知识库(法律、医疗、金融),文档量不大但准确率要求极高的场景。


三种策略对比

对比项固定长度递归切片语义切片
实现复杂度★★★★★★
切片质量★★★★★★★★★★★
速度★★★★★★★★★★★
成本需要 Embedding API
参数敏感度
推荐场景兜底/快速原型通用默认方案高精度场景

我的实际选择:90% 的场景用递归切片就够了。只有对准确率有极致要求的垂直领域(比如法律合同分析),才上语义切片。


不同文件类型的最佳切片策略

不同类型的内容适合不同的切片参数:

普通文档(技术文档、SOP、方案)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", ".", " ", ""],
)

代码文件

代码需要保持函数/类的完整性,分隔符要用代码结构:

code_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,
    chunk_overlap=200,
    separators=[
        "\nclass ",     # 类定义
        "\ndef ",       # 函数定义
        "\n\ndef ",     # 顶级函数
        "\n\n",         # 空行
        "\n",
        " ",
        "",
    ],
)

LangChain 也提供了语言专用的切片器:

from langchain_text_splitters import Language, RecursiveCharacterTextSplitter

python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1500,
    chunk_overlap=200,
)

js_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.JS,
    chunk_size=1500,
    chunk_overlap=200,
)

表格数据

表格(Excel 转成的 Markdown)尽量不要切开行:

table_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=50,
    separators=["\n## Sheet:", "\n\n", "\n"],
)

FAQ / 问答对

FAQ 天然以问答为单位,每个 Q&A 就是一个 chunk:

def split_faq(text: str) -> list[str]:
    """按 Q&A 对切片"""
    pattern = r'(Q[::].*?(?=Q[::]|\Z))'
    matches = re.findall(pattern, text, re.DOTALL)
    return [m.strip() for m in matches if m.strip()]

切片质量评估

切完之后怎么知道效果好不好?不能只凭感觉——需要量化指标。

评估维度

指标说明怎么算
平均长度切片太短信息量不足,太长检索精度差mean(len(chunk) for chunk in chunks)
长度方差方差太大说明切片不均匀std(len(chunk) for chunk in chunks)
边界完整性有多少切片以完整句子结尾以句号结尾的切片占比
信息密度切片内有效信息的比例去除空白后的字符占比

评估脚本

import statistics

def evaluate_chunks(chunks: list[str]) -> dict:
    """评估切片质量"""
    lengths = [len(c) for c in chunks]

    # 边界完整性:以句号/问号/感叹号结尾的比例
    sentence_endings = "。.!?!?"
    complete = sum(1 for c in chunks if c and c[-1] in sentence_endings)
    boundary_score = complete / len(chunks) if chunks else 0

    # 信息密度:非空白字符占比
    densities = []
    for c in chunks:
        non_space = len(c.replace(" ", "").replace("\n", "").replace("\t", ""))
        densities.append(non_space / len(c) if c else 0)

    return {
        "total_chunks": len(chunks),
        "avg_length": statistics.mean(lengths) if lengths else 0,
        "std_length": statistics.stdev(lengths) if len(lengths) > 1 else 0,
        "min_length": min(lengths) if lengths else 0,
        "max_length": max(lengths) if lengths else 0,
        "boundary_completeness": round(boundary_score, 3),
        "avg_density": round(statistics.mean(densities), 3) if densities else 0,
    }


def compare_strategies(text: str) -> None:
    """对比三种切片策略"""
    from langchain_text_splitters import (
        CharacterTextSplitter,
        RecursiveCharacterTextSplitter,
    )

    strategies = {
        "固定长度": CharacterTextSplitter(
            separator="", chunk_size=800, chunk_overlap=100,
        ),
        "递归切片": RecursiveCharacterTextSplitter(
            chunk_size=800, chunk_overlap=100,
            separators=["\n\n", "\n", "。", ".", " ", ""],
        ),
    }

    print(f"原文长度: {len(text)} 字符\n")
    print(f"{'策略':<10} {'切片数':<8} {'平均长度':<10} {'标准差':<10} {'边界完整性':<12} {'信息密度':<10}")
    print("-" * 65)

    for name, splitter in strategies.items():
        chunks = splitter.split_text(text)
        metrics = evaluate_chunks(chunks)
        print(
            f"{name:<10} "
            f"{metrics['total_chunks']:<8} "
            f"{metrics['avg_length']:<10.0f} "
            f"{metrics['std_length']:<10.0f} "
            f"{metrics['boundary_completeness']:<12} "
            f"{metrics['avg_density']:<10}"
        )

使用示例:

compare_strategies(long_document_text)

输出类似:

原文长度: 15000 字符

策略        切片数    平均长度     标准差      边界完整性    信息密度
-----------------------------------------------------------------
固定长度    19       789         12          0.316        0.952
递归切片    21       714         156         0.857        0.948

可以看到递归切片的边界完整性(0.857)远高于固定长度(0.316),意味着绝大部分切片都在完整句子处结束。


进阶:给切片加元数据

光有文本内容还不够,生产环境中每个切片都应该带上元数据,方便后续检索和溯源:

from dataclasses import dataclass

@dataclass
class Chunk:
    content: str
    metadata: dict

    @property
    def char_count(self) -> int:
        return len(self.content)


def split_with_metadata(
    text: str,
    filename: str,
    chunk_size: int = 800,
    chunk_overlap: int = 100,
) -> list[Chunk]:
    """切片并附加元数据"""
    from langchain_text_splitters import RecursiveCharacterTextSplitter

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", ".", " ", ""],
    )

    raw_chunks = splitter.split_text(text)

    chunks = []
    for i, content in enumerate(raw_chunks):
        chunks.append(Chunk(
            content=content,
            metadata={
                "source": filename,
                "chunk_index": i,
                "total_chunks": len(raw_chunks),
                "char_count": len(content),
            },
        ))

    return chunks

这些元数据在检索后回传给 LLM 时非常有用:

来源: 技术方案.pdf | 第 3/15 段

和上一篇的衔接:完整 Pipeline

结合上一篇的 FileParser,现在可以串成一个完整的”解析 → 切片”链路:

def parse_and_chunk(filepath: str) -> list[Chunk]:
    """文件解析 + 切片的完整流程"""
    parser = FileParser()
    doc = parser.parse(filepath)

    chunks = split_with_metadata(
        text=doc.content,
        filename=doc.filename,
        chunk_size=800,
        chunk_overlap=100,
    )

    # 把文档级元数据合并进去
    for chunk in chunks:
        chunk.metadata.update({
            "file_type": doc.file_type,
            **doc.metadata,
        })

    return chunks


# 使用
chunks = parse_and_chunk("./docs/技术方案.pdf")
print(f"解析并切片完成:{len(chunks)} 个切片")
for c in chunks[:3]:
    print(f"  [{c.metadata['chunk_index']}] {c.content[:80]}...")

chunk_size 调参经验

最后分享一些我在实际项目中总结的调参经验:

场景chunk_sizechunk_overlap原因
通用文档800100平衡精度和上下文
FAQ 知识库300-50050问答对本身就短
法律合同500-700150条款需要完整,overlap 大一些
技术文章1000150段落较长,需要更多上下文
代码1500200函数体通常较长
API 文档60080每个接口描述独立且短

通用原则:先用 800/100 跑起来,用评估脚本看效果,再针对性地调。不要一开始就追求完美参数——先跑通再优化。


总结

  1. 切片质量直接决定 RAG 效果——garbage chunk in, garbage answer out。
  2. 三种策略各有适用场景:固定长度最简单但最粗暴,递归切片是默认首选,语义切片精度最高但最贵。
  3. 递归切片是 90% 场景的最佳选择——用 RecursiveCharacterTextSplitter,加上中文标点分隔符。
  4. 不同内容类型需要不同参数:代码要大 chunk,FAQ 要小 chunk,合同要大 overlap。
  5. 切片要带元数据(来源文件、序号、总数),方便检索溯源。
  6. 用量化指标评估切片质量:平均长度、边界完整性、信息密度,不要凭感觉。

下一篇我们解决知识库的另一个工程难题:用户上传 100MB 的 PDF,怎么不卡不断不崩? 分片上传 + 断点续传 + 实时进度追踪的完整前后端方案。


下一篇预告03 | RAG 大文件上传:分片上传、断点续传、进度追踪


讨论话题:你的 RAG 项目用的什么切片策略?chunk_size 设的多少?有没有遇到过切片导致检索不准的问题?评论区聊聊。

评论