把 chunk 从 120 个字符改成 240 个字符,召回率变高了。接着加上重叠窗口,分数又变了。到这一步,你能说检索真的进步了吗?
先别急着选参数。可能正确证据确实更容易找到了,也可能两个实验的“正确 chunk”已经不是同一套东西;还有一种更隐蔽的情况:同样取前两块,大块方案其实给了模型更多文字。
上一篇把“检索命中、答案支持、事实覆盖、拒答”分开计分。这一篇往前走一步:原始材料不变,切块变了,怎样让评测仍然拿着同一把尺子? 我们会真正执行切块、词法排序与上下文选择,定位三种失败:证据被边界切断、重叠占掉预算、旧版本抢走位置。
实验边界:文档与问题均为人工编写的合成教学 fixture;切块、词法检索、预算选择与计分由随文代码实际运行。没有调用 Embedding、BM25、重排模型或生成模型,没有真实用户数据。这是机制实验,不是生产 RAG 或大模型 benchmark。 文中结果只对应下载文件的固定设置。资料核对日期:2026 年 10 月 2 日。
1. 先别让答案跟着 chunk ID 一起搬家#
上一篇的教学计分器按必需证据 ID 计算召回。只要两组检索共享同一批稳定证据,那个口径足够清楚。但如果 ID 直接来自切块序号,比较切块策略就会出现麻烦。
假设一个原始文档中,真正需要的证据位于 [40, 90)。旧方案把它放进 chunk-0,新方案把它拆进 chunk-0 与 chunk-1。此时继续要求“命中 chunk-0”,可能把只找到前半句算作成功;若给新切块分配全新 ID,再直接与旧 ID 比较,又可能把正确结果算作漏检。
Ragas 的 ID-based Context Recall 明确按参考 ID 与检索 ID 的交集计分。这种实现不会替我们判断两套 ID 是否还表示同一个证据对象。更换切块时,要先解决对象对齐,再使用相应指标。Ragas v0.4.3:Context Recall
本文把标注锚定到三个层次:
- 原始文档:稳定的文档 ID,表示是哪份材料
- 不可变版本:明确的版本标识,区分当前说明与历史说明
- 证据范围:该版本原文中的半开区间
[start, end),包括起点、不包括终点
切块只是从这些原文范围派生出来的视图。无论 chunk ID 怎么变化,都能回到原文坐标重新映射。
这一步还有一个容易漏掉的前提:字符坐标必须对应同一份规范化文本。 一边把 Windows 换行转换成单个换行,一边保留原始换行,后面的 offset 就可能全部错位。PDF 提取器升级、表格转文本方式变化、Unicode 规范化也可能产生同样问题。
因此,工程记录不能只存“文档叫使用手册”。应同时保存原始来源、解析器版本、规范化规则与文本摘要校验值;重新解析后重新验证证据文本。本例刻意使用 ASCII 英文原文,避免把中文分词、UTF-16 码元与 token 计数混进同一个教学实验。真实中文资料应明确 offset 单位,并用所选模型的 tokenizer 计算输入预算。
2. 一条事实可以有多个出处,但不能靠重复加分#
如果“日志保留 7 天”同时出现在主文档和 FAQ,找到任何一个有效出处都应该算覆盖。反过来,如果答案必须同时满足“等待 30 秒”和“仅对幂等请求生效”,只找到其中一半就不够。
本例的标注把每道题拆成必需事实,每个事实有若干备选支持组:
- 不同支持组之间是 OR:任意一个完整组成立,就覆盖该事实
- 同一组内部的证据范围是 AND:所有范围都必须被覆盖
- 每个范围只接受同一文档、同一版本的上下文区间
- 多个选中 chunk 可以联合覆盖一个范围;先合并区间,再判断是否有缺口
- 同一事实最多得一次分。两块重复包含同一句话,不会得到两份奖励
本文定义的“原文证据事实覆盖率”是:被完整支持的必需事实数 ÷ 必需事实总数。先逐题计算,再对可回答题做宏平均。它与上一篇“生成答案实际答出了哪些事实”处在不同阶段:这里没有生成答案,只问最终上下文有没有提供标注要求的原文信息。
举个最小边界:目标是 [40, 90),检索区间是 [0, 64) 和 [65, 128)。虽然只缺一个字符,严格覆盖规则仍然判失败。这个规则便于稳定回归,但它可能比人类阅读更苛刻,所以标注时要尽量选完整的、最小充分的证据,避免把无关空格或整页都标进去。
另一方面,范围覆盖也可能高估真正可用性。切块顺序打乱、引用关系丢失,或者“它”“上述条件”的指代不在标注范围内,模型仍可能答错。标注是否充分需要人工检查,计分器只执行已经约定的规则。 不能让被测系统自己选择对它最有利的证据范围。
3. 把切块、排序和预算分开控制#
完整实验只依赖 Node.js 18 或以上版本,无需安装第三方包。先把以下五个文件下载到同一个目录,并查看代码:
- evaluate.mjs:切块、检索与计分
- fixture.json:固定原文、问题与证据标注
- test.mjs:可独立运行的边界测试
- results.json:本文运行的完整结果
- README.md:运行说明与实验边界
node evaluate.mjs fixture.json > actual-results.json
node test.mjs
脚本不联网、不下载模型、不读取凭证。第一条命令实际从原文构建 chunk,再对问题执行检索,不是读取一份事先填好的排行榜。第二条命令检查确定性和失败边界。仓库内也可运行 npm run test:rag-chunking。
对照设置如下:
| 变量 | 本文固定设置 |
|---|---|
| 原文与问题 | 同一份人工合成 fixture;3 道可回答题,1 道无证据题 |
| 固定切块 | 每块最多 120 个 ASCII 字符,步长 120 |
| 重叠切块 | 每块最多 120 个 ASCII 字符,重叠 60,步长 60 |
| 段落切块 | 按原文段落边界切分,保留原文坐标 |
| 排序 | 问题不同词项与 chunk 词项的交集数量;并列使用固定规则 |
| 上下文选择 | 分别测 Top-2,以及总计不超过 240 个字符的整块选择 |
| 版本条件 | 允许历史版本、只保留当前版本,分别运行 |
这个词法排序器没有逆文档频率、词频饱和或长度归一化,因此不能称作 BM25。它也不理解同义词。我们刻意采用简单的确定性函数,让每个排序变化都能追到词项与边界;本例没有资格推导中文向量检索中的最优块大小。
要注意,段落切块是一个不同长度的对照,并没有悄悄强制成 120 字符。它恰好可以暴露固定 Top-k 的问题:两段长文字与两段短文字,数量相同,输入体积不同。
预算选择也要写清楚。本文按排名顺序扫描候选,当前 chunk 放不下就跳过,继续检查后面的较小块;不截断 chunk,也不做背包优化。重叠部分照常占用输入字符,不先去重后报一个更漂亮的成本。换一种选择策略,结果可能不同,所以选择器本身也是实验配置的一部分。
4. 看逐题失败,而不是急着宣布哪种切块胜出#
下面是脚本在随附 fixture 上实际运行的宏平均结果,分母始终是 3 道可回答题,四舍五入到三位小数。每种策略各跑两个版本条件和两种选择规则,共 12 组;没有先挑最好的一组再展示。
| 切块 | 保留历史,Top-2 | 保留历史,240 字符 | 仅当前,Top-2 | 仅当前,240 字符 |
|---|---|---|---|---|
| fixed:120,无重叠 | 0.333 | 0.333 | 0.667 | 0.667 |
| overlap:120,重叠60 | 0.500 | 0.500 | 0.833 | 0.833 |
| paragraph:段落 | 0.667 | 0.333 | 1.000 | 0.667 |
这张表不能被读成“段落切块最好”。同一段落策略,Top-2 是 1.000,限制到 240 字符后就变成 0.667。先看原因。
失败一:条件被切走,关键词还留着#
boundary-condition 需要 retry/v2 的 [100, 178),内容包括 R-17、等待 30 秒,以及“仅当请求幂等”的适用条件。fixture 用点号填充背景,让证据有意跨过 120 字符边界;这些填充并非真实业务语料。
固定切块选中了 50 字符的索引提示 retry-guide,以及 retry/v2 的 [0, 120),合计 170 字符。提示里有三个查询词,却没有实际规则;另一块只带来证据开头。后续块的词项没有命中查询,因此没有进入正分候选。这里还暴露了字符硬切会截断单词的问题,不能把该现象全部解释成语义上下文不足。
重叠方案得到 [60, 180),完整覆盖 [100, 178),于是该题从 0 变为 1。这个反例只说明这次重叠恰好保护了证据边界。换一个证据位置、分词器或 query,排序和结论都可能改变。
失败二:两个位置都被同一条事实占了#
two-facts 要求“保留 7 天”和“支持 JSON 导出”。仅当前版本、240 字符预算下:
| 方案 | 最终选中的 logs/v2 原文区间 | 输入字符 | 重复原文字符 | 覆盖事实 |
|---|---|---|---|---|
| fixed | [0,120) + [120,213) | 213 | 0 | 2/2 |
| overlap | [0,120) + [60,180) | 240 | 60 | 1/2 |
| paragraph | [0,185) + [187,213) | 211 | 0 | 2/2 |
重叠方案的两个块都包含保留期限。导出说明在 [187,213),对应候选确实存在,但排在后面,剩余预算已经装不下了。两个高相关结果并没有给出两个不同事实。
重复字符按同文档、同版本区间并集计算:240 个输入字符只覆盖 180 个不同原文位置,重复了 60 个。不同文档写了相同文字不在这个重复口径内;实际输入还可能有引用标签和分隔符,本例没有把这些包装开销算进去。
失败三:Top-k 满分来自更大的输入#
boundary-condition 的完整段落长 243 字符,加上 50 字符提示,Top-2 实际输入 293 字符,证据覆盖为 1。换成 240 字符预算,243 字符的整段被跳过,只剩 50 字符提示,覆盖变成 0。
不是计分器忽然不喜欢段落了,而是限制条件变了。更合适的下一轮对照可以测试“过长段落二次切分”或“按句子装配”,同时固定真实 token 预算;不能把不受字数限制的 Top-2 与受限方案直接比较效率。
独立对照:旧版排得更高,仍然算错#
current-version 查询使用 current logs retention policy。旧版重复这组词,排在当前有效证据之前。三种切块在不做版本过滤时,该题覆盖都是 0;只允许 fixture 声明的当前版本后,都变为 1。这个变化来自版本资格过滤,不应归功于切块算法。
第四题 no-evidence 没有任何必需证据,覆盖率为 null,不进入宏平均。部分设置仍返回带 archive 的片段,这只说明词法相关并不等于有答案。因为没有生成模型,本文不计算拒答率,也不把检索为空当成正确拒答。
完整结果会保留每个候选的词项得分、原文坐标、选中片段、丢弃原因、已覆盖事实、输入字符与重复字符。调试时先看某题的 ranked、selected、skipped,再看汇总。随附测试既锁定上述失败,也覆盖相邻区间、单字符缺口、不同版本不可拼接、备选证据组、非法参数和命令行结果一致性。
5. 为什么“原文还在”不代表“证据还在上下文里”#
排查时,至少保留三个检查点:
- 索引覆盖:所有切块的并集还能覆盖标注证据吗?解析阶段是否直接丢了表格、脚注或范围末尾?
- 候选覆盖:排序器返回的候选是否覆盖证据?如果缺失,查分词、关键词、Embedding 或召回路径
- 最终上下文覆盖:候选中存在的证据,经过 Top-k、预算选择、版本过滤与重排后还在吗?
只有最终一层是本文主表计分的对象。若把“整个索引中有这个句子”当成召回成功,几乎每种保留原文的切块方式都能拿高分,却没有回答用户问的东西是否真的送进模型。
重叠尤其容易制造错觉。它增加了同一证据被某块完整容纳的机会,同时也增加重复候选。Top-k 是按块占位置,输入窗口是按文本占预算,这两个位置都可能被重复内容消耗。检索去重、邻接合并或父文档扩展可以成为下一轮实验,但必须连同新增上下文体积一起测,不能只比较召回分数。
这也是为什么本文不把“最小 chunk”或“最大 overlap”写成建议参数。对于需要连续操作步骤的问题,过小的块可能把前提切走;对于短错误码查询,过大的块可能让其他主题混在一起。原始材料结构、问题分布和排序器共同影响结果。
HiChunk 的研究把证据密度和切块质量的评测联系起来,提醒我们:一份主要由单点事实组成的问题集,可能暴露不了多段证据任务的失败。这里引用的是研究动机,不代表本文复现了其模型或结论。HiChunk 论文 v3
6. 版本过滤应先于“这个片段看起来很相关”#
旧版说明可能包含更多与问题完全相同的词,因此相关性得分更高。“排得靠前”不会自动证明它仍然有效。
本例的当前版本由 fixture 显式声明,过滤发生在排名之前。历史版本仍保留在原始数据中,以便运行对照和检查;过滤没有把文档永久删除。计分时,旧版本即便逐字覆盖类似内容,也不能充当当前版本的证据。
生产系统则比这个开关复杂得多:“当前”可能随租户、产品版本、生效日期、地区或合同而变。一个查询如果没有说明适用版本,正确动作有时是追问,而不是一律选版本号最大的文档。历史问题还需要查历史资料,因此应保存可复现的知识快照和明确的查询约束。
权限是另一条单独的边界。本文版本过滤不实现 ACL,不验证当前用户能否访问文档,也没有模拟提示注入。不能因为这个教学 fixture 通过,就认为多租户知识库已具备安全隔离。真实项目应把无权文档的召回视作独立阻断条件,而不是允许平均召回上涨抵消它。
7. 把这个小实验换成你的真实回归集#
先找一批获准用于评测的问题,覆盖精确标识、多事实、前提条件、版本冲突和无答案。每个问题保留最小充分证据、备选支持组与适用范围。不要只从现有检索器已能答对的结果反推标注,否则系统的盲区会从评测集里消失。
然后冻结一份实验清单:
知识快照 + 文本摘要 + 解析/规范化版本
问题集 + 标注版本 + 适用版本/权限条件
切块方式 + 长度单位 + size/overlap
检索器版本 + 并列规则 + 候选数量
上下文选择器 + tokenizer + token预算
逐题候选 + 最终上下文 + 丢弃原因
接入真正的生成模型后,再回到第一篇:用实际送入模型的上下文审核答案支持度、事实覆盖与拒答。检索证据覆盖提高,是值得继续验证的信号;答案是否受益仍需单独测量。报告延迟时,区分解析建索引、查询检索和生成;报告成本时,用真实 token 与计费条件,本例没有测量这两项。
Anthropic 的 Contextual Retrieval 讨论了为 chunk 补充来源上下文,并结合词法与语义检索的方案。它为“片段缺少主语或文档背景”提供了一种可测试的改进方向;厂商报告的收益依赖其数据与配置,不能移植成本文的效果数字。Anthropic:Contextual Retrieval
最后要给评测保留一个没被反复调参污染的留出集。本文的 4 道教学题及参数都是为了展示机制而设计,公开、确定、样本极小;它们适合作为单元测试,不适合做显著性分析或泛化结论。真实系统应报告每类失败的数量、逐题差异与重复运行波动,先约定阻断门槛,再看候选结果。
8. 这一次应该带走什么#
切块参数改变后,第一件事是让证据锚点仍然指向同一份原文和适用版本;第二件事是检查真实进入上下文的内容;第三件事才是比较分数、体积和后续答案表现。
如果一次改动让召回率升了,你现在应该能够继续问:升的是哪个阶段?用的是同一标注吗?多占了多少输入?哪些题反而退化?旧版和重复块有没有把位置挤掉?这些问题答得清楚,分数才对下一步改动有用。
系列导航:
- 第一篇:检索命中了,为什么答案还是错的?
- 本篇:切块、稳定证据映射与预算对照
- 下一篇计划:固定候选与预算,比较混合检索和重排;完成可复现实验后发布
- RAG 主题文章
想讨论自己的切块失败,可以先看 AI 实践路线,了解知识星球的内容方向;公众号入口在关于页。带上脱敏后的原文范围、最终上下文和丢弃原因,比只贴一个“召回率 80%”更有助于定位问题。

