LangChain RAG 实战指南——深度拆解 Loader 与 Splitter
在人工智能飞速发展的今天,大语言模型(LLM)已经能够像人类一样进行对话、写作甚至编程。然而,大模型也有它的局限性:它的知识截止于训练数据结束的那一刻,且无法直接访问你私有的文档、最新的新闻或特定的网页内容。为了解决这个问题,“检索增强生成”(RAG, Retrieval-Augmented Generation)技术应运而生。
RAG 的核心思想很简单:当用户提出一个问题时,系统先去相关的文档库中“检索”出最匹配的信息片段,然后将这些片段连同问题一起“喂”给大模型,让大模型基于这些真实资料来回答。这就好比开卷考试,大模型不再是死记硬背,而是学会了查阅资料。
而在 RAG 的整个流水线中,有两个至关重要的初始环节,它们决定了后续检索的质量上限:Loader(文档加载器)和 Splitter(文本切分器)。本文将结合一段实际的代码案例,深入浅出地为你解析这两个组件是如何工作的,即使你是编程小白,也能读懂其中的奥秘。
一、RAG 的基石:为什么需要 Loader 和 Splitter?
想象一下,你要让一个超级聪明的助手阅读一本几百页的 PDF 书籍,或者浏览成千上万个网页,然后回答你的具体问题。如果你直接把整本书的文字一次性扔给它,会发生什么?
首先,大模型有“上下文窗口”的限制,就像人的短期记忆有限一样,一次能处理的文字数量是有限的。如果文档太长,根本塞不进去。 其次,即使能塞进去,在茫茫字海中寻找一个具体的知识点也如同大海捞针,效率极低且容易出错。
因此,我们需要两个步骤来处理原始数据:
- 加载(Loading):把各种格式的文件(PDF、Word、网页、Excel 等)统一转换成计算机能理解的纯文本格式。这就是 Loader 的工作。
- 切分(Splitting):把长长的文本切成一个个大小合适、语义连贯的小片段(Chunks)。这就是 Splitter 的工作。
只有经过这两步处理,数据才能被转化为向量(一种计算机能理解数字表示),存入数据库,供后续快速检索。
import "dotenv/config";
import "cheerio"; // 后端,使用css选择器 像操作前端一样查找DOM节点
// 用来从网页中提取内容
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
// 用来将文档切分成多个小块
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// 用来将文档切分后的小块存储到向量数据库中
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings,ChatOpenAI } from "@langchain/openai";
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452?searchId=20260302193603120AE3328025B138C1FB",
{
selector: ".main-area p"
}
);
const documents = await cheerioLoader.load();
// console.log(documents);
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, // 每个块的字符数 定义了大小
chunkOverlap: 50, // 每个块之间的重叠字符数 定义了重叠 语义的连贯性
separators: ['。', ',', '!', '?'] // 定义了切分的分隔符,数组里的顺序代表了优先级。
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(splitDocuments);
console.log(`文档分割完成,共${splitDocuments.length}个片段`)
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const model = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
console.log("正在创建向量存储...")
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings);
console.log("向量存储创建完成")
const retriever = await vectorStore.asRetriever({ k: 2});
const questions = ["父亲的去世对作者的人生态度产生了怎样的根本性逆转?"];
for (const question of questions) {
console.log("=".repeat(80))
console.log(`[问题]: ${question}`)
console.log("=".repeat(80))
const retrievedDocs = await retriever.invoke(question);
const scoreResults = await vectorStore.similaritySearchWithScore(question, 2);
console.log(scoreResults);
console.log("\n [检索到的文档及相似度评分]");
retrievedDocs.forEach((doc, i) => {
const scoreResult = scoreResults.find(
([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
);
const score = scoreResult ? scoreResult[1] : "null";
const similarity = score ? (1 - score).toFixed(2) : "N/A";
console.log(`\n 文档 ${i+1} 相似度:${similarity}`);
console.log(`内容:${doc.pageContent}`);
if (doc.metadata && Object.keys(doc.metadata).length > 0) {
console.log(`元数据:${JSON.stringify(doc.metadata)}`)
}
});
const content = retrievedDocs
.map((doc, i) => `[片段${i + 1}] ${doc.pageContent}`)
.join("\n\n----\n\n");
const prompt = `你是一个文章辅助阅读助手,根据文章内容来解答:
文章内容:${content}
问题:${question}
回答:
`
console.log("\n [AI 回答]");
const response = await model.invoke(prompt);
console.log(response.content);
}
二、Loader:万物皆可载入的“翻译官”
在我们的代码案例中,首先登场的是 CheerioWebBaseLoader。它的作用非常明确:从互联网上抓取网页内容,并将其提取为结构化文本。
1. 为什么网页加载这么特殊?
网页和普通的 Word 文档不同。网页是由 HTML 代码构成的,里面充斥着大量的标签(如 <div>, <span>, <script>)、广告、导航栏、页脚等噪音。如果我们直接把网页源码扔给大模型,它不仅会读到正文,还会读到一堆乱码般的代码,这会严重干扰模型的判断。
2. 代码中的“手术刀”:CSS 选择器
让我们看看代码中是如何精准提取内容的:
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452?searchId=...",
{
selector: ".main-area p"
}
);
这里使用了 @langchain/community 社区模块提供的加载器。最关键的部分是 selector: ".main-area p"。
.main-area:这告诉程序,只关注网页中类名为main-area的区域。通常这是文章正文所在的容器,排除了头部导航和侧边广告。p:这表示只提取该区域下的段落(paragraph)标签。
这就好比你在图书馆看书,Loader 不是把整本书撕下来给你,而是戴着一副特制的眼镜,只高亮显示正文段落,自动过滤掉页眉页脚和插图说明。这种基于 CSS 选择器的提取方式,源自前端开发中常用的 Cheerio 库,它能像操作 DOM 节点一样精准地“剪下”我们需要的内容。
3. 强大的生态:不止于网页
虽然本例展示的是网页加载,但 RAG 生态系统(如 LangChain)中的 Loader 家族极其庞大。
- PDF Loader:可以读取学术论文、合同文件。
- Word Loader:处理办公文档。
- Notion Loader:直接连接你的笔记软件。
- GitHub Loader:读取代码仓库的文档。
无论数据源在哪里,Loader 的使命只有一个:标准化。它将千奇百怪的文件格式,统一转化为标准的 Document 对象。在这个对象中,主要包含两部分:pageContent(纯文本内容)和 metadata(元数据,如来源网址、文件名、创建时间等)。元数据非常重要,它在后续检索时可以用来过滤范围(例如:“只搜索 2023 年以后的文档”)。
三、Splitter:化整为零的“切割师”
当 Loader 把网页内容变成了一大段长文本后,接下来就轮到 Splitter 登场了。在代码中,我们使用了 RecursiveCharacterTextSplitter(递归字符文本切分器)。这是目前最常用、也是最智能的切分策略之一。
1. 为什么要切分?
前文提到,大模型有长度限制,且检索需要精度。如果我们将一篇 1 万字的文章作为一个整体存入向量数据库,当用户问“作者父亲去世对他有什么影响?”时,系统检索到的是整篇文章。这不仅浪费 token(大模型的计费单位),而且过多的无关信息会产生“噪声”,导致模型回答不准确。
我们需要将文章切成小块,每块只包含一个完整的语义逻辑。这样,检索到的片段就是高度相关的“干货”。
2. 核心参数解读
代码中的切分器配置非常讲究:
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 50,
separators: ['.', ',', '!', '?']
});
A. chunkSize (块大小):400 个字符
这定义了每个碎片的大小上限。
- 太小了会怎样?如果一个句子被强行切断,语义就不完整了。比如“我爱我的...”后面没了,模型就无法理解。
- 太大了会怎样?包含了太多无关信息,增加了检索噪声,也浪费了计算资源。
- 400 是个经验值:对于中文语境,400 个字符大约能容纳 2-3 个完整的段落或一个复杂的长句,既能保证语义完整,又足够精炼。
B. chunkOverlap (重叠度):50 个字符
这是一个非常巧妙的设计。想象你在切面包,如果一刀切下去,两片面包的接触面就断了。但在文本中,语义往往是跨段的。 设置 50 的重叠,意味着第二个片段的前 50 个字符,会和第一个片段的后 50 个字符完全一样。
- 作用:保持上下文的连贯性。防止关键信息(如人名、代词指代)刚好落在切分点上而被割裂。这就像拼图,边缘的重叠确保了拼回去时图案是连续的。
C. separators (分隔符):['.', ',', '!', '?']
这是“递归切分”的灵魂所在。 普通的切分可能只是机械地数够 400 个字就切一刀,不管切在什么地方(可能正好把一个词切成两半)。 而 RecursiveCharacterTextSplitter 会按照优先级的顺序尝试切分:
- 第一优先级:先找换行符(代码中未显式列出,但默认存在),按段落切。
- 第二优先级:如果段落太长,再找句号
.。 - 第三优先级:如果按句号切还是太长,再找逗号
,。 - 第四优先级:最后才考虑感叹号
!或问号?。 - 兜底策略:如果以上都没有,只能按字符数强制切分。
在本例中,我们特别指定了中文标点 ['.', ',', '!', '?']。这意味着切分器会尽力在句子结束的地方下刀,确保每个片段都是以完整的句子结尾的。这对于中文处理至关重要,因为中文不像英文那样有空格作为天然的单词分隔符,标点符号就是语义的边界。
3. 切分后的世界
执行 splitDocuments 后,原本的一整篇网页文章,变成了几十个小的 Document 对象。 例如:
- 片段 1:“作者童年时期,家庭氛围和睦……”(380 字)
- 片段 2:“……家庭氛围和睦。然而,父亲的突然离世打破了宁静。”(390 字,其中前 50 字与片段 1 重叠)
- 片段 3:“……打破了宁静。从那以后,作者开始思考生命的意义。”
每一个片段都独立携带了元数据(比如它来自哪个网址),并且大小适中,语义完整。这就是向量化和检索的完美原料。
四、从切分到智慧:后续流程简述
虽然本文重点在于 Loader 和 Splitter,但为了让你理解全貌,我们简要看看代码后半部分发生了什么。
- **向量化 **(Embedding): 代码使用
OpenAIEmbeddings将切分好的文本片段转化为向量。向量是一串数字列表,它代表了文本的“语义含义”。意思相近的句子,在向量空间里的距离就很近。 - **存储 **(Vector Store): 使用
MemoryVectorStore将这些向量存起来。这就建立了一个专属的“知识库”。 - **检索 **(Retrieval): 当用户提问“父亲的去世对作者的人生态度产生了怎样的根本性逆转?”时,系统会将这个问题也转化为向量,然后在知识库中寻找距离最近的 2 个片段(
k: 2)。 由于之前的 Splitter 工作做得好,检索回来的大概率就是包含“父亲去世”和“人生态度转变”的那两个精准片段,而不是整篇文章。 - **生成 **(Generation): 最后,系统将“检索到的片段” + “用户问题”组装成一个提示词(Prompt),发送给
ChatOpenAI大模型。大模型阅读这些片段,总结出答案。
五、总结
通过这段代码和解析,我们可以清晰地看到,RAG 系统并不是魔法,而是一个精密的工程流程。
Loader 解决了“数据从哪里来”和“如何清洗”的问题。它像一个博学的图书管理员,能从世界的各个角落(网页、文件、数据库)把书找出来,并撕掉封皮和广告,只留下纯净的文字。选择合适的 Loader 和配置正确的选择器(Selector),是保证数据质量的第一道关卡。
Splitter 解决了“数据如何消化”的问题。它像一位经验丰富的编辑,知道如何在合适的地方断句,既保证了阅读的流畅性(语义连贯),又控制了篇幅(Chunk Size)。chunkOverlap 和 separators 的参数调整,直接影响了检索的准确率。如果切分得太碎,上下文丢失;切分得太大,噪声增加。
对于初学者来说,理解 RAG 不必一开始就纠结于复杂的向量数学原理或深度学习模型架构。掌握数据的流入(Loader),往往能解决 80% 的 RAG 应用问题。
在实际应用中,你可能需要根据不同的文档类型调整策略:
- 对于代码文件,可能需要按函数或类来切分,分隔符要改成
{,}等。 - 对于法律合同,可能需要按条款(Article/Section)来切分。
- 对于聊天记录,可能需要按对话轮次来切分。
这段代码虽然只有几十行,却浓缩了 RAG 数据预处理的核心智慧。它展示了如何利用现代工具链(LangChain),将互联网上杂乱的网页信息,转化为大模型可以精准理解和利用的知识养分。
当你下次看到 AI 精准地回答出文档中的细节时,请记得,在那背后,有一个高效的 Loader 在默默抓取,还有一个精细的 Splitter 在精心裁剪。正是这些看似基础的工具,构建了通往人工智能知识殿堂的阶梯。希望这篇文章能帮助你揭开 RAG 的神秘面纱,让你在面对海量文档时,也能构建出属于自己的智能问答系统。