创见博客
LangChain 文档切分实战:为什么要切、切分策略与实现
七崽爱吃小饼干2026/10/18阅读 0

上一篇(demo-21)讲了如何用 Loader 把不同格式的文档读成 Document。但 Loader 只是第一步,读出来的文档往往太长,不能直接拿去做 Embedding 和检索。

这一篇专门讲文档切分(Text Splitting):为什么要切、有哪些策略、以及怎么用 LangChain 实现。

代码基于 JavaScript 版 langchain v1,切分器来自 @langchain/textsplitters。

一、为什么要切分

RAG 的第一步不是向量化,而是把文档切成合适的文本块。原因有四个。

1.1 上下文窗口有限

大模型的上下文长度再大,也有限度。把整本书塞进提示词既不现实,也不经济。

1.2 Embedding 模型有最大输入长度

Embedding 模型通常只能处理几百到几千 token。超长文本会被截断、报错,或者只保留前一部分,导致向量无法代表全文。

1.3 检索精度会下降

向量检索的基本单位是文本块。如果一块里混了多个主题,向量会被「平均」掉:

  • 用户问 A,块里同时有 A、B、C,相似度被稀释;
  • 命中之后,拼进提示词的还有大量无关内容;
  • 模型容易被噪声干扰,答案质量下降。

1.4 成本和延迟

文本块越大,拼进提示词的 Token 越多,调用成本越高、延迟越大。切分让检索结果更聚焦,减少无效上下文。

1.5 切分不是越小越好

块太大块太小
语义完整,但主题混杂容易断章取义
检索噪声多,相似度被稀释命中片段但缺少上下文
成本Token 消耗高块数量多,索引开销大
可引用性定位不精确引用碎片化

切分的目标不是把文本切小,而是让每个块尽量表达一个完整、独立的意思。

二、切分的核心参数

LangChain 的 TextSplitter 有几个通用参数:

参数作用
chunkSize每个块的最大长度
chunkOverlap相邻块的重叠长度
separators切分边界,如段落、换行、句号、空格
lengthFunction如何计算长度(字符数或 token 数)
keepSeparator是否在块中保留分隔符

2.1 chunkSize

chunkSize 决定块的粒度:

  • 太大:块内主题混杂,检索噪声大;
  • 太小:语义不完整,模型拿不到足够上下文。

chunkSize 的单位取决于切分器:

  • 字符切分器:字符数;
  • token 切分器:token 数。

2.2 chunkOverlap

chunkOverlap 让相邻块保留一段重叠,避免关键句正好落在边界上。

比如固定窗口 60 字符、重叠 20 字符:

第 1 块:... 息,模型需要从噪声中寻找答案。文档切得太
第 2 块:息,模型需要从噪声中寻找答案。文档切得太短,单块内容 ...

两块开头有 20 个字符完全一致,关键句不会被彻底切断。

重叠不宜过大,否则会产生大量重复内容,浪费存储和 Token。一般取 chunkSize 的 10%-20%。

2.3 separators

separators 是切分边界。中英文的分隔符不同:

js
// 英文
["\n\n", "\n", " ", ""]

// 中文
["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]

如果只用英文默认分隔符切中文,可能只能按空格和字符切,效果很差。

2.4 metadata 会继承

用 splitDocuments 切分时,原 Document 的 metadata 会复制到每个块:

js
const doc = new Document({
  pageContent: text,
  metadata: { source: "long-article.txt", category: "demo" },
});

const chunks = await splitter.splitDocuments([doc]);
// chunks[0].metadata -> { source: "long-article.txt", category: "demo" }

这为后续引用溯源提供了基础。

三、策略一:CharacterTextSplitter(固定分隔符)

CharacterTextSplitter 只按一个固定分隔符切分。

js
import { CharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new CharacterTextSplitter({
  separator: "\n\n",
  chunkSize: 120,
  chunkOverlap: 20,
});

const chunks = await splitter.splitText(text);

本 demo 的实际输出(原文 441 字):

块数:5
[1] 85 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 78 字符 | 切分的目标不是把文本切得越小越好……
[3] 89 字符 | 一个常见的起点是按段落切分……
[4] 106 字符 | 切分大小通常用字符数或 token 数来衡量……
[5] 74 字符 | 相邻文本块之间可以保留一定重叠……

优点:

  • 简单、可预测、速度快;
  • 分隔符选得准,块边界就比较自然。

缺点:

  • 只认一个分隔符,段落内部不会再拆;
  • 单个段落超过 chunkSize 时,会保留超长块并给出警告;
  • 不理解句子和标题结构。

适合:格式非常规整、段落长度可控的文本。

四、策略二:RecursiveCharacterTextSplitter(推荐默认)

RecursiveCharacterTextSplitter 按分隔符优先级逐级尝试:

先按段落切
  -> 段落还太长,就按句子切
    -> 句子还太长,就按逗号切
      -> 最后退回按字符切
js
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 80,
  chunkOverlap: 10,
  keepSeparator: false,
  separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});

