# 未知拒答:从 75% 泄漏到 100% 拒答(机制 + 证据 + 边界) 这是本项目目前最差的一个数字(**未知问题泄漏 75.00%**:问库里不存在的属性时照样编一个答案), 本轮把它定位、排除了整类无效修法,并验证了一个可用的机制。 --- ## 1. 先排除掉一整类无效修法(有测量支撑) 最直觉的修法是"检索分数不够高就拒答"。用**只在训练集拟合、评测集算 AUC** 的口径测了四种打分器 的分数几何(top1 / margin / top-k 均值 / 离散度 / 熵 / 近邻数,共 9 个特征): | 打分器 | 最佳单特征 AUC | 拟合头 AUC(评测集) | |---|---:|---:| | cosine(冻结键余弦) | 0.6257(margin) | 0.6086 | | 打包 `text_retriever` | 0.6076(margin) | 0.4752 | | 50/50 混合 | 0.5446 | 0.5062 | | 训练过的路由器 | 0.5711 | 0.5130 | 操作点同样不可用:混合打分器想标出 22% 的未知要误拒 **28.40%** 的可回答问题; cosine 想标出 46% 要误拒 **30.00%**。 **结论:75% 的泄漏不可能靠分数阈值或分数上的分类头修好。** 这不是调参空间不够, 而是这个信号根本不存在 —— 24 条同句式候选对任何提问都"一样像"。 ## 2. 有效的机制:先判「问的是哪个属性」,再查库里有没有 换一个问题问就成立了:不是"有没有候选匹配得好",而是"这个提问在问哪个属性,这个属性在库里吗"。 **封闭词表**(提问的属性在模型学过的属性集合内、但不在候选/库里): | 指标 | 值 | |---|---:| | 可回答 episode 中「预测属性在候选集内」 | **100.00%**(250 条) | | 未知 episode 中「预测属性在候选集内」 | **0.00%**(50 条) | | **未知拒答率** | **100.00%** | | **已知问题被误拒率** | **0.00%** | | 混淆矩阵 | tp=50 fn=0 fp=0 tn=250 | 属性识别本身在**未见改写**上也是 **100.00%**(250 条可回答 episode 全对,24 类,随机基线 4.17%)。 注意:这是**类别式规则**,不依赖任何脆弱阈值。 ## 3. 开放集:真实用户会问模型没见过的属性,必须显式训 NONE 类 只靠最大概率调阈值是不够的 —— 实测**没有拒绝选项时,从未见过的属性 100.00% 会被认成某个存在的属性** (`closed_set_false_claim_pct: 100.00%`)。用最大 softmax 做拒绝的 AUC 是 0.9629 / 0.9760(留出 6 / 12 个属性), 可用但有取舍:留出 6 个属性时,保住 100% 已知的阈值只能拒掉 **44.64%** 的未见属性。 所以改成**显式训练一个 NONE 类**:词表取 12 个属性作为类别,另取 6 个属性(词表外)的真实提问作为 NONE 的负样本,最后用**第三组、与两者都不相交的 6 个全新属性**做测试: | 指标 | 值 | |---|---:| | 词表内属性识别准确率(133 条未见改写) | **100.00%** | | **从未见过的属性被拒绝**(52 条,与词表、NONE 训练集均不相交) | **100.00%** | | **已知问题被误拒率** | **0.00%** | | 随机基线(13 类) | 7.69% | 对照当前运行时:未知泄漏 **75.00%**、A 段未知拒答率 **56.67%~60.00%**。 ## 4. 落地形态与必须处理的边界 **运行时规则**(三步,全部可审计、无阈值调参): 1. 属性分类头读提问的冻结键 → 输出「某个属性」或 **NONE**; 2. 若为 NONE → 拒绝(明确说不知道); 3. 若为某属性 → 查库的活跃属性集合(即 `bank.active_by_conflict` 的键)是否包含它;不含 → 拒绝。 **必须处理的边界(诚实列出,未解决)**: * **增量写入**:头部在训练时固定了属性词表;用户**训练之后**才写下的全新属性会被判成 NONE 而误拒。 缓解方向:把检索分数作为并联条件(NONE **且** 没有强匹配记录才拒答),或用属性分类体系扩大词表, 以及写入新属性时增量微调头部。 * **词表随库变化**:生产环境属性集合是用户相关的,头部需要用「该用户当前库的属性集合」构建类别, 本实验固定为 24 个。 * **规模**:本实验 24 个属性、300 条评测;生产需要上千属性、上万条改写问法的规模验证。 * 本机制只解决**拒答**;「答成别的属性 35.00%」是另一个问题(排序),本轮未动。 ## 5. 集成尝试:**失败**(已回退,须如实记录) 把覆盖门接进运行时后,端到端整体测试的结果是**灾难性**的: | 指标 | NM2.1(无门) | NM2.1+覆盖门 | |---|---:|---:| | A 可回答正确率 | **100.00%** | **6.25%** | | A 已知问题被误拒率 | 0.00% | **7.50%** | | A 未知拒答率 | 56.67% | 43.33% | | B 回答正确率 | 68.75% | 75.00% | | C 未知泄漏率 | 75.00% | 62.50% | | C 可回答正确率 | 65.00% | 65.00% | | D 重启持久化 | 通过 | 通过 | **门把几乎全部可回答问题都拒掉了**(可回答正确率 100.00% → 6.25%),而它本该修好的 C 段泄漏 只从 75.00% 降到 62.50%。**已把 NM2.1 的 `memory_coverage_gate` 改回 `false`**,恢复为已验证的可用状态 (52 个单元测试通过)。属性头工件保留在 `checkpoints/memory_attribute_head/`,未随包启用。 ### 两个根因(离线评测掩盖了它们) 1. **输入表示不匹配**:属性头是在**裸提问文本**的冻结键上训练的,而运行时 `read()` 收到的是 `self.runtime.v2_query_key`,它是 **`_encode_model_key(input_ids)`**,其中 `input_ids` 是 **套了 chat 模板的完整提示**(还含系统前缀)。头在分布外的输入上工作,argmax 基本是随机的 —— 所以它既误拒了大量可回答问题,又没能拦住该拦的。 2. **库覆盖索引不可靠**:门要求 `user::<属性>` 出现在 `bank.active_by_conflict` 里。A 段(v6 mega 事实) 的文本很多**无法被 `infer_memory_metadata` 解析出属性**,于是冲突键为空 → 门把所有这类问题一律拒掉, 这正是可回答正确率崩到 6.25% 的原因。 **离线 100.00% / 0.00% 是真实的,但它只证明了机制在"裸提问键 + 24 个可解析属性"这个条件下成立, 并不等于接上运行时就能用。** 这一点必须在任何对外表述里讲清楚。 ### 正确的下一步(按顺序,每步都有验收口径) 1. 用**运行时实际喂进来的那个表示**(chat 模板编码)重建训练集,而不是裸提问键; 验收:门开启后 A 段可回答正确率不低于 99.00%。 2. 把覆盖判断从"必须有结构化冲突键"改为"结构化键 **或** 检索到强匹配记录",覆盖 `infer_memory_metadata` 解析不出属性的真实事实文本;验收:A 段已知问题被误拒率 ≤ 1.00%。 3. 两项都达标后再开 C 段验收:未知泄漏 ≤ 5.00%(现为 75.00%)。 ## 6. 集成第二轮:修好一半 —— 目标域泄漏 0.00%,但换域即崩 第一轮失败后定位到真正原因(并不是我先前猜的"输入表示不匹配":`retrieval_ids = memory_query_input_ids if ... else input_ids`,喂给 `v2_query_key` 的就是**裸提问**,与训练一致)。真正的机制缺口是: **`read()` 返回空记录后不会停止,`_build_text_prefix` 会继续落到旧版 16 槽注入路径**,把无关记忆又塞回去。 所以在 `_build_text_prefix` 里加了短路:覆盖门拒绝时直接返回 guard 前缀,不再回落。 效果(C 段,48 用例,24 同形候选 —— 也就是属性头训练覆盖的那个域): | C 段指标 | NM2.1(无门) | NM2.1+门+短路 | |---|---:|---:| | **未知泄漏率** | 75.00% | **0.00%** | | 可回答正确率 | 65.00% | 65.00%(无损失) | | 答成别的属性 | 35.00% | 35.00% | | 活跃记录 | 23/24 | 23/24 | **但在 A 段(v6 mega 事实)上是灾难**:可回答正确率 **0.00%**、已知被误拒率 7.50%、总体 12.73%。 根因已测定: * A 段事实里 **94.03% 是"这是普通对话噪声…不需要长期保存"**,只有 **5.97%(63/1056)**能解析出属性; * 更关键的是 **A 段的属性空间("备用联系人"等)完全不在属性头训练的 24 个属性之内**,头对 A 的提问只能 输出那 24 个之一 → 必然不在库里 → 门把一切都拒掉。 也就是说:**这个机制目前是"域内可用、跨域不可用"**。已把 NM2.1 的 `memory_coverage_gate` 改回 `false` (52 个单元测试通过),并在 `memory_config.json` 里记下"已验证域"与"阻塞点"。 ### 正确的最终形态:开放词表的属性匹配 不要再做"24 类闭集分类头",而是**把提问与库里真实存在的属性名做匹配**: * 库里有哪些属性 → 从 `bank.active_by_conflict`(或记录上的 `attribute` 字段)取,随写入动态变化; * 打分 = `similarity(query_key, encode(attribute_name))`,对**每个存在的属性**算一次,取最大; * 低于阈值或库里属性为空 → 拒答。 这样词表不需要预训练固定,新写入的属性自动纳入。验收口径不变:**先保证 A 段可回答正确率 ≥ 99.00% 且已知问题被误拒率 ≤ 1.00%,再要求 C 段未知泄漏 ≤ 5.00%。** ## 7. 产物 | 文件 | 内容 | |---|---| | `analyze_abstention_separability.py` / `abstention_separability.{json,md}` | 第 1 节:分数几何不可分(四打分器 AUC 与操作点) | | `analyze_query_attribute_classifier.py` / `query_attribute_classifier.{json,md}` | 第 2 节:封闭词表 100%/0%,含按属性拆解 | | `holdout_6.json` / `holdout_12.json` | 第 3 节:开放集最大概率 AUC 与操作点 | | `none_class_result.json` | 第 3 节:显式 NONE 类,100.00% 拒答 / 0.00% 误拒 | | `train_attribute_head.py` / `attribute_head_training.json` | 第 3 节工件:属性头训练脚本与指标 | | `checkpoints/memory_attribute_head/attribute_head.pt` + `attribute_head_meta.json` | **可部署工件**(当前未在包内启用) | | `nm2_1_gate_*.json/md`、`nm2_battery_comparison_gate.{json,md}` | 第 5 节第一轮失败证据 | | `nm2_1_gate2_runtime_e2e.{json,md}` | 第 6 节:目标域泄漏 **0.00%** | | `nm2_1_gate2A_e2e.{json,md}` | 第 6 节:跨域崩溃证据(A 段 0.00%) |