LLM的“消化系统“:一文讲透RAG文档加载与智能切割 导语 想象一下这个场景好不容易写完了AI应用的Demo信心满满地把一篇万字技术博客喂给大模型结果它消化不了直接报错——上下文超出窗口限制。或者更糟它吞下去了但回答问题时却像个失忆症患者只记得开头和结尾中间的关键信息全丢了。这并非模型能力不行而是我们准备食材的方式出了问题。直接把原始HTML或长文本塞给LLM就像让一个美食家去啃带泥的土豆和整颗带毛的猪——难以下咽。本文将带你深入LangChain生态从手写axioscheerio爬虫开始到使用封装好的CheerioWebBaseLoader一键加载再到用RecursiveCharacterTextSplitter进行米其林级的文本切割。读完此文你将掌握构建高效RAG知识库的第一道核心工序让LLM真正吃透你的文档数据。初识核心技术 1. 从原始爬虫到标准化Loader在AI应用开发中知识库的数据来源五花八门网页、PDF、Word、Markdown……这些格式各异的文件在向量化之前必须被统一为同一种标准结构——Document。LangChain 的Loader加载器就是干这个活的。对于网页爬取最经典的组合是axioscheerio。axios负责把网页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它底层就是对axioscheerio的标准化封装直接输出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();⚠️ 关键注意CheerioWebBaseLoader的load()方法内部调用$(this.selector).text()提取文本这会剥离所有 HTML 标签。如果你的后续流程需要使用MozillaReadabilityTransformer它需要解析 HTML 结构如article、p等标签纯文本会导致其无法正常工作。此时需要自定义 Loader改用.html()方法保留 HTML 结构。 拓展思考Cheerio基于HTML字符串解析轻量快速但无法执行JavaScript因此对客户端渲染CSR的SPA页面无能为力。如果遇到动态加载的页面需要换成PlaywrightWebBaseLoader或PuppeteerWebBaseLoader它们会启动无头浏览器真实渲染页面。2. 为什么必须切割ChunkingLoader产出的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 SplittingRecursiveCharacterTextSplitter的递归二字并非虚名。它的核心算法是一个深度优先的尝试-回退机制理解了这个机制你就掌握了切割的灵魂。1.1 两阶段工作流程阶段一流程原始文本 ↓ 用第一个分隔符如。切分 ↓ 遍历每个片段 → 长度 ≤ chunkSize ├── 是 → 存入候选列表 ✅ └── 否 → 用下一个分隔符递归切分 ↓ 所有分隔符用完 ├── 否 → 继续递归 └── 是 → 字符级兜底切分 ↓ 返回候选片段列表每个 ≤ chunkSize阶段二流程候选片段列表 → 按顺序遍历 ↓ (当前块 下一个片段).length ≤ chunkSize ├── 是 → 合并到当前块 └── 否 → 保存当前块新块从此片段开始 ↓ 返回最终Chunks每个 ≤ chunkSize尽量接近关键步骤解读阶段一递归切分首次尝试取separators数组的第一个元素如中文的。对整个文本执行split()。长度检查遍历切割后的每个片段如果片段长度 ≤chunkSize欣然接受存入候选片段列表。如果片段长度 chunkSize递归调用自己传入下一个分隔符如。递归终止当所有分隔符都用完了某个片段仍然太长此时触发兜底逻辑——按单个字符遍历切分保证每块 ≤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通过cheerio的text()方法提取去标签后的可见文本。但要注意如果后续需要使用MozillaReadabilityTransformer等工具提取正文这些工具需要 HTML 结构如article、p标签。由于CheerioWebBaseLoader默认使用.text()剥离了所有 HTML 标签Readability 将无法正常工作。解决方案有两种自定义 Loader用.html()替代.text()保留 HTML 结构先使用 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 ]分析✅ 所有Chunk都 ≤ 50字严格满足上限✅ Chunk大小在15-48字之间不是固定值✅ 每个Chunk都尽量接近50字贪心合并策略✅ 语义完整没有被截断的句子错误示范 vs 正确示范维度❌ 错误理解✅ 正确理解chunkSize含义每个Chunk必须恰好等于400字每个Chunk ≤ 400字可以更小切割粒度先硬切成400字块再调整先按语义递归切分再贪心合并递归顺序separators: [ , 。, \n]先按空格切separators: [。, , , \n, , ]大边界优先chunkOverlap可有可无不影响语义边界信息保护机制建议10%-20%在切割后处理阶段实现Loader与TransformerCheerio提取后直接给Readability.text()会剥离HTMLReadability需要HTML结构需改用.html()避坑指南 最佳实践 坑点1误以为Chunk必须达到chunkSize错误认知我的Chunk只有200字没到400是不是浪费了正解chunkSize是上限不是目标。200字的完整句子比强行凑成400字的混杂内容更有价值。在RAG检索中精度比填充率重要得多。坑点2chunkOverlap设置过大浪费Token错误认知重叠越多上下文越完整。正解chunkOverlap建议为chunkSize的10%~20%。过大如50%会导致大量冗余增加存储和推理成本。建议从10%开始调优根据实际检索效果逐步增加。坑点3分隔符顺序反了递归失效错误认知反正都是分隔符顺序无所谓。正解把 空格放在。句号前面会先按空格切碎文本句号分隔符失去意义。始终遵循大粒度语义单元 中粒度结构 小粒度补充的顺序。坑点4忽略兜底硬切的触发条件错误认知只要设置了分隔符就不会触发硬切。正解如果遇到完全没有分隔符的文本如连续的无标点英文分隔符用尽后会触发字符级切分通过空字符串兜底此时会严格按chunkSize切分每块正好等于chunkSize。坑点5调用错误的方法错误认知splitText()和splitDocuments()返回结果一样。正解splitText(text: string): Promisestring[]—— 输入纯文本输出字符串数组splitDocuments(documents: Document[]): PromiseDocument[]—— 输入Document数组输出Document数组自动保留并复制metadata并自动添加loc字段记录每个Chunk在原始文档中的行号范围使用splitDocuments()可以保留来源信息URL、标题等和位置信息便于追溯和引用推荐优先使用。坑点6Cheerio提取后Readability失效错误认知CheerioWebBaseLoader提取的内容可以直接交给MozillaReadabilityTransformer处理。正解CheerioWebBaseLoader默认使用.text()提取文本会剥离所有 HTML 标签。而MozillaReadability需要解析 HTML 结构如article、p等标签纯文本输入会导致其无法正常工作。解决方案自定义 Loader用.html()替代.text()保留 HTML 结构或调换顺序先用 Readability 处理 HTML再交给 Splitter 切割最佳实践速查表配置项推荐值/做法说明chunkSize400-1000字符中文取决于LLM窗口和检索精度chunkOverlapchunkSize的10%-20%从10%开始调优边界保护不宜过大separators末尾必须包含确保兜底切分优先使用splitDocuments()保留metadata和loc位置信息中文分隔符。\n\n句号优先LoaderTransformer如需Readability先用.html()保留结构.text()会剥离HTML面试高频考点 Q1RecursiveCharacterTextSplitter和CharacterTextSplitter的核心区别是什么参考答案CharacterTextSplitter按固定字符数切分不识别语义边界可能在句子中间切断。RecursiveCharacterTextSplitter采用两阶段设计——阶段一按优先级列表递归尝试多个分隔符\n\n→\n→ →从最强语义边界开始逐级降级阶段二再贪心合并使每个Chunk接近chunkSize但严格不超过。前者一刀切后者语义感知。Q2chunkSize是目标值还是上限值参考答案上限值。每个Chunk的字符数 ≤chunkSize但可以远小于。这个设计保证语义完整性优先于尺寸均匀性。Q3阶段一递归切分和阶段二合并优化分别解决了什么问题参考答案阶段一解决如何不破坏语义——通过递归尝试不同分隔符确保候选片段都保持在语义边界内。阶段二解决如何让Chunk大小合适——通过贪心合并让每个Chunk尽量接近chunkSize但不超过。Q4什么情况下会触发兜底切分参考答案当所有分隔符都用尽某个片段仍然超过chunkSize时如连续无标点的长字符串通过末尾的空字符串分隔符触发字符级切分保证每块 ≤chunkSize。Q5splitText()和splitDocuments()有什么区别参考答案splitText()输入字符串输出字符串数组适用于纯文本场景。splitDocuments()输入Document数组输出Document数组会自动将原始Document的metadata复制到每个子Chunk中并自动添加loc字段记录行号范围便于追溯来源在RAG场景中更实用。Q6CheerioWebBaseLoader提取内容后为什么后续的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()保留结构学习资源推荐如果你想更深入地学习大模型以下是一些非常有价值的学习资源这些资源将帮助你从不同角度学习大模型提升你的实践能力。一、全套AGI大模型学习路线AI大模型时代的学习之旅从基础到前沿掌握人工智能的核心技能​因篇幅有限仅展示部分资料需要点击文章最下方名片即可前往获取二、640套AI大模型报告合集这套包含640份报告的合集涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师还是对AI大模型感兴趣的爱好者这套报告合集都将为您提供宝贵的信息和启示​因篇幅有限仅展示部分资料需要点击文章最下方名片即可前往获取三、AI大模型经典PDF籍随着人工智能技术的飞速发展AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型如GPT-3、BERT、XLNet等以其强大的语言理解和生成能力正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。因篇幅有限仅展示部分资料需要点击文章最下方名片即可前往获取四、AI大模型商业化落地方案作为普通人入局大模型时代需要持续学习和实践不断提高自己的技能和认知水平同时也需要有责任感和伦理意识为人工智能的健康发展贡献力量。