RAG 的效果,往往不是从大模型开始决定的,而是从知识库里的文档如何加载、清洗和切分开始决定的。
很多人第一次学习 RAG 时,会把注意力放在向量数据库和大模型调用上。但在真正检索之前,还有一个非常重要的准备过程:
text
各种知识来源
-> Loader 加载
-> 统一成 Document
-> 切分成有语义的片段
-> Embedding 向量化
-> 存入向量数据库
本文就从 Document 切分讲起,结合网页加载和网页爬取代码,理解知识库是如何从一个文件或 URL 变成可检索的数据的。
一、知识库里的知识从哪里来?
知识库的来源非常多,例如:
- Word 文档;
- PDF 文件;
- Markdown 或 TXT 文本;
- 网页 URL;
- 视频字幕;
- 企业内部数据库;
- 经过授权的社交媒体内容;
- API 返回的结构化数据。
这些内容的格式不同,读取方式自然也不同。PDF 需要 PDF 解析器,网页需要 HTML 解析器,数据库需要数据库连接器,视频通常需要先获取字幕或语音转写文本。
问题来了:不同来源的数据,如何交给后续统一处理?
答案是:先转换成 LangChain 的标准文档对象 Document。
二、Document 是什么?
在 LangChain 中,Document 可以理解成知识库里的标准数据容器。它通常包含两个部分:
javascript
new Document({
pageContent: "这是文档正文内容",
metadata: {
source: "https://example.com",
title: "文章标题",
},
});
2.1 pageContent
pageContent 是正文内容,也是后续向量化和语义检索的主要对象。
例如网页中的一段文章、PDF 中的一页内容,最终都可以放进 pageContent。
2.2 metadata
metadata 是附加信息,通常不参与向量化,但在实际项目中非常重要:
- 记录文档来源;
- 保存网页标题和 URL;
- 标记章节、页码和作者;
- 后续做过滤;
- 回答时显示引用来源。
可以把 Document 想象成一张知识卡片:pageContent 是卡片正面的正文,metadata 是卡片背面的来源和分类信息。
【配图位置:建议在本节代码之后插入一张"Document 卡片结构图",展示 pageContent 和 metadata 的关系。】
三、Loader:把不同格式统一成 Document
Loader 的职责是:
text
输入:文件、URL 或其他数据源
输出:Document[]
LangChain 社区提供了很多 Loader,用于处理不同类型的数据源。它们主要位于 @langchain/community 包中,也允许开发者根据自己的数据源实现自定义 Loader。
常见的处理思路如下:
| 数据来源 | 处理方式 |
|---|---|
| PDF Loader 读取文本和页码 | |
| 网页 | Web Loader 获取 HTML 并提取正文 |
| Word | DOCX Loader 读取文档内容 |
| TXT / Markdown | 文本 Loader 直接读取 |
| 数据库 | 查询记录并转换成 Document |
| 视频 | 获取字幕或先进行语音转写 |
Loader 并不是向量数据库,也不是切分器。它只负责把原始数据读取出来,并转换为后续流程可以理解的标准格式。
四、网页加载:使用 CheerioWebBaseLoader
下面这段代码使用 CheerioWebBaseLoader 加载一篇网页:
javascript
import "dotenv/config";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const cheerioLoader = new CheerioWebBaseLoader(
"https://juejin.cn/post/7660707431753678854",
{
selector: ".main-area p",
},
);
const documents = await cheerioLoader.load();
4.1 导入 Loader
javascript
import {
CheerioWebBaseLoader,
} from "@langchain/community/document_loaders/web/cheerio";
CheerioWebBaseLoader 可以完成两件事:
- 请求目标 URL,获取网页 HTML;
- 使用 Cheerio 解析 HTML,并提取指定 CSS 选择器匹配的内容。
4.2 为什么需要 selector?
一个网页通常包含很多内容:导航、广告、评论、推荐文章、页脚和正文。如果把整个 HTML 都放进知识库,检索质量会明显下降。
javascript
{
selector: ".main-area p",
}
这表示只提取 .main-area 区域中的段落 <p>。它的作用是缩小内容范围,让知识库尽量只保留真正有价值的正文。
CSS 选择器可以根据网页结构调整,例如:
javascript
"article p"
".content"
"main h1, main h2, main p"
但 CSS 选择器依赖网页结构。如果目标网站改版,选择器可能失效,因此生产环境需要增加异常处理和抓取结果校验。
4.3 调用 load()
javascript
const documents = await cheerioLoader.load();
load() 会执行真正的加载过程,并返回 Document[]。
此时得到的 Document 往往还比较大,一篇文章可能对应一个或少数几个 Document。直接把它们向量化并不理想,下一步还要进行切分。
五、为什么不能直接把整篇文章向量化?
假设一篇文章有几万字,如果把它整体做成一个向量,会产生几个问题:
- 一个向量包含太多主题;
- 用户问题只对应文章中的一小段;
- 检索结果不够精确;
- 放入 Prompt 时会浪费上下文窗口;
- 大模型需要从很长的内容里重新寻找答案。
因此,RAG 通常会把大 Document 切成多个较小的 chunk。
text
一篇长文章
-> 片段 1
-> 片段 2
-> 片段 3
-> 片段 4
每个 chunk 都应该尽量表达一个相对完整的语义。检索时,系统才有机会准确返回与问题最相关的片段。
六、RecursiveCharacterTextSplitter:递归切分文本
LangChain 提供了 RecursiveCharacterTextSplitter:
javascript
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
separators: ["。", "!", "?"],
chunkOverlap: 100,
});
const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(splitDocuments);
6.1 chunkSize
javascript
chunkSize: 400
它表示每个 chunk 的目标大小。这里的 400 通常按字符长度理解,但具体行为还会受到语言、实现和编码方式影响,因此不要把它当成绝对精确的字数限制。
chunkSize 太大:
- 单个片段包含太多无关信息;
- 检索精度可能下降;
- Prompt 占用更多上下文。
chunkSize 太小:
- 语义容易被拆散;
- 片段缺少上下文;
- 用户问题可能无法匹配到完整答案。
6.2 separators
javascript
separators: ["。", "!", "?"]
分隔符告诉切分器:优先尝试在哪些位置断开文本。
中文文章中,。、!、? 往往比逗号更适合作为切分边界,因为一句话通常比一个短语包含更完整的语义。
例如,不建议优先按照逗号切分:
text
光光走进球场,拿起足球,准备开始比赛
如果在逗号处随意断开,可能得到很多过短、缺少意义的片段。
实际项目通常会设置多级分隔符,从更大的结构逐步细分,例如:
javascript
separators: ["\n\n", "\n", "。", "!", "?", ",", ""]
不同版本的切分器参数行为可能略有差异,应结合当前版本文档验证。
6.3 chunkOverlap
javascript
chunkOverlap: 100
chunkOverlap 表示相邻两个 chunk 之间保留一部分重复内容。
为什么需要重叠?因为硬切可能会把相关语义拆到两个片段中:
text
片段 1:光光拿起足球,准备传给东东,
片段 2:东东接球后射门得分。
如果问题涉及完整事件,单独检索其中一个片段时,信息可能不够。加入重叠后,可以让相邻片段保留一定上下文。
text
片段 1:光光拿起足球,准备传给东东,东东接球后
片段 2:传给东东,东东接球后射门得分。
重叠并不是越大越好。重叠太大,会产生重复向量、增加存储成本和检索噪声。通常需要根据 chunk 大小和文档类型调整。
七、完整代码:网页加载与切分
javascript
import "dotenv/config";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const targetUrl = "https://juejin.cn/post/7660707431753678854";
const loader = new CheerioWebBaseLoader(targetUrl, {
selector: ".main-area p",
});
const documents = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 100,
separators: ["\n\n", "\n", "。", "!", "?", ",", ""],
});
const splitDocuments = await splitter.splitDocuments(documents);
console.log(`原始文档数量:${documents.length}`);
console.log(`切分后片段数量:${splitDocuments.length}`);
console.log(splitDocuments[0]);
整个流程可以总结为:
text
URL
-> CheerioWebBaseLoader
-> HTML 正文
-> Document[]
-> RecursiveCharacterTextSplitter
-> 更小的 Document[]
这些切分后的 Document,才适合交给 Embedding 模型向量化并存入向量数据库。
八、Loader 和手写爬虫有什么区别?
除了直接使用 LangChain Loader,也可以自己使用 axios 和 cheerio 完成网页抓取。
javascript
import axios from "axios";
import * as cheerio from "cheerio";
const targetUrl = "https://juejin.cn/post/7660707431753678854";
async function crawlPage() {
try {
const { data: html } = await axios.get(targetUrl);
const $ = cheerio.load(html);
const pageContent = $(".main-area p").text();
console.log(pageContent);
} catch (error) {
console.error("网页抓取失败:", error.message);
}
}
crawlPage();
8.1 axios 做什么?
javascript
const { data: html } = await axios.get(targetUrl);
它向目标 URL 发送 HTTP 请求,并拿到服务器返回的 HTML 字符串。
此时拿到的只是文本:
text
<html>
<body>
<p>文章内容</p>
</body>
</html>
8.2 cheerio.load() 做什么?
javascript
const $ = cheerio.load(html);
它会在内存中解析 HTML,构建一个类似 DOM 的结构。这样,前端开发者熟悉的 CSS 选择器就可以继续使用。
8.3 使用 CSS 选择器提取正文
javascript
const pageContent = $(".main-area p").text();
这行代码会找到 .main-area 下的所有段落,并把文本内容合并起来。
整个过程可以理解成:
text
HTML 字符串
-> cheerio.load()
-> 内存中的 DOM 树
-> CSS 选择器定位节点
-> 提取文本
九、什么时候用 Loader,什么时候自己写爬虫?
| 方式 | 优点 | 适合场景 |
|---|---|---|
| LangChain Loader | 封装完整,能直接输出 Document | 快速搭建 RAG、统一处理流程 |
axios + cheerio |
灵活,可完全控制请求和解析 | 特殊网页结构、定制爬取逻辑 |
实际项目也可以组合两者:先用 axios 完成复杂的请求和鉴权,再用 cheerio 解析正文,最后手动创建 Document,交给后续切分流程。
需要注意:网页抓取必须遵守目标网站的 robots 规则、服务条款、版权要求和访问频率限制。
十、文档切分真正解决了什么问题?
文档切分不是简单地把一大段文字切成小段,它解决的是"检索粒度"和"语义完整性"的平衡。
好的 chunk 应该满足:
- 足够小,能够精确匹配用户问题;
- 足够完整,不会丢失关键上下文;
- 带有来源和章节等元数据;
- 长度适合 Embedding 和 Prompt 上下文窗口。
可以把 chunk 想象成知识库中的一张卡片。卡片太大,查找困难;卡片太小,信息不完整。chunkSize、separators 和 chunkOverlap 就是调整卡片大小和相邻卡片关联性的三个主要工具。
十一、AI 时代程序员的价值
AI 可以帮助我们生成代码,但真正困难的部分往往变成了:
- 能不能提出准确的问题;
- 能不能提供完整上下文;
- 能不能判断生成结果是否可靠;
- 能不能把多个组件组织成稳定系统;
- 能不能考虑安全、成本、部署和长期运行。
这也是所谓的"Vibe Coding"容易被误解的地方。它不是完全不写代码,而是把更多精力放到问题定义、上下文组织、系统设计和结果验证上。
在 RAG 项目中,Loader 选择、文档清洗、切分策略和检索评估,都是程序员需要做判断的地方。代码可以由 AI 辅助生成,但这些决策不能只靠复制粘贴。
十二、生产环境注意事项
12.1 网页内容可能变化
CSS 选择器依赖网页结构。网站改版后,原来的选择器可能抓不到内容,应该检查:
javascript
if (!pageContent.trim()) {
throw new Error("没有提取到正文,请检查 CSS 选择器");
}
12.2 处理请求失败和限流
网络请求可能超时、被拒绝或触发频率限制。生产环境应增加超时、重试、日志和限流策略。
12.3 清洗无关内容
导航、广告、评论和推荐内容会降低检索质量。Loader 提取正文后,还可以继续做去重、HTML 清洗和标题保留。
12.4 保存来源信息
切分时要尽量继承原始 Document 的 metadata。否则,模型虽然能回答问题,但用户无法知道答案来自哪篇文章、哪一页或哪一章。
十三、总结
RAG 的知识库构建,可以记住下面这条链路:
text
知识来源
-> Loader 加载
-> Document 标准化
-> TextSplitter 切分
-> Embedding 向量化
-> VectorStore 存储
-> Retriever 检索
-> 大模型生成回答
本文的核心概念:
| 概念 | 作用 |
|---|---|
| Loader | 将文件、网页等数据源转换成 Document |
| Document | LangChain 统一的文档数据结构 |
pageContent |
参与向量化的正文 |
metadata |
来源、章节和分类等附加信息 |
| TextSplitter | 把长文档切成可检索的 chunk |
chunkSize |
控制片段目标大小 |
separators |
指定优先切分的位置 |
chunkOverlap |
在相邻片段间保留上下文 |
最后用一句话总结:
RAG 的第一步不是调用大模型,而是把不同来源的知识,整理成大小合适、语义完整、来源清晰的 Document 片段。