AI 电子伴侣
创建时间: 2026-08-01最后更新: 2026-08-06

上一篇我们看到,一份资料要经过读取、解析、清理、切块和向量化,才会进入 RAG 的索引。真正容易被低估的,并不是如何调用 Embedding API,而是交给 Embedding 模型的内容究竟是什么

如果原始文件中的标题、表头、否定词和段落关系在解析时丢失,后面的向量只是在准确表达一份已经损坏的文本。系统启动时通常看不出这个问题,直到用户提问时才发现:知识库里明明有答案,检索结果却总是找不到它。

这篇文章从原始资料开始,完整走一遍文档处理过程。我们先看文件如何被读取和解析,再讨论清理与标准化,最后进入语义切块,并说明参数应该怎样选择、上下文如何保存以及结果如何检查。

1. 从文件到可检索文本

切块参数先放到后面。对 RAG 来说,原始文件并不是天然适合检索的文本,通常要经历几个阶段:

document-processing.txt
1
PDF / Markdown / 网页 / 数据库
2
-> 读取文件
3
-> 解析正文与结构
4
-> 清除格式噪声
5
-> 恢复标题、表格和段落关系
6
-> 生成统一的 Document
7
-> 切分为多个 Chunk

其中,解析正文与结构是很关键的一步。同样一句话,来自网页正文、PDF 表格或 OCR 图片时,得到的上下文可能完全不同。切块器只能按照收到的文本工作,它不知道某一行原本是表头,也不会自动判断跨页的两段内容属于同一张表。

所以,文档处理并不是把文件转成字符串就结束了。转换后的文本还应该尽量接近人类阅读时看到的结构。

2. 一个解析失败的例子

假设员工手册里有这样一张表:

policy-source.txt
1
请假时长 审批要求
2
少于三天 直属负责人
3
连续三天及以上 直属负责人和部门负责人

员工提问“连续请三天年假由谁审批”时,正常结果应该命中最后一行。但 PDF 解析后,内容可能变成:

parsed-policy.txt
1
少于三天
2
直属负责人
3
4
连续三天及以上
5
直属负责人和部门负责人

如果表头留在了上一页,或者列顺序在双栏解析时发生变化,系统可能只把“连续三天及以上”作为独立片段交给切块器。这个片段既没有说明它描述的是请假时长,也没有说明后面的文字属于审批要求。

Embedding 模型不是数据库修复工具。它可以帮助系统理解不同说法之间的相似性,却不能稳定补回已经丢失的表头和列关系。遇到检索错误时,先检查原始文本和 Chunk,通常比立刻更换 Embedding 模型更有价值。

3. 原始资料的来源

不同来源需要关注的问题并不相同,可以先从这张表建立基本判断:

来源常见问题处理重点
Markdown标题、代码块和列表层级混杂保留标题层级、代码边界和列表关系
HTML / 网页导航、推荐内容、Cookie 提示混入正文明确正文区域,移除页面噪声
PDF页眉页脚、分页、双栏、表格顺序错乱保留页码,检查阅读顺序和跨页结构
Word段落样式和表格结构容易丢失识别标题、表格和列表的边界
图片 / OCR断行、错字、数字和否定词识别错误对关键字段做抽样校验
数据库字段语义分散,缺少文档层级重新组合标题、正文和业务 metadata

这张表并不是说每种来源都要配一套完全不同的系统,而是提醒我们,清理规则不能脱离来源类型。删除网页里的导航菜单,和删除 PDF 中所有短行,风险完全不同。

4. 用 Document 保存正文与来源

LangChain 使用 Document 表示一段可以参与检索的文本。在当前 JavaScript API 中,它主要包含 pageContentmetadata 和可选的 id

document-contract.ts
01
import { Document } from '@langchain/core/documents'
02
03
const document = new Document({
04
id: 'leave-policy:v4:section-3',
05
pageContent: `年假审批
06
07
少于两天,由直属负责人审批。
08
连续两天及以上,由直属负责人和部门负责人共同审批。`,
09
metadata: {
10
sourceId: 'leave-policy',
11
sourceVersion: 4,
12
sectionPath: ['考勤制度', '年假审批'],
13
pageStart: 8,
14
pageEnd: 8,
15
tenantId: 'company-a',
16
visibility: 'employee',
17
},
18
})

