从零理解 RAG 文档切割:爬虫、Loader 与语义分块
前言
在构建 RAG(Retrieval-Augmented Generation,检索增强生成)应用时,文档切割是一个绕不开的核心环节。本文将结合一个实际的代码项目,从爬虫抓取到文档分块,手把手带你理解整个流程。
一、知识从哪来?—— 知识库的源头
知识库里的知识来源五花八门:
- 📄 一个 Word 文档
- 📑 一个 PDF 文件
- 🎬 一个 Bilibili 视频
- 🔗 一个 URL 链接
- 🐦 一条靠谱的 Twitter
这些不同格式的文件,最终都要变成向量数据库中的向量。但在此之前,它们需要先被统一成一种标准格式 —— Document。
LangChain 提供了标准的 Document 格式:
pageContent(页面内容)+metadata(元数据)。
这个将原始文件转换为 Document 对象的过程,就叫做 Loader(加载器) 。
二、Loader:从文件到 Document 的桥梁
LangChain 生态提供了 180 多种 Loader,覆盖了几乎所有常见的文件格式。它们分为两类:
| 来源 | 说明 |
|---|---|
@langchain/community | 社区维护,覆盖面广,我们也可以贡献 Loader |
@langchain/core | 官方维护,核心功能 |
每种文件后缀都对应着不同的 Loader,但它们的职责一致:输入是文件,输出是 Document。
三、爬虫实战:用 Cheerio 抓取网页内容
来看 crawl.mjs 中的代码,它演示了如何手动爬取一个网页:
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl = 'https://juejin.cn/post/7660707431753678854';
async function crawlPage() {
const { data: html } = await axios.get(targetUrl);
const $ = cheerio.load(html); // 在内存中构建 DOM 树
const pageContent = $('.main-area p').text(); // CSS 选择器提取内容
console.log(pageContent);
}
爬虫的两个核心步骤
第一步:发送 HTTP 请求,拿到 HTML 字符串
用 axios 发送 GET 请求,服务端返回 text/html 类型的 HTML 文档。这一步很简单。
第二步:解析 HTML,提取需要的文本内容
这是真正的难点。传统做法是用正则表达式,但 HTML 结构复杂,正则很难精准匹配。
Cheerio 另辟蹊径——它提供了一种前端思维:
cheerio.load(html)将 HTML 字符串在内存中虚拟化为一个 DOM 对象(树状结构)- 然后你就可以像写前端代码一样,用 CSS 选择器(如
.main-area p)来定位和提取内容 - Cheerio 负责遍历 DOM 树,返回匹配节点的文本
💡 核心价值:Cheerio 让 JS 开发者能以前端思维,简单高效地完成指定 URL、指定部分的爬取工作,完全不需要跟正则表达式较劲。
四、LangChain 的 CheerioWebBaseLoader
手动爬虫虽然能跑,但在 RAG 体系中,我们更希望一步到位得到标准的 Document 对象。index.mjs 展示了优雅的做法:
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();
CheerioWebBaseLoader 集成了爬虫 + Cheerio 解析的能力,同时还能传入 CSS 选择器来缩小提取范围。返回的结果已经是 LangChain 标准的 Document 对象,可以直接进入下一步:分块(Split) 。
五、分块的意义:保持语义完整性
为什么要分块?
一个完整的文章可能有几千甚至上万字,直接把它作为一个整体塞给 LLM 是不现实的(上下文窗口有限,检索精度也差)。我们需要把它切分成一定大小、具有独立语义的 Chunk(块) ,这样才能精准检索。
怎么切才不伤语义?
切割的核心矛盾在于:
ChunkSize 会切断相邻 Chunk 之间的语义联系——上一个 Chunk 的最后一句和下一个 Chunk 的第一句,它们的语义相关性往往是最大的,但因为 ChunkSize 的限制被硬生生切开了。
为了解决这个"语义遗憾",LangChain 的 RecursiveCharacterTextSplitter 做了两件事:
第一层:智能选择分隔符(Separators)
不是随便找个位置就切,而是按照语义的基本构成符号来切:
。(句号)✅ —— 一句话结束,语义完整!(感叹号)✅ —— 语气结束,语义完整?(问号)✅ —— 问题结束,语义完整,(逗号)❌ —— 话还没说完,切开会破坏语义
代码中这样配置:
separators: ["。", "!", "?"]
递归的策略是:先尝试用最优先的分隔符切,如果 Chunk 还是太大,就尝试下一个分隔符,直到找到最优的切分点,使每个 Chunk 都尽量保持语义完整,同时接近 ChunkSize。
第二层:Overlap 重叠补救
即使选择了最优的分隔符,仍然会有"硬切"的情况。这时就用 chunkOverlap(重叠) 来补救:
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个 chunk 约 400 字符
separators: ["。", "!", "?"],
chunkOverlap: 100, // 相邻 chunk 重叠 100 字符
});
Overlap 的逻辑:让相邻 Chunk 之间有一定量的内容重叠。这样,即使某段语义被切在了边界上,它也会完整地出现在相邻的某个 Chunk 中,检索时不会丢失。
六、完整流程图
原始 URL
│
▼
┌──────────────────────┐
│ CheerioWebBaseLoader │ ← CSS 选择器提取指定内容
│ (爬虫 + Cheerio) │
└──────────┬───────────┘
│
▼
┌──────────┐
│ Document │ ← LangChain 标准格式 (pageContent + metadata)
└─────┬────┘
│
▼
┌──────────────────────────┐
│ RecursiveCharacterTextSplitter │
│ · chunkSize: 400 │ ← 智能语义分块
│ · separators: 。!? │
│ · chunkOverlap: 100 │ ← 重叠补救
└──────────┬───────────────┘
│
▼
┌──────────────┐
│ splitDocuments│ ← 可直接存入向量数据库
└──────────────┘
七、AI 时代程序员的思考
项目的 readme 中有一段值得深思的话:
- 程序员的价值不再是 coding,交给 AI
- Vibe Coding 的核心能力:问出好的问题(Prompt)、提供丰富准确的上下文(Context)、驾驭并部署 Agent 产品(Harness & FDE)、设计长时间稳定运行的 Loop
- 用好 AI,快速成为一名 AI 架构师
这其实点明了学习 RAG 这类技术的深层意义:我们不再需要手写每一行爬虫代码或正则表达式,但我们必须理解整个链路的设计逻辑——为什么要分块?分隔符怎么选?Overlap 怎么设?这些决策直接影响最终 RAG 应用的检索质量。
代码可以被 AI 代劳,但架构决策和语义理解,始终需要人来把关。
总结
| 环节 | 工具/概念 | 核心作用 |
|---|---|---|
| 内容获取 | Cheerio + CSS Selector | 精准提取网页指定区域 |
| 格式标准化 | LangChain Loader | 统一为 Document 对象 |
| 语义分块 | RecursiveCharacterTextSplitter | 按语义边界切割 |
| 完整性保障 | chunkOverlap | 用冗余换语义完整 |
这三个文件串起了一条完整的 RAG 文档处理链路,从原始 URL 到可直接入库的 Chunk,每一步都体现了工程上对"语义完整性"的追求。希望这篇文章能帮你建立起对 RAG 文档切割的直观理解!