别再把文档"一刀切":用 LangChain.js 做好 RAG 的第一公里
一套 RAG 系统最终能不能答对问题,往往在调用大模型之前就已经决定了。
真正影响检索质量的,不只是 Embedding 模型和向量数据库,还有一个看起来很朴素、却经常被忽略的步骤:文档切分(Document Splitting)。
前言:RAG 的问题,可能根本不在大模型
假设我们正在做一个技术文章知识库。
用户问:
chunkOverlap为什么能缓解语义丢失?
原文中恰好有这样一段内容:
如果只按照固定长度切分,上一块的最后一句和下一块的第一句可能存在很强的语义关联。重叠一部分内容,可以在一定程度上保留跨边界的上下文。
但如果程序恰好从"语义关联"中间切开,向量数据库收到的可能是两个残缺片段:
text
Chunk A:......上一块的最后一句和下一块的第一句可能存在很强的
Chunk B:语义关联。重叠一部分内容,可以在一定程度上保留......
这时,即使后面使用再强的 Embedding 模型、再贵的大模型,也是在残缺的上下文上工作。
这就是文档切分的重要性:
切分不是简单地把长文本变短,而是在长度约束下,尽可能保住文本的语义单元。
本文将用 LangChain.js 完成一个小而完整的 RAG 预处理流程:
text
网页 URL
↓
CheerioWebBaseLoader
↓
LangChain Document
↓
RecursiveCharacterTextSplitter
↓
可供向量化与检索的 Chunks
同时,我们还会解决几个实际开发中很容易遇到的问题:
- Loader 到底解决了什么问题?
- 为什么不能只用
text.slice()切文本? chunkSize和chunkOverlap应该怎么配?- 中文分隔符应该怎么写?
- 为什么代码最后打印出来的可能是一个
Promise? - 怎样判断自己的切分结果是否真的可用?
一、先统一数据结构:Loader 不只是一个"爬虫"
知识库里的数据来源通常非常杂:
- Markdown、TXT、Word、PDF;
- 网页、博客、论坛帖子;
- 视频字幕、会议记录;
- 数据库记录和第三方 API。
如果为每种数据源都编写一套后续处理逻辑,系统很快就会变得混乱。
LangChain 的 Loader 层做了一件非常关键的事:把不同来源的数据统一转换成 Document。
一个典型的 Document 可以理解为:
js
{
pageContent: '真正参与切分、向量化和检索的正文',
metadata: {
source: 'https://example.com/article',
title: '文章标题'
}
}
其中:
pageContent保存正文;metadata保存来源、标题、作者、发布时间、页码等辅助信息。
这个设计的价值不只在"格式统一"。进入检索阶段后,我们还需要用 metadata 追溯答案出处、过滤数据范围,甚至在界面上展示引用链接。
因此,一个更准确的理解是:
Loader 是数据源与 RAG 流水线之间的适配层。
使用 Cheerio 加载网页
对于静态网页,可以使用 CheerioWebBaseLoader:
js
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
const loader = new CheerioWebBaseLoader(
'https://juejin.cn/post/your-post-id',
{
selector: '.main-area p',
},
)
const documents = await loader.load()
selector 用来控制抓取范围。这里借助 CSS 选择器,只提取文章区域里的段落,避免把导航栏、按钮、推荐列表和页脚一起写入知识库。
这是一个非常值得关注的细节:进入切分器之前的文本越干净,后面的检索噪声越少。
不过,CSS 选择器依赖目标网站的 DOM 结构。网站一旦改版,选择器就可能失效。因此生产环境至少应该增加:
- 空内容检查;
- 正文长度检查;
- 抓取失败重试;
- 页面结构变化告警;
- 对动态渲染页面使用浏览器抓取方案。
二、为什么固定长度切分不够好?
最直接的切分方案可能是:
js
const chunks = []
for (let i = 0; i < text.length; i += 400) {
chunks.push(text.slice(i, i + 400))
}
它确实能保证每块不超过 400 个字符,但它完全不理解文本结构。
句号中间、专有名词中间、代码块中间,甚至一段因果关系中间,都可能成为切点。
例如:
text
错误切法:
因为 chunkOverlap 能保留相邻文本之间的部分上下文,所以它可以缓
解固定长度切分造成的语义断裂。
更理想的切法是:
text
合理切法:
因为 chunkOverlap 能保留相邻文本之间的部分上下文,
所以它可以缓解固定长度切分造成的语义断裂。
这也是 RecursiveCharacterTextSplitter 的基本思路:它不是上来就按字符硬切,而是按照一组从"强语义边界"到"弱语义边界"的分隔符,递归尝试切分文本。
可以把它理解为:
- 优先按章节或段落切;
- 段落仍然太长,再按句子切;
- 句子仍然太长,再按短语或空格切;
- 最后才退化到字符级别,确保块大小不失控。
它并不真正"理解"语义,但会尽量尊重人类书写时留下的结构边界。
三、三个核心参数:Size、Overlap 与 Separators
1. chunkSize:每个块的目标上限
js
chunkSize: 500
块太小与块太大,各有问题:
| 切分策略 | 优点 | 风险 |
|---|---|---|
| 较小的 Chunk | 定位精确、单块噪声少 | 上下文不足、块数量和索引成本增加 |
| 较大的 Chunk | 上下文更完整 | 主题混杂、检索精度下降、占用更多 Token |
因此,chunkSize 没有适用于所有项目的"黄金数字"。它至少受以下因素影响:
- 文档类型;
- 用户问题的粒度;
- Embedding 模型的输入限制;
- 最终送给大模型的上下文预算;
- 检索器的
topK; - 文本使用的语言和符号密度。
对于中文技术文章,可以先从 400~800 个字符做基线实验,再用真实问题集调整。请注意,字符数不等于模型 Token 数,不同模型的分词方式也不同。
2. chunkOverlap:用冗余换取边界连续性
js
chunkOverlap: 80
Overlap 会让相邻 Chunk 保留一部分重复内容:
text
Chunk A:A B C D
Chunk B: C D E F
└─重叠─┘
它主要缓解的是切分边界附近的语义丢失。
但重叠并非越大越好。重叠过大会导致:
- 向量数据库存储更多重复内容;
- 检索结果中出现多个高度相似的 Chunk;
- 传给大模型的上下文重复;
- Token 成本增加;
- 相同证据被重复计算,影响结果多样性。
一个实用起点是让 chunkOverlap 占 chunkSize 的 10%~20% 。例如 chunkSize 为 500 时,可以先尝试 50~100。
还有一条必须满足的约束:
text
0 <= chunkOverlap < chunkSize
3. separators:把语言习惯写进切分策略
对于中文文章,只配置句号、逗号、感叹号和问号还不够。更稳妥的顺序可以是:
js
const separators = [
'\n\n', // 段落
'\n', // 换行
'。',
'!',
'?',
';',
',',
' ',
'', // 最终兜底:字符级切分
]
顺序非常重要。越靠前,代表越希望优先保留的语义边界。
这里把段落放在句子之前,把句子放在短语之前,并使用空字符串兜底。这样即使遇到一整段没有任何标点的超长文本,切分器仍然可以控制块大小。
如果文档中包含大量 Markdown,还可以考虑加入:
js
'\n# ',
'\n## ',
'\n### ',
'```',
不过,对代码块要格外谨慎。仅靠字符切分器很可能破坏函数或类的完整性。代码知识库更适合使用面向编程语言结构的切分方式,或者先通过 AST 提取符号,再按函数、类和模块建立索引。
四、完成一个可用的网页切分脚本
先安装依赖:
bash
npm install dotenv cheerio @langchain/community @langchain/textsplitters
然后创建 crawl.mjs:
js
import 'dotenv/config'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
const url = 'https://juejin.cn/post/your-post-id'
const loader = new CheerioWebBaseLoader(url, {
selector: '.main-area p',
})
const documents = await loader.load()
if (documents.length === 0) {
throw new Error('没有抓取到正文,请检查 URL、网络或 CSS 选择器')
}
const totalCharacters = documents.reduce(
(sum, document) => sum + document.pageContent.length,
0,
)
if (totalCharacters === 0) {
throw new Error('Document 已创建,但 pageContent 为空')
}
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 80,
separators: ['\n\n', '\n', '。', '!', '?', ';', ',', ' ', ''],
})
// 注意:splitDocuments() 返回 Promise,必须 await
const chunks = await splitter.splitDocuments(documents)
console.log({
source: url,
documentCount: documents.length,
totalCharacters,
chunkCount: chunks.length,
})
for (const [index, chunk] of chunks.entries()) {
console.log(`\n--- Chunk ${index + 1} / ${chunks.length} ---`)
console.log(`length: ${chunk.pageContent.length}`)
console.log(chunk.pageContent)
}
这段代码完成了四件事:
- 从网页中加载文章正文;
- 将内容转换为标准
Document; - 按中文文本结构递归切分;
- 输出切分数量、长度和实际内容,方便人工检查。
一个容易忽略的异步坑
下面这段代码看起来没有问题:
js
const chunks = splitter.splitDocuments(documents)
console.log(chunks)
但 splitDocuments() 返回的是 Promise。如果忘记 await,打印结果可能只是:
text
Promise { <pending> }
正确写法是:
js
const chunks = await splitter.splitDocuments(documents)
因为示例使用 .mjs,Node.js 支持顶层 await。如果项目使用的是 CommonJS,则需要把逻辑放进异步函数:
js
async function main() {
const documents = await loader.load()
const chunks = await splitter.splitDocuments(documents)
console.log(chunks)
}
main().catch(console.error)
五、不要只看 Chunk 数量:这样检查切分质量
程序成功运行,不等于切分策略有效。
一个切分器可能输出了 100 个格式正确的 Document,但它们依然可能充满导航文本、语义残缺或重复内容。
1. 检查长度分布
js
const lengths = chunks.map((chunk) => chunk.pageContent.length)
const stats = {
min: Math.min(...lengths),
max: Math.max(...lengths),
average: Math.round(
lengths.reduce((sum, length) => sum + length, 0) / lengths.length,
),
}
console.table(stats)
需要留意两种异常:
- 大量 Chunk 特别短:可能是选择器过碎,或者分隔符策略不合理;
- 部分 Chunk 远超预期:可能缺少字符级兜底,或者文档存在异常长文本。
2. 抽查边界
重点检查每个 Chunk 的开头和结尾:
js
for (const [index, chunk] of chunks.entries()) {
const text = chunk.pageContent
console.log({
index,
start: text.slice(0, 40),
end: text.slice(-40),
})
}
观察是否经常出现这些情况:
- 句子从中间开始;
- 因果关系被拆散;
- 标题和正文分离;
- 代码说明与代码块分离;
- 列表项失去所属标题。
3. 检查重复率
Overlap 本来就会制造重复,但如果检索结果总是返回几乎相同的片段,就需要降低重叠比例,或者在召回后加入去重与重排。
4. 用真实问题做检索评估
最可靠的评估方式不是"看起来切得不错",而是建立一小组真实问答:
js
const evaluationSet = [
{
question: 'chunkOverlap 有什么作用?',
expectedKeywords: ['边界', '上下文', '语义'],
},
{
question: 'Loader 的输出结构是什么?',
expectedKeywords: ['pageContent', 'metadata'],
},
]
然后比较不同参数组合下,正确证据是否稳定出现在 Top K 结果中。
例如:
| 方案 | chunkSize | overlap | Top 3 命中率 | 平均重复率 |
|---|---|---|---|---|
| A | 300 | 50 | 82% | 12% |
| B | 500 | 80 | 91% | 18% |
| C | 800 | 160 | 88% | 31% |
这里的数据需要通过自己的知识库实测。表格的意义不在于寻找一个通用数字,而在于把"凭感觉调参"变成"用数据比较"。
六、进一步优化:从 Demo 走向工程化
当前脚本可以完成基础实验,但要进入真实项目,还可以继续向以下方向演进。
1. 为 Chunk 补充可追溯的 Metadata
切分后可以为每个块增加索引和抓取时间:
js
const enrichedChunks = chunks.map((chunk, index) => ({
...chunk,
metadata: {
...chunk.metadata,
source: url,
chunkIndex: index,
crawledAt: new Date().toISOString(),
},
}))
这样在生成答案时,就能展示来源;在重新抓取文档时,也能按 source 删除旧索引。
2. 先清洗,再切分
网页正文中可能包含:
- "点赞、收藏、关注";
- 广告和相关推荐;
- 重复版权声明;
- 多余空白字符;
- 不可见控制字符。
如果这些内容参与向量化,就会污染检索结果。一个简单的清洗函数可以是:
js
function normalizeText(text) {
return text
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
const normalizedDocuments = documents.map((document) => ({
...document,
pageContent: normalizeText(document.pageContent),
}))
清洗规则应当克制。不要为了"干净"误删代码缩进、表格结构或具有语义的换行。
3. 按文档类型选择切分策略
不同文档适合不同边界:
| 文档类型 | 优先保留的结构 |
|---|---|
| 技术文章 | 标题、段落、句子、代码块 |
| API 文档 | Endpoint、参数表、示例 |
| PDF 报告 | 章节、页码、表格、脚注 |
| 对话记录 | 轮次、说话人、时间戳 |
| 源代码 | 模块、类、函数、方法 |
真正成熟的 RAG 数据管道通常不会让所有内容共用一个切分器。
4. 在召回之后增加重排
切分只解决"索引单元如何构造"的问题。完整的检索链路通常还包括:
text
Query
↓
向量召回 / 关键词召回
↓
候选 Chunks
↓
去重与 Rerank
↓
拼接上下文
↓
LLM 生成答案
如果 Chunk 之间存在重叠,召回后去重尤其重要;如果文章中存在多个相似概念,Reranker 也能进一步改善排序。
七、关于文档切分的三个常见误区
误区一:Chunk 越小,检索越精确
Chunk 变小后,主题可能更单一,但上下文也会减少。一个只包含"因此应该使用重叠"的片段,如果失去了前文,模型根本不知道"因此"指什么。
精确不是越短越好,而是每块都应该形成足够独立的证据单元。
误区二:Overlap 越大,语义越完整
Overlap 只能缓解边界问题,不能修复糟糕的正文提取,也不能替代合理的结构化切分。过大的重叠只是在反复存储同一段内容。
误区三:参数调好一次就结束了
当数据源从博客变成 API 文档,当用户问题从"解释概念"变成"定位具体参数",原来的参数可能马上失效。
切分策略应该和真实查询一起迭代,而不是脱离业务场景独立优化。
总结
一套基础的 RAG 文档预处理流程,可以概括为:
- 使用 Loader 读取不同数据源,并统一为
Document; - 在 Loader 阶段尽量提取干净正文;
- 使用递归字符切分器优先保留段落和句子边界;
- 用
chunkSize控制粒度,用chunkOverlap缓解边界语义丢失; - 为中文内容配置符合语言习惯的分隔符,并保留字符级兜底;
- 记得
await splitter.splitDocuments(documents); - 不只检查程序是否运行,还要用长度分布、边界抽查和真实问题评估检索质量。
文档切分看起来只是 RAG 流程中的一个小步骤,但它决定了向量数据库究竟保存什么,也决定了大模型最后能看到什么。
高质量的 RAG,不是从"选哪个大模型"开始,而是从"如何把知识整理成可检索的证据"开始。
如果你正在搭建自己的知识库,不妨先暂停对模型参数的反复调整,打开几条实际 Chunk 看一看:它们是否完整、干净、可理解?
答案很可能就藏在这里。