RAG 系统的质量有三个决定性因素:检索精度、上下文组装、LLM 理解能力。而检索精度的第一道门槛就是文本切片(chunking)——如果切分时就把语义单元切碎了,后续再怎么优化 embedding 和排序也补救不回来。

本文不是"四行代码接入 LangChain"的教程。我们从分析主流方案的取舍出发,解释为什么最终选择了自实现的递归字符切分,并详细展开中文优化的工程细节。


1. 主流方案概览

2024 年以来,文本切片领域涌现了大量新方法。按技术路线大致可分为四类:

1.1 固定大小切分(Fixed-size / Token-window)

按固定字符数或 token 数滑动窗口切分,相邻窗口有 overlap。例如 Jina AI 的 FixedSizeChunker

  • 优点:实现极简,零额外成本
  • 缺点:完全不理解语义边界,句子可能被拦腰截断
  • 适用场景:英文短文本、对切分质量要求不高的原型阶段

1.2 递归字符切分(Recursive Character Split)

LangChain 的 RecursiveCharacterTextSplitter 是这类方案的典型代表。按"语义完整性递减"的分隔符优先级列表逐级尝试切分——先段落、再句子、再子句、最后字符兜底。

ChromaDB 的 2024 年评测 [1] 对比了五类策略(Character/Token、Recursive、Semantic、Cluster Semantic、LLM Semantic),结论是:递归切分作为轻量级方案,性能始终强劲,在多个 benchmark 上与计算成本高昂的语义切分差距不大。

1.3 语义切分(Semantic / Embedding-based Chunking)

计算相邻句子的 embedding 余弦相似度,在"语义断层"处分块。比递归切分更智能,但每个文档需要做 N 次 embedding 调用(N = 句子数),且效果严重依赖 embedding 模型质量。

Vectara 在 2024 年的系统性研究 [2] 发现:在真实数据集上,语义切分与固定大小切分的差异极小。在 embedding 本身足够好的情况下,语义切分的额外算力开销往往得不偿失。

1.4 LLM 驱动的切分(LLM-based / Agentic Chunking)

让 LLM 直接输出"在什么地方该切"。典型工作包括:

  • LumberChunker(EMNLP 2024)[3]:迭代地将文本段落输入 LLM,让模型判断内容主题何时开始偏移,在检索指标 DCG@20 上比最强基线高 7.37%。
  • zChunk(ZeroEntropy, 2024)[4]:用 Llama-70B 的 logprob 分析插入特殊 token("段")标记块边界,450K 字符在 A100 上约 3 分钟完成。

这类方法质量最高,但算力开销也最大,暂不适合作为通用文档上传管线的默认方案。


2. 我们的选择

回到实际场景:一个企业内部的知识库平台,用户上传 PDF/Word/Markdown,期望在秒级完成索引,并能在对话中准确检索。

对这个场景的需求排序:

优先级 需求
P0 不切碎句子——chunk 内语义完整
P0 中文友好——中文特有的标点符号、段落结构被正确理解
P0 零额外算力——不能每个文档多出几十次 LLM/embedding 调用
P1 chunk 大小尽量均匀——batch embedding 效率高
P2 单元测试可验证——确定性行为,不依赖 LLM 随机性

经过评估:

  • 语义切分被排除:每个句子调一次 embedding(叠加到文档上传管线上,一个 500 句的文档就是 500 次 embedding 调用),而 Vectara 的研究已经表明它在真实数据集上提升有限。
  • LLM 切分被排除:质量最好,但成本和延迟不适合做默认管线(可作为高价值文档的可选增强)。
  • 固定窗口切分被排除:已经用了一版(500 token 滑动窗口),确实存在句子被切断的问题。
  • 递归字符切分入选:确定性、零额外算力、ChromaDB 评测背书其作为 strong baseline。需要做的只是中文适配。

结论:递归字符切分是性价比最优解。


3. 算法设计

3.1 核心思想

一句话概括:按"语义完整性递减"的分隔符优先级列表逐级尝试切分,优先在最"大"的语义边界下刀;只有当当前级无法再切时才降级用更"小"的边界;全部边界都失效了,才退化为字符级硬切兜底。

它是贪心 + 递归降级算法:每一刀都落在当前能找到的最完整的语义单元上,硬切只是最后一道保险,实际语料中极少触发。

3.2 中文分隔符优先级表

英文默认分隔符是 ["\n\n", "\n", " ", ""](段落 → 行 → 词 → 字符)。但中文没有空格分词,直接用英文默认值会导致从 \n 直接降级到字符级,退化回固定窗口硬切。

