
别再让大模型“吃”整本 PDF 了好的 RAG 从一次优雅的文档切割开始。从“喂资料”到“喂知识”中间隔着一道 Document 切割做 RAG 应用的朋友都有一个共识知识库的质量直接决定了大模型回答的天花板。但大多数人在“喂知识”这一步就翻车了——直接把一整本 PDF、一篇万字长文、一个网页的 HTML 字符串塞给向量数据库然后抱怨检索不准、回答跑偏。问题出在哪儿你没把“资料”切成“知识块”。说白了向量数据库检索的最小单元是Document或者叫Chunk。这个单元太小语义不完整太大检索噪音多。切割就是平衡语义完整性与检索精度的第一道手艺活。今天这篇我不讲虚的。直接从一条 URL 开始带你手写一个知识库预处理管线爬取 → 解析 → 标准化 → 智能切割。每一步都有代码每一个参数都有解释每一个坑都踩过。知识库里的“知识”到底长什么样从文件到 Document先问一个看似简单的问题知识库里放的是什么你可能会说“放的是知识”。但技术上更准确的答案是放的是 Document 对象。一个Document长这样LangChain 的标准化格式{ pageContent: 这里是真正的文本内容..., metadata: { source: https://juejin.cn/post/xxx, title: 文章标题, // 你还可以加任意自定义字段 } }pageContent喂给 embedding 模型的文本内容metadata用于过滤、溯源、展示的附属信息你电脑里的.docx、.pdf、.txt或者网上的一个 URL、一个 B站视频的字幕本质上都是知识的原始载体不是Document。从原始文件到 Document需要一道工序——Loader。Loader 的哲学万物皆可 Load180 种姿势任你选Loader做的事很简单输入各种格式的原始文件/数据源输出标准化的Document[]数组LangChain 生态里langchain/community维护了180 多种 LoaderPDFLoader、CSVLoader、JSONLoader、S3Loader、NotionLoader……社区的力量让“万物皆可 Load”成为现实。而langchain/core则负责维护核心抽象接口保证所有 Loader 行为一致。甚至你自己也可以写一个 Loader——只要实现load()方法返回Document[]就行。实战用 CheerioWebBaseLoader 爬取掘金文章今天我们拿一条真实的掘金文章 URL 开刀演示如何用CheerioWebBaseLoader完成“网页 → Document”的转换。import dotenv/config; import { CheerioWebBaseLoader } from langchain/community/document_loaders/web/cheerio; const cheerioLoader new CheerioWebBaseLoader( https://juejin.cn/post/7660707431753678854, { // 关键用 CSS 选择器精准定位正文区域 selector: .main-area p } ); const documents await cheerioLoader.load(); console.log(documents);selector: .main-area p的含义只提取classmain-area下的所有段落p标签。为啥不爬全文因为页面里有导航、侧边栏、广告、评论区这些噪音会污染知识库。用 CSS 选择器精准命中正文是爬虫的第一步专业度体现。徒手撸一个爬虫深入理解 Cheerio 的“前端思维”如果你不想用 Loader或者想更精细地控制爬取逻辑自己写一个也很简单。下面我们用手写的crawl.mjs拆解整个流程并看看每个阶段到底发生了什么。爬虫流程全景图含各阶段输入/输出各阶段输入输出对照表阶段输入输出说明① HTTP 请求目标 URL 字符串HTML 字符串含所有标签、样式、脚本原始网页源码通常几 KB 到几 MB② 构建 DOM 树HTML 字符串Cheerio 实例类 DOM 对象在内存中构建可查询的树结构③ CSS 选择器查询Cheerio 实例 选择器.main-area p匹配到的节点集合cheerio 对象只命中正文区域的段落标签④ 提取文本节点集合每个节点的纯文本内容去除 HTML 标签剥离所有p内部及嵌套标签只留文字⑤ 拼接合并多个文本片段一段完整的连续文本按文档顺序拼接作为最终pageContent用代码对应上面的流程import axios from axios; import * as cheerio from cheerio; const targetUrl https://juejin.cn/post/7660707431753678854; async function crawlPage() { try { // 阶段①HTTP 请求 → HTML 字符串 const { data: html } await axios.get(targetUrl); // 阶段②解析 DOM 树 const $ cheerio.load(html); // 阶段③④CSS 选择器查询 提取文本 const paragraphs $(.main-area p); let fullText ; paragraphs.each((i, el) { fullText $(el).text() \n; // 阶段⑤拼接 }); // 阶段⑥输出清洗后的纯文本 console.log(fullText); } catch (e) { console.error(爬取失败, e); } } crawlPage();Cheerio 的巧妙之处它让你用前端操作 DOM 的思维方式$(selector).text()去写爬虫而不是写一堆令人头秃的正则表达式。传统的爬虫靠正则“硬抠”Cheerio 靠 CSS 选择器“精准定位”——前者是体力活后者是脑力活。切割为什么 400 字一个 Chunk 比一整篇更聪明现在documents拿到了但这只是一个或几个大 Document。如果直接拿去向量化会出现什么问题检索粒度太粗用户问“如何配置 chunkOverlap”结果把整篇 8000 字的文章都召回了语义被淹没相关的那几句话被大量无关上下文稀释embedding 相似度被拉低所以必须切。LangChain 提供了RecursiveCharacterTextSplitter——目前最主流的文本切割器。它的核心思想是递归尝试不同的分隔符找到最优的切割点使每个 chunk 尽量接近 chunkSize同时尽量保证语义完整。import { RecursiveCharacterTextSplitter } from langchain/textsplitters; const textSplitter new RecursiveCharacterTextSplitter({ chunkSize: 400, // 每个 chunk 的目标大小字符数 separators: [。, , ], // 切割优先级从高到低 chunkOverlap: 100, // 相邻 chunk 重叠的字符数 }); const splitDocuments await textSplitter.splitDocuments(documents); console.log(splitDocuments);RecursiveCharacterTextSplitter 的递归机制到底是怎样的很多同学看到“递归”两个字就发怵其实这里的逻辑非常直白。我们用一个具体的文本例子把它的工作流程掰开揉碎。递归的本质按优先级依次“试切”RecursiveCharacterTextSplitter的递归并不是自己调用自己那种经典递归而是按分隔符列表的顺序依次尝试用当前分隔符去切分文本如果切出来的块仍然大于chunkSize就换下一个分隔符再切——这个过程是迭代的但官方称之为“递归”是因为它会逐级深入从最粗的粒度比如段落分隔符到最细的粒度比如字符。它的执行逻辑可以简化为用separators[0]第一个分隔符去切文本得到若干片段遍历每个片段如果片段长度 ≤chunkSize保留为一个 chunk如果片段长度 chunkSize则用separators[1]对该片段再次切割重复此过程直到所有片段都 ≤chunkSize或者所有分隔符都用完了如果用完所有分隔符还有片段超长就暴力截断按字符数硬切看个例子秒懂假设我们有这样一段文本为了演示我编个短一点的今天天气真好。我们决定去公园散步。公园里有很多花有红的、黄的、紫的非常漂亮孩子们在草地上奔跑笑声不断。玩了一会儿大家有点饿了就去附近的餐厅吃饭。餐厅的菜很好吃尤其是那道糖醋里脊。我们设置参数chunkSize: 30为了演示效果设小一点separators: [。, , , , 、, ]注意优先级chunkOverlap: 0先忽略重叠执行过程第一轮按。 切文本被切成两句S1:今天天气真好。长度 7≤30保留为 chunk1S2:我们决定去公园散步。公园里有很多花有红的、黄的、紫的非常漂亮孩子们在草地上奔跑笑声不断。玩了一会儿大家有点饿了就去附近的餐厅吃饭。餐厅的菜很好吃尤其是那道糖醋里脊。长度 6430需要继续切第二轮按切 S2用切 S2得到S2a:我们决定去公园散步。公园里有很多花有红的、黄的、紫的非常漂亮长度 28但注意里面还有。 和不过由于我们在第二轮只用切所以切出来的片段可能仍包含其他分隔符但长度 28≤30保留为 chunk2S2b:孩子们在草地上奔跑笑声不断。玩了一会儿大家有点饿了就去附近的餐厅吃饭。餐厅的菜很好吃尤其是那道糖醋里脊。长度 4630继续第三轮按切 S2b但这里没有问号所以无效没有S2b 依然 30第四轮按切 S2b用切得到S2b1:孩子们在草地上奔跑长度 10≤30chunk3S2b2:笑声不断。玩了一会儿长度 11≤30chunk4S2b3:大家有点饿了长度 6≤30chunk5S2b4:就去附近的餐厅吃饭。餐厅的菜很好吃长度 17≤30chunk6S2b5:尤其是那道糖醋里脊。长度 11≤30chunk7最终得到 7 个 chunk每个都 ≤30。整个过程中切割器从最语义完整的句号开始尝试切不动了就降级到感叹号、问号、逗号……这就是“递归”的真正含义——由粗到细逐级逼近最优切割点。递归结束后最终保留了哪些 chunk切割完成后RecursiveCharacterTextSplitter会输出一个Document[]数组其中每一个 chunk 的长度字符数都 ≤chunkSize除非原始文本本身为空但那是极端情况。如果某个片段经过所有分隔符切割后仍超长例如一段连续的无标点英文长字符串切割器会在最后使用字符级硬切强行按chunkSize截断此时最后一个片段可能不完整但这是保底策略实际中文场景很少触发。所有被切割出来的 chunk 都会被保留包括那些长度远小于chunkSize的“孤点”比如一段只有 5 个字的独立句子。不会因为块太小而丢弃——因为这可能是重要的知识点只是原文就这么短。当chunkOverlap 0时切割器会为每个 chunk 额外包含前一个 chunk 末尾的chunkOverlap个字符起始 chunk 除外。这些重叠内容在相邻 chunk 中重复出现但不会无限叠加而是仅与相邻块共享。最终输出的 chunk 数量会比无重叠时略多因为重叠会使得总文本增加但总字符数不变只是冗余。简单说最终保留所有切出来的块每一块都小于等于 chunkSize且相邻块之间有重复内容来“粘住”上下文。三个核心参数在实际文本中的联合作用我们回到真实的 400 字场景看看三个参数如何协同工作separators决定切分的“刀法” 切割器会按separators数组的顺序依次尝试切割先按。 切如果切出来的 chunk 还是太大再按切还不行再按切如果所有分隔符都试过了还是超长最终会硬切按字符数截断关键原则分隔符越靠前语义完整性越高。你肯定不希望在一个句子中间拦腰切断所以。 的优先级最高。chunkSize不是“严格等于”是“尽量接近”很多人误以为chunkSize: 400表示每个 chunk 恰好 400 字符。并不是。它的真实含义是切割器会尽量让每个 chunk 的长度接近 400但以分隔符为准宁短勿破句。比如某一段只有 200 字符那就单独成一个 chunk不会强行拼接下一段凑到 400。chunkOverlap在相邻块之间“搭桥”假设 chunk3 末尾是“孩子们在草地上奔跑”chunk4 开头是“笑声不断。玩了一会儿”虽然它们被句号切开了但语义上“奔跑”和“笑声”强相关。chunkOverlap会让 chunk3 的结尾部分比如最后 30 字也出现在 chunk4 的开头这样无论检索到哪一块都能看到完整的“奔跑笑声”场景。假设你按。 切割一个 chunk 的最后一句和下一个 chunk 的第一句在语义上往往关联性最强。但硬切之后这两个句子被分开了。chunkOverlap的作用是让相邻 chunk 有 100 个字符的重叠内容确保被切开的关键语义在两边各出现一次检索时无论命中哪一段都能获得完整的上下文。如果说chunkSize是“切得准”那chunkOverlap就是“粘得住”——用一点点冗余换回语义的连续性。用一句话概括separators管怎么下刀chunkSize管切多大chunkOverlap管切口怎么粘——三刀合璧才是完美切割。一图胜千言切割前后对比维度切割前整篇切割后chunk 化检索粒度粗全文召回细精准命中段落语义完整性高但冗余较高overlap 补偿向量质量被噪音稀释聚焦核心语义存储成本1 条向量N 条向量可控AI 时代程序员的“新三件套”聊完了技术说点题外话。现在大家都在说“Vibe Coding”——让 AI 写代码人类只管提需求。但你会发现真正拉开差距的不是 coding 能力而是三种新能力问出好问题Prompt精准表达需求少让 AI “猜”提供丰富的上下文Context把合适的 Document 喂给合适的模型驾驭并部署 AgentHarness Deploy让 AI 工作流长期稳定跑下去文档切割这件事本质上就是在为第 2 项能力“提供上下文”打基础。你切得越好AI 读得越懂回答就越靠谱。