知识库的检索参数有三组:分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍,每一组都留下了实测数据。
起因是一个看不太出来的现象:知识库好像没被用上。
一、分块:中位数 212 字
先量现状。生产库里的分块统计:
13244 个分块中位数只有 212 字,而目标 2400 字,62% 的块不足 300 字。
目标块大小是 2400 字,实际交付的是 212。差了十倍。
分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题,但清单型、模板型文档里,几乎每一行列表项都会被标题识别逻辑认成标题:
1. 原本想达成什么?
这一行被当成标题,于是自己成了一个块。一篇文档被切成几十个几十字的碎片,注入给模型的全是碎片。
更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里,不写进块正文。于是被误判成标题的那一行文字直接消失:
标题行只活在面包屑里,整行文字直接丢失(那类文档的四个核心问题在索引里根本不存在)。
两处改动:
// 攒够了才在这里切:标题是「首选切点」,不是「强制切点」。
// 不变量:每一行输入都要落进某个块的正文,面包屑只是附加元数据。
// 老实现让标题行只活在面包屑里,于是被 detectHeading 误判成标题的列表项
// (「1. 原本想达成什么?」这类)整行文字就没了——连续几个列表项时只留得住最后一条。
// 与面包屑重复一次可以接受,丢字不行。
标题从「强制切点」降为「首选切点」:缓冲区不足阈值时,标题并入当前块,不切。同时标题行本身写进正文,成为一条不变量——每一行输入都要落进某个块的正文。
实测效果:一篇文档从 3 块 52/67/100 字(四个核心问题全丢)变成 1 块 235 字,内容完整。
二、按比例推算,推出了全场最差点
第一版把阈值定成 maxChars × 0.6。生产 maxChars 是 2400,算出来是 1440。
结果整篇文档并成一个块。上线后实测检索:
5 篇文档 6 个查询,每篇取最佳命中分再平均
阈值 0(纯按小节切) 0.7046
阈值 250 0.6750
阈值 1440(线上) 0.4978
1440 正好是最差的那个。
同一篇文档对「核心四问」这个查询,切成小节时得分 0.77,并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了,却再也检索不到。
根因在生产用的向量模型上:
生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。
原来那个「一个标题一个块」的设计,主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。
最终取值 250:
/**
* 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的,不是拍脑袋:
* 5 篇文档 6 个查询,取每篇的最佳命中分做平均——
* 阈值 0(纯按小节切)0.7046 / 250 → 0.6750 / 1440(= maxChars×0.6)→ 0.4978
* 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……
* 所以阈值必须小,千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440,
* 正好是最差点。
* 取 250 而不是 0:排序只差 4%,但块从几十字变成 250~330 字,
* 同样召回 6 段能多喂两三倍的正文。
*/
取 250 而不是 0 的理由是这段话里第二重要的部分:排序只差 4%,但每个块从几十字变成两三百字,同样召回 6 段能多喂几倍的正文。
代码里还留了一条兜底:
// 取 min:maxChars 很小的配置(测试里 300)不能让阈值反超块大小本身
const minChunkChars =
opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));
以及一条守卫测试,专门防「有人再推一遍公式」:
加一条守卫测试钉死生产口径(阈值退回按比例推算即变红),防止将来有人再推一遍公式又回到 1440。
三、召回阈值:会把整轮清零
分块改大之后,绝对余弦分整体下移。而召回阈值还是旧值 0.5:
正确命中落在 0.43~0.63,旧值会把最高分 0.488 的查询整轮清零——
检索到了正确文档却被门槛全部丢弃,用户看到的就是「知识库没被使用」。
这条解释了我一开始看到的那个现象。检索其实命中了,只是分数没过门槛,于是整轮被丢掉,模型什么也没拿到。
改成 0.35。
四、精排:从 9/12 到 12/12
同一天启用了 cross-encoder 精排。A/B 实测:
12 条查询 A/B 实测:
纯向量 命中 9/12,整轮零注入 1 次
开精排 命中 12/12,整轮零注入 0 次,目标文档 10 条排第 1
修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子:
「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段
(相关文档被向量埋在后面,精排提到第 2 名)
精排启用时把两个阈值也一起改了。这是当天最曲折的一处。
第一版把精排阈值设成 0.15。发版后跑完整测试,出现随机漏召。
原因是精排的绝对分不稳定:
同一查询同一候选集,「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——
排名都稳定第 1,只是分数漂了近一半。0.15 卡在中间,于是第二次整轮零注入。
排名是稳定的,分数会漂。所以不能用绝对分当门槛去「把关质量」。
阈值扫描:
0.02~0.10 均 12/12 零丢弃,0.15/0.20 → 11/12 丢 1 次
最终取 0.05,并把职责写清楚:
/**
* 精排阈值。**它的职责只是扔掉垃圾,不是把关质量**——质量由排序保证:
* 12 条查询里精排把正确目标全部放进了前 3 名(10 条第 1)。
*
* 取 0.05 而不是更高,是因为**精排的绝对分不稳定**……
* 观测到的正确目标最低分 0.131,取 0.05 留约 60% 余量;
* 垃圾档在 0.014~0.025,仍被干净滤掉。
*/
观测到的垃圾档在 0.014~0.025,正确目标最低 0.131。0.05 落在两者之间,两边都有余量。
五、超时:静默降级最贵
同一批里还修了超时。
精排失败会自动降级回向量序,不阻断聊天。这个设计是对的——但有代价:
法律知识库 30 块共 4.5 万字,冷调用实测 2263ms,只剩 737ms 余量。
完整测试里有一条查询当场超时降级。
超时是静默的,日志不报错,用户侧表现为「有时准有时不准」,极难归因。
「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图,这里是检索。
超时从 3000 毫秒提到 8000,给冷调用 3.5 倍余量。真卡死仍有降级兜底。
六、三个阈值放在一起
三条修正的形式不同,结论是同一条:阈值不能推导,只能测量。
| 参数 | 推导值 | 实测值 | 推导错在哪 |
|---|---|---|---|
| 分块合并阈值 | 1440(按比例 0.6) | 250 | 假设「块越大越好」,而该模型偏好主题聚焦的小段 |
| 召回阈值 | 0.5(惯例值) | 0.35 | 分块改动后分数分布整体下移,旧门槛把命中全丢 |
| 精排阈值 | 0.15(留余量) | 0.05 | 绝对分本身会漂近一半,拿漂移量当门槛必然随机漏召 |
三个值都有实测数字支撑,也都配了守卫测试。其中两条测试写的是「不许回到某个值」,而不是「必须等于某值」:
it("向量阈值不得回到会整轮清零的 0.5", () => {
expect(DEFAULT_KB_MIN_SCORE).toBeLessThanOrEqual(0.4);
});
it("精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131", () => {
expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThanOrEqual(0.1);
});
这样写是因为真正的风险不是「有人改了数值」,而是「有人又推了一遍公式」。测试防的是后者。
还有一条测试值得抄下来:
it("精排阈值必须低于向量阈值——两者不是同一个量纲", () => {
// 精排是 cross-encoder 分,向量是余弦分,拿同一个数去卡两边必然有一边错
expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);
});
同一个数量级、看起来可比,实际是两个不同的量纲。这种错觉在阈值调参里很常见,能用一条断言拦下来比写在注释里可靠。
七、评测集的缺口
这一轮所有数字来自临时扫的查询集:5 篇文档 6 个查询、12 条查询。
仓库里确实有一个召回评测脚本(用户问题、期望命中的文档名、recall@K / precision@K / MRR 双模式),但它的标注集还是占位内容——两条用例,kbIds 的值是字面量 <替换为真实 kbId>。
也就是说,这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里,跑分的脚本和查询集没有留下。
这是这次工作里最该补上的一环。阈值有实测依据,但依据本身不可复现。 下次有人想调整,只能重新扫一遍查询。
补的做法是明确的:把这一轮用的查询集和期望结果补进评测脚本的标注集,让它成为下一次调参的起点。参数变更时先跑评测再改值,改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行,而不是一次性的手测。
■