Document 切分:RAG 知识库的第一道关键工序

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 卡片结构图",展示 pageContentmetadata 的关系。】

三、Loader:把不同格式统一成 Document

Loader 的职责是:

text 复制代码
输入:文件、URL 或其他数据源
输出:Document[]

LangChain 社区提供了很多 Loader,用于处理不同类型的数据源。它们主要位于 @langchain/community 包中,也允许开发者根据自己的数据源实现自定义 Loader。

常见的处理思路如下:

数据来源 处理方式
PDF 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 可以完成两件事:

  1. 请求目标 URL,获取网页 HTML;
  2. 使用 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,也可以自己使用 axioscheerio 完成网页抓取。

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 想象成知识库中的一张卡片。卡片太大,查找困难;卡片太小,信息不完整。chunkSizeseparatorschunkOverlap 就是调整卡片大小和相邻卡片关联性的三个主要工具。

十一、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 片段。

相关推荐
JerrySir2 小时前
别让短实验排队等数据:一个“预搬全量 vs 按需水合”的训练数据调度设计
面试
weedsfly2 小时前
前端开发中的命令模式——从点餐退单到异步任务调度
前端·javascript·面试
weedsfly2 小时前
前端中的适配器模式 vs 外观模式
前端·javascript·面试
咖啡八杯2 小时前
GoF设计模式——责任链模式
设计模式·面试·架构
江畔柳前堤3 小时前
Go04-控制结构
大数据·人工智能·面试·职场和发展·go·业界资讯
触底反弹4 小时前
💥 暴力解 LeetCode 导致 OOM:10000 行数据撑爆 V8 堆内存的完整排查
javascript·算法·面试
wdfk_prog11 小时前
嵌入式面试真题第 10 题:高优化等级下共享状态可见性、内存模型与系统级同步设计
java·linux·开发语言·面试·职场和发展·架构·c
胡萝卜术13 小时前
RAG 的知识炼油厂:从原始网页到可检索知识块的工程化之路
面试·架构·github