LLM的“消化系统“:一文讲透RAG文档加载与智能切割

导语 🔥

想象一下这个场景:好不容易写完了AI应用的Demo,信心满满地把一篇万字技术博客喂给大模型,结果它"消化不了"直接报错——上下文超出窗口限制。或者更糟,它"吞"下去了,但回答问题时却像个失忆症患者,只记得开头和结尾,中间的关键信息全丢了。

这并非模型能力不行,而是我们准备"食材"的方式出了问题。直接把原始HTML或长文本塞给LLM,就像让一个美食家去啃带泥的土豆和整颗带毛的猪——难以下咽。

本文将带你深入LangChain生态,从手写axios+cheerio爬虫开始,到使用封装好的CheerioWebBaseLoader一键加载,再到用RecursiveCharacterTextSplitter进行"米其林级"的文本切割。读完此文,你将掌握构建高效RAG知识库的第一道核心工序,让LLM真正"吃透"你的文档数据。🎯

初识核心技术 🧐

1. 从原始爬虫到标准化Loader

在AI应用开发中,知识库的数据来源五花八门:网页、PDF、Word、Markdown……这些格式各异的文件,在向量化之前必须被统一为同一种标准结构——Document。LangChain 的Loader(加载器)就是干这个活的。

对于网页爬取,最经典的组合是axios+cheerioaxios负责把网页HTML拉下来,cheerio则在内存里把HTML解析成DOM树,让开发者能用类似jQuery的CSS选择器提取内容。手动实现大概是这样的:

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对象数组:

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();

⚠️ 关键注意CheerioWebBaseLoaderload()方法内部调用$(this.selector).text()提取文本,这会剥离所有 HTML 标签。如果你的后续流程需要使用MozillaReadabilityTransformer(它需要解析 HTML 结构如<article><p>等标签),纯文本会导致其无法正常工作。此时需要自定义 Loader,改用.html()方法保留 HTML 结构。

🧩 拓展思考:Cheerio基于HTML字符串解析,轻量快速,但无法执行JavaScript,因此对客户端渲染(CSR)的SPA页面无能为力。如果遇到动态加载的页面,需要换成PlaywrightWebBaseLoaderPuppeteerWebBaseLoader,它们会启动无头浏览器真实渲染页面。

2. 为什么必须切割(Chunking)?

Loader产出的Document对象可能非常庞大(比如一篇几万字的博客)。直接把它塞进LLM会面临三个现实问题:

  1. 上下文窗口限制:LLM的输入Token数是有限的(如GPT-4的128K),超了直接报错。
  2. 检索精度低:如果一整个文档作为一个检索单元,查询时匹配的粒度太粗,会召回大量无关信息,像"大海捞针"。
  3. 丢失关键信息:长文本中,重要的细节往往被淹没在"上下文海洋"中,模型容易"失忆"。

切割的意义在于:把大文档拆解成一个个具有独立语义的"知识碎片"(Chunk)。这样在检索时,系统就能精准地找到与问题最相关的那个碎片,而不是扔一整本书给模型。

3. 切割的核心难题:如何不"断章取义"?

最简单的切割是按字符数硬切,CharacterTextSplitter就是这种方式的典型代表——按固定字符数切分,不识别语义边界,可能在句子中间切断,导致信息碎片化。

这会导致灾难性后果:一个完整的句子或段落被拦腰斩断,语义彻底崩坏。

为了解决这个问题,LangChain提供了递归切割器RecursiveCharacterTextSplitter。它是官方推荐的通用文本切割方案,核心思路是按优先级列表递归尝试分隔符(如["\n\n", "\n", " ", ""]),直到 chunks 足够小为止。它的设计包含两大核心阶段

阶段一:递归切分—— 按照优先级列表(如["。", "!", "?", "\n\n", "\n", ";", ",", "、", " ", ""]),从最有力的语义边界开始尝试切分,如果切出的片段仍然太大,则递归使用下一个分隔符继续切分,直到所有片段都小于chunkSize或分隔符用尽。

阶段二:合并优化—— 将切分产生的"候选片段"按顺序合并,每个Chunk尽量接近chunkSize严格不超过

这种**"先拆解为语义碎片,再组装为合适大小"**的两阶段设计,是RecursiveCharacterTextSplitter区别于普通切割器的精髓所在。

原理深水区 ⚙️

1. 深入剖析递归切分机制(Recursive Splitting)

RecursiveCharacterTextSplitter的"递归"二字并非虚名。它的核心算法是一个深度优先的尝试-回退机制,理解了这个机制,你就掌握了切割的灵魂。

1.1 两阶段工作流程

阶段一流程

原始文本 ↓ 用第一个分隔符(如'。')切分 ↓ 遍历每个片段 → 长度 ≤ chunkSize? ├── 是 → 存入候选列表 ✅ └── 否 → 用下一个分隔符递归切分 🔁 ↓ 所有分隔符用完? ├── 否 → 继续递归 └── 是 → 字符级兜底切分 🛟 ↓ 返回候选片段列表(每个 ≤ chunkSize)

阶段二流程

候选片段列表 → 按顺序遍历 ↓ (当前块 + 下一个片段).length ≤ chunkSize? ├── 是 → 合并到当前块 🟢 └── 否 → 保存当前块,新块从此片段开始 ↓ 返回最终Chunks(每个 ≤ chunkSize,尽量接近)

关键步骤解读

阶段一(递归切分)

  1. 首次尝试:取separators数组的第一个元素(如中文的'。'),对整个文本执行split()
  2. 长度检查:遍历切割后的每个片段:
    • 如果片段长度 ≤chunkSize欣然接受,存入候选片段列表。
    • 如果片段长度 >chunkSize递归调用自己,传入下一个分隔符(如'!')。
  3. 递归终止:当所有分隔符都用完了,某个片段仍然太长,此时触发兜底逻辑——按单个字符遍历切分,保证每块 ≤chunkSize