const chunks = await splitter.splitDocuments([document]);

本 demo 的实际输出:

块数:8
[1] 54 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 29 字符 | 文档切得太短,单块内容可能缺少完整语义……
[3] 78 字符 | 切分的目标不是把文本切得越小越好……
[4] 67 字符 | 一个常见的起点是按段落切分……
[5] 20 字符 | 这种从大到小逐级尝试的思路,就是递归切分。
...

为什么推荐它作为默认:

  • 优先保留段落和句子边界;
  • 超长内容会继续降级切分;
  • 对中英文都比较友好;
  • 参数直观,容易调。

中文场景记得把 。!?;, 加进 separators,否则容易只按空格和字符切。

keepSeparator: false 会把分隔符从块里去掉,避免块以「。」开头;设为 true 会保留分隔符,但在部分实现中它可能出现在下一块的开头,使用时要看实际输出。

五、策略三:TokenTextSplitter(按 token 计数)

字符数不等于 token 数。中英文混合、代码、标点的 token 比例都不同。

当你要严格控制模型上下文或成本时,应该按 token 切分:

js
import { TokenTextSplitter } from "@langchain/textsplitters";

const splitter = new TokenTextSplitter({
  chunkSize: 40,        // 40 个 token
  chunkOverlap: 8,
  encodingName: "o200k_base",
});

const chunks = await splitter.splitText(text);

本 demo 的实际输出(每块最多 40 token):

块数:10
[1] 50 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 49 字符 | ,模型需要从噪声中寻找答案。文档切得太短……
[3] 52 字符 | 断章取义。切分的目标不是把文本切得越小越好……
...

要点:

  • chunkSize 的单位是 token,不是字符;
  • encodingName 要和目标模型匹配,例如 cl100k_base、o200k_base;
  • 中文建议选对中文支持更好的编码;
  • token 切分可能把词或句子切断,通常用于严格长度限制,或者和递归切分组合使用。

本 demo 使用 o200k_base。默认的 gpt2 编码在切中文时容易产生替换字符,实际使用时要根据模型选择编码。

六、策略四:结构化切分(Markdown / Code)

结构化的文档应该优先按结构切分,而不是按固定长度硬切。

6.1 Markdown

MarkdownTextSplitter 会优先在标题、列表、段落等边界切分:

js
import { MarkdownTextSplitter } from "@langchain/textsplitters";

const splitter = new MarkdownTextSplitter({
  chunkSize: 120,
  chunkOverlap: 20,
});

const chunks = await splitter.splitText(markdownText);

本 demo 的实际输出:

块数:5
[1] 106 字符 | # RAG 文档切分指南 ## 为什么需要切分……
[2] 113 字符 | ## 切分的基本参数 chunkSize 控制……
[3] 104 字符 | ## 常见切分策略 固定长度切分实现简单……
[4] 76 字符  | ## 结构化切分 Markdown 可以按标题……
[5] 70 字符  | ## 切分后的处理 每个文本块都应该……

每个块都保留了标题,检索结果更容易理解,也方便把标题路径写进 metadata。

6.2 代码

代码可以按函数、类、代码块切分:

js
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = RecursiveCharacterTextSplitter.fromLanguage("js", {
  chunkSize: 140,
  chunkOverlap: 0,
});

const chunks = await splitter.splitText(code);

本 demo 的输出:

块数:5
[1] 53 字符  | import { Document } from "@langchain/core/documents";
[2] 90 字符  | export function splitByBlankLine(text) { ... }
[3] 87 字符  | export function splitBySentence(text) { ... }
[4] 68 字符  | export function countCharacters(text) { ... }
[5] 118 字符 | export function toDocument(content, source) { ... }

fromLanguage 支持的语言:

cpp、go、java、js、php、proto、python、rst、ruby、rust、
scala、swift、markdown、latex、html、sol

注意:它不是语法解析器,只是为不同语言准备了一套更合适的分隔符。chunkSize 仍然可能触发降级切分。

七、策略五:语义切分(概念)

语义切分(Semantic Chunking)不按固定分隔符,而是按「意思」切:

  1. 先把文本拆成句子;
  2. 计算相邻句子或句组的 embedding;
  3. 相似度高就合并,相似度骤降就切开。
句子 1 + 句子 2 + 句子 3  -> 同一主题,合并
句子 4 与前面相似度骤降    -> 在这里切分

