前 28 篇构建了一个功能完整的搜索系统。但"能跑"和"搜得好"之间还有距离。搜索相关性需要持续迭代——改了分词器、加了同义词、调了 BM25 参数,怎么判断是变好了还是变差了?
本篇建立可重复的相关性迭代流程:归因失败查询、做盲评、写离线实验报告、设计 A/B 实验。每次变更都有证据支撑接受或拒绝的决定。
失败查询归因
什么是失败查询
评测集中 nDCG@10 = 0 的查询是明确的失败查询——Top-10 中没有相关文档。但更多失败是隐性的:
- nDCG@10 < 0.3:有相关文档但排位很低
- Recall@100 = 0:相关文档根本不在召回集中
- 用户从搜索结果页直接退出(线上才能观测到)
四类失败
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| enum FailureType { COVERAGE, RECALL, RANKING, UNDERSTANDING }
FailureType diagnose(String query, List<String> relevantDocIds, IndexSearcher searcher) throws IOException { for (String docId : relevantDocIds) { TermQuery q = new TermQuery(new Term("id", docId)); if (searcher.count(q) == 0) { return FailureType.COVERAGE; } }
SearchResult top100 = search(query, 100); Set<String> recalledIds = top100.docIds();
boolean anyRecalled = relevantDocIds.stream() .anyMatch(recalledIds::contains); if (!anyRecalled) { return FailureType.RECALL; }
SearchResult top10 = search(query, 10); boolean inTop10 = relevantDocIds.stream() .anyMatch(top10.docIds()::contains); if (!inTop10) { return FailureType.RANKING; }
return FailureType.UNDERSTANDING; }
|
归因统计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| void analyzeFailures(List<QueryJudgment> evalSet, IndexSearcher searcher) throws IOException { Map<FailureType, List<String>> failures = new EnumMap<>(FailureType.class); for (FailureType type : FailureType.values()) { failures.put(type, new ArrayList<>()); }
for (QueryJudgment qj : evalSet) { double ndcg = evaluateNdcg(qj, searcher, 10); if (ndcg < 0.3) { FailureType type = diagnose(qj.query(), qj.relevantDocIds(), searcher); failures.get(type).add(qj.query()); } }
System.out.println("=== 失败查询归因 ==="); for (var entry : failures.entrySet()) { System.out.printf("%s: %d 查询 (%.0f%%)\n", entry.getKey(), entry.getValue().size(), 100.0 * entry.getValue().size() / evalSet.size()); for (String q : entry.getValue()) { System.out.printf(" - %s\n", q); } } }
|
输出示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| === 失败查询归因 === COVERAGE: 3 查询 (10%) - WebAssembly 运行时比较 - Rust 异步编程框架 - gRPC 流式传输 RECALL: 5 查询 (17%) - 数据库连接池配置 - 日志级别动态调整 - ... RANKING: 7 查询 (23%) - Java 内存模型 - 分布式锁实现 - ... UNDERSTANDING: 2 查询 (7%) - 苹果(水果 vs 公司歧义) - ...
|
归因结果指导优化方向:COVERAGE 多 → 扩大采集范围。RECALL 多 → 检查分词和查询扩展。RANKING 多 → 调整评分参数或重排模型。
盲评
为什么要盲评
知道哪个结果来自新系统、哪个来自旧系统会产生确认偏差。盲评消除这种偏差。
Side-by-Side 比较
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| record SideBySideCase( String query, List<DocScore> leftResults, // 随机分配 A 或 B List<DocScore> rightResults, String leftLabel, // 揭盲前不可见 String rightLabel ) {}
List<SideBySideCase> generateBlindEval( List<String> queries, SearchService systemA, SearchService systemB) { Random rng = new Random(42);
return queries.stream().map(query -> { List<DocScore> resultsA = systemA.search(query, 10).hits(); List<DocScore> resultsB = systemB.search(query, 10).hits();
if (rng.nextBoolean()) { return new SideBySideCase(query, resultsA, resultsB, "A", "B"); } else { return new SideBySideCase(query, resultsB, resultsA, "B", "A"); } }).toList(); }
|
评估收集
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| enum Preference { LEFT, RIGHT, TIE }
record BlindJudgment(String query, Preference preference) {}
void summarizeBlindEval(List<BlindJudgment> judgments, List<SideBySideCase> cases) { int aWins = 0, bWins = 0, ties = 0;
for (int i = 0; i < judgments.size(); i++) { BlindJudgment j = judgments.get(i); SideBySideCase c = cases.get(i);
String winner = switch (j.preference()) { case LEFT -> c.leftLabel(); case RIGHT -> c.rightLabel(); case TIE -> "TIE"; };
switch (winner) { case "A" -> aWins++; case "B" -> bWins++; default -> ties++; } }
int total = judgments.size(); System.out.printf(""" === 盲评结果 === 系统 A 胜: %d (%.0f%%) 系统 B 胜: %d (%.0f%%) 平局: %d (%.0f%%) """, aWins, 100.0 * aWins / total, bWins, 100.0 * bWins / total, ties, 100.0 * ties / total); }
|
离线实验报告
报告模板
每次搜索系统变更前,生成标准化的实验报告:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| record ExperimentReport( String changeDescription, String hypothesis, Map<String, Double> baselineMetrics, Map<String, Double> experimentMetrics, List<RegressionCase> regressions, String decision // ACCEPT / REJECT / CONDITIONAL ) {}
record RegressionCase( String query, double baselineNdcg, double experimentNdcg, String analysis ) {}
|
生成报告
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| ExperimentReport runExperiment( String description, String hypothesis, SearchService baseline, SearchService experiment, List<QueryJudgment> evalSet) {
Map<String, Double> baseMetrics = evaluate(baseline, evalSet); Map<String, Double> expMetrics = evaluate(experiment, evalSet);
List<RegressionCase> regressions = new ArrayList<>(); for (QueryJudgment qj : evalSet) { double baseNdcg = evaluateNdcg(qj, baseline, 10); double expNdcg = evaluateNdcg(qj, experiment, 10);
if (expNdcg < baseNdcg - 0.05) { regressions.add(new RegressionCase( qj.query(), baseNdcg, expNdcg, "待人工分析")); } }
String decision; double ndcgDelta = expMetrics.get("nDCG@10") - baseMetrics.get("nDCG@10"); if (ndcgDelta >= 0.02 && regressions.size() <= evalSet.size() * 0.1) { decision = "ACCEPT"; } else if (ndcgDelta >= 0 && regressions.isEmpty()) { decision = "ACCEPT"; } else if (ndcgDelta >= 0.02 && regressions.size() > evalSet.size() * 0.1) { decision = "CONDITIONAL"; } else { decision = "REJECT"; }
return new ExperimentReport(description, hypothesis, baseMetrics, expMetrics, regressions, decision); }
|
报告输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| void printReport(ExperimentReport report) { System.out.printf(""" === 离线实验报告 === 变更: %s 假设: %s
指标对比: 基线 实验 变化 nDCG@10 %.4f %.4f %+.4f MRR@10 %.4f %.4f %+.4f Recall@100 %.4f %.4f %+.4f
回归查询: %d 条 """, report.changeDescription(), report.hypothesis(), report.baselineMetrics().get("nDCG@10"), report.experimentMetrics().get("nDCG@10"), report.experimentMetrics().get("nDCG@10") - report.baselineMetrics().get("nDCG@10"), report.baselineMetrics().get("MRR@10"), report.experimentMetrics().get("MRR@10"), report.experimentMetrics().get("MRR@10") - report.baselineMetrics().get("MRR@10"), report.baselineMetrics().get("Recall@100"), report.experimentMetrics().get("Recall@100"), report.experimentMetrics().get("Recall@100") - report.baselineMetrics().get("Recall@100"), report.regressions().size());
for (RegressionCase rc : report.regressions()) { System.out.printf(" [回归] '%s': %.4f → %.4f (%+.4f)\n", rc.query(), rc.baselineNdcg(), rc.experimentNdcg(), rc.experimentNdcg() - rc.baselineNdcg()); }
System.out.printf("\n决定: %s\n", report.decision()); }
|
A/B 实验设计
无线上流量时的交付
教学系统没有真实用户流量,无法做线上 A/B 实验。交付的是实验设计文档,而非实验结果。
实验设计文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| record ABExperimentDesign( String name, String hypothesis, String primaryMetric, List<String> guardrailMetrics, double trafficSplit, int minSampleSize, int durationDays, double significanceLevel, double minimumDetectableEffect, String decisionCriteria ) {}
void printDesign(ABExperimentDesign design) { System.out.printf(""" === A/B 实验设计 === 实验名称: %s 假设: %s
指标: 主要指标: %s 护栏指标: %s
流量分配: 对照 %.0f%% / 实验 %.0f%% 最小样本量: %,d 查询/组 实验周期: %d 天 显著性水平: %.2f 最小可检测效应: %.4f
决策标准: %s """, design.name(), design.hypothesis(), design.primaryMetric(), String.join(", ", design.guardrailMetrics()), (1 - design.trafficSplit()) * 100, design.trafficSplit() * 100, design.minSampleSize(), design.durationDays(), design.significanceLevel(), design.minimumDetectableEffect(), design.decisionCriteria()); }
|
样本量估算
1 2 3 4 5 6 7 8 9 10 11
| int estimateSampleSize(double baselineMetric, double mde, double alpha, double power) { double zAlpha = 1.96; double zBeta = 0.84; double sigma = baselineMetric * 0.1; double delta = mde;
return (int) Math.ceil( 2 * Math.pow(zAlpha + zBeta, 2) * Math.pow(sigma, 2) / Math.pow(delta, 2)); }
|
示例实验设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| === A/B 实验设计 === 实验名称: 同义词扩展对信息查询的影响 假设: 对信息类查询启用同义词扩展将提升 nDCG@10 至少 0.02
指标: 主要指标: nDCG@10 护栏指标: P95 延迟, 错误率, 导航查询 nDCG@10
流量分配: 对照 50% / 实验 50% 最小样本量: 5,000 查询/组 实验周期: 7 天 显著性水平: 0.05 最小可检测效应: 0.0200
决策标准: 接受: p < 0.05 且 nDCG@10 提升 >= 0.02 且护栏指标无显著下降 拒绝: p >= 0.05 或 nDCG@10 提升 < 0.02 或护栏指标显著下降 延长: 0.05 < p < 0.10 且趋势正向,延长至 14 天
|
变更接受/拒绝的证据链
标准流程
1 2 3 4 5 6 7 8 9 10
| 1. 提出变更假设 2. 实现变更(feature branch) 3. 运行离线评测 → 生成实验报告 4. 报告中看 nDCG/MRR 变化和回归查询 5. 对回归查询做人工归因分析 6. 决定: - ACCEPT: 指标提升且回归可接受 - REJECT: 指标下降或回归严重 - CONDITIONAL: 指标提升但需要限制应用范围 7. 记录决定和理由 → 进入变更日志
|
变更日志
1 2 3 4 5 6 7 8
| record ChangeLogEntry( String date, String change, String decision, double ndcgDelta, int regressionCount, String rationale ) {}
|
每次变更的决定和理由形成可追溯的审计记录。
当前局限
- 评测集只有 30 条查询——统计显著性不足
- 没有线上用户行为数据——无法计算点击率、跳出率
- 盲评需要人工操作——自动化程度低
- A/B 实验只是设计——没有实际流量验证
练习
- 对当前评测集做失败查询归因,统计四类失败的比例
- 选一个变更(如调整 BM25 参数 k1),生成离线实验报告
- 实现 side-by-side 盲评工具,对比变更前后的结果
- 写一份 A/B 实验设计文档,包括假设、指标、样本量估算
- 建立变更日志,记录本系列中所有影响搜索质量的变更
延伸阅读
- Ron Kohavi et al., “Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing”
- TREC: Text REtrieval Conference 评测方法论
- Cranfield 范式:离线信息检索评测的起源