# C 组(BM25 + 向量 + RRF)评测报告(`T3-11`,M5) > **结论先行**:按 `ADR-14` §10.4 的 `V4`,向量路径 **不发布**。 > `V1` 未达(`natural@5 = 0.143 < 0.40`)、`V2` 未达(`exact@5 = 0.917 ≠ 1.000`,且逐条回归 1 条)。 > 判据逐条见 §4;`V3` 未跑的理由见 §5;**`natural` 桶已扩到 20 条并复测,结论不变(见 §10)**。 > > 报告日期:2026-09-25 | 语料:`/tmp/dsh-coderag-t3-03-corpus`(3967 文件 / 63046 chunk)| golden:`m3-b1` --- ## 1. 配置(C = B + 可选后端) | 项 | 值 | |---|---| | 后端 / 模型 | Ollama `http://127.0.0.1:11434` / `bge-m3`(dim 1024) | | 开关 | `CODERAG_SEMANTIC=on`(其余 `CODERAG_SEMANTIC_*` 取默认:`_BATCH=16`、`_MAX_CHUNKS=100000`、`_TIMEOUT=30`) | | 融合 | RRF `k = 60`(`ADR-16` §2 冻结),等权、无 reranker | | 每路候选 | `k = 5`(与模型实际看到的条数一致) | | 排序档位 | 非测试优先(`ADR-16` 2026-09-24 更新块),**截断之前**生效 | | 输出顺序 | 源码顺序(`ADR-05` 不变) | **向量索引构建**(`T3-11` 接线 + 增量复用后首次全量): ``` {"event": "semantic_index_built", "model": "bge-m3", "chunks": 63046, "reused": 0, "embedded": 63046} {"event": "semantic_index_ready", "dim": 1024, "chunks": 63046, "duration_ms": 7659505.6} ``` 冷跑 **7659.5 s ≈ 127.7 min**;落盘 `embeddings.npy` 258 MB + `manifest.json` 5.2 MB。 ## 2. 命令 ```sh # 建向量索引(首次全量;之后按 (chunk_id, content_hash) 增量复用) CODERAG_SEMANTIC=on CODERAG_SEMANTIC_URL=http://127.0.0.1:11434 \ caffeinate -i python -c "from pathlib import Path; from dsh_coderag import index_sync; \ print(index_sync(Path('/tmp/dsh-coderag-t3-03-corpus')))" # C 组 L1:跑一遍评测并与 B 基线做逐 query 门禁(这就是 V1/V2 的判定) CODERAG_SEMANTIC=on CODERAG_SEMANTIC_URL=http://127.0.0.1:11434 \ python -m dsh_coderag.eval gate \ --tasks eval/tasks.jsonl --root /tmp/dsh-coderag-t3-03-corpus \ --baseline eval/runs/l1-baseline.json \ --out eval/runs/l1-c.json --gate-out eval/runs/l1-c-gate.json \ --markdown eval/runs/l1-c-gate.md ``` ## 3. C 组 L1 结果(对照 B 组) | 桶 | n | B(纯 BM25)S@5 | **C(+向量+RRF)S@5** | Δ | B MRR | C MRR | |---|---|---|---|---|---|---| | **overall** | 30 | 0.433 | **0.567** | **+13.3pp** | 0.313 | 0.228 | | **exact** | 12 | **1.000** | **0.917** | **−8.3pp** | 0.700 | 0.451 | | **crossfile** | 11 | 0.091 | **0.455** | **+36.4pp** | 0.091 | 0.112 | | **natural** | 7 | 0.000 | **0.143** | **+14.3pp** | 0.000 | 0.029 | C 组分层明细:`overall S@1 0.067 / S@3 0.300 / S@5 0.567`、`natural S@1 0.000 / S@3 0.000 / S@5 0.143`、`token_median 1974.5`。 > **一句话**:向量把 `crossfile` 抬了 36.4pp、`natural` 从 0 抬到 0.143,但**把 `exact` 从满分打下来 8.3pp**,并且 `natural` 的绝对水平远未达到 `S5` 的 0.40。`MRR` 整体下降、名次变差 6 条 vs 变好 2 条。 ## 4. `V1`–`V4` 判定 | # | 判据(`ADR-14` §10.4 / `ADR-16` §7.2) | 实测 | 结论 | |---|---|---|---| | **V1** | `natural@5 ≥ 0.40` **且** > B 组的 0.000 | `0.143`(1/7);>`0.000` ✅ 但 `< 0.40` | ❌ **未达** | | **V2** | `exact@5 == 1.000` **且** 逐条回归 `== 0` | `0.917`;未豁免回归 **1** 条(`L-015`) | ❌ **未达** | | **V3** | C 组 L2 配对符号检验,**α = 0.025**,单侧 | **未跑**(见 §5) | — | | **V4** | `V1` 或 `V3` 未过 → **不发布**向量路径 | `V1` 未过 | ✅ **触发:不发布** | 回归与改善(`eval/runs/l1-c-gate.md`): - **回归 1 条**:`L-015`(exact)`hit #3 packages/ssh/ssh/src/protocol.ts` → **miss**(query `SSH_MAX_PROCESS_HANDLES`) - **改善 5 条**:`L-007`、`L-019`、`L-021`、`L-025`(crossfile)、`L-026`(natural) - 名次变好 2 条 / 变差 6 条 ## 5. `V3` 为什么没跑 `ADR-14` §10.4 的 `V4` 是「`V1` **或** `V3` 未过即不发布」,二者是**析取**:`V1` 已经未达,`V3` 无论结果如何都不能改变「不发布」这个结论。因此 L2 的 39 次 A/B(需模型 API key,按 `T3-05`/`T3-06` 先例)**按规则已无必要**,本次不跑,并如实标注 **`V3` 未测量**——本报告不声称「C 组与 B 组无显著差异」,只声称「`V1` 未达 → `V4` 触发」。 ## 6. 为什么融合后 `natural` 远低于探针上限(0.143 vs 0.429) `T5-14` 探针测的是**向量单路**的理论上限(`natural S@5 = 0.429`),而生产走的是 **RRF 融合**。两者差距的机制已定位并用实测坐实: - 向量腿本身**正确**:对 `L-009`,`vector_candidates()` 单独返回的 top-5 是 `request-files.ts(0.634)`、`rpc.ts(0.628)`、**`llm/llm/src/retry-policy.ts(0.622)`**、`commands.ts`、`gateway`——目标文件在第 3 位,与探针一致。 - 但 **BM25 对中文自然语言查询会返回 5 条非空却无关的命中**——大量是 `packages/client/**/locales.ts`(多语言翻译表,含与查询相同的汉字词)。RRF 等权求和时,`BM25 #1` 得 `1/61 = 0.0164`,而`向量 #3` 只得 `1/63 = 0.0159`,于是**翻译表被排在真正的实现之前**,目标文件被挤出 top-5。 - 这不是实现 bug 而是 **RRF 在该语料上的固有行为**:当一路检索器对某类查询**总是**返回一整页低质结果时,等权 RRF 无法识别它是噪声。`T5-14` 的「上限」与融合后的实际得分因此天然存在落差。 > **对 `T5-14` 结论的补充**:探针的 GO 是**必要条件而非充分条件**;C 组实测证明「向量能表示」不等于「融合后能兑现」。 ## 7. 实现侧的一处修复(本任务发现并已修) `T5-14` 冻结的「非测试优先」档位最初只在**融合阶段**生效,而向量腿仍是「先按余弦取 top-5、再套档位」。本语料 61% 的 chunk 是测试文件,正确文件因此**从未进入候选并集**——`natural` 一度为 **0.000**。 修复(`fd49168`):档位改为在 `cosine_top_k()` 的 **top-k 截断之前**生效,`TEST_PATH_TIER_SQL` 作为 Python 侧档位的唯一来源。修复后 `natural` 从 0.000 → 0.143、`crossfile` 0.182 → 0.455。**注意**:修复只把分数抬到仍不达标的水平,不改变 §4 的判定。 同一次修复还处理了 macOS Accelerate BLAS 的一条误报(`divide by zero/overflow ... in matmul`):实测 `m @ q` 全有限、与 float64 最大差 `1.4e-8`、top-20 完全一致,属 FP 标志误报;现以 `errstate` 抑制并**显式校验有限性**(非有限即 `SEMANTIC_EMBED_FAILED`)。 ## 8. 限制(结论的适用范围) - **样本小**:30 条,`natural` 只有 **7** 条,单条翻转即移动 14.3pp;`0.143` 与 `0.40` 的距离是「6 条未命中」。 - **融合参数按冻结条文取等权 RRF(k=60)**,未做加权、未做 reranker、每路只取 top-5(不是深层融合)。**换一种融合方式可能不同**——但那属于新的决策,需先立 ADR。 - **`V3` 未测量**(§5),故本报告不给出 L2 显著性结论。 - 只对 `bge-m3` + 本语料 + 这 30 条成立;云端后端**未被本报告背书**(`ADR-17` §1.6/§7 要求按后端独立判定)。 ## 9. 后续影响(需要项目所有者裁决) 1. **向量路径不发布**(`V4`)。`T3-08`–`T3-11` 的实现保留在仓库,但 2.0.0 的发布范围里**不应包含**它;是否仍在默认安装中保留「可选但默认关闭」的代码,需要在 `ADR-16` 里补一条日期化更新来冻结(本任务产出文件不含 ADR)。 2. 由此**`T5-15`(extra/安装脚本)、`T5-16`(可选安装冒烟)、`T5-17`(发布 2.0.0)的对象需要重新定义**——若向量不发布,这三个任务的「可选 extra」前提不成立。 3. 云端后端(`T5-20`)同样要过 `V1`/`V2` 才能发布;它在**本机没有 key**,因此其 `S5` 目前**无法判定**。 --- ## 10. 后续更新(2026-09-25):`natural` 桶扩到 20 条后的复测 ### 10.1 为什么要扩桶 `V1` 是**桶级**判据,约束它的是 `natural` 桶自己的条数:7 条时单条翻转 = **14.3pp**,`0.143` 与 `0.40` 之间只隔 2 条——**样本量本身在决定结论**。因此把 `natural` 由 7 扩到 **20**(新增 `L-031`…`L-043`,提交 `c5d5848`),`RATIO` 与 `EVAL.md` §2.3 同步改为 `12:11:20`,`golden_version` 由 `m3-b1` 升到 **`m3-b2`**。 **这 13 条新题是在「档位门控 / 按分数裁剪 / 数据档位」这三个机制设计完成之后加入的,且其逐条结果从未被查看**,因此对那套设计构成 **holdout**——扩桶同时就是那次设计的泛化检验。 ### 10.2 扩桶后的 B 基线(纯 BM25,43 条,`m3-b2`) | 桶 | 命中 | |---|---| | overall | 13/43 = **0.302** | | exact | 12/12 = **1.000** | | crossfile | 1/11 = 0.091 | | **natural** | **0/20 = 0.000** | 13 条新题 BM25 **全部未命中**,确认它们确实是零词法重叠(A5)的行为型问题。 ### 10.3 只读原型:五种融合策略在新题集上的实测 原型脚本 `/tmp/m5-fusion-probe.py`(一次性,**不进仓库**),复用现成的向量索引与 BM25 各档位,逐条模拟;`S0` 复现当前生产实现(`0.917 / 0.455 / 0.150`,即换桶后的现状)。 | 策略 | exact | crossfile | **natural (20)** | overall | MRR | 回归 | |---|---|---|---|---|---|---| | **S0** 现状(等权 RRF + 位置裁剪) | 0.917 | 0.455 | **0.150** | 0.442 | 0.170 | 1(`L-015`) | | S1 档位门控 | 1.000 | 0.455 | 0.250 | 0.512 | 0.318 | 0 | | S2 S1 + 数据文件档位 | 1.000 | 0.455 | 0.250 | 0.512 | 0.336 | 0 | | S3 门控 + 按分数裁剪 | 1.000 | 0.636 | 0.250 | 0.558 | 0.341 | 0 | | **S4 = S1 + S2 + S3** | **1.000** | **0.636** | **0.300** | **0.581** | **0.367** | **0** | `S4` 的 6 条命中:`L-009`(5)、`L-026`(3)、`L-029`(3)、`L-032`(3)、`L-034`(3)、`L-036`(2)。其余 14 条全部落空。 ### 10.4 结论:`V4` 不变,而且这次有 20 条支撑 - **`V1` 仍未达**:`natural@5 = 6/20 = 0.300 < 0.40`(需 8/20)。 - **`V2` 在 `S4` 下达标**(`exact 1.000`、0 回归),但在**现状**(`S0`)下未达标(`0.917` + 1 条回归)。 - **最重要的发现**:`§1`–`§9` 那次 `3/7 = 0.429` 的「贴线通过」是**小样本假象**。同一套机制在 20 条上只有 `0.300`;其中 **3 条命中来自新题**(`L-032`/`L-034`/`L-036`),说明机制**有**泛化能力,只是**幅度不足以过线**。扩桶把原本可能被误读为「通过」的结果证伪了——这正是 `EVAL.md` §2.4 Step 6 ③ 要求 holdout 的原因。 - 因此 **`V4`:向量路径不发布**(与 §4 一致,但证据强度从 7 条提升到 20 条)。 ### 10.5 对未来的价值 `S4` 仍是一套**真实改进**(overall `0.442 → 0.581`、MRR `0.170 → 0.367`、**0 回归**),只是不足以过 `V1`。若将来要重启向量线(换模型、换语料、或放宽 `S5` 阈值),应**从 `S4` 起步**而不是重头再来;但按 §2.4 Step 6 ③,**下一次调参前必须再留出新的 holdout**——本次的 13 条已经用掉了。 ### 10.6 目标核对(2026-09-25,用户要求) 对 13 条新题逐个到**真实仓库** `/Users/vermi/projects/dsh` 核对,分两步: 1. **文件存在性与一致性**:13 条的全部 `expect_paths` 都存在,且与 `/tmp` 语料副本 **sha256 逐字节一致** (语料是真实仓库的 rsync 副本,没有被改动过)。 2. **题面与代码的实质对应**:逐条打开文件取证。**发现并修正 4 条**(`golden_version` `m3-b2 → m3-b3`): | 题 | 问题 | 修正 | |---|---|---| | `L-037` | 目标是包入口 `schedule/src/index.ts`(只 wire `ScheduleRuntime`),**持久化不在其中** | 改为 `persistence.ts`(durability barrier)+ `projection.ts`(持久投影/折叠),任一命中即可 | | `L-038` | 目标是适配器接线 `webhook-github/src/index.ts` | 改为 `handler.ts`(`x-hub-signature-256` → `verify()` → 401) | | `L-041` | 目标是后端入口 `terminal-bash/src/index.ts` | 改为 `session.ts`(`utf8Tail` / `maxBytes` / `truncated`) | | `L-040` | 题面写「**写**数据之前校验」,但该文件是**读取/物化时**校验(`load and validate every stored record`) | **改写题面**为读取时校验;目标不变 | 其余 9 条经取证**确认无误**(目标文件自身即实现该行为,不是 wiring):`L-031`–`L-036`、`L-039`、`L-042`、`L-043`。 **对分数的影响:无。** 修正后重建基线仍是 `13/43`,`S4` 仍是 `natural 6/20 = 0.300`——这 4 条在修正前后都是未命中。 修正消除的是**潜在的假阴性**(检索找对了概念却落在同包另一个文件会被判失败),不是提高分数。 **教训已固化**:`EVAL.md` §2.4 Step 6 由「三道人工保险」扩为**四道**,新增第 ④ 条「逐条打开 `expect_paths` 确认它自己实现了题面行为;包入口常常只是 wiring,须先 `ls` 同包全部文件再定目标」。