从零搞懂 RAG 文档处理:逐行拆解 Loader 和 Splitter,一行代码背后发生了什么
写在前面
刚开始接触 RAG(检索增强生成)时,我最困惑的不是 LLM 怎么生成答案,而是前面那一坨"文档处理"到底在干什么。
什么是 Document?它和我电脑里的 PDF 文件有什么区别?Loader 是干嘛的?为什么要切割?chunkOverlap 为什么要重叠?RecursiveCharacterTextSplitter 名字里的 "Recursive" 到底递归了什么?
这篇文章带你从零拆解 RAG 文档预处理链路。我会逐行拆解源码,用代码 + 图解的方式,把每个概念讲透。
一、知识库里的"知识"从哪来
知识库放的是知识,但知识的来源千奇百怪:
- 一个 Word 文档(
.docx) - 一个 PDF 文件(
.pdf) - 一个 B站视频(字幕)
- 一个 URL(网页)
- 一条靠谱的 Twitter 推文
你不能直接把一个 PDF 二进制文件丢给 LLM,也不能直接对一个视频做向量化。
所以第一步:把所有格式统一成同一种标准格式。
二、File ≠ Document ------ 很多人搞混的第一个概念
刚学的时候我也有这个困惑:文件不就是文档吗?
文件(File) 是物理层面的东西------存在磁盘上的二进制数据。一个 PDF 是二进制流,一个 Word 文档实际上是 ZIP 压缩包里面包着 XML,一个视频压根连"文本"都不是。
Document 是 LangChain 定义的逻辑数据结构,只有两个字段:
css
{
pageContent: "这是从文件里提取出来的纯文本内容...",
metadata: {
source: "https://juejin.cn/post/xxx",
title: "从回调地狱到优雅异步:Node.js 文件操作进化史"
}
}
| File(文件) | Document(文档) | |
|---|---|---|
| 层面 | 物理磁盘上的二进制数据 | 内存中的结构化对象 |
| 格式 | 千奇百怪(.pdf .docx .mp4) | 统一:{ pageContent, metadata } |
| 能直接用吗 | ❌ 不能,要先解析 | ✅ 下游所有环节都认它 |
| 类比 | 没拆封的快递箱 | 拆开分类摆好的物品 |
pageContent 是内容本身,pageMetadata 是描述内容的"快递单"。 你不能自己手搓一个 Document------必须通过 Loader 去加载文件,由 Loader 负责提取和封装。
三、Loader:把千奇百怪的格式变成统一 Document
Loader 是干嘛的
各种格式的原始文件 → Loader → Document(标准格式)
Loader 干两件事:
- 根据文件类型选对应的 Loader ------PDF 用
PDFLoader,Word 用DocxLoader,网页用CheerioWebBaseLoader - 输出标准 Document ------统一的
{ pageContent, metadata }
Loader 从哪来
| 包名 | 维护方 | 说明 |
|---|---|---|
@langchain/core |
官方维护 | 核心抽象:BaseDocumentLoader 基类、Document 类 |
@langchain/community |
社区维护 | 各种具体的 Loader 实现,任何人都可以贡献 |
四、逐行拆解 CheerioWebBaseLoader 源码
CheerioWebBaseLoader 是 LangChain 社区提供的网页加载器。名字拆开看:
| 部分 | 含义 |
|---|---|
| Cheerio | 底层用 cheerio 库解析 HTML |
| Web | 数据来源是网页(URL) |
| Base | 基础实现类 |
| Loader | 加载器 |
它是一个类,不是函数
很多人第一反应以为它是函数,其实不是:
csharp
// 用 new 关键字创建实例 → 这是类
const cheerioLoader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{ selector: '.main-area p' }
)
// .load() 是实例上的方法
const documents = await cheerioLoader.load()
为什么设计成类?因为它有状态需要记住 ------构造时传的 URL 和 selector 存在 this.webPath 和 this.selector 上,调用 .load() 时直接用,不用再传一遍。
翻源码:构造函数
ini
constructor(webPath, fields) {
super();
this.webPath = webPath;
const { timeout, selector, textDecoder, headers, ...rest } = fields ?? {};
this.timeout = timeout ?? 1e4; // 默认 10 秒超时
this.caller = new AsyncCaller(rest); // 处理重试和并发控制
this.selector = selector ?? "body"; // 默认提取整个 body
this.textDecoder = textDecoder;
this.headers = headers;
}
| 参数 | 默认值 | 干什么的 |
|---|---|---|
webPath |
必传 | 要抓取的网页 URL |
timeout |
10000(10秒) |
HTTP 请求超时时间 |
selector |
"body" |
CSS 选择器------只提取匹配元素内的文本 |
headers |
undefined |
自定义 HTTP 请求头 |
如果你不传 selector,默认就是 "body"------整页所有文字全抓,包括导航栏、侧边栏、评论区。传了 selector: '.main-area p' 就只提取文章正文段落。
核心方法:一行 .load(),五件事
翻出 node_modules 里的源码:
javascript
async load() {
// ① + ② 发 HTTP 请求 + 解析为 DOM 树
const $ = await this.scrape();
// ③ 提取页面标题(作为元数据)
const title = $("title").text();
// ④ CSS 选择器提取正文 + ⑤ 封装为标准 Document
return [new Document({
pageContent: $(this.selector).text(),
metadata: {
source: this.webPath,
title
}
})];
}
注意返回的是数组 [new Document(...)] ------虽然只有一个元素。这是 LangChain 的统一设计,所有 Loader 的 .load() 都返回 Document[] 数组。
展开 _scrape(),里面是真正的网络请求和 HTML 解析:
javascript
static async _scrape(url, caller, timeout, textDecoder, options) {
const { load } = await CheerioWebBaseLoader.imports();
// ① 发 HTTP GET 请求(带超时控制)
const response = await caller.call(fetch, url, {
signal: timeout ? AbortSignal.timeout(timeout) : void 0,
headers
});
// ② 把 HTML 字符串解析为 DOM 树,返回 $ 查询器
return load(
textDecoder?.decode(await response.arrayBuffer())
?? await response.text(),
cheerioOptions
);
}
完整数据流
一行 .load() 干了五件事:
scss
① fetch(url) → 发 HTTP GET 请求(10 秒超时)
② response.text() → 拿到 HTML 字符串(193KB 纯文本)
③ cheerio.load(html) → 在内存中解析为 DOM 树,返回 $ 遥控器
④ $(selector).text() → CSS 选择器遍历树,提取纯文本
⑤ new Document({...}) → 封装为 { pageContent, metadata }
│
▼
return [Document] → 数组形式返回
这就是封装的意义------把繁琐的步骤藏起来,给你一个干净入口。
五、手动拆开------用 axios + cheerio 一步步重现
为了彻底理解 Loader 内部在干嘛,我手写了一个 crawl.mjs,用最底层的方式一步步重现:
axios.get() 返回了什么
csharp
const res = await axios.get(targetUrl)
| 字段 | 是什么 | 实际值 |
|---|---|---|
res.data |
响应体 | HTML 字符串(193KB) |
res.status |
HTTP 状态码 | 200 |
res.headers |
响应头 | content-type: text/html; charset=utf-8 |
Content-Type: text/html → axios 自动当字符串放 res.data。如果返回 JSON,会自动解析成 JS 对象。
解构 + 重命名
csharp
const { data: html } = await axios.get(targetUrl)
// ↑ ↑
// 原名 起个别名叫 html
把 res.data 取出来改名 html------变量名更语义化,一看就知道是 HTML 字符串。
cheerio.load()------把字符串变成 DOM 树
javascript
import * as cheerio from 'cheerio'
const $ = cheerio.load(html)
xml
"<!doctype html><html lang="zh">..."
│ ← 193KB 纯文本,无结构
▼
在内存中构建一棵 DOM 树:
html
/ \
head body
│ │
title div(class="main-area")
│ / \
"从回调地狱..." p p
│ │
"写在前面..." "刚开始学 Node.js..."
│
▼
返回 $(操作这棵树的"遥控器")
关键认知:$ 不是 DOM 树本身,它是操作树的查询函数。
javascript
console.log(typeof $); // 'function' ← 它是个函数!
一个 cheerio.load() 建一棵树,你想建几棵都行------不像浏览器里全局只有一个 document。
CSS 选择器提取文本
ini
const pageContent = $('.main-area p').text()
执行过程:
javascript
$('.main-area p')
├── 找到 class="main-area" 的元素
│ └── 在它下面找所有 <p> 标签
├── 返回节点集合
└── .text() 遍历,去标签,拼成纯文本
.text() 去掉所有 HTML 标签,多个节点直接拼接。
什么是"前端思维"?
以前用正则爬网页:
css
// 又丑又脆,HTML 结构一变就崩
const regex = /<p[^>]*class="[^"]*main-area[^"]*"[^>]*>([\s\S]*?)</p>/g
正则把 HTML 当纯字符串匹配------但 HTML 有嵌套、属性顺序随意、空白任意位置,这些都是正则的灾难。
cheerio:
javascript
// 和写前端 jQuery 一模一样
$('.main-area p').text()
| 正则 | cheerio | |
|---|---|---|
| 怎么想 | "匹配 <p 开头 </p> 结尾的字符串" |
"选 class 下所有的 p 标签" |
| 操作对象 | 纯文本字符串 | DOM 树(有层级结构的) |
| 可靠性 | 结构一变就崩 | 真正理解 HTML 语法 |
在后端写爬虫,和在前端操作 DOM,用同一套脑回路。 这叫"前端思维"。
过程四步总结
markdown
1. HTML 字符串在命令行运行,内存中虚拟化一个 DOM 对象
→ cheerio 在 Node.js 内存里凭空造了一棵 DOM 树,不需要浏览器
2. DOM 对象就是树状结构,申请分配比较大的内存空间
→ 193KB HTML 解析后变成几万个节点对象,每个有父子指针,比原文更占内存
3. CSS Selector 在树里面查找
→ $('.main-area p') 从根节点出发,按层级遍历,找到所有匹配节点
4. cheerio 让 JS 开发者以前端思维完成爬取
→ 不需要正则,用 jQuery 手感写爬虫
六、Splitter:为什么要切割?
Loader 拿到的 Document 可能几千上万字。两个问题:
| 问题 | 说明 |
|---|---|
| 太长 | 模型上下文窗口有限 |
| 精度差 | 搜"回调地狱",匹配整篇不如精准匹配那一段 |
切成 Chunk 。核心:保证语义完整的同时控制大小。三个问题:用什么切?切多大?切断了怎么办?
七、RecursiveCharacterTextSplitter 深度解析
"Recursive" 到底递归了什么?
分隔符降级策略:
arduino
传入文本,目标 chunkSize: 400
│
▼
① 先试 "。"(句号)切 → 完整句子,语义最好
│ 某句超过 400?
▼
② 再试 "!"(叹号)切
│ 还是太大?
▼
③ 再试 "?"(问号)切
│ 还是太大?
▼
④ 字符级硬切(兜底)
从语义最完整 的边界逐级降到语义最小的边界。句号切出完整句子,叹号问号也是。实在不行才硬切。
代码
csharp
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 chunk 最多 400 字符
separators: ["。", "!", "?"], // 分隔符优先级
chunkOverlap: 100, // 相邻 chunk 重叠 100 字符
})
const splitDocuments = await textSplitter.splitDocuments(documents)
三个参数逐一解释
chunkSize(400) :最大字符数,不是精确值。如果某句 410 字符但是完整句子,会保留。语义排第一,大小排第二。
separators("。", "!", "?") :优先级列表,顺序重要。中文用句号叹号问号(自然句子边界);英文默认 ["\n\n", "\n", " ", ""](段落→行→单词→字符)。
chunkOverlap(100) :最重要又最容易被忽视的参数。
为什么要重叠?一个例子
不重叠:
vbnet
chunk1: "回调地狱让人头疼。为了解决它,出现了"
chunk2: "Promise。Promise 让异步代码更优雅。"
搜 "出现了什么" → chunk1 匹配到了,但里面没有"Promise" !答案在 chunk2 头部,刚好被切断,丢了 ❌
重叠(100 字符):
vbnet
chunk1: "回调地狱让人头疼。为了解决它,出现了 Promise。"
chunk2: "为了解决它,出现了 Promise。Promise 让异步代码更优雅。"
↑ 重叠区域
搜 "出现了什么" → 两边都有"出现 Promise",都能答 ✅
重叠 = 给切断边界处的关键上下文留退路。 那一刀附近的内容,复制到两边,不管命中哪边信息都不丢。
重叠不是越多越好
"为了语义完整,少一点 chunkOverlap"------这个"少一点"很关键。
太大(300)的后果:每段三分之一重复,浪费存储、浪费 Embedding token、检索结果大量冗余。
经验值:chunkSize 的 10%~25% 。400 → overlap 50~100 合理。
八、两个文件对比------同一件事,两个抽象层次
| index.mjs(坐车) | crawl.mjs(开车) | |
|---|---|---|
| 方式 | Loader 封装好的 | axios + cheerio 手动 |
| 发请求 | Loader 内部 fetch | 自己 axios.get() |
| 解析 HTML | Loader 内部 cheerio | 自己 cheerio.load() |
| 提取内容 | 传 selector 配置 |
自己 $(selector).text() |
| 返回 | 标准 Document | 纯字符串 |
| 下游可用 | ✅ 直接丢 Splitter | ❌ 还得自己包装 metadata |
crawl.mjs 的意义不是"好用"------是让你看清 Loader 内部到底干了什么。
九、完整数据流
scss
各种知识文件(PDF / Word / URL / 视频 / Twitter)
│
▼
Loader(加载)
选对应 Loader,调 .load()
│
▼
Document(标准格式)
{ pageContent, metadata }
│
▼
Splitter(切割)
递归降级分隔符保证语义
overlap 保住切断处上下文
chunkSize 控制每个块大小
│
▼
Chunks(小 Document[])
语义完整、大小可控、相邻重叠
│
▼
Embedding(向量化)
│
▼
向量数据库(存储)
│
▼
Retrieval(检索)
用户提问 → 向量化 → 相似度搜索 → 召回 Chunk → LLM 生成
十、ESM 模块系统速查
| 语法 | 含义 | 类比 |
|---|---|---|
export default |
默认导出,只有一个 | 工具箱里最常用的锤子 |
export |
具名导出,可以很多 | 其他工具 |
import xxx from |
导入默认,名字随意 | 伸手拿锤子 |
import { xxx } |
导入具名,名字要对 | 按名拿螺丝刀 |
import * as xxx |
全部导入打包 | 整个工具箱端走 |
const { a: b } = obj |
解构+重命名 | 取 obj.a 改叫 b |
写在最后
搞懂 Loader 和 Splitter 后,你会发现设计真的精妙:
- Loader ------ 把千奇百怪的来源统一成
{ pageContent, metadata }。这是整个 RAG pipeline 能"通用化"的基石。 - Splitter ------ 递归降级分隔符 + overlap 保语义。在"语义完整"和"大小可控"之间找最优解,不是简单按字数硬切。
最后引 readme 里一段话:
不再是 coding,交给 AI。Vibe coding 的核心能力:问出好问题(Prompt )、提供准确上下文(Context )、驾驭并部署 Agent 产品(Harness + FDE )、设计稳定运行的 Loop 。快速成长为一名 AI 架构师。
理解底层原理,才能更好驾驭上层封装。