上一篇我们看到,一份资料要经过读取、解析、清理、切块和向量化,才会进入 RAG 的索引。真正容易被低估的,并不是如何调用 Embedding API,而是交给 Embedding 模型的内容究竟是什么。
如果原始文件中的标题、表头、否定词和段落关系在解析时丢失,后面的向量只是在准确表达一份已经损坏的文本。系统启动时通常看不出这个问题,直到用户提问时才发现:知识库里明明有答案,检索结果却总是找不到它。
这篇文章从原始资料开始,完整走一遍文档处理过程。我们先看文件如何被读取和解析,再讨论清理与标准化,最后进入语义切块,并说明参数应该怎样选择、上下文如何保存以及结果如何检查。
1. 从文件到可检索文本
切块参数先放到后面。对 RAG 来说,原始文件并不是天然适合检索的文本,通常要经历几个阶段:
1PDF / Markdown / 网页 / 数据库2-> 读取文件3-> 解析正文与结构4-> 清除格式噪声5-> 恢复标题、表格和段落关系6-> 生成统一的 Document7-> 切分为多个 Chunk
其中,解析正文与结构是很关键的一步。同样一句话,来自网页正文、PDF 表格或 OCR 图片时,得到的上下文可能完全不同。切块器只能按照收到的文本工作,它不知道某一行原本是表头,也不会自动判断跨页的两段内容属于同一张表。
所以,文档处理并不是把文件转成字符串就结束了。转换后的文本还应该尽量接近人类阅读时看到的结构。
2. 一个解析失败的例子
假设员工手册里有这样一张表:
1请假时长 审批要求2少于三天 直属负责人3连续三天及以上 直属负责人和部门负责人
员工提问“连续请三天年假由谁审批”时,正常结果应该命中最后一行。但 PDF 解析后,内容可能变成:
1少于三天2直属负责人34连续三天及以上5直属负责人和部门负责人
如果表头留在了上一页,或者列顺序在双栏解析时发生变化,系统可能只把“连续三天及以上”作为独立片段交给切块器。这个片段既没有说明它描述的是请假时长,也没有说明后面的文字属于审批要求。
Embedding 模型不是数据库修复工具。它可以帮助系统理解不同说法之间的相似性,却不能稳定补回已经丢失的表头和列关系。遇到检索错误时,先检查原始文本和 Chunk,通常比立刻更换 Embedding 模型更有价值。
3. 原始资料的来源
不同来源需要关注的问题并不相同,可以先从这张表建立基本判断:
| 来源 | 常见问题 | 处理重点 |
|---|---|---|
| Markdown | 标题、代码块和列表层级混杂 | 保留标题层级、代码边界和列表关系 |
| HTML / 网页 | 导航、推荐内容、Cookie 提示混入正文 | 明确正文区域,移除页面噪声 |
| 页眉页脚、分页、双栏、表格顺序错乱 | 保留页码,检查阅读顺序和跨页结构 | |
| Word | 段落样式和表格结构容易丢失 | 识别标题、表格和列表的边界 |
| 图片 / OCR | 断行、错字、数字和否定词识别错误 | 对关键字段做抽样校验 |
| 数据库 | 字段语义分散,缺少文档层级 | 重新组合标题、正文和业务 metadata |
这张表并不是说每种来源都要配一套完全不同的系统,而是提醒我们,清理规则不能脱离来源类型。删除网页里的导航菜单,和删除 PDF 中所有短行,风险完全不同。
4. 用 Document 保存正文与来源
LangChain 使用 Document 表示一段可以参与检索的文本。在当前 JavaScript API 中,它主要包含 pageContent、metadata 和可选的 id。
01import { Document } from '@langchain/core/documents'0203const document = new Document({04id: 'leave-policy:v4:section-3',05pageContent: `年假审批0607少于两天,由直属负责人审批。08连续两天及以上,由直属负责人和部门负责人共同审批。`,09metadata: {10sourceId: 'leave-policy',11sourceVersion: 4,12sectionPath: ['考勤制度', '年假审批'],13pageStart: 8,14pageEnd: 8,15tenantId: 'company-a',16visibility: 'employee',17},18})
pageContent 决定 Embedding 模型能看到什么,metadata 决定系统能否过滤、引用和追溯。两者职责不同,不能为了缩短正文把标题和必要上下文全部移到 metadata,也不能把权限字段混进正文,期待模型替我们完成数据隔离。
一套实用的 metadata,至少要能够回答这些问题:
- 这段内容来自哪份资料;
- 属于哪个版本和章节;
- 在原文中的位置是什么;
- 哪个租户和哪些角色有权访问;
- 由哪个解析与切分流程生成。
这些字段不一定都要交给模型,但索引系统必须能够通过它们回到原文。否则用户无法核对引用,开发者也很难解释一次错误检索究竟来自哪一页文件。
5. 解析时保留结构
5.1 Markdown 和代码
Markdown 里的标题不是普通装饰文字,它通常说明下面几段正文属于什么主题。如果切块时只留下正文,用户问“缓存策略中的过期时间”时,模型可能只看到“7 天”,却不知道这个数字属于缓存策略还是另一项配置。
代码也不适合按普通句子拆分。函数签名、参数说明和示例调用往往需要一起出现。至少要避免在代码块中间截断,并在 metadata 中记录语言和文件路径。
可以把标题路径补到每个 Chunk 的开头:
1章节:缓存策略 > 过期时间23缓存默认保存 7 天。管理员可以在环境变量 CACHE_TTL 中修改这个值。
这样做不是为了制造重复文字,而是把原本依赖页面位置才能理解的上下文,明确带到检索文本中。
5.2 网页正文
网页通常同时包含导航栏、面包屑、相关推荐、登录提示和正文。直接把整份 HTML 转成文本,这些重复内容很容易占据大量 Chunk,导致同一条菜单被多次召回。
处理网页时,应先确定正文容器,再读取文章标题、更新时间、作者、路径和正文。标题与路径可以放进 metadata,也可以在正文开头保留一份简短的章节信息,具体取决于后续是否需要通过语义检索找到它。
网页内容还可能随着请求参数、广告位和个性化推荐发生变化。为了让增量更新稳定,可以保存规范化后的正文哈希,并记录抓取时间和来源 URL,不要把一次抓取结果当成永久事实。
5.3 PDF、表格与 OCR
PDF 的难点不在于能不能打开,而在于它保存的是页面排版,不一定保存人类阅读时的逻辑顺序。常见问题包括:
- 每一页重复出现相同页眉和页脚;
- 双栏内容被交叉读取;
- 表格的行列关系被打散;
- 一句话跨页后,后一页缺少标题;
- OCR 把数字、字母或“不”识别错误。
对表格来说,最好先恢复成带表头的文本,而不是把每个单元格简单拼接。比如可以把原来的表格整理成下面这种形式:
1规则:年假审批23请假时长:少于三天4审批要求:直属负责人56请假时长:连续三天及以上7审批要求:直属负责人和部门负责人
这种格式不一定是唯一答案,但它把“字段名”和“字段值”的关系保留下来了。用户提问时,Embedding 模型和关键词检索都有机会利用这些信息。
对 OCR 文档,还要特别检查否定词、日期、金额、版本号和错误码。它们通常决定业务规则是否被正确理解,不能只用“文本非空”作为解析成功的判断。
6. 清理与标准化
6.1 从低风险标准化开始
如果暂时不能确定某段文本有没有业务意义,先统一换行和空白,不要急着删除内容:
1function normalizeText(input: string) {2return input3.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。可以为每次处理记录来源文件哈希、解析器版本、清理规则版本和切分流程版本:
1type PipelineMetadata = {2sourceHash: string3parserVersion: string4normalizerVersion: string5pipelineVersion: number6}
当同一份文件在不同时间得到不同 Chunk 时,这些字段可以帮助我们判断究竟是内容变了,还是处理方式变了。需要回滚时,也可以据此找到上一套稳定的索引结果。
7. 围绕完整语义切块
长文档不能简单地整篇生成一个向量。内容越多,主题越容易被平均,用户只问其中一条规则时,整篇文档的向量未必足够接近问题。
切块也不是每 800 个字符截断一次。更重要的是,Chunk 脱离原文后仍然应该能够回答一个相对完整的问题。标题、正文、表格表头和适用条件,都应该尽量留在同一个语义范围内。
继续看请假制度:
1年假审批23员工可以按半天为单位使用年假。4少于两天,由直属负责人审批。5连续两天及以上,由直属负责人和部门负责人共同审批。67病假审批89病假一天以内需要提交就诊记录,超过一天还需要医疗证明。
比较合适的切分结果,是让“年假审批”标题和三条规则留在同一个 Chunk,病假进入另一个 Chunk。这样用户问“半天年假”或“两天年假”时,命中的文本会同时包含主题、条件和审批结果。
如果按固定字符切分,可能会得到:
1chunk 1:员工可以按半天为单位使用年假。少于两天,由直属2chunk 2:负责人审批。连续两天及以上,由直属负责人和部门负责人3chunk 3:共同审批。病假审批。病假一天以内需要提交就诊记录
每个 Chunk 都丢失了一部分关系。即使三个结果都被召回,模型仍然需要猜测哪些句子属于同一条规则,检索质量和回答稳定性都会下降。
8. 使用 LangChain 切分文档
通用文本可以先使用 RecursiveCharacterTextSplitter。它会按照段落、换行、空格等分隔符递归尝试切分,直到满足目标大小,比直接按索引截断更容易保留自然边界。
1yarn add @langchain/core @langchain/textsplitters
01import { Document } from '@langchain/core/documents'02import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'0304const source = new Document({05id: 'leave-policy:v4',06pageContent: rawPolicyText,07metadata: {08sourceId: 'leave-policy',09sourceVersion: 4,10tenantId: 'company-a',11},12})1314const splitter = new RecursiveCharacterTextSplitter({15chunkSize: 800,16chunkOverlap: 120,17separators: ['\n## ', '\n### ', '\n\n', '\n', '。', ';', ',', ' '],18})1920const chunks = await splitter.splitDocuments([source])2122for (const [index, chunk] of chunks.entries()) {23chunk.id = `leave-policy:v4:${index}`24chunk.metadata.chunkIndex = index25chunk.metadata.pipelineVersion = 126}
这里的 800 和 120 是起点,不是标准答案。chunkSize 的实际计算方式取决于 splitter 使用的长度函数,不能一律把它理解成 Token 数。正式项目应该结合模型、语言和文档类型,观察切分后的实际长度。
LangChain 官方当前也把 RecursiveCharacterTextSplitter 作为通用文本的推荐起点,示例使用 chunkSize: 1000 和 chunkOverlap: 200。这些参数可以帮助我们跑通流程,但仍然需要用自己的数据集验证。LangChain Semantic Search
9. chunkSize 怎么选
块太大和块太小都会降低检索质量,只是表现不一样。没有一个对所有知识库都适用的固定数字。
9.1 块太大
如果一个 Chunk 同时包含年假、病假、报销和考勤内容,向量会表达多个主题。用户只问“半天年假”时,这个大块和问题的相似度可能不如一段短小但碰巧出现“半天”的无关通知。
大块还会占用更多上下文预算。最终能放进模型的候选数量减少,正确答案更容易被其他内容挤掉,Token 成本和生成延迟也会上升。
9.2 块太小
如果每句话单独成块,“连续两天及以上”可能和“需要部门负责人审批”分开。前一个 Chunk 有条件没有结论,后一个有结论没有条件。
小块还会产生大量高度相似的候选,增加向量数量、存储成本和重排压力。为了弥补上下文缺失而不断增大 chunkOverlap,通常也会让重复问题变得更严重。
9.3 从真实问题反推粒度
选择 Chunk 大小时,先收集用户真正会问的问题,再观察回答通常需要多大范围的原文:
- 一个问题通常由同一段落回答,就优先按段落切分;
- 答案依赖标题和表格中的一整行,就保留标题、表头和这一行;
- 代码文档的回答需要函数签名和参数说明,就不要把它们拆开;
- 一个规则经常包含条件、例外和处理结果,就不要只保留其中一句。
面试时被问到“chunkSize 应该设多少”,比直接说某个固定数字更可靠的回答是:先根据文档结构和答案跨度设定初始值,再用检索评测集比较 Recall@K、上下文完整度、索引规模和生成效果。
10. chunkOverlap 解决什么问题
Overlap 会让相邻 Chunk 共享一小段内容,主要用来减轻答案刚好落在切分边界上的问题。
假设一条规则跨越了切分点:
1报销申请应在费用发生后 30 天内提交。2超过 30 天,需要直属负责人补充说明。
如果第一句在 Chunk 1,第二句在 Chunk 2,用户问“报销超过 30 天怎么办”时,两个 Chunk 都有价值。适量重叠可以让条件和处理方式在同一个候选中出现。
Overlap 过大也会带来副作用。大量重复文本会让前几名结果几乎一样,占满 Top-K,却没有带回其他互补资料。后面的去重和 MMR 可以缓解,但更好的做法仍然是优先改善语义切分,避免依赖过大的重叠范围。
一般可以让 overlap 占 chunkSize 的一小部分,然后观察边界问题和重复率。对于标题层级清晰的 Markdown,优先按标题和语义块切分,往往比不断增大 overlap 更有效。
11. 不同内容要使用不同切分策略
RecursiveCharacterTextSplitter 适合通用文本,但并不意味着所有内容都应该用同一组分隔符。
Markdown 可以优先按 ##、### 和段落切分;代码需要按文件、类、函数或语法结构切分;表格需要先恢复行列关系,再决定一行或一组记录是否构成一个 Chunk;FAQ 则可以让一个问题和对应答案保持在一起。
对于长篇技术文档,可以把标题路径加到每个 Chunk 前面:
1章节:RAG 检索增强生成 > 文档切块 > chunkOverlap23Overlap 会让相邻 Chunk 共享一小段内容,主要用于减轻答案刚好落在边界上的问题。
这样做会增加少量重复文字,但可以让 Chunk 脱离原文位置后仍然保留主题。是否需要加入标题路径,可以通过检索评测比较,不必把它当成所有场景的固定规则。
12. 检索粒度和阅读粒度可以分开
有些问题适合用小 Chunk 负责召回,但生成答案时又需要完整章节。比如用户问“两天年假由谁审批”,最具体的一小段规则很容易被找到;不过模型还可能需要知道这条规则只适用于正式员工,或者有一个特殊例外。
这时可以把检索粒度和阅读粒度分开:索引中保存小 Chunk,同时在 metadata 中记录 parentId。小 Chunk 命中后,再读取父级章节或相邻 Chunk 交给模型。
1type ChunkMetadata = {2sourceId: string3parentId: string4chunkIndex: number5sectionPath: string[]6}
例如小 Chunk 负责命中“连续两天及以上”的审批规则,组装上下文时再补充“年假审批”整节,使模型同时看到适用范围和例外说明。
Parent-Child Retrieval 会增加一次正文读取、排序和去重过程。数据量较小时,不必一开始就实现,先通过评测确认确实存在“检索很准,但回答缺少上下文”的问题,再引入这层结构更合适。
13. 用稳定 Chunk 支持增量更新
每次重新处理文档都生成全新的随机 ID,会导致旧向量难以删除,缓存和引用也会失效。可以根据来源、版本、顺序和内容哈希构建稳定标识:
01import { createHash } from 'node:crypto'0203function contentHash(content: string) {04return createHash('sha256').update(content).digest('hex')05}0607function buildChunkId(08sourceId: string,09sourceVersion: number,10chunkIndex: number,11content: string,12) {13return [14sourceId,15sourceVersion,16chunkIndex,17contentHash(content).slice(0, 16),18].join(':')19}
内容没有变化的 Chunk 可以复用已有 Embedding;发生变化的块重新计算;已经不存在的 ID 从索引中删除。切分参数或解析规则变化时,提高 pipelineVersion,不要把两套切分结果混在同一个线上版本里。
稳定 ID 还会影响引用展示。用户点击“查看来源”时,系统需要根据 sourceId、版本、页码和 Chunk 位置回到原始资料。只保存一串随机 ID,后续很难稳定完成这个过程。
14. 检查切块质量
完成切块后,不要立刻把全部数据写入向量库。先抽样检查这些问题:
- 每个 Chunk 是否包含可以理解的主题;
- 标题、表头和否定词是否保留;
- 是否存在只有页码、菜单或版权声明的空洞 Chunk;
- 相邻 Chunk 的重复内容是否过多;
- metadata 能否准确回到原文位置;
- 典型问题的答案是否完整落在一个 Chunk 或可恢复的父级范围内。
还可以建立一组结构性测试。例如“年假两天由谁审批”的期望 Chunk ID 是 leave-policy:v4:3。切分算法调整后先运行这组测试,确认关键答案仍然能够被定位,再继续生成 Embedding。
一个实用的抽样方法,是把测试问题和最终答案需要的原文范围放在一起检查:如果人工阅读也无法判断这个 Chunk 在讲什么,向量检索自然不会稳定;如果 Chunk 能看懂但缺少权限或版本 metadata,线上仍然可能出现越权或命中旧资料。
15. 总结
文档处理决定了检索系统能够看到什么。解析阶段丢掉的表头、否定词和章节关系,后面的向量模型无法可靠恢复。
一份可靠的处理流程应该先识别来源格式,再解析正文和结构,接着做可追溯的清理与标准化,最后围绕完整语义切块。Document 负责连接正文和来源信息,chunkSize 与 chunkOverlap 需要根据真实问题调整,父子块可以在召回精度和上下文完整性之间取得平衡,稳定 ID 则让更新、删除和引用变得可控。
当这些 Chunk 经过检查后,才适合进入 Embedding 和向量检索。下一篇会继续解释文本如何转换成向量,以及向量检索为什么能够找到表达不同但意思相近的内容。