别再把文档“一刀切”:用 LangChain.js 做好 RAG 的第一公里

别再把文档"一刀切":用 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() 切文本?
  • chunkSizechunkOverlap 应该怎么配?
  • 中文分隔符应该怎么写?
  • 为什么代码最后打印出来的可能是一个 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 的基本思路:它不是上来就按字符硬切,而是按照一组从"强语义边界"到"弱语义边界"的分隔符,递归尝试切分文本。

可以把它理解为:

  1. 优先按章节或段落切;
  2. 段落仍然太长,再按句子切;
  3. 句子仍然太长,再按短语或空格切;
  4. 最后才退化到字符级别,确保块大小不失控。

它并不真正"理解"语义,但会尽量尊重人类书写时留下的结构边界。


三、三个核心参数: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 成本增加;
  • 相同证据被重复计算,影响结果多样性。

一个实用起点是让 chunkOverlapchunkSize10%~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)
}

这段代码完成了四件事:

  1. 从网页中加载文章正文;
  2. 将内容转换为标准 Document
  3. 按中文文本结构递归切分;
  4. 输出切分数量、长度和实际内容,方便人工检查。

一个容易忽略的异步坑

下面这段代码看起来没有问题:

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 文档预处理流程,可以概括为:

  1. 使用 Loader 读取不同数据源,并统一为 Document
  2. 在 Loader 阶段尽量提取干净正文;
  3. 使用递归字符切分器优先保留段落和句子边界;
  4. chunkSize 控制粒度,用 chunkOverlap 缓解边界语义丢失;
  5. 为中文内容配置符合语言习惯的分隔符,并保留字符级兜底;
  6. 记得 await splitter.splitDocuments(documents)
  7. 不只检查程序是否运行,还要用长度分布、边界抽查和真实问题评估检索质量。

文档切分看起来只是 RAG 流程中的一个小步骤,但它决定了向量数据库究竟保存什么,也决定了大模型最后能看到什么。

高质量的 RAG,不是从"选哪个大模型"开始,而是从"如何把知识整理成可检索的证据"开始。

如果你正在搭建自己的知识库,不妨先暂停对模型参数的反复调整,打开几条实际 Chunk 看一看:它们是否完整、干净、可理解?

答案很可能就藏在这里。


相关推荐
Bs_MoneyMagnet14 分钟前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
AlienZHOU4 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
狗头大军之江苏分军7 小时前
《潮水漫过十七岁》开学了
后端
苏三说技术7 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong8 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7048 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc8 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae9 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
计算机魔术师9 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen9 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程