引言
在构建 RAG(Retrieval-Augmented Generation,检索增强生成)应用时,文档切割(Document Splitting)是一个至关重要却常被忽视的环节。它决定了你的 AI 应用能否精准地从海量知识中检索到最相关的内容。本文将结合实战代码,带你深入理解文档切割的全流程------从多种知识来源的加载,到语义化分块,再到最终存入向量数据库。
知识库:万物皆可为知识
在 AI 应用中,知识来源极其丰富且多样:
- 传统文档:Word 文档、PDF 文件
- 多媒体内容:视频(通过语音转文字)、音频
- 网络资源:URL 链接、网页内容
- 社交媒体:Twitter 帖子、博客文章
- 企业数据:内部 Wiki、工单系统、聊天记录
这些格式各异的数据,无法直接为 AI 所用。我们需要一个统一的抽象层,将它们转化为结构化的数据格式。
LangChain Document:统一的文档标准
LangChain 提供了一个标准的 Document 对象格式,由两个核心部分组成:
csharp
Document {
pageContent: string, // 文档的实际文本内容
metadata: object // 元数据(来源、作者、时间等)
}
这个简洁但强大的数据结构成为了整个 RAG 流程的"通用语言"。无论原始文件是什么格式,最终都会被转化为这个标准格式,方便后续的索引、检索和生成。
Loader:知识转化的桥梁
Loader(加载器)是整个流程的第一道关口。它的核心职责是:
输入 :各类原始文件(通过文件后缀区分类型) 输出:标准化的 Document 对象数组
LangChain 生态提供了超过 180 种不同的 Loader,覆盖了常见的文件类型:
- PDF 文件 →
PDFLoader - Word 文档 →
DocxLoader - CSV 数据 →
CSVLoader - 网页内容 →
WebBaseLoader - 代码仓库 →
GitLoader
社区版与官方版
在 LangChain 的生态中,Loader 分为两个层级:
| 层级 | 包名 | 维护方 | 说明 |
|---|---|---|---|
| 官方核心 | @langchain/core |
LangChain 官方团队 | 基础抽象与核心接口 |
| 社区扩展 | @langchain/community |
社区维护 | 丰富的第三方 Loader |
值得注意的是,切片的工具 RecursiveCharacterTextSplitter 则来自 @langchain/textsplitters,这是一个独立的包,专门负责文本切割相关的功能。LangChain 将不同职责拆分到不同的包中,体现了良好的模块化设计。
爬虫与网页内容提取
网页是知识库最重要的来源之一。一个典型的网页爬取流程分为两步:
第一步:获取 HTML
从目标 URL 发起 HTTP 请求,拿到原始 HTML 字符串。这一步处理的是网络请求、重定向、编码等问题。
第二步:解析提取
从 HTML 字符串中提取需要的文本内容。这里有两种主流思路:
- 正则表达式:直接对 HTML 文本做模式匹配,简单但脆弱
- CSS 选择器(cheerio):以"前端思维"来处理 HTML,将 HTML 加载为类 DOM 结构,然后像写前端代码一样用 CSS 选择器精准筛选内容
javascript
const $ = cheerio.load(html);
const title = $('h1.article-title').text();
const content = $('div.article-content').text();
cheerio 的思路另辟蹊径------它让后端开发者能够用熟悉的前端 API(类似 jQuery)来解析 HTML,大幅降低了学习成本。
实战:用 CheerioWebBaseLoader 加载网页
LangChain 已经帮我们封装好了基于 cheerio 的 Loader------CheerioWebBaseLoader。我们无需手动写请求和解析逻辑,只需指定 URL 和 CSS 选择器即可:
javascript
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{
selector: '.main-area p' // CSS 选择器,精准提取文章段落
}
);
const documents = await cheerioLoader.load();
代码解析:
- 导入 Loader :从
@langchain/community的 web 子模块中引入,说明这是社区维护的网页加载器 - 指定目标 URL:这里以掘金(Juejin)上的一篇文章为例,可以是任意网页地址
- CSS 选择器
.main-area p:这是 cheerio 的强大之处------不提取整个页面,而是精准定位到文章主体区域(.main-area)中的每一个段落(p标签)。这避免了导航栏、侧边栏、评论区等无关内容的干扰 - 调用
load()方法:执行实际的网络请求和 HTML 解析,返回标准化的 Document 对象数组
这一步完成后,我们就得到了一个或多个 Document 对象。但如果文章很长,每个 Document 仍然可能包含几千字的文本------这就要进入下一步:分块。
分块(Chunking):大文件的关键处理
将文件转化为 Document 后,还需要做一件同样重要的事情------分块。
为什么需要分块?
- LLM 上下文窗口有限:即使是最新的大模型,上下文窗口也是有限的,不可能一次性塞入整个文档库
- 检索精度要求:用户提问通常只与文档中的某几个段落相关,返回整个文档会淹没关键信息
- 语义完整性:每个分块必须是一段具有独立语义的文本,不能随意切割
分块的核心原则
- 每个 chunk 应该是语义完整的单元,能够脱离上下文被理解
- chunk 的大小需要在精细度和覆盖率之间取得平衡
- 过小 → 语义碎片化,丢失上下文
- 过大 → 检索精度下降,噪声增加
实战:递归语义分块器 RecursiveCharacterTextSplitter
理论讲完了,看代码如何落地。RecursiveCharacterTextSplitter 是 LangChain 中最常用的文本切片器,它的名字里藏着核心设计思想------递归:
javascript
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 chunk 的目标大小
separators: ["。", "!", "?"], // 中文句子分隔符
chunkOverlap: 100, // 块间重叠 100 字符
});
const splitDocuments = await textSplitter.splitDocuments(documents);
让我们逐行理解这个配置的精妙之处:
chunkSize: 400 ------ 为什么是 400?
400 个字符大约相当于 5-6 个中文句子,或一个中等长度的段落。这个大小在中文 RAG 场景下有几个优点:
- 足够的语义密度:一段 400 字的文本通常能完整表达一个观点,不会让检索到的内容过于零碎
- 适合向量化:Embedding 模型对中等长度的文本能生成更稳定的向量表示
- 经济高效:更小的 chunk 意味着更少的 token 消耗,在保证质量的前提下控制成本
separators: "。", "!", "?" ------ 语义优先的中文切割
这是整个分块策略中最体现"语义优先"设计理念的参数。思考一下:为什么选择这三个符号?
。(句号):中文中最自然的语义边界,标志一个完整陈述的结束!(感叹号):表达强烈情感的完整语义单元?(问号):一个完整问题的边界
而逗号 , 不适合作为分隔符。因为逗号分隔的只是句内成分,切割后会产生"半句话",语义不完整,检索时很难被准确匹配。
递归算法:从最优到次优的降级策略
"递归"二字的含义是:splitter 会按优先级依次尝试分隔符。
- 第一轮 :尝试用
"。"(句号)切割。如果能切出接近 400 字符的块,完美 - 第二轮 :对于句号切割后仍然太长的块,尝试用
"!"(感叹号)进一步切割 - 第三轮 :还是太长?用
"?"(问号)继续切割 - 兜底策略:如果所有分隔符都无法将文本切到 chunkSize 以内,则直接硬切(强制在 chunkSize 位置切断)
这种逐级降级的策略,确保了每个 chunk 都尽可能地保持语义完整------优先用最自然的句子边界切割,只有在不得已时才退而求其次。
chunkOverlap: 100 ------ 不完美中的补救
尽管递归切割已经很智能了,但无论如何切割,都可能把某个完整的语义片段一分为二。chunkOverlap: 100 意味着相邻的两个 chunk 之间有 100 个字符的重叠。
diff
chunk1: [======400 字符======]
<--100 字符重叠-->
chunk2: [======400 字符======]
<--100 字符重叠-->
chunk3: [======400 字符======]
这个设计解决了什么问题?假设某个关键观点恰好横跨了 chunk1 的末尾和 chunk2 的开头:
- 没有重叠:chunk1 里只有前半句,chunk2 里只有后半句,两个 chunk 都无法独立表达完整语义 → 检索失败
- 有重叠:重叠区域中包含了完整的转折上下文,chunk1 和 chunk2 各自都能被独立检索出来 → 检索成功
100 字符大约是 1-2 个中文句子,足以覆盖大多数语义片段被切割边界的场景。虽然这是一种"补救"措施,但在实际应用中极大地提升了检索的鲁棒性。
完整流程串联
将 Loader 和 Splitter 串联起来,整个数据处理管道就清晰了:
scss
原始 URL
│
▼
CheerioWebBaseLoader.load()
│ 使用 CSS 选择器提取页面正文
▼
Document[] (大文档)
│
▼
RecursiveCharacterTextSplitter.splitDocuments()
│ 按句子边界递归切割,chunkSize=400,overlap=100
▼
Document[] (小 chunk)
│
▼
存入向量数据库 → 等待检索
这就是 RAG 中数据预处理的核心 Pipeline:加载 → 切割 → 向量化入库。
AI 时代程序员的价值重塑
文档切割只是整个 AI 应用开发链条中的一环。在 AI 时代,程序员的角色正在发生根本性的转变:
| 传统程序员 | AI 时代程序员 |
|---|---|
| 专注于 Coding(编写代码) | Coding 逐渐成为 AI 的任务 |
| 实现具体功能 | 设计系统架构 |
| 写代码 | 写 Prompt |
新的核心能力
-
问出好的问题(Prompt Engineering)
- 精准描述需求,引导 AI 产出高质量的代码和内容
-
提供丰富准确的上下文(Context)
- 就像 RAG 中的文档切割一样,给 AI 恰到好处的信息量
-
驾驭(Harness)并部署 AI Agent 产品
- 理解 Agent 的工作原理,设计可控的自主决策流程
-
设计长时间稳定运行的 Loop
- AI 应用不同于传统软件,需要处理不确定性。设计鲁棒的反馈循环是核心挑战
-
用好 AI,成为 AI 架构师
- 不再亲手写每一行代码,而是设计系统蓝图,让 AI 来完成具体实现
Vibe Coding 与 AI 架构师
"Vibe Coding" 强调的不是放弃编程,而是用更高维度来思考问题。当你不再被具体的语法和 API 细节所束缚,你可以:
- 更关注业务逻辑和用户体验
- 更深入思考系统的可扩展性和稳定性
- 将更多精力投入到"AI 能做什么"的探索上
正如本文中的代码------短短 40 行就完成了一个完整的网页爬取 + 智能分块 Pipeline。这些代码你当然可以让 AI 来写,但理解为何选择 chunkSize: 400、为何用句号而非逗号作为分隔符、为何需要 chunkOverlap------这些架构决策的能力,才是 AI 时代的程序员真正不可替代的价值。
总结
从知识库到向量数据库的整个流程,可以浓缩为两个核心动作:
-
选择合适的 Loader ------ 将各式各样的原始文件转化为统一的 Document 格式。
CheerioWebBaseLoader封装了 cheerio 的能力,只需一行 CSS 选择器就能精准提取网页内容,将爬虫、解析、标准化三步合一 -
合理的分块策略 ------
RecursiveCharacterTextSplitter以递归方式逐级尝试最优分隔符,配合chunkSize控制块大小、chunkOverlap确保语义连续性,在中文场景下用["。", "!", "?"]作为分隔符实现语义优先的切割
这两个看似简单的步骤,每一个参数的背后都蕴含着对语义理解和检索效果的深刻考量。理解它们,你就在通往 AI 架构师的道路上迈出了坚实的一步。