pageContent 决定 Embedding 模型能看到什么,metadata 决定系统能否过滤、引用和追溯。两者职责不同,不能为了缩短正文把标题和必要上下文全部移到 metadata,也不能把权限字段混进正文,期待模型替我们完成数据隔离。

一套实用的 metadata,至少要能够回答这些问题:

  • 这段内容来自哪份资料;
  • 属于哪个版本和章节;
  • 在原文中的位置是什么;
  • 哪个租户和哪些角色有权访问;
  • 由哪个解析与切分流程生成。

这些字段不一定都要交给模型,但索引系统必须能够通过它们回到原文。否则用户无法核对引用,开发者也很难解释一次错误检索究竟来自哪一页文件。

5. 解析时保留结构

5.1 Markdown 和代码

Markdown 里的标题不是普通装饰文字,它通常说明下面几段正文属于什么主题。如果切块时只留下正文,用户问“缓存策略中的过期时间”时,模型可能只看到“7 天”,却不知道这个数字属于缓存策略还是另一项配置。

代码也不适合按普通句子拆分。函数签名、参数说明和示例调用往往需要一起出现。至少要避免在代码块中间截断,并在 metadata 中记录语言和文件路径。

可以把标题路径补到每个 Chunk 的开头:

markdown-chunk.txt
1
章节:缓存策略 > 过期时间
2
3
缓存默认保存 7 天。管理员可以在环境变量 CACHE_TTL 中修改这个值。

这样做不是为了制造重复文字,而是把原本依赖页面位置才能理解的上下文,明确带到检索文本中。

5.2 网页正文

网页通常同时包含导航栏、面包屑、相关推荐、登录提示和正文。直接把整份 HTML 转成文本,这些重复内容很容易占据大量 Chunk,导致同一条菜单被多次召回。

处理网页时,应先确定正文容器,再读取文章标题、更新时间、作者、路径和正文。标题与路径可以放进 metadata,也可以在正文开头保留一份简短的章节信息,具体取决于后续是否需要通过语义检索找到它。

网页内容还可能随着请求参数、广告位和个性化推荐发生变化。为了让增量更新稳定,可以保存规范化后的正文哈希,并记录抓取时间和来源 URL,不要把一次抓取结果当成永久事实。

5.3 PDF、表格与 OCR

PDF 的难点不在于能不能打开,而在于它保存的是页面排版,不一定保存人类阅读时的逻辑顺序。常见问题包括:

  • 每一页重复出现相同页眉和页脚;
  • 双栏内容被交叉读取;
  • 表格的行列关系被打散;
  • 一句话跨页后,后一页缺少标题;
  • OCR 把数字、字母或“不”识别错误。

对表格来说,最好先恢复成带表头的文本,而不是把每个单元格简单拼接。比如可以把原来的表格整理成下面这种形式:

normalized-table.txt
1
规则:年假审批
2
3
请假时长:少于三天
4
审批要求:直属负责人
5
6
请假时长:连续三天及以上
7
审批要求:直属负责人和部门负责人

这种格式不一定是唯一答案,但它把“字段名”和“字段值”的关系保留下来了。用户提问时,Embedding 模型和关键词检索都有机会利用这些信息。

对 OCR 文档,还要特别检查否定词、日期、金额、版本号和错误码。它们通常决定业务规则是否被正确理解,不能只用“文本非空”作为解析成功的判断。

6. 清理与标准化

6.1 从低风险标准化开始

如果暂时不能确定某段文本有没有业务意义,先统一换行和空白,不要急着删除内容:

normalize-text.ts
1
function normalizeText(input: string) {
2
return input
3
.replace(/\r\n/g, '\n')
4
.replace(/[ \t]+\n/g, '\n')
5
.replace(/\n{3,}/g, '\n\n')
6
.trim()
7
}

