前 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 {
// 1. 检查相关文档是否在索引中
for (String docId : relevantDocIds) {
TermQuery q = new TermQuery(new Term("id", docId));
if (searcher.count(q) == 0) {
return FailureType.COVERAGE;
}
}

// 2. 检查是否在 Top-100 召回集中
SearchResult top100 = search(query, 100);
Set<String> recalledIds = top100.docIds();

boolean anyRecalled = relevantDocIds.stream()
.anyMatch(recalledIds::contains);
if (!anyRecalled) {
return FailureType.RECALL;
}

// 3. 检查排位
SearchResult top10 = search(query, 10);
boolean inTop10 = relevantDocIds.stream()
.anyMatch(top10.docIds()::contains);
if (!inTop10) {
return FailureType.RANKING;
}

// 在 Top-10 中但 nDCG 低 → 查询理解问题
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; // alpha=0.05 双侧
double zBeta = 0.84; // power=0.80
double sigma = baselineMetric * 0.1; // 假设标准差为均值的 10%
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 实验只是设计——没有实际流量验证

练习

  1. 对当前评测集做失败查询归因,统计四类失败的比例
  2. 选一个变更(如调整 BM25 参数 k1),生成离线实验报告
  3. 实现 side-by-side 盲评工具,对比变更前后的结果
  4. 写一份 A/B 实验设计文档,包括假设、指标、样本量估算
  5. 建立变更日志,记录本系列中所有影响搜索质量的变更

延伸阅读

  • Ron Kohavi et al., “Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing”
  • TREC: Text REtrieval Conference 评测方法论
  • Cranfield 范式:离线信息检索评测的起源