阶段二(合并优化): 4.顺序合并:将候选片段按原始顺序逐个合并。 5.严格上限:每次合并前检查(当前块 + 下一个片段).length ≤ chunkSize只有不超过上限才能合并。 6.输出最终Chunks:每个Chunk都严格 ≤chunkSize,并尽量接近。

1.2 源码级视角:两阶段实现

在LangChain源码中,核心逻辑大致如下(简化伪代码):

// 阶段一:递归切分 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 实战分隔符配置

针对中文技术文档,我推荐这样的分隔符配置(末尾必须包含空字符串作为兜底):

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也能保留这部分信息。

// 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通过cheeriotext()方法提取去标签后的可见文本。

但要注意:如果后续需要使用MozillaReadabilityTransformer等工具提取正文,这些工具需要 HTML 结构(如<article><p>标签)。由于CheerioWebBaseLoader默认使用.text()剥离了所有 HTML 标签,Readability 将无法正常工作。解决方案有两种:

  1. 自定义 Loader,用.html()替代.text()保留 HTML 结构
  2. 先使用 Readability 处理 HTML,再交给 Splitter 切割

🧩 拓展思考:这其实是职责链模式的体现。Loader负责加载和初步提取,Splitter负责切割,Transformer负责清洗或格式转换。各司其职,通过管道组合。

实战落地演练 🛠️

完整代码:网页爬取 + 智能切割流水线
// 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,演示切割过程:

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字):

[ "递归切割器通过优先级列表尝试分割文本。它首先尝试按段落分割。如果段落仍然太大,它会递归地尝试按句子分割。", // 48字 ✅ ≤ 50 "最终,它会尝试按单词分割。如果一切失败,它会按字符分割。\n这种设计确保了在任何情况下都能产生输出。", // 42字 ✅ ≤ 50 "即使是最坏的情况,也不会丢失数据。", // 15字 ✅ ≤ 50 "但在实践中,我们更关注的是如何让切割结果既保持语义完整,又不会超过LLM的上下文限制。", // 38字 ✅ ≤ 50 "\nLangChain提供了多种切割策略,开发者可以根据自己的场景选择最合适的一种。" // 27字 ✅ ≤ 50 ]

分析

  1. ✅ 所有Chunk都 ≤ 50字(严格满足上限)
  2. ✅ Chunk大小在15-48字之间(不是固定值)
  3. ✅ 每个Chunk都尽量接近50字(贪心合并策略)
  4. ✅ 语义完整,没有被截断的句子
错误示范 vs 正确示范
维度❌ 错误理解✅ 正确理解
chunkSize含义每个Chunk必须恰好等于400字每个Chunk ≤ 400字,可以更小
切割粒度先硬切成400字块,再调整先按语义递归切分,再贪心合并
递归顺序separators: [' ', '。', '\n'],先按空格切separators: ['。', '!', '?', '\n', ' ', ''],大边界优先
chunkOverlap可有可无,不影响语义边界信息保护机制,建议10%-20%,在切割后处理阶段实现
Loader与TransformerCheerio提取后直接给Readability.text()会剥离HTML,Readability需要HTML结构,需改用.html()

避坑指南 & 最佳实践 🚫

坑点1:误以为Chunk必须达到chunkSize

错误认知:"我的Chunk只有200字,没到400是不是浪费了?"

正解chunkSize上限不是目标。200字的完整句子比强行凑成400字的混杂内容更有价值。在RAG检索中,精度比填充率重要得多

坑点2:chunkOverlap设置过大浪费Token

错误认知:"重叠越多,上下文越完整。"

正解chunkOverlap建议为chunkSize10%~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>等标签),纯文本输入会导致其无法正常工作。

解决方案

  1. 自定义 Loader,用.html()替代.text()保留 HTML 结构
  2. 或调换顺序:先用 Readability 处理 HTML,再交给 Splitter 切割
最佳实践速查表
配置项推荐值/做法说明
chunkSize400-1000字符(中文)取决于LLM窗口和检索精度
chunkOverlapchunkSize的10%-20%,从10%开始调优边界保护,不宜过大
separators末尾必须包含''确保兜底切分
优先使用splitDocuments()保留metadata和loc位置信息
中文分隔符'。'>'!'>'?'>'\n\n'句号优先
Loader+Transformer如需Readability,先用.html()保留结构.text()会剥离HTML

面试高频考点 💡

Q1:RecursiveCharacterTextSplitterCharacterTextSplitter的核心区别是什么?

参考答案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的两阶段切割逻辑。

核心认知

  1. chunkSize硬上限,所有Chunk ≤chunkSize
  2. 切割是递归切分 + 合并优化两阶段,不是一刀切
  3. 分隔符顺序决定语义保护级别,末尾**必须包含空字符串''**作为兜底
  4. chunkOverlap边界保险,在切割后处理阶段实现,建议10%-20%,从10%开始调优
  5. splitDocuments()自动保留metadata并添加loc位置信息(行号范围),是RAG场景的首选方法
  6. CheerioWebBaseLoader.text()会剥离HTML标签,如需配合Readability使用,需改用.html()保留结构

学习资源推荐

如果你想更深入地学习大模型,以下是一些非常有价值的学习资源,这些资源将帮助你从不同角度学习大模型,提升你的实践能力。

一、全套AGI大模型学习路线

AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!​

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

二、640套AI大模型报告合集

这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示

​因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

三、AI大模型经典PDF籍

随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

四、AI大模型商业化落地方案

作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量。