小白鸭框架
29.71M · 2026-03-25
很多人在刚接触 RAG 时,注意力都会集中在向量数据库、Embedding 模型和相似度检索上。这当然没错,因为这些组件直接决定了“问的时候能不能找到资料”。
但如果你真的开始做一个能落地的知识库系统,很快就会发现另一个更现实的问题:
知识根本不是天然以 Document 对象的形式存在的。
它可能是一份 PDF、一篇网页文章、一段飞书文档、一个 Notion 页面、一份 Word 手册,甚至是一条视频字幕或一组邮件归档。你不先把这些来源转换成统一的文档格式,后面的向量化、检索、生成都无从谈起。
这就是 Loader 的作用。
而当你终于把内容加载进来了,第二个问题又会马上出现:原始文档往往太长,直接整篇做向量化和检索,效果通常并不好。因为用户提问时真正相关的,往往只是其中某一小段,而不是整篇文档。
这就是 Splitter 的作用。
所以如果你从工程视角看 RAG,会发现它不只是“用户提问后怎么检索”的问题,它同样是“知识在进入系统之前,怎么被清洗、加载、切分和组织”的问题。Loader 和 Splitter,恰好就是这个入库流程里最基础的两个组件。
我们先把问题说透。
RAG 的主流程通常被描述成这样:
这个流程本身没问题,但它默认了一个前提:你已经拥有结构化、可处理、可切分的文本文档。
而现实中的知识源通常并不满足这个前提。
比如:
换句话说,RAG 的第一步其实不是 Embedding,而是把不同来源的内容转成统一的 Document 表示。只有这样,后面的切块、向量化和检索才有稳定输入。
Loader 可以理解为“文档接入层”。
它的职责不是生成答案,也不是做向量检索,而是把不同来源的数据读出来,并转换成 LangChain 可以处理的 Document 对象。
一个 Document 通常至少包含两部分:
pageContent:真正参与向量化和检索的正文内容metadata:来源地址、标题、作者、时间、章节、文件路径等附加信息这一步非常关键,因为一旦你把原始数据规范成 Document,后面不管是做切块、做向量化、做过滤检索,还是给回答结果展示“内容来源”,都有了统一接口。
从这个角度看,Loader 不是一个零碎工具,而是 RAG 系统的输入标准化层。
假设你已经把一篇网页文章通过 Loader 成功读成了一个 Document,是不是就可以直接做 Embedding 了?
技术上可以,效果上通常不推荐。
原因很简单:大文档对检索不友好。
如果一篇文章有几千字,而用户的问题只和其中两段相关,那么把整篇文章作为一个向量,等于把大量无关内容也一起编码进去了。这样做会带来几个问题:
所以,真正适合进入向量库的,通常不是原始整篇文档,而是拆分后的文档块,也就是 chunk。
这就是 Splitter 的职责:把大文档切成若干更适合检索和拼接上下文的小块。
“切块”听起来像是一个简单的字符串处理问题,但如果你做过实际项目,就会知道这里有不少细节。
切得太大,会导致每个 chunk 语义过于混杂,检索不够精准。
切得太小,又会导致上下文不完整,模型拿到的只是零碎句子,回答时容易缺失因果关系和前后文。
所以 Splitter 的核心目标并不是“平均切开”,而是:
在检索粒度和语义完整性之间找到平衡。
这也是为什么很多文本切分器除了 chunkSize,还会提供 chunkOverlap。
chunkSize 控制每个块的大致长度chunkOverlap 控制相邻块之间重叠多少内容重叠的意义在于保留上下文连续性。因为很多重要信息并不会刚好落在一个切块边界内,如果没有 overlap,语义可能会在切块处断裂。
把 Loader 和 Splitter 放回整个 RAG 链路里,你可以把流程理解成下面这样:
Document其中,Loader 和 Splitter 共同负责的是入库前半段,也就是知识准备过程。它们不直接对外回答问题,但它们的质量会直接决定检索质量。
为了把这个过程讲清楚,我们用一个非常常见的场景来演示:把一篇网页文章抓下来,抽取正文,切成小块,再放进向量库,最后完成一次基于文章内容的问答。
这个案例比“手动 new 几个 Document”更接近真实业务,因为大部分知识库系统都要先面对“内容来自哪里”的问题。
先安装依赖:
pnpm add cheerio @langchain/community
这里用到的 CheerioWebBaseLoader 适合抓取网页内容,并结合 CSS 选择器抽取正文区域。
创建 src/loader-and-splitter.mjs:
import "dotenv/config";
import "cheerio";
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();
console.log(documents);
这段代码做的事情并不复杂,但非常典型:
selector 只提取正文部分的段落Document[]为什么要加选择器,而不是整个页面都抓下来?因为真实网页里会混入大量无关内容,比如侧边栏、推荐阅读、评论区、作者卡片。这些内容一旦被一起送去向量化,会明显污染检索结果。
所以 Loader 的一个核心价值,不只是“读数据”,而是“尽量把无关噪声挡在知识库之外”。
如果你直接打印 documents[0].pageContent.length,通常会发现网页正文相当长。这个长度对检索来说并不理想,所以需要接入 Splitter。
安装文本切分器:
pnpm add @langchain/textsplitters
然后把文档分块:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 50,
separators: ["。", "!", "?"],
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(`文档分割完成,共 ${splitDocuments.length} 个分块`);
这里选 RecursiveCharacterTextSplitter 的原因是,它不是简单地暴力截断,而是会优先按你给定的分隔符去寻找更自然的切分位置。对中文内容来说,句号、感叹号、问号通常比纯字符长度更符合语义边界。
这个配置背后有三个考虑:
chunkSize: 400:每个块不要太大,保证检索粒度chunkOverlap: 50:保留前后文连续性,避免语义断裂separators:优先按更自然的句子边界切分,减少机械断句当 splitDocuments 准备好之后,后面的流程就和你熟悉的 RAG 基础版一致了。
先安装需要的依赖:
pnpm add @langchain/core @langchain/openai @langchain/classic dotenv
然后创建一个完整示例 src/loader-and-splitter2.mjs:
import "dotenv/config";
import "cheerio";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
const model = new ChatOpenAI({
temperature: 0,
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const loader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7233327509919547452",
{
selector: ".main-area p",
},
);
const documents = await loader.load();
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
separators: ["。", "!", "?"],
});
const splitDocuments = await textSplitter.splitDocuments(documents);
const vectorStore = await MemoryVectorStore.fromDocuments(
splitDocuments,
embeddings,
);
const retriever = vectorStore.asRetriever({ k: 2 });
const question = "父亲的去世对作者的人生态度产生了怎样的变化?";
const retrievedDocs = await retriever.invoke(question);
const context = retrievedDocs
.map((doc, index) => `[片段 ${index + 1}]n${doc.pageContent}`)
.join("nn");
const prompt = `
你是一名文章阅读助手。请严格基于给定内容回答问题:
1. 如果资料明确提到,就提炼出关键结论
2. 如果资料没有覆盖,就明确说明资料不足
3. 回答尽量准确、简洁,不要脱离原文发挥
资料:
${context}
问题:
${question}
`;
const response = await model.invoke(prompt);
console.log(response.content);
这个完整版本把三件事连起来了:
如果你把它和“手工创建几个 Document 再问答”的 demo 对比,就会发现它更接近真实系统的知识接入过程。
很多初学者在看 LangChain 示例时,容易把注意力放在 API 名字上,比如 load()、splitDocuments()、fromDocuments()、asRetriever()。这些方法当然要会用,但更重要的是理解它们分别位于哪一层。
load() 负责从外部世界读取内容splitDocuments() 负责把原始文档切成检索友好的粒度fromDocuments() 负责把 chunk 变成可搜索的向量集合asRetriever() 负责把向量库封装成检索接口一旦你从职责角度理解这条链路,就不会把 Loader、Splitter、Embedding、Retriever 混成一团。
这一点值得单独强调,因为它直接影响 RAG 的检索效果。
假设一篇文章里同时包含以下内容:
用户只问“父亲去世对作者有什么影响”,如果整篇文章只有一个向量,那么检索系统只能判断“这篇文章整体跟问题有点关系”。但如果文章被拆成多个 chunk,系统就更有机会精准命中“家庭变故”和“态度变化”那几段。
所以切块的本质,不是为了节省存储,而是为了提升召回粒度。
这两个组件看起来像预处理工具,但实际项目里经常是效果瓶颈。
常见问题包括:
如果选择器不准,广告、评论、导航、版权声明都会进入知识库。结果就是模型检索到一堆和问题无关的内容。
如果 chunk 太短,单个块里信息不足,模型拿到的上下文支离破碎,回答就会不完整。
如果 chunk 太长,召回结果虽然“看上去相关”,但其实混入了很多噪声,Prompt 质量会下降。
如果切块之后丢失来源信息,后面就很难做结果溯源、分组展示、时间过滤和权限控制。
网页文章、技术文档、法律文本、聊天记录、表格数据,它们的结构差异很大。统一用一个 chunkSize 和一套分隔符,效果未必稳定。
如果你只是为了理解原理,当前这个 demo 已经足够。但如果目标是上线一个真实知识库,后面至少还可以从这些方向继续打磨:
这也是为什么成熟的 RAG 系统,往往既要调 Prompt,也要调入库链路。因为知识进库的方式,本身就决定了后面模型能拿到什么。
很多人把 RAG 理解成“向量检索 + 大模型回答”,这个理解不算错,但还不完整。因为在检索发生之前,知识首先要能被系统接收、清洗、组织和切分。
Loader 负责把外部世界的各种内容来源转成统一的 Document;Splitter 负责把大文档切成适合语义检索的 chunk。它们虽然不直接生成答案,却决定了知识库的输入质量,也决定了向量检索的上限。
所以,如果说 Embedding 和 Retriever 决定了 RAG“查得准不准”,那么 Loader 和 Splitter 决定的就是“有没有机会查准”。
理解了这两个组件,你才算真正迈进了知识库工程化的第一步。下一步再去看不同类型的 Loader、不同策略的 Splitter、混合检索和重排序,整个 RAG 技术栈就会连起来了。