做 RAG 不能只会检索:为什么 Loader 和 Splitter 才是知识库入库的第一步

0 阅读11分钟

很多人在刚接触 RAG 时,注意力都会集中在向量数据库、Embedding 模型和相似度检索上。这当然没错,因为这些组件直接决定了“问的时候能不能找到资料”。

但如果你真的开始做一个能落地的知识库系统,很快就会发现另一个更现实的问题:

知识根本不是天然以 Document 对象的形式存在的。

它可能是一份 PDF、一篇网页文章、一段飞书文档、一个 Notion 页面、一份 Word 手册,甚至是一条视频字幕或一组邮件归档。你不先把这些来源转换成统一的文档格式,后面的向量化、检索、生成都无从谈起。

这就是 Loader 的作用。

而当你终于把内容加载进来了,第二个问题又会马上出现:原始文档往往太长,直接整篇做向量化和检索,效果通常并不好。因为用户提问时真正相关的,往往只是其中某一小段,而不是整篇文档。

这就是 Splitter 的作用。

所以如果你从工程视角看 RAG,会发现它不只是“用户提问后怎么检索”的问题,它同样是“知识在进入系统之前,怎么被清洗、加载、切分和组织”的问题。Loader 和 Splitter,恰好就是这个入库流程里最基础的两个组件。

为什么 RAG 不能直接把原始文件扔进向量库

我们先把问题说透。

RAG 的主流程通常被描述成这样:

  1. 把知识库内容向量化
  2. 用户提问时做相似度检索
  3. 把召回结果拼进 Prompt
  4. 让大模型基于上下文回答

这个流程本身没问题,但它默认了一个前提:你已经拥有结构化、可处理、可切分的文本文档。

而现实中的知识源通常并不满足这个前提。

比如:

  • 网页里会混着导航栏、广告位、页脚和正文
  • PDF 里可能有页眉页脚、断行、分页和表格
  • Word 文档有标题层级、段落样式和批注
  • 视频内容需要先转录成文本
  • 数据库记录可能还需要拼接多个字段才能形成可检索语义

换句话说,RAG 的第一步其实不是 Embedding,而是把不同来源的内容转成统一的 Document 表示。只有这样,后面的切块、向量化和检索才有稳定输入。

Loader 到底解决什么问题

Loader 可以理解为“文档接入层”。

它的职责不是生成答案,也不是做向量检索,而是把不同来源的数据读出来,并转换成 LangChain 可以处理的 Document 对象。

一个 Document 通常至少包含两部分:

  • pageContent:真正参与向量化和检索的正文内容
  • metadata:来源地址、标题、作者、时间、章节、文件路径等附加信息

这一步非常关键,因为一旦你把原始数据规范成 Document,后面不管是做切块、做向量化、做过滤检索,还是给回答结果展示“内容来源”,都有了统一接口。

从这个角度看,Loader 不是一个零碎工具,而是 RAG 系统的输入标准化层。

image.png

为什么只加载文档还不够

假设你已经把一篇网页文章通过 Loader 成功读成了一个 Document,是不是就可以直接做 Embedding 了?

技术上可以,效果上通常不推荐。

原因很简单:大文档对检索不友好。

如果一篇文章有几千字,而用户的问题只和其中两段相关,那么把整篇文章作为一个向量,等于把大量无关内容也一起编码进去了。这样做会带来几个问题:

  • 向量语义被整篇文档“平均化”,细粒度信息被稀释
  • 检索命中后,返回上下文过长,噪声增加
  • Prompt 成本更高,模型更难聚焦关键证据
  • 文档过长时,还可能触发模型上下文窗口限制

所以,真正适合进入向量库的,通常不是原始整篇文档,而是拆分后的文档块,也就是 chunk。

这就是 Splitter 的职责:把大文档切成若干更适合检索和拼接上下文的小块。

image.png

Splitter 为什么不能随便切

“切块”听起来像是一个简单的字符串处理问题,但如果你做过实际项目,就会知道这里有不少细节。

切得太大,会导致每个 chunk 语义过于混杂,检索不够精准。

切得太小,又会导致上下文不完整,模型拿到的只是零碎句子,回答时容易缺失因果关系和前后文。

所以 Splitter 的核心目标并不是“平均切开”,而是:

在检索粒度和语义完整性之间找到平衡。

这也是为什么很多文本切分器除了 chunkSize,还会提供 chunkOverlap

  • chunkSize 控制每个块的大致长度
  • chunkOverlap 控制相邻块之间重叠多少内容

重叠的意义在于保留上下文连续性。因为很多重要信息并不会刚好落在一个切块边界内,如果没有 overlap,语义可能会在切块处断裂。

一个典型的知识入库链路

把 Loader 和 Splitter 放回整个 RAG 链路里,你可以把流程理解成下面这样:

  1. 从外部数据源读取原始内容
  2. 用 Loader 转成统一的 Document
  3. 用 Splitter 把大文档拆成多个 chunk
  4. 用 Embedding 模型把 chunk 向量化
  5. 把向量和元数据存进向量数据库
  6. 用户提问时,再把问题向量化去做召回
  7. 把召回结果注入 Prompt,交给 LLM 生成回答

其中,Loader 和 Splitter 共同负责的是入库前半段,也就是知识准备过程。它们不直接对外回答问题,但它们的质量会直接决定检索质量。

image.png

用网页文章演示 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:优先按更自然的句子边界切分,减少机械断句

image.png

第三步:把切分后的文档送入向量库

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 技术栈就会连起来了。