这段代码只处理换行和多余空白,不会擅自删除正文。确认来源格式之后,再针对页眉页脚、导航菜单或重复版权声明增加更具体的规则。

6.2 谨慎处理短行

很多解析结果里会出现“第 4 页”“联系我们”“版权所有”等短行,但标题、表头、警告语和“禁止”“无需”也可能只有几个字。删除所有短行确实会让文本看起来更干净,却很容易把最重要的条件一起删掉。

清理规则上线前,应该准备包含以下内容的测试文件:

  • 跨页段落和跨页表格;
  • 多级标题、列表和代码块;
  • “不允许”“无需”“除非”这类否定或例外表达;
  • 日期、金额、版本号和错误码;
  • 正文中确实需要保留的短标题。

清理前后都应该保留抽样结果。出现召回异常时,我们才能判断究竟是原文解析错误,还是清理规则删掉了内容。

6.3 保留清理过程

生产系统不适合只保存最后的 pageContent。可以为每次处理记录来源文件哈希、解析器版本、清理规则版本和切分流程版本:

pipeline-metadata.ts
1
type PipelineMetadata = {
2
sourceHash: string
3
parserVersion: string
4
normalizerVersion: string
5
pipelineVersion: number
6
}

当同一份文件在不同时间得到不同 Chunk 时,这些字段可以帮助我们判断究竟是内容变了,还是处理方式变了。需要回滚时,也可以据此找到上一套稳定的索引结果。

7. 围绕完整语义切块

长文档不能简单地整篇生成一个向量。内容越多,主题越容易被平均,用户只问其中一条规则时,整篇文档的向量未必足够接近问题。

切块也不是每 800 个字符截断一次。更重要的是,Chunk 脱离原文后仍然应该能够回答一个相对完整的问题。标题、正文、表格表头和适用条件,都应该尽量留在同一个语义范围内。

Drawing canvas

继续看请假制度:

leave-policy.txt
1
年假审批
2
3
员工可以按半天为单位使用年假。
4
少于两天,由直属负责人审批。
5
连续两天及以上,由直属负责人和部门负责人共同审批。
6
7
病假审批
8
9
病假一天以内需要提交就诊记录,超过一天还需要医疗证明。

比较合适的切分结果,是让“年假审批”标题和三条规则留在同一个 Chunk,病假进入另一个 Chunk。这样用户问“半天年假”或“两天年假”时,命中的文本会同时包含主题、条件和审批结果。

如果按固定字符切分,可能会得到:

bad-chunks.txt
1
chunk 1:员工可以按半天为单位使用年假。少于两天,由直属
2
chunk 2:负责人审批。连续两天及以上,由直属负责人和部门负责人
3
chunk 3:共同审批。病假审批。病假一天以内需要提交就诊记录

每个 Chunk 都丢失了一部分关系。即使三个结果都被召回,模型仍然需要猜测哪些句子属于同一条规则,检索质量和回答稳定性都会下降。

8. 使用 LangChain 切分文档

通用文本可以先使用 RecursiveCharacterTextSplitter。它会按照段落、换行、空格等分隔符递归尝试切分,直到满足目标大小,比直接按索引截断更容易保留自然边界。

terminal
1
yarn add @langchain/core @langchain/textsplitters
split-documents.ts
01
import { Document } from '@langchain/core/documents'
02
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
03
04
const source = new Document({
05
id: 'leave-policy:v4',
06
pageContent: rawPolicyText,
07
metadata: {
08
sourceId: 'leave-policy',
09
sourceVersion: 4,
10
tenantId: 'company-a',
11
},
12
})
13
14
const splitter = new RecursiveCharacterTextSplitter({
15
chunkSize: 800,
16
chunkOverlap: 120,
17
separators: ['\n## ', '\n### ', '\n\n', '\n', '。', ';', ',', ' '],
18
})
19
20
const chunks = await splitter.splitDocuments([source])
21
22
for (const [index, chunk] of chunks.entries()) {
23
chunk.id = `leave-policy:v4:${index}`
24
chunk.metadata.chunkIndex = index
25
chunk.metadata.pipelineVersion = 1
26
}

