第 15 篇把搜索内核切换到了 Lucene,同一组 fixture 在两个后端上对照通过。但 fixture 只有十来篇文档、几个查询——不足以暴露真正的相关性问题。
本篇把评测集从 30 个查询扩展到 100 个,分离开发集与冻结测试集,然后在开发集上逐步调整中文分词、字段权重和同义词,观察每个变量对搜索质量的实际影响。
为什么 30 个查询不够
第 03 篇建立的 30 个查询覆盖了基本查询类型,但存在统计问题:
- 单个查询的 nDCG 波动大——一个查询从 0.8 变到 0.6,30 个查询的平均值就移动了 0.007
- 某些查询类型只有 2-3 个样本,无法判断改善是否稳定
- 调参时容易"碰巧"在这 30 个查询上找到最优值,换一批查询就失效
100 个查询不算多,但足以让每个类型有 10+ 个样本,统计结论更可靠。
查询分类与扩展
六种查询类型
| 类型 |
数量 |
示例 |
特征 |
| 精确标识符 |
15 |
NullPointerException、BM25Similarity |
必须精确匹配,不能被分词或同义词替换 |
| 概念查询 |
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 分为开发集和测试集。分配规则:
- 每种查询类型按比例分配,保证测试集中每种类型至少有 4 个查询
- 分配后固定,不再调整——测试集一旦冻结,任何参数调优都不能看测试集结果
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(); return new TokenStreamComponents(tokenizer); } };
|
更实际的方案是直接用 SmartChineseAnalyzer,它内部对英文部分使用标准分词规则:
1 2
| Analyzer analyzer = new SmartChineseAnalyzer();
|
分词对比实验
对开发集的所有查询和文档,记录两个分析器的分词差异:
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 = 0 时完全只看最高分字段;tieBreaker = 1 时退化为求和。0.1 是常用起点。
Grid Search
在开发集上搜索最优参数组合:
| 参数 |
候选值 |
| 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 保持原样。
实验流程
- Baseline:SmartCN + 最优字段权重,无同义词
- 变体 A:加入全部同义词
- 变体 B:只加入缩写展开(GC→垃圾回收)
- 变体 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 只尝试了有限的参数组合——实际可能存在更优配置
练习
- 将 30 个 baseline 查询扩展到 100 个,覆盖六种查询类型
- 对比 CJKBigramFilter 和 SmartChineseAnalyzer 在 10 个中文查询上的分词结果
- 用 DisjunctionMaxQuery 替换 BooleanQuery,在开发集上比较 nDCG@10
- 添加 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