我们设计的中文分隔符优先级:

"\n\n" → "\n" → "。" → "!" → "?" → ";" → "," → "、" → " " → ""
 段落     换行    句号    感叹    问号    分号    逗号    顿号   空格   字符兜底

对应语义层级:

段落 > 换行 > 句子边界 > 分句边界 > 词间空格 > 字符

顺序至关重要——\n\n 必须排在 \n 前面,否则 \n\n 会被当作两个 \n 处理,丢失段落边界信息。。!?; 同处"句子"层,,、 处于"分句"层,体现了句内逗号比句尾标点更弱的语义分界。

3.3 算法流程

splitRecursive(text, sepIndex):
    # 1. 找到第一个能真正切开 text 的分隔符
    #    (从 sepIndex 开始沿优先级表查找,命中即止)
    sep = first(separators[sepIndex:].filter(s -> text.contains(s)))

    # 2. 如 sep 为空串 → 字符级兜底:按 chunkSize 硬切
    if sep == "":
        return charLevelSplit(text)

    # 3. 按 sep 切分,保留分隔符到前一片末尾
    pieces = splitKeepSep(text, sep)
    #   "你好。世界。" → ["你好。", "世界。"]

    # 4. 对每个分片:
    #    a. ≤ chunkSize → 直接保留
    #    b. > chunkSize → 递归降级:splitRecursive(piece, sepIndex+1)
    for piece in pieces:
        if len(piece) <= chunkSize:
            result.add(piece)
        else:
            result.addAll(splitRecursive(piece, sepIndex + 1))

3.4 分隔符保留

一个容易被忽视但至关重要的细节:切分后分隔符去了哪里?

大多数实现用 String.split(separator),分隔符直接丢弃。结果是 chunk 中的句末标点消失——不仅影响 LLM 阅读理解(一个不带句号的句子读起来很奇怪),也影响 embedding 质量(标点携带语句类型信息:疑问、感叹、陈述)。

我们的做法:splitKeepSepString.indexOf 定位分隔符,将分隔符连同前面的文本一起切出:

// "你好。世界。" 按 "。" 切分
// → ["你好。", "世界。"]
while (idx < text.length()) {
    int next = text.indexOf(sep, idx);
    if (next == -1) { result.add(text.substring(idx)); break; }
    result.add(text.substring(idx, next + sep.length())); // 分隔符包含在内
    idx = next + sep.length();
}

使用 indexOf 而非正则 Pattern.quote 是为了避免不同 JVM/platform encoding 下的行为差异。这也是实践中踩出来的坑——Windows 上的 CJK 字符在 Pattern.quote + split 的组合下出现过静默丢失分隔符的情况。

3.5 误切防护

中文句号 (U+3002)作为分隔符时,需要排除两种误切场景:

场景 示例 为什么不应在此切开
小数点 3.14 这是拉丁句号 . 而非中文句号
条款编号 参见第 3.2.1 节 3.2.1 中的点是数字上下文中的分隔

注意拉丁句号 . 根本不在我们的分隔符列表中(中文文档极少用 . 作句末标点),因此英文缩写(Dr.e.g.)天然不会被误切。唯一的防护需求是:当文本中出现 . 时,不要错误地把它识别为 (这两个字符在不同字体下可能难以区分,但在 Unicode 层面是不同的码位)。

我们用一个负向断言正则对 级别做保护:

Pattern.compile("(?<![0-9])\\。(?![0-9])")
// 仅当句号前后不是数字时才视作句子边界

匹配到的位置用占位符 __DOT_PROTECT__ 替换,切分完成后再恢复。

3.6 贪心合并 + Overlap

递归切分产出的是"原子分片"——每片都不超过 chunkSize,但很多片可能远小于 chunkSize。接下来需要把这些原子分片贪心合并为接近 chunkSize 的 chunk。

合并策略(mergeWithOverlap):

1. 顺序累积分片,直到下一个分片会超出 chunkSize
2. 封块输出
3. 从当前累积的头部逐片移除,直到剩余长度 ≤ chunkOverlap
4. 剩余分片成为下一个 chunk 的起始(即 overlap 内容)

关键设计:overlap 在合并阶段做,不在切分阶段做。切分阶段的职责是找出语义边界;overlap 是"借"相邻 chunk 的尾部文本,让上下文连续。在合并阶段做 overlap 的优势是:overlap 的内容天然对齐到分隔符边界(因为分片本身就是按分隔符切出的),不会出现"借了半个句子"的情况。

