很多人在刚接触 RAG 时,注意力都会集中在向量数据库、Embedding 模型和相似度检索上。这当然没错,因为这些组件直接决定了“问的时候能不能找到资料”。
但如果你真的开始做一个能落地的知识库系统,很快就会发现另一个更现实的问题:
知识根本不是天然以 Document 对象的形式存在的。
它可能是一份 PDF、一篇网页文章、一段飞书文档、一个 Notion 页面、一份 Word 手册,甚至是一条视频字幕或一组邮件归档。你不先把这些来源转换成统一的文档格式,后面的向量化、检索、生成都无从谈起。
这就是 Loader 的作用。
而当你终于把内容加载进来了,第二个问题又会马上出现:原始文档往往太长,直接整篇做向量化和检索,效果通常并不好。因为用户提问时真正相关的,往往只是其中某一小段,而不是整篇文档。
这就是 Splitter 的作用。
所以如果你从工程视角看 RAG,会发现它不只是“用户提问后怎么检索”的问题,它同样是“知识在进入系统之前,怎么被清洗、加载、切分和组织”的问题。Loader 和 Splitter,恰好就是这个入库流程里最基础的两个组件。
为什么 RAG 不能直接把原始文件扔进向量库
我们先把问题说透。
RAG 的主流程通常被描述成这样:
- 把知识库内容向量化
- 用户提问时做相似度检索
- 把召回结果拼进 Prompt
- 让大模型基于上下文回答
这个流程本身没问题,但它默认了一个前提:你已经拥有结构化、可处理、可切分的文本文档。
而现实中的知识源通常并不满足这个前提。
比如:
- 网页里会混着导航栏、广告位、页脚和正文
- PDF 里可能有页眉页脚、断行、分页和表格
- Word 文档有标题层级、段落样式和批注
- 视频内容需要先转录成文本
- 数据库记录可能还需要拼接多个字段才能形成可检索语义
换句话说,RAG 的第一步其实不是 Embedding,而是把不同来源的内容转成统一的 Document 表示。只有这样,后面的切块、向量化和检索才有稳定输入。
Loader 到底解决什么问题
Loader 可以理解为“文档接入层”。
它的职责不是生成答案,也不是做向量检索,而是把不同来源的数据读出来,并转换成 LangChain 可以处理的 Document 对象。
一个 Document 通常至少包含两部分:
pageContent:真正参与向量化和检索的正文内容metadata:来源地址、标题、作者、时间、章节、文件路径等附加信息
这一步非常关键,因为一旦你把原始数据规范成 Document,后面不管是做切块、做向量化、做过滤检索,还是给回答结果展示“内容来源”,都有了统一接口。
从这个角度看,Loader 不是一个零碎工具,而是 RAG 系统的输入标准化层。
为什么只加载文档还不够
假设你已经把一篇网页文章通过 Loader 成功读成了一个 Document,是不是就可以直接做 Embedding 了?
技术上可以,效果上通常不推荐。
原因很简单:大文档对检索不友好。
如果一篇文章有几千字,而用户的问题只和其中两段相关,那么把整篇文章作为一个向量,等于把大量无关内容也一起编码进去了。这样做会带来几个问题:
- 向量语义被整篇文档“平均化”,细粒度信息被稀释
- 检索命中后,返回上下文过长,噪声增加
- Prompt 成本更高,模型更难聚焦关键证据
- 文档过长时,还可能触发模型上下文窗口限制
所以,真正适合进入向量库的,通常不是原始整篇文档,而是拆分后的文档块,也就是 chunk。
这就是 Splitter 的职责:把大文档切成若干更适合检索和拼接上下文的小块。
Splitter 为什么不能随便切
“切块”听起来像是一个简单的字符串处理问题,但如果你做过实际项目,就会知道这里有不少细节。
切得太大,会导致每个 chunk 语义过于混杂,检索不够精准。
切得太小,又会导致上下文不完整,模型拿到的只是零碎句子,回答时容易缺失因果关系和前后文。
所以 Splitter 的核心目标并不是“平均切开”,而是:
在检索粒度和语义完整性之间找到平衡。
这也是为什么很多文本切分器除了 chunkSize,还会提供 chunkOverlap。
chunkSize控制每个块的大致长度chunkOverlap控制相邻块之间重叠多少内容
重叠的意义在于保留上下文连续性。因为很多重要信息并不会刚好落在一个切块边界内,如果没有 overlap,语义可能会在切块处断裂。
一个典型的知识入库链路
把 Loader 和 Splitter 放回整个 RAG 链路里,你可以把流程理解成下面这样:
- 从外部数据源读取原始内容
- 用 Loader 转成统一的
Document - 用 Splitter 把大文档拆成多个 chunk
- 用 Embedding 模型把 chunk 向量化
- 把向量和元数据存进向量数据库
- 用户提问时,再把问题向量化去做召回
- 把召回结果注入 Prompt,交给 LLM 生成回答
其中,Loader 和 Splitter 共同负责的是入库前半段,也就是知识准备过程。它们不直接对外回答问题,但它们的质量会直接决定检索质量。
用网页文章演示 Loader 和 Splitter
为了把这个过程讲清楚,我们用一个非常常见的场景来演示:把一篇网页文章抓下来,抽取正文,切成小块,再放进向量库,最后完成一次基于文章内容的问答。
这个案例比“手动 new 几个 Document”更接近真实业务,因为大部分知识库系统都要先面对“内容来自哪里”的问题。
第一步:用 Loader 把网页正文转成 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 的一个核心价值,不只是“读数据”,而是“尽量把无关噪声挡在知识库之外”。
第二步:把长文档切成更适合检索的 chunk
如果你直接打印 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("\n\n");
const prompt = `
你是一名文章阅读助手。请严格基于给定内容回答问题:
1. 如果资料明确提到,就提炼出关键结论
2. 如果资料没有覆盖,就明确说明资料不足
3. 回答尽量准确、简洁,不要脱离原文发挥
资料:
${context}
问题:
${question}
`;
const response = await model.invoke(prompt);
console.log(response.content);
这个完整版本把三件事连起来了:
- Loader:从网页中提取文章正文
- Splitter:把正文切成更适合检索的 chunk
- RAG:对 chunk 建立向量索引,再做检索增强回答
如果你把它和“手工创建几个 Document 再问答”的 demo 对比,就会发现它更接近真实系统的知识接入过程。
这段代码最值得理解的,不是 API,而是职责分工
很多初学者在看 LangChain 示例时,容易把注意力放在 API 名字上,比如 load()、splitDocuments()、fromDocuments()、asRetriever()。这些方法当然要会用,但更重要的是理解它们分别位于哪一层。
load()负责从外部世界读取内容splitDocuments()负责把原始文档切成检索友好的粒度fromDocuments()负责把 chunk 变成可搜索的向量集合asRetriever()负责把向量库封装成检索接口
一旦你从职责角度理解这条链路,就不会把 Loader、Splitter、Embedding、Retriever 混成一团。
为什么“先切块再向量化”通常比“整篇直接向量化”更好
这一点值得单独强调,因为它直接影响 RAG 的检索效果。
假设一篇文章里同时包含以下内容:
- 作者早年经历
- 家庭变故
- 人生态度变化
- 对教育的思考
用户只问“父亲去世对作者有什么影响”,如果整篇文章只有一个向量,那么检索系统只能判断“这篇文章整体跟问题有点关系”。但如果文章被拆成多个 chunk,系统就更有机会精准命中“家庭变故”和“态度变化”那几段。
所以切块的本质,不是为了节省存储,而是为了提升召回粒度。
Loader 和 Splitter 也有很多坑
这两个组件看起来像预处理工具,但实际项目里经常是效果瓶颈。
常见问题包括:
1. Loader 把噪声一起抓进来了
如果选择器不准,广告、评论、导航、版权声明都会进入知识库。结果就是模型检索到一堆和问题无关的内容。
2. Splitter 切得太碎
如果 chunk 太短,单个块里信息不足,模型拿到的上下文支离破碎,回答就会不完整。
3. Splitter 切得太大
如果 chunk 太长,召回结果虽然“看上去相关”,但其实混入了很多噪声,Prompt 质量会下降。
4. 没有元数据
如果切块之后丢失来源信息,后面就很难做结果溯源、分组展示、时间过滤和权限控制。
5. 误以为所有文档都适合同一种切分策略
网页文章、技术文档、法律文本、聊天记录、表格数据,它们的结构差异很大。统一用一个 chunkSize 和一套分隔符,效果未必稳定。
从工程落地角度,后面还可以怎么继续优化
如果你只是为了理解原理,当前这个 demo 已经足够。但如果目标是上线一个真实知识库,后面至少还可以从这些方向继续打磨:
- 为不同数据源选择不同 Loader,而不是一套方案通吃
- 根据文档类型定制切分策略,比如按标题、段落、代码块或语义边界切
- 在 chunk 元数据里保留标题层级、来源地址、更新时间、权限标签
- 把内存向量库换成持久化方案
- 在向量检索前后加入关键词检索或 rerank,提升召回精度
- 建立评估集,验证切块策略是否真的提升了问答效果
这也是为什么成熟的 RAG 系统,往往既要调 Prompt,也要调入库链路。因为知识进库的方式,本身就决定了后面模型能拿到什么。
总结
很多人把 RAG 理解成“向量检索 + 大模型回答”,这个理解不算错,但还不完整。因为在检索发生之前,知识首先要能被系统接收、清洗、组织和切分。
Loader 负责把外部世界的各种内容来源转成统一的 Document;Splitter 负责把大文档切成适合语义检索的 chunk。它们虽然不直接生成答案,却决定了知识库的输入质量,也决定了向量检索的上限。
所以,如果说 Embedding 和 Retriever 决定了 RAG“查得准不准”,那么 Loader 和 Splitter 决定的就是“有没有机会查准”。
理解了这两个组件,你才算真正迈进了知识库工程化的第一步。下一步再去看不同类型的 Loader、不同策略的 Splitter、混合检索和重排序,整个 RAG 技术栈就会连起来了。