面向读者:架构师 / 开发者 · 预计阅读:25 分钟 前置依赖:{{chapter:11}} 本章目标:理解 quality loop 如何驱动 codegraph 持续改进
代码图谱的"查询体验"不会自动变好。每改一次抽取器、改一次排序、改一次混合搜索权重,都需要一组公平的、可重放的、覆盖多种意图的 fixture 去度量它。codegraph 把这套评测做成了一条质量环 (quality loop):test-cases.ts 描述"用户会问什么",scoring.ts 给出recall / MRR / pass 的判定,runner.ts 把判定结果汇总成 JSON 报告,任何失败的 case 都会把缺失符号 (missedSymbols) 露出来,引导维护者定位是抽取问题还是排序问题。本章拆开 __tests__/evaluation/ 的四个文件,讲清 PASS_THRESHOLD=0.5 的来历、7 类人工测试电池的边界,以及如何让 CI 在"严苛到抓回归"和"宽松到不误报"之间找到平衡点。
| 指标 | 含义 | 适用 API |
|---|---|---|
| recall | 期望命中的符号里有多少真的被搜出来 | 全部 |
| MRR (Mean Reciprocal Rank) | 第一个期望符号在结果列表里的排名倒数 | 主要用于 searchNodes(排序敏感) |
| latencyMs | 端到端耗时,从调用起到返回 | 全部 |
| edge-density | 返回子图的"边 / 节点"比,衡量结构化程度 | findRelevantContext 才统计 |
recall 与 MRR 的差异值得记住:recall 只问"找没找全",MRR 问"第一名准不准"。一个把目标符号排到第三位的搜索引擎,recall 可以是 1.0,MRR 只有 0.33。findRelevantContext 是带回子图的接口,排序不是首要目标,所以该接口的 MRR 字段恒为 0(scoring.ts:74),让评估者把注意力放到 recall 和结构化质量上。
scoring.ts:3 写死了判定门:
export const PASS_THRESHOLD = 0.5;这是个折中:0.5 意味着期望符号至少命中一半才记 PASS。高于 0.7 会让任何"用户期望里有 1 个冷僻别名"的用例被反复刷红;低于 0.3 又会让严重召回塌方蒙混过关。scoring.ts:33 把门同时套在两条得分函数上,确保所有 fixture 受同一阈值约束。如果维护者觉得过严或过松,改这一行就能全局生效——见场景 15.3。
runner.ts:96 汇总出三个关键 summary 字段:
- meanRecall:所有用例 recall 的算术平均
- meanMRR:仅对
search-*用例计(因为explore-*的 MRR 恒 0,会拉低分母) - passed / failed:严格按
pass = recall >= PASS_THRESHOLD计算
JSON 报告写在 __tests__/evaluation/results/<timestamp>.json,同一仓库多次跑会留下时间序列,方便对照"哪次改动让质量掉了"。
types.ts 定义了三层接口,从配置到结果到报告:
EvalTestCase { id, query, api, expectedSymbols, kinds?, options? }
EvalResult { caseId, pass, recall, mrr, foundSymbols, missedSymbols,
nodeCount?, edgeCount?, edgeDensity?, latencyMs }
EvalReport { timestamp, codebasePath, codegraphSha,
summary: { total, passed, failed, meanRecall, meanMRR },
results }两点设计值得注意:
api字段是判别联合:'searchNodes' | 'findRelevantContext',runner.ts:40用它做分支。一个用例只走一条 API,避免"pass = recall>=0.5但接口语义不同"的混乱。EvalResult后半段字段可选:nodeCount / edgeCount / edgeDensity只在findRelevantContext时填,types.ts:19用?显式声明。这种"形状不同的结果共用一个类型"的写法在 fixture 矩阵扩展(比如未来加getCallers)时省一次 schema 重写。
两条得分函数对应两种 API:
scoreSearchNodes(排序敏感):
// scoring.ts:18-29
let firstRank = 0;
for (let i = 0; i < expectedLower.length; i++) {
const idx = resultNames.indexOf(expectedLower[i]);
if (idx !== -1) {
found.push(expectedSymbols[i]);
if (firstRank === 0) firstRank = idx + 1;
} else { missed.push(expectedSymbols[i]); }
}
const recall = found.length / expectedSymbols.length;
const mrr = firstRank > 0 ? 1 / firstRank : 0;注意 expectedLower / resultNames 都做了小写化(scoring.ts:11-12),所以代码图谱里 CacheBuilder 和用户搜索词 cachebuilder 等价——不必为大小写差异维护两套 fixture。
scoreFindRelevantContext(集合敏感):
// scoring.ts:48-68
const expectedLower = new Set(expectedSymbols.map(s => s.toLowerCase()));
const nodeNames = new Set<string>();
for (const node of subgraph.nodes.values()) nodeNames.add(node.name.toLowerCase());
for (const sym of expectedSymbols) {
if (nodeNames.has(sym.toLowerCase())) found.push(sym);
else missed.push(sym);
}
const recall = found.length / expectedSymbols.length;
const edgeDensity = nodeCount > 0 ? edgeCount / nodeCount : 0;edgeDensity 是子图健康度的一个代理指标。density < 1 意味着平均每个节点不到一条边,可能是子图被截断或抽取器漏边;density ≥ 2 通常意味着有多跳关系展开正常。scoring.ts 不直接对 edgeDensity 设阈值——它是诊断信号,不是 PASS 条件。
runner.ts:86-104 在屏幕上打 PASS / FAIL 表格,并生成 JSON 报告:
search-class-exact PASS recall=1.00 mrr=1.00 12ms
explore-rest-layer PASS recall=0.75 density=2.40 185ms
explore-bulk-indexing FAIL recall=0.33 density=1.10 210ms
missed: TransportBulkAction
SUMMARY: 11/12 passed | recall=0.87 | mrr=0.94
runner.ts:89 的 mrrResults 过滤条件是 r.mrr > 0 || r.caseId.startsWith('search-'),所以即使 mrr=0 的 findRelevantContext 用 case id 以 search- 开头也会被算进均值——这是用前缀约定代替类型分支的小心机。
docs/SEARCH_QUALITY_LOOP.md 不是 Python pytest / npm test 那种自动化测试,而是一份面向"语言启用"的人工签收清单——每当一条新语言要被加进 supported 列表,都得跑完这 7 类场景:
codegraph_explore— Deep Exploration(MOST IMPORTANT) 用至少 5 种自然语言问法(子系统概览、类深挖、横切关注、数据流、实现细节),调findRelevantContext,观察:entry points 是否切题、文件分布是否合理、边类型是否多样(不只是contains)、节点数过少(<5=失败)或过多(噪声)。codegraph_search— Symbol Lookup 5 类查询:按类名、带 disambiguation 的方法名、常见方法名(get)、interface、enum。验证:目标符号进 top 3,零结果 = bug。codegraph_callers/codegraph_callees— Call Chain Tracing 选 3-4 个核心方法,验证:callees 与 callers 都不为 0(0 意味着抽取器漏边)、调用对象语义合理、计数合理。codegraph_impact— Change Impact Analysis 挑一个被广泛依赖的核心类/接口,跑getImpactRadius(id, 2),验证:受影响的文件确实 import / extend / use 它,影响半径非空。- Edge Extraction Quality(直查 SQLite)
用
SELECT kind, COUNT(*) FROM edges GROUP BY kind看占比:contains > calls > imports > extends > implements。calls接近 0 即抽取漏边;extends/implements为 0 但语言支持继承即extractInheritance()没接好 AST。 - Node Extraction Completeness(直查 SQLite)
SELECT kind, COUNT(*) FROM nodes GROUP BY kind,与预期表对照(file / class / method / function / interface / enum / enum_member / import / variable / field / struct / trait)。期望 kind 计数为 0 = 该语言 extractor 漏 AST 类型。 - Real-World LLM Prompts
至少 5 个开发者真实问题模板(How does X / Where is X / What calls X / What breaks if I change X / How do X and Y interact / Show flow from A to B / Implementations of X / bug investigation)。每条 prompt 都用
findRelevantContext跑,人工判定 verdict=PASS(有 entry points、有边、跨多文件)或 FAIL。
这 7 类与 scoring.ts 的关系:types.ts 的 fixture 体系自动化的是 "类别 1+2+3+4" 的子集——eval/ 里的 12 个 case(6 个 searchNodes + 6 个 findRelevantContext)是当前签收 elasticsearch 代码库的产物。类别 5/6 是 SQL 探针,类别 7 是 LLM-as-judge 的人工评估。两者并行,自动化打分提供回归基线,人工电池保证"LLM 真的能用"。
质量环的运行节奏可以画成下面这张流水线:
%% F-11 评估管线与质量环(7 类测试)
flowchart LR
FC["fixtures<br/>__tests__/evaluation/<br/>test-cases.ts"] -->|"EvalTestCase[]"| R["runner.ts<br/>tsx"]
R -->|"searchNodes(q)<br/>kinds / limit"| S1["CodeGraph DB<br/>searchNodesFTS · BM25"]
R -->|"findRelevantContext(q)<br/>traversalDepth: 3"| S2["Hybrid Search<br/>+ BFS graph traversal"]
S1 -->|"ranked results"| SC["scoring.ts<br/>recall · MRR"]
S2 -->|"subgraph nodes/edges"| SC
SC -->|"pass = recall ≥ 0.5"| REP["EvalReport JSON<br/>__tests__/evaluation/results/<br/>meanRecall · meanMRR · p95"]
REP -->|"failed case → missed symbols"| QL["Quality Loop<br/>FAIL → diagnose → fix → re-run"]
QL -->|"raise PASS_THRESHOLD<br/>or extend fixtures"| FC
style FC fill:#e8f1ff,stroke:#2563eb
style R fill:#fff7e6,stroke:#d97706
style SC fill:#faf3ff,stroke:#7c3aed
style REP fill:#eaf8ef,stroke:#16803c
style QL fill:#fff0f0,stroke:#dc2626
四步循环:
- 运行:
npm run eval(pkg.json里的脚本会在build之后tsx跑runner.ts)对已索引的目标仓库(默认 ElasticSearchcodebasePath)打 12 个用例。 - 失败定位:
runner.ts:80在每条 FAIL 用例下打missed: A, B, C,直接列出没搜到的符号。如果edgeDensity异常低,说明召回到了但子图被截断。 - 修复:对照
SEARCH_QUALITY_LOOP.md的 Diagnosing Failures 表,定位到src/extraction/languages/<lang>.ts或src/db/queries.ts的具体行。修完跑npm run build && node dist/bin/codegraph.js init -iv重索引,再跑npm test兜底。 - 回归:把 PASS / FAIL 基线写进
__tests__/evaluation/results/的时间序列,新一次跑分与之对比,确保改动不破已通过的用例。
(已完成静态等价验证,见验证日志 Ch15 §15.4.1)
下面命令对已经构建并索引的代码库跑完 12 个 fixture:
$ cd ~/new/codegraph
$ npm run eval -- ~/new/elasticsearch # 或 EVAL_CODEBASE=~/new/elasticsearch npm run eval输出末尾形如:
SUMMARY: 11/12 passed | recall=0.87 | mrr=0.94
Report saved: __tests__/evaluation/results/2026-07-27T12-30-00-000Z.json
JSON 报告含完整 EvalReport,可被 CI 消费:summary.failed > 0 即视为回归,卡 PR 合入。
(已完成静态等价验证,见验证日志 Ch15 §15.4.2)
往 testCases 末尾塞一个一定会失败的查询:
{
id: 'search-deliberate-miss',
query: 'TransportService',
api: 'searchNodes',
expectedSymbols: ['NonexistentHelper'],
kinds: ['function'],
},跑 npm run eval,runner.ts:80-82 会打印:
search-deliberate-miss FAIL recall=0.00 mrr=0.00 8ms
missed: NonexistentHelper
recall=0 直接低于 PASS_THRESHOLD=0.5,FAIL 立现,无需人工核对。删掉这个 fixture 即可回到原基线。
(已完成静态等价验证,见验证日志 Ch15 §15.4.3)
把 scoring.ts:3 的 0.5 临时改成 0.7,再跑一次 eval,你会看到原本 recall 在 0.5–0.7 之间的"中等用例"(比如 explore-engine-implementations 真召回 0.67)瞬间从 PASS 翻 FAIL。从而:
- 把阈值推到 0.7 ⇒ CI 更严,适合 release branch
- 把阈值降到 0.3 ⇒ CI 更松,适合快速迭代
- 维持 0.5 ⇒ dev 与 release 折中
改完别忘 commit,别忘把临时改动 revert——这就是 scoring.ts 把阈值抬到导出常量的好处:一处定义,全局可调。
- codegraph 的评估体系由fixture + score + runner三层组成,各自在
__tests__/evaluation/下的test-cases.ts / scoring.ts / runner.ts。 - 4 个核心指标:
recall(找全率)、MRR(首位准度,只对排序敏感 API)、latencyMs、edge-density(结构化健康度)。 PASS_THRESHOLD = 0.5(scoring.ts:3)是质量环的"流量阀",改它等于改 CI 严苛度。docs/SEARCH_QUALITY_LOOP.md列出 7 类人工测试电池,覆盖探索 / 查符号 / 调用链 / 影响面 / 边质量 / 节点完整度 / LLM 真实问题,为新语言启用提供签收清单。- quality loop 把"失败 fixture → missed symbols → 抽取层修复 → 重新索引 → 回归"做成闭环,JSON 报告留时间序列便于回溯。
- 评测 fixture 与代码版本脱节:fixture 写死了
TransportService这种类名,ES 重构一改就大量 FAIL。修复方式:fixture 与目标仓库同一 commit 锁,或者把 fixture 改成"找描述某个子系统"的松散形式。 - 跨语言 parity 失败:
SEARCH_QUALITY_LOOP.md已签收 Go / Swift / Java / Python / Rust / C / C++ / C# / Ruby / TS / Dart / Kotlin / Svelte / PHP。给一条新语言添 fixture 前,务必先用 7 类人工电池证签,不要直接套别语言的 fixture。 - 阈值过严导致 CI 永远红:从 0.5 改到 0.7 但又不舍得砍掉"难"用例,CI 会变成红绿灯。先减 fixture 再抬阈值。
- 混淆 MRR 适用域:给
findRelevantContext用例加上"期望符号严格有序",导致 mrr 永远 0、均值被拉低。MRR 只在searchNodes路径度量。 edgeDensity当 PASS 条件:它没有阈值,只是个观察信号。看到<1应当再读runner.ts输出的Edges:分布判断是哪种边缺,不要直接 fail CI。
{{chapter:16}} 将进入部署与运维:daemon 生命周期、CI 上的 telemetry gate、版本回滚与 codegraph upgrade 的兼容性表,把质量环从"开发期"过渡到"生产期"。
codegraph/__tests__/evaluation/types.ts—EvalTestCase / EvalResult / EvalReport三层接口codegraph/__tests__/evaluation/scoring.ts:3—PASS_THRESHOLD = 0.5codegraph/__tests__/evaluation/scoring.ts:18-29—scoreSearchNodes实现codegraph/__tests__/evaluation/scoring.ts:48-68—scoreFindRelevantContext实现codegraph/__tests__/evaluation/runner.ts:96— JSON 报告汇总codegraph/docs/SEARCH_QUALITY_LOOP.md— 7 类人工测试电池codegraph/package.json:eval与test:eval两个 npm 脚本- 上游基础:{{chapter:11}}(节点 / 边 schema)