3.7 Token 硬上限兜底

中英混合极端文本(如大量英文日志、URL、base64 编码内容)可能字符数不高但 token 数极高。我们用 jtokkit 的 CL100K_BASE encoding 做 token 计数兜底:

if (piece.length() > chunkSize && nextIdx == SEPARATORS.size()):
    tokens = encoding.countTokens(piece)
    if (tokens <= maxTokens):
        result.add(piece)   # 虽超字符但 token 可控
    else:
        result.addAll(charLevelSplit(piece))  # 硬切

只在递归降级的最底层(字符级)才检查——上层总是优先递归降级。


4. 参数选择

参数 推荐值 理由
chunkSize 500 字符 中文信息密度高,500 字的信息量相当于英文 800-1000 字。text-embedding-3-small 输入上限 8191 token,500 汉字约 750-1000 token,完全安全
chunkOverlap 60 字符(~12%) Vectara 的研究 [2] 表明 10-15% 的 overlap 在防断裂和存储效率间取得最佳平衡
maxTokens 1200(CL100K_BASE) 覆盖中英混合极端情况的硬上限,避免超出 embedding 窗口

实际部署时,chunkSizechunkOverlap 通过 application.yml 中的 lingshu.knowledge.chunk.* 配置项注入,不同 profile 可独立调整。


5. 与现有管线的集成

旧的 ChunkSplitter(500 token 固定窗口 + 50 token overlap)和新的 RecursiveChunkSplitter 并存,由 DocumentProcessingPipeline 决定使用哪个。迁移路径:

阶段 1(当前): Pipeline 使用旧 ChunkSplitter,RecursiveChunkSplitter 并行存在
阶段 2: Agent 配置新增 chunkStrategy 字段(fixed | recursive),默认 fixed
阶段 3: 灰度验证后,默认改为 recursive

新旧 splitter 的公开 API 签名完全一致:

public List<DocumentChunk> split(String docId, String kbId, String spaceId,
                                  String content, String metadataJson)

这意味着在 Pipeline 中切换只需改一行注入。


6. 能力边界

递归字符切分不是一个 silver bullet。我们需要诚实地说出它做不到什么:

  1. 不理解段落级以上的结构。章节标题和正文之间的归属关系、表格和表格标题之间的关联、跨页表格的连续性——这些它都无法感知。需要结构感知切分(Markdown 标题层级、PDF 的 ToC 信息)来补充。
  2. 对无标点长文本退化。如果一个中文段落持续 2000 字不使用任何标点(尽管现实中极少见),递归切分会一路降级到字符兜底。
  3. 不解决"语义分散"问题。同一主题的信息可能分布在文档的不同位置,递归切分无法将它们聚合——这属于检索+重排序的范畴,不是切分的问题。

因此,递归字符切分在方案栈中的定位是:结构感知切分之后的二级切法,以及在弱结构纯文本场景下的主力方案


7. 相关资源

类型 描述
学术论文 [1] ChromaDB Research (2024). Evaluating Chunking Strategies for Retrieval. 五类策略的系统性评测,递归切分作为 strong baseline。
学术论文 [2] Vectara (2024). Is Semantic Chunking Worth the Computational Cost? 挑战"语义切分一定更好"的假设,固定大小切分 + 高质量 embedding 在真实数据集上往往不输语义切分。
学术论文 [3] Duarte et al. (EMNLP 2024). LumberChunker: Long-Form Narrative Document Segmentation. LLM 驱动的动态文档分割。
学术论文 [4] ZeroEntropy (2024). zChunk: LLM-Based Semantic Chunking with Logprobs. 用 Llama-70B 的 logprob 做块边界检测。
工程实践 LangChain RecursiveCharacterTextSplitter — 递归字符切分概念的开创性实现,本文的中文适配版受其启发。
工程实践 Hearst, M. (1997). TextTiling: Segmenting Text into Multi-paragraph Subtopic Passages. 文本分割领域的奠基性工作——虽然我们最终没有采用基于词汇共现的语义切分,但"在自然边界切分"的思想可以追溯至此。

总结:递归字符切分不是最 fancy 的方案,但在性价比这个维度上,它是最正确的选择。ChromaDB 和 Vectara 的评测从不同角度验证了这一点。我们的中文适配版在这个基础上补齐了分隔符优先级、分隔符保留、误切防护三个工程细节,使得它在中文 RAG 场景下开箱即用。100 行核心代码,零外部依赖,48 个单元测试覆盖各级切分路径——做到"普通人看了觉得简单,行家看了知道不简单"。