优点:

  • 块内语义更集中,检索效果通常更好;
  • 不依赖标题、段落等显式结构。

代价:

  • 需要 embedding 模型,索引阶段成本更高;
  • 阈值难调,文本很短时不稳定;
  • JavaScript 版 LangChain 目前没有内置 SemanticChunker, 需要自己实现或使用外部服务。

Python 侧有 langchain_experimental.text_splitter.SemanticChunker 可以直接用。

实践建议:

  • 大多数项目先用 RecursiveCharacterTextSplitter;
  • 文档结构清晰时优先用 Markdown / 代码切分;
  • 只有检索效果不理想时,再考虑语义切分;
  • 还可以用小到大检索:索引小块,命中后返回更大的父块。

八、策略对比与参数选择

策略边界依据适用场景
CharacterTextSplitter单一分隔符格式规整的纯文本
RecursiveCharacterTextSplitter分隔符优先级通用默认
TokenTextSplittertoken 数量严格控制长度 / 成本
MarkdownTextSplitterMarkdown 结构文档、知识库
fromLanguage("js")代码结构源码、配置文件
Semantic Chunking语义相似度高质量检索

8.1 参数怎么定

  1. 先看 embedding 模型的最大输入长度,别超限;
  2. 再按内容类型选边界:Markdown 用标题,代码用函数;
  3. chunkSize 从 200-800 字符左右开始试,用真实问题评估召回;
  4. chunkOverlap 一般取 chunkSize 的 10%-20%;
  5. 不要凭感觉调参,保留一批问题做 Recall@K 对比。

这些数字不是标准答案,最终要由你的数据、问题和 embedding 模型决定。

8.2 常见坑

  1. 只按英文字符切中文:中文没有空格,容易切出大量碎片;
  2. 块太小:检索命中但模型缺少上下文;
  3. 块太大:检索噪声多,Token 成本高;
  4. overlap 过大:重复内容多,索引膨胀;
  5. 丢掉 metadata:无法溯源、无法按权限过滤;
  6. 把表格和代码硬切:结构被破坏,检索效果差;
  7. PDF 页眉页脚重复:每个块都带着同样的噪声;
  8. 没有评估:调了参数但不知道效果有没有变好。

8.3 工程实践

  • 切分后保留 source、标题路径、页码、行号等 metadata;
  • 过滤空块、重复块、过短的块;
  • 文档更新时只重建受影响的分块;
  • 检索命中后可以返回父块,兼顾精度和上下文;
  • 用真实问题集评估 Recall@K,而不是只看块数。

九、Python 对应写法

Python 侧使用 langchain_text_splitters:

python
from langchain_text_splitters import (
    CharacterTextSplitter,
    RecursiveCharacterTextSplitter,
    TokenTextSplitter,
    MarkdownTextSplitter,
    Language,
)

9.1 固定分隔符

python
splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=120,
    chunk_overlap=20,
)
chunks = splitter.split_text(text)

9.2 递归字符切分

python
splitter = RecursiveCharacterTextSplitter(
    chunk_size=80,
    chunk_overlap=10,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
)
docs = splitter.split_documents(documents)

9.3 Token 切分

python
splitter = TokenTextSplitter(
    chunk_size=40,
    chunk_overlap=8,
    encoding_name="o200k_base",
)
chunks = splitter.split_text(text)

9.4 Markdown 与代码

python
md_splitter = MarkdownTextSplitter(
    chunk_size=120,
    chunk_overlap=20,
)
md_chunks = md_splitter.split_text(markdown_text)

code_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=140,
    chunk_overlap=0,
)
code_chunks = code_splitter.split_text(code)

9.5 语义切分

python
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(model="text-embedding-3-small"),
)
chunks = splitter.split_text(text)

十、小结

  • 为什么要切分:上下文窗口、embedding 上限、检索精度、成本与延迟。
  • 核心参数:chunkSize、chunkOverlap、separators、lengthFunction。
  • 常用策略:
    • 固定分隔符 → CharacterTextSplitter;
    • 逐级递归 → RecursiveCharacterTextSplitter(默认推荐);
    • 按 token → TokenTextSplitter;
    • 按结构 → MarkdownTextSplitter / fromLanguage;
    • 按语义 → Semantic Chunking(JS 需自行实现)。
  • 统一入口:
    • splitter.splitText(text);
    • splitter.splitDocuments(documents)。
  • 选择顺序:结构化切分 > 递归字符切分 > 固定切分;有严格长度要求时再叠加 token 计数。

一句话:先找语义边界,再控制块大小;先保证块内完整,再追求块间精准。

评论
0/100