这里的 800120 是起点,不是标准答案。chunkSize 的实际计算方式取决于 splitter 使用的长度函数,不能一律把它理解成 Token 数。正式项目应该结合模型、语言和文档类型,观察切分后的实际长度。

LangChain 官方当前也把 RecursiveCharacterTextSplitter 作为通用文本的推荐起点,示例使用 chunkSize: 1000chunkOverlap: 200。这些参数可以帮助我们跑通流程,但仍然需要用自己的数据集验证。LangChain Semantic Search

9. chunkSize 怎么选

块太大和块太小都会降低检索质量,只是表现不一样。没有一个对所有知识库都适用的固定数字。

9.1 块太大

如果一个 Chunk 同时包含年假、病假、报销和考勤内容,向量会表达多个主题。用户只问“半天年假”时,这个大块和问题的相似度可能不如一段短小但碰巧出现“半天”的无关通知。

大块还会占用更多上下文预算。最终能放进模型的候选数量减少,正确答案更容易被其他内容挤掉,Token 成本和生成延迟也会上升。

9.2 块太小

如果每句话单独成块,“连续两天及以上”可能和“需要部门负责人审批”分开。前一个 Chunk 有条件没有结论,后一个有结论没有条件。

小块还会产生大量高度相似的候选,增加向量数量、存储成本和重排压力。为了弥补上下文缺失而不断增大 chunkOverlap,通常也会让重复问题变得更严重。

9.3 从真实问题反推粒度

选择 Chunk 大小时,先收集用户真正会问的问题,再观察回答通常需要多大范围的原文:

  • 一个问题通常由同一段落回答,就优先按段落切分;
  • 答案依赖标题和表格中的一整行,就保留标题、表头和这一行;
  • 代码文档的回答需要函数签名和参数说明,就不要把它们拆开;
  • 一个规则经常包含条件、例外和处理结果,就不要只保留其中一句。

面试时被问到“chunkSize 应该设多少”,比直接说某个固定数字更可靠的回答是:先根据文档结构和答案跨度设定初始值,再用检索评测集比较 Recall@K、上下文完整度、索引规模和生成效果。

10. chunkOverlap 解决什么问题

Overlap 会让相邻 Chunk 共享一小段内容,主要用来减轻答案刚好落在切分边界上的问题。

假设一条规则跨越了切分点:

overlap-source.txt
1
报销申请应在费用发生后 30 天内提交。
2
超过 30 天,需要直属负责人补充说明。

如果第一句在 Chunk 1,第二句在 Chunk 2,用户问“报销超过 30 天怎么办”时,两个 Chunk 都有价值。适量重叠可以让条件和处理方式在同一个候选中出现。

Overlap 过大也会带来副作用。大量重复文本会让前几名结果几乎一样,占满 Top-K,却没有带回其他互补资料。后面的去重和 MMR 可以缓解,但更好的做法仍然是优先改善语义切分,避免依赖过大的重叠范围。

一般可以让 overlap 占 chunkSize 的一小部分,然后观察边界问题和重复率。对于标题层级清晰的 Markdown,优先按标题和语义块切分,往往比不断增大 overlap 更有效。

11. 不同内容要使用不同切分策略

RecursiveCharacterTextSplitter 适合通用文本,但并不意味着所有内容都应该用同一组分隔符。

Markdown 可以优先按 ##### 和段落切分;代码需要按文件、类、函数或语法结构切分;表格需要先恢复行列关系,再决定一行或一组记录是否构成一个 Chunk;FAQ 则可以让一个问题和对应答案保持在一起。

对于长篇技术文档,可以把标题路径加到每个 Chunk 前面:

section-context.txt
1
章节:RAG 检索增强生成 > 文档切块 > chunkOverlap
2
3
Overlap 会让相邻 Chunk 共享一小段内容,主要用于减轻答案刚好落在边界上的问题。

这样做会增加少量重复文字,但可以让 Chunk 脱离原文位置后仍然保留主题。是否需要加入标题路径,可以通过检索评测比较,不必把它当成所有场景的固定规则。

