引言:AI 的"阅读障碍"与 RAG 的诞生
想象一下,你让一个刚读完《三体》的朋友回答"二向箔是什么",他能脱口而出。但如果你让他从一座图书馆里找到所有关于"降维打击"的描述,他可能会崩溃------这就是大语言模型的困境:知识固化、上下文窗口有限、无法实时检索外部信息。
RAG(检索增强生成)技术的出现,相当于给 AI 配了一位"图书管理员+速读专家"。它先在海量文档中快速检索相关片段,再把这些片段"喂"给大模型生成答案。但这一切的前提是:AI 得先"读"懂文档,并且把长文档拆成它能消化的小块。
今天,我们就来深入 RAG 管道的"第一公里"------Loader(加载器)和 Splitter(分割器)。这看似简单的两步,藏着多少门道?为什么同样的文档,不同分割方式会让 AI 的智商忽高忽低?让我们用代码和故事,拆解这个"让 AI 学会读书"的关键环节。
一、Loader:万物皆可"读",但得用对工具
1.1 从 PDF 到网页,Loader 是 AI 的"翻译官"
现实世界的文档千奇百怪:PDF、Word、Markdown、HTML、JSON、甚至音频转录文本。LangChain 的 @langchain/community 提供了海量 Loader,就像给 AI 配了多语种翻译。
最常用的网页 Loader ------ CheerioWebBaseLoader
javascript
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const loader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452",
{ selector: '.main-area p' } // 只提取正文段落
);
const documents = await loader.load();
这里用了 cheerio------一个在后端像 jQuery 一样操作 DOM 的库。通过 CSS 选择器,我们可以精准提取正文,过滤广告、导航等噪音。Loader 的核心价值:将非结构化数据转成统一的 Document 对象 (包含 pageContent 和 metadata)。
1.2 Loader 的家族图谱
- 文件类 :
PDFLoader、DocxLoader、CSVLoader、JSONLoader - 网页类 :
CheerioWebBaseLoader、PuppeteerWebBaseLoader(渲染 JS) - 数据库类 :
S3Loader、GoogleDriveLoader - 特殊格式 :
NotionLoader、GitbookLoader
选 Loader 的核心原则:让数据"干净"地进入管道 。比如抓取技术博客,用 selector 去掉侧边栏;解析 PDF,用 PDFLoader 配合 splitPages: false 合并整篇。
二、Splitter:长文档的"庖丁解牛"艺术
2.1 为什么必须分割?
假设我们加载了一篇 5000 字的技术文章,直接塞给 GPT-4(上下文 128K 虽能装下,但成本高、检索精度差)。RAG 的检索环节需要把文档切成语义完整的"片段",每个片段对应一个问题可能的相关内容。
分割的三重境界:
- 按字符切------简单粗暴,但会切断句子
- 按语义切------保持句子、段落完整
- 按 token 切------精确控制模型开销
2.2 RecursiveCharacterTextSplitter:最"聪明"的递归分割器
这是 LangChain 的默认分割器,它的工作流程像一位耐心的编辑:
javascript
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每块最大字符数
chunkOverlap: 50, // 块间重叠字符数
separators: ['。', '!', '?', ','] // 优先级递减的分隔符
});
const chunks = await splitter.splitDocuments(documents);
递归逻辑揭秘:
- 先用优先级最高的分隔符(如"。")切,尽量保持句子完整
- 如果某块仍超过
chunkSize,换下一个分隔符(如"!")继续切 - 实在切不动,强行截断(但会加重叠防止语义断裂)
为什么要重叠(Overlap)?
假设有一句话跨越两个 chunk:"父亲的去世,让作者彻底改变了人生态度。"如果前半句在 chunk A,后半句在 chunk B,检索时问"父亲去世对作者的影响",可能会漏掉后半句。chunkOverlap 让相邻块共享部分文字,牺牲 10% 的空间换取 90% 的语义连贯性。
2.3 实战对比:三种 Splitter 的"性格差异"
| Splitter | 切割逻辑 | 适用场景 |
|---|---|---|
CharacterTextSplitter |
按固定字符切割 | 日志文件、代码(按行分割) |
RecursiveCharacterTextSplitter |
按优先级分隔符递归 | 文章、小说、说明书(推荐) |
TokenTextSplitter |
按 Token 数量切割 | 精确控制 API 成本 |
实验:切割一段系统日志
javascript
const log = `[2024-01-15 10:00:00] INFO: Application started
[2024-01-15 10:00:05] DEBUG: Loading configuration...`;
// 用 RecursiveCharacterTextSplitter(separators: ["\n", "。"])
const chunks = await splitter.splitDocuments([logDoc]);
// 输出:按行切割,每条日志独立
如果换成 TokenTextSplitter(chunkSize: 50),则会按 token 数量截断,可能把一条完整日志切成两半。所以选 Splitter 要看文档结构:日志用换行符,文章用句号,代码用空格/换行。
三、Token 的"经济学":为什么中文更"费钱"?
我们做一个有趣的实验:用 js-tiktoken 比较不同语言的 token 消耗。
javascript
import { getEncoding } from "js-tiktoken";
const enc = getEncoding("cl100k_base"); // GPT-4 使用的编码
console.log('apple:', enc.encode('apple').length); // 1 token
console.log('pineapple:', enc.encode('pineapple').length); // 1 token?
console.log('苹果:', enc.encode('苹果').length); // 2 tokens
console.log('吃饭:', enc.encode('吃饭').length); // 3 tokens?
结果惊不惊喜?
- 英文常见词可能 1 个 token
- 中文每个字通常是 1-2 个 token
- 这意味着同样语义,中文文档消耗更多 token,成本更高
TokenTextSplitter 的价值: 当我们需要严格控制 API 调用成本时,用 Token 计数分割,确保每块不超过模型上限(比如 GPT-4 的 8192 tokens)。但代价是可能牺牲语义完整性------这是一个经典的"成本 vs 质量"权衡。
四、完整 RAG 流程实战:从"啃"文章到智能问答
我们用一篇掘金技术文章做完整演示:
4.1 加载 → 分割 → 向量化
javascript
// 1. Loader:提取正文
const loader = new CheerioWebBaseLoader(url, { selector: '.main-area p' });
const rawDocs = await loader.load();
// 2. Splitter:递归分割
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 50,
separators: ['。', ',', '!', '?']
});
const chunks = await splitter.splitDocuments(rawDocs);
// 3. 向量存储(Embeddings)
const embeddings = new OpenAIEmbeddings({ model: "text-embedding-3-small" });
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings);
4.2 检索 + 生成(RAG 核心)
javascript
// 4. 检索器:返回最相关的 2 个片段
const retriever = vectorStore.asRetriever({ k: 2 });
// 5. 用户提问
const question = "父亲的去世对作者的人生态度产生了怎样的根本性逆转?";
// 6. 检索相关片段
const retrievedDocs = await retriever.invoke(question);
// 7. 构造 Prompt
const content = retrievedDocs.map(d => d.pageContent).join("\n\n");
const prompt = `根据以下内容回答问题:\n${content}\n\n问题:${question}`;
// 8. 调用 LLM 生成
const model = new ChatOpenAI({ model: "gpt-4" });
const response = await model.invoke(prompt);
console.log(response.content);
4.3 相似度评分:检验分割质量
我们打印检索结果和相似度分数:
javascript
const scoreResults = await vectorStore.similaritySearchWithScore(question, 2);
scoreResults.forEach(([doc, score]) => {
console.log(`相似度: ${(1 - score).toFixed(2)}`); // 转换为 0-1 相似度
console.log(doc.pageContent);
});
如果相似度很低(<0.6),说明分割可能切断了关键语义,或者 chunk 大小不合适。调参三要素: chunkSize(太大检索精度下降,太小丢失上下文)、chunkOverlap(一般设为 10%-20%)、separators(优先语言自然边界)。
五、分割的艺术:何时用"刀",何时用"针"
5.1 技术文档(带代码块)
javascript
const splitter = new RecursiveCharacterTextSplitter({
separators: ['\n```', '```\n', '\n\n', '\n', '。', ' '],
// 先保护代码块完整性,再切段落
});
5.2 多语言混合文档
用 Language 枚举指定语言,让分割器识别语法边界:
javascript
import { RecursiveCharacterTextSplitter, Language } from "langchain/text_splitter";
const splitter = RecursiveCharacterTextSplitter.fromLanguage(Language.JS, {
chunkSize: 1000,
chunkOverlap: 100,
});
5.3 Markdown 文档
MarkdownTextSplitter 继承自 RecursiveCharacterTextSplitter,优先按 #、##、### 标题层级递归分割------这是最"懂"技术博客的分割器。
六、踩坑指南与最佳实践
❌ 常见错误:
- 不分文档类型统一用
CharacterTextSplitter→ 语义撕裂 chunkOverlap设为 0 → 关键信息落在边界被遗漏- 忽略元数据 → 丢失来源、时间等信息,影响检索排序
✅ 黄金法则:
- 先定分隔符,再调大小:多试几种组合,观察切割结果是否完整
- 按语义单元切:比如日志按行,文章按段落,代码按函数
- 保留元数据:Loader 自动抓取 URL、标题、时间,检索时用于过滤
- 小步快跑调参 :从
chunkSize=512, overlap=50开始,根据检索效果微调
结语:让 AI 从"翻书"到"懂书"
Loader 和 Splitter 是 RAG 的"地基工程"。没有好 Loader,AI 读的是"乱码";没有好 Splitter,AI 看的是"碎片"。当我们用 RecursiveCharacterTextSplitter 小心翼翼地保留每一个句子的灵魂,用 TokenTextSplitter 精打细算每一分成本,其实是在教 AI 如何像人类一样------抓住重点,忽略噪音,在茫茫文海中找到那颗最亮的珍珠。
下一次当你的 RAG 应用给出惊艳回答时,别忘了背后那个默默"读书"的 Loader,和那个"庖丁解牛"般的 Splitter。它们才是真正的无名英雄。