📚 RAG 文档切割详解

从「一个 URL」到「一段语义完整的向量」,本文拆解 RAG 流水线中最容易被忽视、却决定检索质量的一环 ------ 文档切割(Text Splitting)

配套代码位于本目录的 src/crawl.mjssrc/index.mjs


🗂️ 一、起点:知识库里的「知识」长什么样

知识库要存放知识,但知识的来源五花八门:

来源 形态 处理难点
📄 Word 文档 二进制 .docx 需要解析出段落结构
📕 PDF 文件 版式复杂的二进制 文本、表格、图片混排
📺 B 站视频 视频流 需先转字幕/语音转文本
🔗 一个 URL 网页 HTML 需要剔除导航、广告等噪音
🐦 一条 Twitter 短文本 + 元数据 上下文少、含转推/评论干扰

无论来源是什么,它们都有一个共同目标:

各种格式的文件 → 向量化前的 Document 对象

但我们不能直接拿原始文件去建 Document,中间必须经历「加载」与「切割」两道工序。


🧩 二、统一的标准:Document 对象

为了屏蔽格式差异,LangChain 定义了一套标准文档格式,让所有下游(向量化、检索)只认这一种结构:

js 复制代码
Document {
  pageContent: "文档的内容......",   // 📝 页面正文
  metadata: {                     // 🏷️ 元数据
    source: "https://juejin.cn/...",
    title: "...",
  }
}
  • pageContent:文档的实际内容,是后续向量化的原料。
  • metadata:文档的元信息(来源、作者、时间等),检索命中后可用于展示出处。

💡 一句话记住:pageContent 是「说了什么」,metadata 是「从哪来」。


🔌 三、第一道工序:Loader(加载器)

把「各种格式的文件」转成「标准 Document」,靠的是 Loader

markdown 复制代码
知识库 ──Loader──▶ 向量数据库
        (输入:文件)   (输出:Documents)

Loader 要解决两件事:

  1. 选对 loader ------ LangChain 提供了 180 多种 loader,.docx.pdf.md、网页、视频字幕...... 不同后缀对应不同 loader。
  2. 分块 ------ 文件太大,检索真正需要的是「一定大小、具有一定语义」的 chunk。

而 Loader 本身也有两个来源:

  • 🏛️ 官方维护@langchain/core ------ 核心、稳定。
  • 👥 社区维护@langchain/community ------ 由社区贡献,我们每个人都可以写 loader 贡献回去。

实战:用 CheerioWebBaseLoader 加载网页

网页是最常见的知识来源之一。这里用官方/社区提供的 loader,把「爬取 + 解析 + 标准化」一步到位:

js 复制代码
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';

// 访问网址并提取文档内容
// cheerio 通过 CSS 选择器提取内容,缩小范围,得到符合标准的 Document
const cheerioLoader = new CheerioWebBaseLoader(
  'https://juejin.cn/post/7660707431753678854',
  {
    selector: '.main-area p', // 只取「文章段落」
  }
);

const documents = await cheerioLoader.load();

selector: '.main-area p' 是关键 ------ 它像放大镜一样,只锁定正文段落,把页眉、导航、广告、侧栏统统排除在外。


🕷️ 四、追本溯源:Loader 背后发生了什么

CheerioWebBaseLoader 的底层逻辑,本质就是「爬虫 + 解析 」。拆开看,它做了两件事,对应 crawl.mjs 里的手工实现:

1️⃣ 发请求,拿 HTML 字符串(axios

js 复制代码
import axios from 'axios'; // 标准 HTTP 请求库

const targetUrl = 'https://juejin.cn/post/7660707431753678854';

async function crawlPage() {
  try {
    const { data: html } = await axios.get(targetUrl);
    console.log(html); // html 字符串
  } catch (e) {}
}

axios.get 返回的是 text/html 类型的 HTML 字符串。但这只是第一步 ------ 一串文本还无法按语义提取。

2️⃣ 解析 HTML,提取内容(cheerio

js 复制代码
import * as cheerio from 'cheerio'; // esm 全量引入

// 1. 把 html 字符串在内存中虚拟化成 DOM 对象(树状结构)
const $ = cheerio.load(html);

// 2. 用 CSS 选择器在树中查找,提取文本
const pageContent = $('.main-area p').text();
console.log(pageContent);

整条链路可以画成一条流水线:

css 复制代码
html 字符串 ──cheerio.load──▶ DOM 树 ──CSS 选择器──▶ 树的遍历 ──▶ 返回节点 ──▶ .text()

🎯 为什么用 cheerio 而不是正则?

解析 HTML 用正则又难又脆(HTML 不是正则语言,嵌套标签会翻车)。cheerio 让 JS 开发者以前端思维 、用熟悉的 CSS 选择器,简单高效地完成「指定 URL + 指定部分」的爬取 ------ 无需手写任何正则。


✂️ 五、第二道工序:切割(Text Splitting)

拿到大的 Document 后,要把它切成小的 Document ,才能更精细地处理语义。这步的难点在于:切得不好,语义就碎了

怎么切才合理?

  • 按段落切? 段落可能太长,也可能太短,不稳定。
  • 按句子切? 。!? 是天然边界,但单句有时信息量不足。
  • 按字符数切? 简单粗暴,但可能在句子中间一刀两断。

所以正确姿势是**「语义优先,大小兜底」**,这正是 RecursiveCharacterTextSplitter(递归字符文本分割器)的设计初衷。

实战代码

js 复制代码
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 400,               // 📏 每个 chunk 的目标大小(字符)
  separators: ['。', '!', '?'], // ✂️ 优先尝试的语义分隔符
  chunkOverlap: 100,            // 🔁 相邻 chunk 的重叠字符数
});

