别再把文档“一刀切”:用 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 看一看:它们是否完整、干净、可理解?

答案很可能就藏在这里。


相关推荐
Liora_Yvonne1 小时前
前端请求层到底怎么封?为什么全局 axios 单例越用越难维护
前端
用户713874229001 小时前
claude_rules 深度解析:Claude Code 的模块化路径作用域规则
后端
卡卡罗特AI1 小时前
曾经狂推的 Superpowers,今天我终于把它卸载了! 臃肿,Token吞金兽!
后端·chatgpt·ai编程
皮卡穆1 小时前
React 中使用 react-org-tree 实现组织结构树
前端·react.js·前端框架
MariaH1 小时前
费曼学习法与effort-learning理论
前端
qiaofugui1 小时前
如何用 Codex 做一次可靠的项目重构:方法、节奏和可复用提示词
前端
涛涛ing1 小时前
前端圈7月大地震:TS 7发布、Bun 11天从Zig迁Rust、Kimi K3登顶、大厂裁员50%
前端
用户713874229001 小时前
深入理解 Claude Code 的 Skill 机制:可复用能力与上下文成本的平衡术
后端
亦暖筑序1 小时前
AgentScope-Java 入门:保存评审状态并生成 Markdown 报告
java·人工智能·后端