从「一个 URL」到「一段语义完整的向量」,本文拆解 RAG 流水线中最容易被忽视、却决定检索质量的一环 ------ 文档切割(Text Splitting)。
配套代码位于本目录的
src/crawl.mjs与src/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 要解决两件事:
- 选对 loader ------ LangChain 提供了 180 多种 loader,
.docx、.pdf、.md、网页、视频字幕...... 不同后缀对应不同 loader。 - 分块 ------ 文件太大,检索真正需要的是「一定大小、具有一定语义」的 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 |
用冗余补偿「硬切」带来的语义断裂 |
🔁 为什么叫「递归」?
分割器会按顺序尝试不同分隔符:
- 先尝试用
。切 ------ 切出的块够接近chunkSize就停下; - 切不出来(比如一段没有句号的长文)就退到下一个分隔符
!; - 依次类推...... 始终寻找「最优分隔符」,切出语义最完整的 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_NAME、EMBEDDINGS_MODEL_NAME、OPENAI_BASE_URL 等 |