12. 检索粒度和阅读粒度可以分开

有些问题适合用小 Chunk 负责召回,但生成答案时又需要完整章节。比如用户问“两天年假由谁审批”,最具体的一小段规则很容易被找到;不过模型还可能需要知道这条规则只适用于正式员工,或者有一个特殊例外。

这时可以把检索粒度和阅读粒度分开:索引中保存小 Chunk,同时在 metadata 中记录 parentId。小 Chunk 命中后,再读取父级章节或相邻 Chunk 交给模型。

parent-child.ts
1
type ChunkMetadata = {
2
sourceId: string
3
parentId: string
4
chunkIndex: number
5
sectionPath: string[]
6
}

例如小 Chunk 负责命中“连续两天及以上”的审批规则,组装上下文时再补充“年假审批”整节,使模型同时看到适用范围和例外说明。

Parent-Child Retrieval 会增加一次正文读取、排序和去重过程。数据量较小时,不必一开始就实现,先通过评测确认确实存在“检索很准,但回答缺少上下文”的问题,再引入这层结构更合适。

13. 用稳定 Chunk 支持增量更新

每次重新处理文档都生成全新的随机 ID,会导致旧向量难以删除,缓存和引用也会失效。可以根据来源、版本、顺序和内容哈希构建稳定标识:

stable-chunk-id.ts
01
import { createHash } from 'node:crypto'
02
03
function contentHash(content: string) {
04
return createHash('sha256').update(content).digest('hex')
05
}
06
07
function buildChunkId(
08
sourceId: string,
09
sourceVersion: number,
10
chunkIndex: number,
11
content: string,
12
) {
13
return [
14
sourceId,
15
sourceVersion,
16
chunkIndex,
17
contentHash(content).slice(0, 16),
18
].join(':')
19
}

内容没有变化的 Chunk 可以复用已有 Embedding;发生变化的块重新计算;已经不存在的 ID 从索引中删除。切分参数或解析规则变化时,提高 pipelineVersion,不要把两套切分结果混在同一个线上版本里。

稳定 ID 还会影响引用展示。用户点击“查看来源”时,系统需要根据 sourceId、版本、页码和 Chunk 位置回到原始资料。只保存一串随机 ID,后续很难稳定完成这个过程。

14. 检查切块质量

完成切块后,不要立刻把全部数据写入向量库。先抽样检查这些问题:

  1. 每个 Chunk 是否包含可以理解的主题;
  2. 标题、表头和否定词是否保留;
  3. 是否存在只有页码、菜单或版权声明的空洞 Chunk;
  4. 相邻 Chunk 的重复内容是否过多;
  5. metadata 能否准确回到原文位置;
  6. 典型问题的答案是否完整落在一个 Chunk 或可恢复的父级范围内。

还可以建立一组结构性测试。例如“年假两天由谁审批”的期望 Chunk ID 是 leave-policy:v4:3。切分算法调整后先运行这组测试,确认关键答案仍然能够被定位,再继续生成 Embedding。

一个实用的抽样方法,是把测试问题和最终答案需要的原文范围放在一起检查:如果人工阅读也无法判断这个 Chunk 在讲什么,向量检索自然不会稳定;如果 Chunk 能看懂但缺少权限或版本 metadata,线上仍然可能出现越权或命中旧资料。

15. 总结

文档处理决定了检索系统能够看到什么。解析阶段丢掉的表头、否定词和章节关系,后面的向量模型无法可靠恢复。

一份可靠的处理流程应该先识别来源格式,再解析正文和结构,接着做可追溯的清理与标准化,最后围绕完整语义切块。Document 负责连接正文和来源信息,chunkSizechunkOverlap 需要根据真实问题调整,父子块可以在召回精度和上下文完整性之间取得平衡,稳定 ID 则让更新、删除和引用变得可控。

当这些 Chunk 经过检查后,才适合进入 Embedding 和向量检索。下一篇会继续解释文本如何转换成向量,以及向量检索为什么能够找到表达不同但意思相近的内容。