第 15 篇把搜索内核切换到了 Lucene,同一组 fixture 在两个后端上对照通过。但 fixture 只有十来篇文档、几个查询——不足以暴露真正的相关性问题。

本篇把评测集从 30 个查询扩展到 100 个,分离开发集与冻结测试集,然后在开发集上逐步调整中文分词、字段权重和同义词,观察每个变量对搜索质量的实际影响。

为什么 30 个查询不够

第 03 篇建立的 30 个查询覆盖了基本查询类型,但存在统计问题:

  • 单个查询的 nDCG 波动大——一个查询从 0.8 变到 0.6,30 个查询的平均值就移动了 0.007
  • 某些查询类型只有 2-3 个样本,无法判断改善是否稳定
  • 调参时容易"碰巧"在这 30 个查询上找到最优值,换一批查询就失效

100 个查询不算多,但足以让每个类型有 10+ 个样本,统计结论更可靠。

查询分类与扩展

六种查询类型

类型 数量 示例 特征
精确标识符 15 NullPointerExceptionBM25Similarity 必须精确匹配,不能被分词或同义词替换
概念查询 25 “倒排索引原理”、“分布式一致性协议” 需要语义理解,多个文档可能相关
中英混合 20 “Java 内存模型”、“Spring Boot 自动配置” 分词器需要正确处理语言切换边界
同义改写 15 “垃圾回收” vs “GC 调优”、“容器” vs “Docker” 测试同义词扩展效果
多条件 15 “Java 21 虚拟线程性能对比” 多个 term 共同约束,考验评分公式
无答案 10 “Rust 所有权模型”(语料中无 Rust 内容) 应返回零结果或极低分结果,不能乱召回

标注流程

每个查询需要标注候选文档的相关性等级:

等级 含义 示例
3 高度相关,直接回答 搜"BM25 公式"→ 详细推导 BM25 的文档
2 相关,回答部分问题 搜"BM25 公式"→ 提到 BM25 但重点是其他内容
1 边缘相关 搜"BM25 公式"→ 讨论搜索引擎但只一句提到 BM25
0 无关 搜"BM25 公式"→ 讨论数据库索引

候选池用 pooling 方法生成:对每个查询,取 BM25 baseline 的 Top-20、加权变体的 Top-20、同义词扩展的 Top-20,合并去重后标注。未进入候选池的文档默认标注为 0——这意味着存在漏标风险,需要在报告中说明。

开发集与冻结测试集

100 个查询按 60/40 分为开发集和测试集。分配规则:

  1. 每种查询类型按比例分配,保证测试集中每种类型至少有 4 个查询
  2. 分配后固定,不再调整——测试集一旦冻结,任何参数调优都不能看测试集结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
record QuerySet(List<AnnotatedQuery> dev, List<AnnotatedQuery> test) {
static QuerySet split(List<AnnotatedQuery> all, long seed) {
List<AnnotatedQuery> shuffled = new ArrayList<>(all);
Collections.shuffle(shuffled, new Random(seed));

Map<String, List<AnnotatedQuery>> byType = shuffled.stream()
.collect(Collectors.groupingBy(AnnotatedQuery::type));

List<AnnotatedQuery> dev = new ArrayList<>();
List<AnnotatedQuery> test = new ArrayList<>();
for (var entry : byType.entrySet()) {
List<AnnotatedQuery> group = entry.getValue();
int splitPoint = (int) (group.size() * 0.6);
dev.addAll(group.subList(0, splitPoint));
test.addAll(group.subList(splitPoint, group.size()));
}
return new QuerySet(dev, test);
}
}

固定随机种子保证分割可重复。

中文分词升级

从 CJK Bigram 到词典分词

第 15 篇使用的 CJKBigramFilter 对"信息检索"产出 [信息, 息检, 检索]。其中"息检"是无意义的 bigram——它出现在所有包含"信息检索"的文档中,却不是一个有意义的检索单元。

Lucene 内置的 SmartChineseAnalyzer 基于隐马尔可夫模型做中文分词:

1
Analyzer smartCn = new SmartChineseAnalyzer();

对"信息检索"的输出:[信息, 检索]——没有噪声 token。

Maven 依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-smartcn</artifactId>
<version>10.5.1</version>
</dependency>

混合分析器

问题:SmartChineseAnalyzer 对英文和代码标识符的处理不如 StandardTokenizer。需要一个混合方案:

1
2
3
4
5
6
7
8
9
Analyzer mixedAnalyzer = new Analyzer() {
@Override
protected TokenStreamComponents createComponents(String fieldName) {
Tokenizer tokenizer = new SmartChineseAnalyzer()
.tokenStream(fieldName, "").getTokenizer();
// SmartChineseAnalyzer 内部已经处理了中英文混合
return new TokenStreamComponents(tokenizer);
}
};

更实际的方案是直接用 SmartChineseAnalyzer,它内部对英文部分使用标准分词规则:

1
2
Analyzer analyzer = new SmartChineseAnalyzer();
// "Java信息检索" → [java, 信息, 检索]

分词对比实验

对开发集的所有查询和文档,记录两个分析器的分词差异:

1
2
3
4
5
6
7
8
void compareStemming(Analyzer old, Analyzer next, String text) {
List<String> oldTokens = analyze(old, text);
List<String> newTokens = analyze(next, text);
if (!oldTokens.equals(newTokens)) {
System.out.printf("差异: '%s'\n CJK Bigram: %s\n SmartCN: %s\n",
text, oldTokens, newTokens);
}
}

预期:SmartCN 消除噪声 bigram,在概念查询和中英混合查询上改善明显;精确标识符查询不受影响(英文 token 切分方式相同)。

字段权重调优

当前配置

第 15 篇的默认配置:

1
2
3
4
5
6
Query titleQuery = new BoostQuery(titleParser.parse(q), 2.0f);
Query bodyQuery = bodyParser.parse(q);
BooleanQuery combined = new BooleanQuery.Builder()
.add(titleQuery, BooleanClause.Occur.SHOULD)
.add(bodyQuery, BooleanClause.Occur.SHOULD)
.build();

title 权重 2.0,body 权重 1.0。这是经验值,没有经过数据验证。

DisjunctionMaxQuery

BooleanQuery(SHOULD) 的问题:它把各字段的分数加起来。一篇文档如果在 title 和 body 中都匹配了查询词,它的分数是两个字段分数之和。这会让"查询词出现在多个字段但每个字段分数都不高"的文档排在"查询词只出现在 title 但高度相关"的文档前面。

DisjunctionMaxQuery 取各字段的最高分,加上其他字段分数的折扣:

1
finalScore = max(fieldScores) + tieBreaker × sum(otherFieldScores)
1
2
3
4
5
6
7
DisjunctionMaxQuery dismaxQuery = new DisjunctionMaxQuery(
List.of(
new BoostQuery(titleQuery, titleBoost),
bodyQuery
),
0.1f // tieBreaker
);

tieBreaker = 0 时完全只看最高分字段;tieBreaker = 1 时退化为求和。0.1 是常用起点。

在开发集上搜索最优参数组合:

参数 候选值
titleBoost 1.5, 2.0, 3.0, 5.0
tieBreaker 0.0, 0.1, 0.3
queryType BooleanQuery / DisjunctionMaxQuery

每次只改一个变量,记录 nDCG@10:

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
record ExperimentResult(String config, double devNdcg, Map<String, Double> perTypeNdcg) {}

void gridSearch(QuerySet querySet) {
List<ExperimentResult> results = new ArrayList<>();
double[] boosts = {1.5, 2.0, 3.0, 5.0};
double[] tieBreakers = {0.0, 0.1, 0.3};

for (double boost : boosts) {
for (double tie : tieBreakers) {
double ndcg = evaluateConfig(querySet.dev(), boost, tie, true);
Map<String, Double> perType = evaluatePerType(querySet.dev(), boost, tie, true);
results.add(new ExperimentResult(
"dismax_boost=%.1f_tie=%.1f".formatted(boost, tie),
ndcg, perType));
}
}

results.sort(Comparator.comparingDouble(ExperimentResult::devNdcg).reversed());
for (var r : results) {
System.out.printf("%s → nDCG@10=%.4f [标识符=%.4f 概念=%.4f 混合=%.4f]\n",
r.config(), r.devNdcg(),
r.perTypeNdcg().get("identifier"),
r.perTypeNdcg().get("concept"),
r.perTypeNdcg().get("mixed"));
}
}

回归检测

精确标识符查询是最容易被"优化"破坏的类型——它们在 baseline 上往往已经表现很好,字段权重调整可能反而让它们变差。

每次实验后检查:

1
2
3
4
5
6
7
8
9
10
void regressionCheck(List<ExperimentResult> baseline, List<ExperimentResult> current) {
for (int i = 0; i < baseline.size(); i++) {
double baseNdcg = baseline.get(i).ndcg();
double currNdcg = current.get(i).ndcg();
if (currNdcg < baseNdcg - 0.05) {
System.out.printf("⚠ 回归: 查询 '%s' nDCG %.4f → %.4f\n",
baseline.get(i).query(), baseNdcg, currNdcg);
}
}
}

同义词扩展

同义词表

手工维护一份技术同义词表,覆盖教学语料中的常见等价关系:

1
2
3
4
5
6
7
# synonyms.txt — Solr 格式
GC, 垃圾回收, garbage collection
OOM, 内存溢出, OutOfMemoryError
JVM, Java虚拟机, Java Virtual Machine
索引, index
检索, 搜索, search
分词, tokenization, 切词

Lucene SynonymGraphFilter

1
2
3
4
5
6
7
8
Analyzer analyzerWithSynonyms = CustomAnalyzer.builder()
.withTokenizer(StandardTokenizerFactory.class)
.addTokenFilter(LowerCaseFilterFactory.class)
.addTokenFilter(SynonymGraphFilterFactory.class,
"synonyms", "synonyms.txt",
"format", "solr",
"expand", "true")
.build();

expand=true:查询 “GC” 时同时搜索 “垃圾回收” 和 “garbage collection”。

同义词的风险

同义词扩展提高召回率的同时会降低精度。典型误伤:

查询 扩展 问题
“Java index” → “Java 索引” 正确——技术语境下 index = 索引
“Python list” → “Python 列表” 正确
“spring boot” → “弹簧 启动” 错误——专有名词不应该被翻译

解决方案:同义词表只包含确定的等价关系,不做跨语言翻译。Spring Boot 保持原样。

实验流程

  1. Baseline:SmartCN + 最优字段权重,无同义词
  2. 变体 A:加入全部同义词
  3. 变体 B:只加入缩写展开(GC→垃圾回收)
  4. 变体 C:只加入中英对照(索引↔index)

在开发集上比较四种配置,重点关注同义改写类查询的 Recall 变化和精确标识符查询的回归。

冻结测试集报告

开发集调参完成后,用最终配置在冻结测试集上运行一次,输出正式报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
== 冻结测试集报告 ==
配置: SmartCN + DisjunctionMax(titleBoost=3.0, tie=0.1) + 同义词变体B
日期: 2026-09-07
查询数: 40

整体指标:
nDCG@10: 0.xxxx
MRR@10: 0.xxxx
Recall@100: 0.xxxx
零结果率: x/40

逐类型:
精确标识符 (6): nDCG@10=0.xxxx
概念查询 (10): nDCG@10=0.xxxx
中英混合 (8): nDCG@10=0.xxxx
同义改写 (6): nDCG@10=0.xxxx
多条件 (6): nDCG@10=0.xxxx
无答案 (4): 零结果率=x/4

vs Baseline (CJK Bigram + BooleanQuery + 无同义词):
nDCG@10: +0.xxxx
最大改善查询: "xxx" (0.xx → 0.xx)
最大回归查询: "xxx" (0.xx → 0.xx)

这份报告只运行一次。如果结果不好,不能回去改参数再跑——那就失去了测试集的意义。

当前局限

  • 100 个查询仍然偏少——工业界标准评测集通常有数千查询
  • 单人标注存在偏差——条件允许时应双人复核
  • SmartChineseAnalyzer 的词典不含最新技术术语——“Kubernetes”、“gRPC” 等需要用户词典补充
  • 同义词表手工维护,覆盖有限——生产系统会用自动同义词挖掘
  • Grid search 只尝试了有限的参数组合——实际可能存在更优配置

练习

  1. 将 30 个 baseline 查询扩展到 100 个,覆盖六种查询类型
  2. 对比 CJKBigramFilter 和 SmartChineseAnalyzer 在 10 个中文查询上的分词结果
  3. 用 DisjunctionMaxQuery 替换 BooleanQuery,在开发集上比较 nDCG@10
  4. 添加 5 组同义词,观察同义改写类查询的召回率变化
  5. 在冻结测试集上运行最终配置,输出正式报告

延伸阅读

  • Introduction to Information Retrieval, Ch 9: Relevance feedback and query expansion
  • Apache Lucene: SmartChineseAnalyzer Javadoc
  • Apache Lucene: DisjunctionMaxQuery Javadoc
  • Apache Lucene: SynonymGraphFilter Javadoc
  • BEIR benchmark: https://github.com/beir-cellar/beir