const splitDocuments = await textSplitter.splitDocuments(documents);
console.log(splitDocuments);

三个参数到底在干什么?

参数 作用 这里的取值 背后的考量
separators 语义最基本构成符号 ['。', '!', '?'] 句号/感叹号/问号是句子的天然边界,优先在句末切
chunkSize chunk 的目标大小 400 控制每个切片的粒度,保证向量能覆盖足够语义
chunkOverlap 相邻 chunk 重叠部分 100 用冗余补偿「硬切」带来的语义断裂

🔁 为什么叫「递归」?

分割器会按顺序尝试不同分隔符

  1. 先尝试用 切 ------ 切出的块够接近 chunkSize 就停下;
  2. 切不出来(比如一段没有句号的长文)就退到下一个分隔符
  3. 依次类推...... 始终寻找「最优分隔符」,切出语义最完整的 chunk。

🧠 它的目标始终是:尽量贴着 chunkSize 切,但绝不破坏语义完整性

🔁 为什么需要 overlap(重叠)?

这是整个切割中最精妙的设计。设想:

复制代码
......这是第一个 chunk 的最后一句。|(chunkSize 处硬切)| 这是第二个 chunk 的第一句......

chunkSize 一刀切开后,chunk 的最后一句和下一个 chunk 的第一句,语义相关性其实是最大的 ------ 但硬切把它们拆散了。

chunkOverlap 就是来补救的:让相邻 chunk 重叠一部分字符 (这里 100 个),用一定的冗余来确保语义的完整性,检索时才不会漏掉跨边界的上下文。


🧭 六、全貌:一张图串起整条流水线

markdown 复制代码
  🔗 URL / 📄 PDF / 📺 视频字幕 / 🐦 Twitter
                    │
                    ▼
        ┌─────────────────────┐
        │  Loader(加载)      │   axios 发请求 + cheerio 解析
        │  → 标准 Document    │
        └─────────────────────┘
                    │
                    ▼
        ┌─────────────────────┐
        │  Splitter(切割)    │   RecursiveCharacterTextSplitter
        │  → 语义完整的小块    │
        └─────────────────────┘
                    │
                    ▼
        ┌─────────────────────┐
        │  Embedding(向量化) │
        │  → 向量数据库       │
        └─────────────────────┘

💡 七、思考:切割的意义,也是 AI 时代程序员的价值

切割的本质:保持语义完整性

  • separators 是「语义最基本的构成符号」------ 。!?,而不是
  • chunkSize 切割组装,是为了让向量「大小可控、语义可控」。
  • 遇到硬切,用 overlap 兜底,用冗余换语义完整。

AI 时代,程序员的价值在迁移

不再是单纯的 coding ,而是 Vibe Coding

真正值钱的能力变成了:

  • 🎤 问出好问题(prompt)
  • 🧠 提供丰富准确的上下文(context)
  • 🎛️ 驾驭(Harness)并部署 Agent 产品
  • 🔁 设计长时间稳定运行的 Loop
  • 🏗️ 快速成长为一名 AI 架构师

写代码这件事交给 AI 了,但**「知道为什么这么切」「能设计出语义完整的流水线」**,才是人不可替代的地方。本文这套「加载 → 切割 → 向量化」的 RAG 前置链路,正是这种价值的缩影。


📖 附录:项目结构

bash 复制代码
rag_splitter/
├── readme.md          # 原始笔记
├── package.json       # 依赖:langchain、axios、cheerio、dotenv
├── .env               # 模型与 API 配置
└── src/
    ├── crawl.mjs      # 手工实现爬虫:axios + cheerio
    └── index.mjs      # 生产写法:CheerioWebBaseLoader + RecursiveCharacterTextSplitter
文件 扮演角色 说明
crawl.mjs 🔬 教学版 手写爬虫,讲清楚 loader 底层原理
index.mjs 🚀 生产版 用 LangChain 现成组件,一步到位
.env 🔑 配置 MODEL_NAMEEMBEDDINGS_MODEL_NAMEOPENAI_BASE_URL
相关推荐
IT_陈寒1 小时前
Vue的v-for不听话?我被这个Key的坑整懵了
前端·人工智能·后端
水獭比特1 小时前
localhost 不是安全边界:给 Agent Web 入口补上四层门禁
人工智能·python
赟爸1 小时前
直播切片素材杂乱不好复用,易元AI要怎么处理
大数据·人工智能·python
文叔叔1 小时前
我开源了一个 Codex Desktop ↔ Claude Desktop 双向调用工具
人工智能
qpsj1 小时前
让 LLM 操作 CAD:四条技术路线的取舍
人工智能·llm
东方小月1 小时前
从零开发一个 Coding Agent(十):实现 CLI 参数解析与静态命令
前端·人工智能·开源
前端小白乘风2 小时前
github Copilot 接入deepseek-v4-pro
人工智能
还不秃顶的计科生2 小时前
具身智能论文学习1:PaLM: Scaling Language Modeling with Pathways
人工智能·语言模型·palm
qq_454245032 小时前
Agent数据价值分类存储原则
大数据·人工智能·分类