从零搞懂 RAG 文档处理:逐行拆解 Loader 和 Splitter,一行代码背后发生了什么

从零搞懂 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 干两件事:

  1. 根据文件类型选对应的 Loader ------PDF 用 PDFLoader,Word 用 DocxLoader,网页用 CheerioWebBaseLoader
  2. 输出标准 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.webPaththis.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 架构师

理解底层原理,才能更好驾驭上层封装。

相关推荐
come112341 小时前
Vue 3 与现代 JavaScript 常用语法指南
前端·javascript·vue.js
槑有老呆1 小时前
从零搭建 LLM 流式对话前端:一文读懂 SSE、二进制流与 Vue 3 响应式
前端·javascript
没落英雄1 小时前
7. 从零开始搭建一个 AI Agent —— UI 卡片渲染系统
前端·人工智能·架构
前端缘梦1 小时前
LangGraph 核心特性技术详解:流式输出、持久化、记忆体系与中断实战。
前端·人工智能·程序员
张龙6871 小时前
前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
前端·javascript
渣波1 小时前
从零手写 Vite Mock 流式 AI 对话,搞懂 LLM 的“打字机”特效
前端·javascript
温柔过期啦1 小时前
网站改了标题但是搜索引擎却没改?怎么样才能快速更新?
前端·搜索引擎·百度
cxxcode2 小时前
JavaScript 模块化:从模块规范到打包原理
前端·webpack
驰子1052 小时前
不重写、不换框架:让十年 jQuery 老项目写出 Vue 手感的渐进式改造
前端