LLM的"消化系统":一文讲透RAG文档加载与智能切割
从
axios+cheerio手写爬虫到RecursiveCharacterTextSplitter,LangChain文档预处理全链路拆解
导语 🔥
想象一下这个场景:好不容易写完了AI应用的Demo,信心满满地把一篇万字技术博客喂给大模型,结果它"消化不了"直接报错------上下文超出窗口限制。或者更糟,它"吞"下去了,但回答问题时却像个失忆症患者,只记得开头和结尾,中间的关键信息全丢了。
这并非模型能力不行,而是我们准备"食材"的方式出了问题。直接把原始HTML或长文本塞给LLM,就像让一个美食家去啃带泥的土豆和整颗带毛的猪------难以下咽。
本文将带你深入LangChain生态,从手写axios+cheerio爬虫开始,到使用封装好的CheerioWebBaseLoader一键加载,再到用RecursiveCharacterTextSplitter进行"米其林级"的文本切割。读完此文,你将掌握构建高效RAG知识库的第一道核心工序,让LLM真正"吃透"你的文档数据。🎯
初识核心技术 🧐
1. 从原始爬虫到标准化Loader
在AI应用开发中,知识库的数据来源五花八门:网页、PDF、Word、Markdown......这些格式各异的文件,在向量化之前必须被统一为同一种标准结构------Document。LangChain 的 Loader(加载器) 就是干这个活的。
对于网页爬取,最经典的组合是axios+cheerio。axios负责把网页HTML拉下来,cheerio则在内存里把HTML解析成DOM树,让开发者能用类似jQuery的CSS选择器提取内容。手动实现大概是这样的:
javascript
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl = 'https://juejin.cn/post/example';
const { data: html } = await axios.get(targetUrl);
const $ = cheerio.load(html);
const pageContent = $('.main-area').text(); // 提取文章主体文本
但手动处理太繁琐了。LangChain 社区包@langchain/community提供了封装好的CheerioWebBaseLoader,它底层就是对axios+cheerio的标准化封装,直接输出Document对象数组:
javascript
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const loader = new CheerioWebBaseLoader(
'https://juejin.cn/post/7660707431753678854',
{ selector: '.main-area' } // 🎯 提取匹配元素内的文本
);
const documents = await loader.load();
⚠️ 关键注意 :
CheerioWebBaseLoader的load()方法内部调用$(this.selector).text()提取文本,这会剥离所有 HTML 标签 。如果你的后续流程需要使用MozillaReadabilityTransformer(它需要解析 HTML 结构如<article>、<p>等标签),纯文本会导致其无法正常工作。此时需要自定义 Loader,改用.html()方法保留 HTML 结构。
🧩 拓展思考:Cheerio基于HTML字符串解析,轻量快速,但无法执行JavaScript,因此对客户端渲染(CSR)的SPA页面无能为力。如果遇到动态加载的页面,需要换成PlaywrightWebBaseLoader或PuppeteerWebBaseLoader,它们会启动无头浏览器真实渲染页面。
2. 为什么必须切割(Chunking)?
Loader产出的Document对象可能非常庞大(比如一篇几万字的博客)。直接把它塞进LLM会面临三个现实问题:
- 上下文窗口限制:LLM的输入Token数是有限的(如GPT-4的128K),超了直接报错。
- 检索精度低:如果一整个文档作为一个检索单元,查询时匹配的粒度太粗,会召回大量无关信息,像"大海捞针"。
- 丢失关键信息:长文本中,重要的细节往往被淹没在"上下文海洋"中,模型容易"失忆"。
切割的意义在于:把大文档拆解成一个个具有独立语义的"知识碎片"(Chunk)。这样在检索时,系统就能精准地找到与问题最相关的那个碎片,而不是扔一整本书给模型。
3. 切割的核心难题:如何不"断章取义"?
最简单的切割是按字符数硬切,CharacterTextSplitter 就是这种方式的典型代表------按固定字符数切分,不识别语义边界,可能在句子中间切断,导致信息碎片化。
这会导致灾难性后果:一个完整的句子或段落被拦腰斩断,语义彻底崩坏。
为了解决这个问题,LangChain提供了递归切割器 :RecursiveCharacterTextSplitter。它是官方推荐的通用文本切割方案,核心思路是按优先级列表递归尝试分隔符 (如 ["\n\n", "\n", " ", ""]),直到 chunks 足够小为止。它的设计包含两大核心阶段:
阶段一:递归切分 ------ 按照优先级列表(如["。", "!", "?", "\n\n", "\n", ";", ",", "、", " ", ""]),从最有力的语义边界开始尝试切分,如果切出的片段仍然太大,则递归使用下一个分隔符继续切分,直到所有片段都小于chunkSize或分隔符用尽。
阶段二:合并优化 ------ 将切分产生的"候选片段"按顺序合并,每个Chunk尽量接近chunkSize但严格不超过。
这种**"先拆解为语义碎片,再组装为合适大小"**的两阶段设计,是RecursiveCharacterTextSplitter区别于普通切割器的精髓所在。
原理深水区 ⚙️
1. 深入剖析递归切分机制(Recursive Splitting)
RecursiveCharacterTextSplitter的"递归"二字并非虚名。它的核心算法是一个深度优先的尝试-回退机制,理解了这个机制,你就掌握了切割的灵魂。
1.1 两阶段工作流程
阶段一流程:
arduino
原始文本
↓
用第一个分隔符(如'。')切分
↓
遍历每个片段 → 长度 ≤ chunkSize?
├── 是 → 存入候选列表 ✅
└── 否 → 用下一个分隔符递归切分 🔁
↓
所有分隔符用完?
├── 否 → 继续递归
└── 是 → 字符级兜底切分 🛟
↓
返回候选片段列表(每个 ≤ chunkSize)
阶段二流程:
markdown
候选片段列表 → 按顺序遍历
↓
(当前块 + 下一个片段).length ≤ chunkSize?
├── 是 → 合并到当前块 🟢
└── 否 → 保存当前块,新块从此片段开始
↓
返回最终Chunks(每个 ≤ chunkSize,尽量接近)
关键步骤解读:
阶段一(递归切分):
- 首次尝试 :取
separators数组的第一个元素(如中文的'。'),对整个文本执行split()。 - 长度检查 :遍历切割后的每个片段:
- 如果片段长度 ≤
chunkSize,欣然接受,存入候选片段列表。 - 如果片段长度 >
chunkSize,递归调用自己 ,传入下一个分隔符(如'!')。
- 如果片段长度 ≤
- 递归终止 :当所有分隔符都用完了,某个片段仍然太长,此时触发兜底逻辑 ------按单个字符遍历切分,保证每块 ≤
chunkSize。
阶段二(合并优化) : 4. 顺序合并 :将候选片段按原始顺序逐个合并。 5. 严格上限 :每次合并前检查 (当前块 + 下一个片段).length ≤ chunkSize,只有不超过上限才能合并 。 6. 输出最终Chunks :每个Chunk都严格 ≤ chunkSize,并尽量接近。
1.2 源码级视角:两阶段实现
在LangChain源码中,核心逻辑大致如下(简化伪代码):
javascript
// 阶段一:递归切分
function _splitText(text, separators) {
const currentSeparator = separators[0];
if (!currentSeparator) {
// 🔴 兜底:按字符遍历切分,每块 ≤ chunkSize
const chunks = [];
for (let i = 0; i < text.length; i += chunkSize) {
chunks.push(text.slice(i, i + chunkSize));
}
return chunks;
}
const splits = text.split(currentSeparator);
const result = [];
for (const split of splits) {
if (split.length <= chunkSize) {
result.push(split); // ✅ 直接接受,存入候选片段
} else {
// 🔁 递归切分超长片段(去掉已使用的分隔符)
const subSplits = _splitText(split, separators.slice(1));
result.push(...subSplits);
}
}
return result; // 返回候选片段列表(每个 ≤ chunkSize)
}
// 阶段二:合并优化(贪心策略)
function _mergeSplits(splits, chunkSize) {
const merged = [];
let currentChunk = '';
for (const split of splits) {
// 🟢 贪心检查:能加就加,加不了就切
if ((currentChunk + split).length <= chunkSize) {
currentChunk += split; // 合并成功
} else {
// 当前块已满(加不下了),保存并开始新块
if (currentChunk) {
merged.push(currentChunk);
}
currentChunk = split; // 新块从当前片段开始
}
}
if (currentChunk) {
merged.push(currentChunk);
}
return merged; // 返回最终Chunks(每个 ≤ chunkSize)
}
理解这段代码的关键在于:
- 阶段一的递归 体现在:超长片段会不断尝试更小的分隔符,直到所有片段都 ≤
chunkSize。 - 每次递归,
separators数组都会去掉第一个元素(因为已经试过了),这个过程一直持续到分隔符用尽。 - 阶段二的贪心体现在:只要不超过上限就继续合并,一旦超过立即保存当前块。
1.3 为什么这种设计是"语义优先"的?
这个算法的精妙之处在于它的**"最小破坏原则"**:
- 最优先 :用最强力的语义分隔符(句号、感叹号)切,确保每个候选片段至少以句子为单位,语义最完整。
- 逐级妥协 :如果句子太长(比如一段长代码注释无标点),退而求其次,尝试用换行、逗号、空格,牺牲部分语义精度 换取结构完整。
- 合并优化:将语义碎片重新组装,确保最终Chunk既保持语义连贯,又接近目标大小。
- 严格上限 :
chunkSize是硬约束,任何Chunk都不会超过这个值。
🧩 拓展思考: 这种"递归切分 + 合并优化"的设计,其实是分治思想 的完美体现------先化整为零(切分成语义碎片),再化零为整(组装成合适大小)。与
CharacterTextSplitter的"一刀切"相比,RecursiveCharacterTextSplitter更像一个语义感知的裁缝,先按照纹理(标点符号)把布料裁开,再把裁片缝合成合适的尺寸。
1.4 实战分隔符配置
针对中文技术文档,我推荐这样的分隔符配置(末尾必须包含空字符串作为兜底):
javascript
const separators = [
'。', // 最优先:完整句子
'!',
'?',
'\n\n', // 段落分隔
'\n', // 行分隔
';', // 分号
',', // 逗号
'、', // 顿号
' ', // 空格
'' // 🛟 兜底:字符级切分(必须保留)
];
这个顺序背后是有逻辑的:优先保证中文字义单元(句号),再兼顾段落结构(换行),最后才用标点符号(逗号、顿号) 。末尾的''空字符串确保任何文本都能被切分,不会导致死循环或报错。
2. 核心重难点:chunkSize是上限,不是目标
这是一个最容易误解 的概念。chunkSize: 400 表示:
- ✅ 每个Chunk ≤ 400字符(严格上限)
- ✅ 可以远小于400(如只包含1个短句)
- ❌ 不是每个Chunk都必须达到400字
- ❌ 不会为了凑够400字而破坏语义
为什么这样设计? 因为语义完整性比尺寸均匀更重要。在RAG系统中,一个语义完整但只有50字的Chunk,远比一个刚好400字但语义割裂的Chunk更有检索价值。
3. 核心重难点:重叠(Overlap)的智慧
即便用了递归策略和合并优化,某些Chunk仍然可能偏小或切断在关键位置。chunkOverlap(块重叠)允许相邻两个Chunk有一部分内容重叠,确保边界信息不丢失。
实现机制 :重叠不是在递归切分或合并阶段产生的,而是在切割完成后的后处理步骤 中实现的------具体来说,是将前一个Chunk末尾的overlap个字符复制并添加到下一个Chunk的开头。这样即使关键的上下文恰好落在切割边界上,相邻Chunk也能保留这部分信息。
javascript
// chunkOverlap 实现示意(后处理阶段)
function applyOverlap(chunks, overlapSize) {
if (overlapSize <= 0) return chunks;
const result = [];
for (let i = 0; i < chunks.length; i++) {
let chunk = chunks[i];
if (i > 0) {
// 取前一个Chunk末尾的 overlapSize 个字符
const prevChunk = chunks[i - 1];
const overlapText = prevChunk.slice(-overlapSize);
chunk = overlapText + chunk; // 插入到当前Chunk开头
}
result.push(chunk);
}
return result;
}
开发者如何理解? 想象一下你看书时,视线会扫到上一页的最后一行再去读下一页的第一行。chunkOverlap就是给AI阅读时提供的"视线余光"。
4. 核心重难点:从HTML到文本的"降噪"
很多人会忽略一个问题:网页HTML里充满了标签、样式、脚本,这些都是噪声 。CheerioWebBaseLoader通过cheerio的text()方法提取去标签后的可见文本。
但要注意:如果后续需要使用 MozillaReadabilityTransformer 等工具提取正文,这些工具需要 HTML 结构(如 <article>、<p> 标签)。由于 CheerioWebBaseLoader 默认使用 .text() 剥离了所有 HTML 标签,Readability 将无法正常工作。解决方案有两种:
- 自定义 Loader,用
.html()替代.text()保留 HTML 结构 - 先使用 Readability 处理 HTML,再交给 Splitter 切割
🧩 拓展思考: 这其实是职责链模式 的体现。
Loader负责加载和初步提取,Splitter负责切割,Transformer负责清洗或格式转换。各司其职,通过管道组合。
实战落地演练 🛠️
完整代码:网页爬取 + 智能切割流水线
javascript
// 1. 环境准备
import 'dotenv/config';
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// 2. 定义目标URL和选择器
const targetUrl = 'https://juejin.cn/post/7660707431753678854';
// 选择器:Cheerio 的 $(selector).text() 会提取匹配元素内的文本
// 如果后续需要使用 MozillaReadabilityTransformer,请注意 .text() 会剥离 HTML
const SELECTOR = '.main-area';
// 3. 使用Loader加载
const loader = new CheerioWebBaseLoader(targetUrl, {
selector: SELECTOR,
});
const rawDocs = await loader.load();
console.log(`✅ 原始文档加载完成,共 ${rawDocs.length} 个Document`);
// 4. 配置切割器(注意末尾的空字符串兜底)
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 硬上限:每个Chunk ≤ 400字符
separators: ['。', '!', '?', '\n\n', '\n', ';', ',', '、', ' ', ''],
chunkOverlap: 40, // 重叠40字符(chunkSize的10%,后处理阶段插入)
});
// 5. 执行切割
// 方式一:直接处理Document数组(推荐)
const splitDocuments = await textSplitter.splitDocuments(rawDocs);
// splitDocuments 是 Document[],每个包含:
// - pageContent: 切割后的文本
// - metadata: 原始metadata + 自动添加的 loc 字段(记录在原文档中的行号范围)
// 官方示例:{ loc: { lines: { from: 1, to: 1 } } }
// 方式二:先提取文本再切割(⚠️ 仅当 rawDocs 来自同一来源时使用)
// const fullText = rawDocs.map(doc => doc.pageContent).join('\n');
// const textChunks = await textSplitter.splitText(fullText);
// textChunks 是 string[]
// 6. 查看结果
console.log(`🔪 切割完成,共生成 ${splitDocuments.length} 个Chunk`);
splitDocuments.forEach((doc, index) => {
console.log(`\n--- Chunk ${index + 1} (长度: ${doc.pageContent.length} 字符) ---`);
console.log(doc.pageContent.slice(0, 100) + '...'); // 预览前100字符
console.log(`来源: ${doc.metadata.source}`);
// splitDocuments 会自动添加 loc 字段记录位置信息
if (doc.metadata.loc) {
console.log(`位置: 行 ${doc.metadata.loc.lines?.from} - ${doc.metadata.loc.lines?.to}`);
}
});
完整示例演示
设 chunkSize: 50,演示切割过程:
javascript
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const text = `
递归切割器通过优先级列表尝试分割文本。它首先尝试按段落分割。如果段落仍然太大,它会递归地尝试按句子分割。最终,它会尝试按单词分割。如果一切失败,它会按字符分割。
这种设计确保了在任何情况下都能产生输出。即使是最坏的情况,也不会丢失数据。但在实践中,我们更关注的是如何让切割结果既保持语义完整,又不会超过LLM的上下文限制。
LangChain提供了多种切割策略,开发者可以根据自己的场景选择最合适的一种。
`;
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 50,
separators: ['。', '!', '?', '\n', ',', ' ', ''],
chunkOverlap: 0,
});
const chunks = await splitter.splitText(text);
console.log(chunks);
输出结果(每个Chunk ≤ 50字):
javascript
[
"递归切割器通过优先级列表尝试分割文本。它首先尝试按段落分割。如果段落仍然太大,它会递归地尝试按句子分割。",
// 48字 ✅ ≤ 50
"最终,它会尝试按单词分割。如果一切失败,它会按字符分割。\n这种设计确保了在任何情况下都能产生输出。",
// 42字 ✅ ≤ 50
"即使是最坏的情况,也不会丢失数据。",
// 15字 ✅ ≤ 50
"但在实践中,我们更关注的是如何让切割结果既保持语义完整,又不会超过LLM的上下文限制。",
// 38字 ✅ ≤ 50
"\nLangChain提供了多种切割策略,开发者可以根据自己的场景选择最合适的一种。"
// 27字 ✅ ≤ 50
]
分析:
- ✅ 所有Chunk都 ≤ 50字(严格满足上限)
- ✅ Chunk大小在15-48字之间(不是固定值)
- ✅ 每个Chunk都尽量接近50字(贪心合并策略)
- ✅ 语义完整,没有被截断的句子
错误示范 vs 正确示范
| 维度 | ❌ 错误理解 | ✅ 正确理解 |
|---|---|---|
| chunkSize含义 | 每个Chunk必须恰好等于400字 | 每个Chunk ≤ 400字,可以更小 |
| 切割粒度 | 先硬切成400字块,再调整 | 先按语义递归切分,再贪心合并 |
| 递归顺序 | separators: [' ', '。', '\n'],先按空格切 |
separators: ['。', '!', '?', '\n', ' ', ''],大边界优先 |
| chunkOverlap | 可有可无,不影响语义 | 边界信息保护机制,建议10%-20%,在切割后处理阶段实现 |
| Loader与Transformer | Cheerio提取后直接给Readability | .text() 会剥离HTML,Readability需要HTML结构,需改用 .html() |
避坑指南 & 最佳实践 🚫
坑点1:误以为Chunk必须达到chunkSize
错误认知:"我的Chunk只有200字,没到400是不是浪费了?"
正解 :chunkSize是上限 不是目标。200字的完整句子比强行凑成400字的混杂内容更有价值。在RAG检索中,精度比填充率重要得多。
坑点2:chunkOverlap设置过大浪费Token
错误认知:"重叠越多,上下文越完整。"
正解 :chunkOverlap建议为chunkSize的10%~20% 。过大(如50%)会导致大量冗余,增加存储和推理成本。建议从10%开始调优,根据实际检索效果逐步增加。
坑点3:分隔符顺序反了,递归失效
错误认知:"反正都是分隔符,顺序无所谓。"
正解 :把' '(空格)放在'。'(句号)前面,会先按空格切碎文本,句号分隔符失去意义。始终遵循大粒度语义单元 > 中粒度结构 > 小粒度补充的顺序。
坑点4:忽略兜底硬切的触发条件
错误认知:"只要设置了分隔符,就不会触发硬切。"
正解 :如果遇到完全没有分隔符的文本(如连续的无标点英文),分隔符用尽后会触发字符级切分(通过空字符串''兜底),此时会严格按chunkSize切分,每块正好等于chunkSize。
坑点5:调用错误的方法
错误认知 :"splitText()和splitDocuments()返回结果一样。"
正解:
splitText(text: string): Promise<string[]>------ 输入纯文本,输出字符串数组splitDocuments(documents: Document[]): Promise<Document[]>------ 输入Document数组,输出Document数组,自动保留并复制metadata ,并自动添加loc字段记录每个Chunk在原始文档中的行号范围
使用splitDocuments()可以保留来源信息(URL、标题等)和位置信息,便于追溯和引用,推荐优先使用。
坑点6:Cheerio提取后Readability失效
错误认知 :"CheerioWebBaseLoader 提取的内容可以直接交给 MozillaReadabilityTransformer 处理。"
正解 :CheerioWebBaseLoader 默认使用 .text() 提取文本,会剥离所有 HTML 标签 。而 MozillaReadability 需要解析 HTML 结构(如 <article>、<p> 等标签),纯文本输入会导致其无法正常工作。
解决方案:
- 自定义 Loader,用
.html()替代.text()保留 HTML 结构 - 或调换顺序:先用 Readability 处理 HTML,再交给 Splitter 切割
最佳实践速查表
| 配置项 | 推荐值/做法 | 说明 |
|---|---|---|
chunkSize |
400-1000字符(中文) | 取决于LLM窗口和检索精度 |
chunkOverlap |
chunkSize的10%-20%,从10%开始调优 |
边界保护,不宜过大 |
separators |
末尾必须包含'' |
确保兜底切分 |
| 优先使用 | splitDocuments() |
保留metadata和loc位置信息 |
| 中文分隔符 | '。' > '!' > '?' > '\n\n' |
句号优先 |
| Loader+Transformer | 如需Readability,先用.html()保留结构 |
.text()会剥离HTML |
面试高频考点 💡
Q1:RecursiveCharacterTextSplitter和CharacterTextSplitter的核心区别是什么?
参考答案 :CharacterTextSplitter按固定字符数切分,不识别语义边界,可能在句子中间切断。RecursiveCharacterTextSplitter采用两阶段设计 ------阶段一按优先级列表递归尝试多个分隔符 (\n\n → \n → → ""),从最强语义边界开始逐级降级,阶段二再贪心合并,使每个Chunk接近chunkSize但严格不超过。前者一刀切,后者语义感知。
Q2:chunkSize是目标值还是上限值?
参考答案 :上限值 。每个Chunk的字符数 ≤ chunkSize,但可以远小于。这个设计保证语义完整性优先于尺寸均匀性。
Q3:阶段一(递归切分)和阶段二(合并优化)分别解决了什么问题?
参考答案 :阶段一解决"如何不破坏语义"------通过递归尝试不同分隔符,确保候选片段都保持在语义边界内。阶段二解决"如何让Chunk大小合适"------通过贪心合并,让每个Chunk尽量接近chunkSize但不超过。
Q4:什么情况下会触发兜底切分?
参考答案 :当所有分隔符都用尽,某个片段仍然超过chunkSize时(如连续无标点的长字符串),通过末尾的空字符串''分隔符触发字符级切分,保证每块 ≤ chunkSize。
Q5:splitText()和splitDocuments()有什么区别?
参考答案 :splitText()输入字符串,输出字符串数组,适用于纯文本场景。splitDocuments()输入Document数组,输出Document数组,会自动将原始Document的metadata复制到每个子Chunk中 ,并自动添加loc字段记录行号范围,便于追溯来源,在RAG场景中更实用。
Q6:CheerioWebBaseLoader 提取内容后,为什么后续的 MozillaReadabilityTransformer 可能失效?
参考答案 :CheerioWebBaseLoader 默认调用 .text() 提取文本内容,这会剥离所有 HTML 标签 。MozillaReadability 需要解析 HTML 结构(如识别 <article>、<h1>、<p> 等元素),纯文本输入会导致其无法正常工作。如需同时使用两者,建议自定义 Loader 改用 .html() 方法保留 HTML 结构。
总结与展望 📈
一句话概括:Loader决定"吃什么",Splitter决定"怎么嚼"。 本文深入拆解了从爬虫到Document标准化,再到RecursiveCharacterTextSplitter的两阶段切割逻辑。
核心认知:
chunkSize是硬上限 ,所有Chunk ≤chunkSize- 切割是递归切分 + 合并优化两阶段,不是一刀切
- 分隔符顺序决定语义保护级别 ,末尾**必须包含空字符串
''**作为兜底 chunkOverlap是边界保险,在切割后处理阶段实现,建议10%-20%,从10%开始调优splitDocuments()自动保留metadata并添加loc位置信息(行号范围),是RAG场景的首选方法CheerioWebBaseLoader的.text()会剥离HTML标签 ,如需配合Readability使用,需改用.html()保留结构