前面十四篇从零实现了一个教学级搜索引擎:分词、倒排索引、BM25、段文件、崩溃恢复、摘要高亮、HTTP API。它能跑,能搜,能翻页。但它有一个根本问题——每一个组件都是教学级的,没有经过大规模生产验证。

Apache Lucene 是目前最广泛使用的开源搜索库。Elasticsearch、Solr、OpenSearch 底层都是 Lucene。它的分词器、索引格式、评分算法、段合并策略经过二十多年的工程打磨。与其继续在自制引擎上堆功能,不如把核心替换为 Lucene,把精力放在搜索质量和上层功能上。

本篇保持外部接口(搜索 API、文档导入格式)不变,把内部实现从自制内核切换到 Lucene 10.5.1。切换后用同一组 fixture 文档和查询对照两个后端,逐项记录差异。

迁移策略

保持契约不变

外部接口不变:

1
2
输入:Document(title, url, content, language)
输出:SearchResult(items[title, url, snippet, score], totalHits, page)

内部替换:

组件 自制 Lucene
分词器 手写 UAX#29 + CJK bigram StandardTokenizer + CJKBigramFilter
索引写入 SPIMI + 段文件 IndexWriter
索引读取 自制 SegmentReader DirectoryReader + IndexSearcher
评分 手写 BM25 BM25Similarity
存储字段 自制 StoredFieldsReader StoredFields
删除 Tombstone BitSet IndexWriter.deleteDocuments
段合并 自制 k-way merge TieredMergePolicy

两个后端并行

迁移期间保留自制后端作为对照。搜索 API 可以同时调用两个后端,对比结果差异。确认 Lucene 后端行为符合预期后,再移除自制后端。

Lucene 依赖

Maven 依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>10.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
<version>10.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>10.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>10.5.1</version>
</dependency>

四个模块:核心、分析器、查询解析器、高亮器。总计约 10 MB。

Analyzer 对齐

自制分析器回顾

第 06 篇的分析器流程:

1
原文 → UAX#29 Tokenizer → LowerCase Filter → CJK Bigram Filter → Term Stream

对 “Java搜索引擎Tutorial” 产出:[java, 搜索, 索引, 引擎, tutorial]

Lucene 等价分析器

1
2
3
4
5
6
Analyzer analyzer = CustomAnalyzer.builder()
.withTokenizer(StandardTokenizerFactory.class)
.addTokenFilter(CJKWidthFilterFactory.class)
.addTokenFilter(LowerCaseFilterFactory.class)
.addTokenFilter(CJKBigramFilterFactory.class)
.build();

StandardTokenizer 实现 UAX#29,CJKBigramFilter 对 CJK 字符做 bigram,LowerCaseFilter 做小写化。行为与自制分析器基本一致。

已知差异

场景 自制 Lucene 影响
emoji 可能作为单个 token 可能拆分或丢弃 微小,教学语料几乎无 emoji
数字 作为独立 token StandardTokenizer 保留数字 一致
连字符 可能拆分 标准行为拆分 一致
CJK 标点 过滤 CJKWidthFilter 处理全角 微小差异

验证方法:对 10 篇 fixture 文档,分别用两个分析器分词,对比 token 列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void compareTokenization(String text) {
List<String> selfTokens = selfAnalyzer.analyze(text);
List<String> luceneTokens = luceneAnalyze(analyzer, "body", text);

if (!selfTokens.equals(luceneTokens)) {
System.out.printf("分词差异:\n 自制: %s\n Lucene: %s\n", selfTokens, luceneTokens);
}
}

List<String> luceneAnalyze(Analyzer analyzer, String field, String text) throws IOException {
List<String> tokens = new ArrayList<>();
try (TokenStream ts = analyzer.tokenStream(field, text)) {
CharTermAttribute attr = ts.addAttribute(CharTermAttribute.class);
ts.reset();
while (ts.incrementToken()) {
tokens.add(attr.toString());
}
ts.end();
}
return tokens;
}

索引写入

Lucene IndexWriter

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
class LuceneIndexBackend implements IndexBackend {
private final Directory directory;
private final Analyzer analyzer;
private final IndexWriter writer;

LuceneIndexBackend(Path indexPath, Analyzer analyzer) throws IOException {
this.directory = FSDirectory.open(indexPath);
this.analyzer = analyzer;
IndexWriterConfig config = new IndexWriterConfig(analyzer);
config.setSimilarity(new BM25Similarity(1.2f, 0.75f));
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
this.writer = new IndexWriter(directory, config);
}

void indexDocument(SearchDocument doc) throws IOException {
Document luceneDoc = new Document();
luceneDoc.add(new TextField("title", doc.title(), Field.Store.YES));
luceneDoc.add(new TextField("body", doc.content(), Field.Store.YES));
luceneDoc.add(new StringField("url", doc.url(), Field.Store.YES));
luceneDoc.add(new StringField("lang", doc.language(), Field.Store.YES));
luceneDoc.add(new StringField("docId", doc.externalId(), Field.Store.YES));
writer.addDocument(luceneDoc);
}

void commit() throws IOException {
writer.commit();
}
}

TextField 会经过分析器分词并建立倒排索引。StringField 不分词,用于精确匹配和过滤。Field.Store.YES 表示原文存储在索引中,查询时可以取回。

删除与更新

1
2
3
4
5
6
7
8
void deleteDocument(String externalId) throws IOException {
writer.deleteDocuments(new Term("docId", externalId));
}

void updateDocument(SearchDocument doc) throws IOException {
Document luceneDoc = toLuceneDoc(doc);
writer.updateDocument(new Term("docId", doc.externalId()), luceneDoc);
}

Lucene 的 updateDocument 内部就是 delete + add——和第 12 篇自制引擎的实现思路完全一样。

搜索

Lucene IndexSearcher

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
SearchResult search(String queryStr, int page, int size, String lang) throws IOException {
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setSimilarity(new BM25Similarity(1.2f, 0.75f));

BooleanQuery.Builder boolQuery = new BooleanQuery.Builder();

QueryParser bodyParser = new QueryParser("body", analyzer);
Query bodyQuery = bodyParser.parse(queryStr);
QueryParser titleParser = new QueryParser("title", analyzer);
Query titleQuery = new BoostQuery(titleParser.parse(queryStr), 2.0f);
boolQuery.add(bodyQuery, BooleanClause.Occur.SHOULD);
boolQuery.add(titleQuery, BooleanClause.Occur.SHOULD);

if (lang != null && !lang.isEmpty()) {
boolQuery.add(new TermQuery(new Term("lang", lang)), BooleanClause.Occur.FILTER);
}

Query query = boolQuery.build();
int topN = page * size;
TopDocs topDocs = searcher.search(query, topN);

int from = (page - 1) * size;
List<ResultItem> items = new ArrayList<>();
StoredFields storedFields = searcher.storedFields();
for (int i = from; i < Math.min(topDocs.scoreDocs.length, topN); i++) {
ScoreDoc sd = topDocs.scoreDocs[i];
Document doc = storedFields.document(sd.doc);
String snippet = extractSnippet(doc.get("body"), queryStr);
items.add(new ResultItem(doc.get("title"), doc.get("url"), snippet, sd.score));
}

reader.close();
return new SearchResult(queryStr, topDocs.totalHits.value(), page, items);
}

BooleanClause.Occur.FILTER

FILTERMUST 的区别:FILTER 不贡献分数。语言过滤用 FILTER——它缩小候选集但不影响 BM25 评分。这与第 08 篇"过滤与布尔查询的区别"一致。

评分差异分析

Norm 量化

Lucene 将文档长度归一化因子(norm)编码为单个字节(SmallFloat 格式),有精度损失。自制引擎用 double 存储,没有精度损失。

影响:两篇文档的长度差异很小时(如 200 词 vs 201 词),Lucene 可能编码为相同的 norm,导致与自制引擎的排序不同。

IDF 公式

自制引擎(第 09 篇):idf = ln(1 + (N - df + 0.5) / (df + 0.5))

Lucene BM25Similarity:idf = ln(1 + (N - df + 0.5) / (df + 0.5))

公式相同。但 Lucene 的 N 和 df 是按段累加的,自制引擎是全局统计。在只有一个段的情况下(教学场景),两者一致。

平分规则

自制引擎:docId 较小的排前面。Lucene 默认:docId 较小的排前面(在同一个段内)。基本一致,但跨段时 Lucene 的 docId 编号方式不同——可能导致微小排序差异。

Fixture 对照

对照流程

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
void compareBackends(List<SearchDocument> fixtures, List<String> queries) {
SelfIndexBackend self = new SelfIndexBackend(selfPath);
LuceneIndexBackend lucene = new LuceneIndexBackend(lucenePath, analyzer);
for (SearchDocument doc : fixtures) {
self.indexDocument(doc);
lucene.indexDocument(doc);
}
self.commit();
lucene.commit();

for (String q : queries) {
SearchResult selfResult = self.search(q, 1, 10, null);
SearchResult luceneResult = lucene.search(q, 1, 10, null);
compareResults(q, selfResult, luceneResult);
}
}

void compareResults(String query, SearchResult self, SearchResult lucene) {
System.out.printf("查询: %s\n", query);
System.out.printf(" 自制 Top-5: %s\n", topTitles(self, 5));
System.out.printf(" Lucene Top-5: %s\n", topTitles(lucene, 5));

List<String> selfTop = topDocIds(self, 5);
List<String> luceneTop = topDocIds(lucene, 5);
if (selfTop.equals(luceneTop)) {
System.out.println(" ✓ Top-5 一致");
} else {
System.out.println(" ✗ Top-5 不一致");
analyzeDifference(query, self, lucene);
}
}

差异分类与处理

差异类型 原因 是否为缺陷
分词差异 StandardTokenizer 细节不同 不是——查看哪个更合理
分数精度差异 norm 量化 不是——Lucene 的精度足够
排序微调 精度差异导致 不是——只要 Top-5 主体一致
字段权重差异 BoostQuery 与线性加权 需要调整——确保标题权重效果一致

预期:80%+ 的查询 Top-5 完全一致,剩余的因 norm 精度差异导致相邻位置互换。

迁移后的清理

确认 Lucene 后端行为符合预期后:

  1. 搜索 API 默认使用 Lucene 后端
  2. 自制后端保留为教学参考实现,不再接受新功能
  3. 后续章节(16-30)全部基于 Lucene 后端

自制引擎的价值是教学——理解每个组件的原理。Lucene 的价值是工程——在此基础上做搜索质量优化和上层功能。

当前局限

  • Lucene QueryParser 的语法与自制解析器不同(如 field:term、通配符、模糊查询)——本篇不全部使用
  • 没有调优 Lucene 的 TieredMergePolicy 参数——用默认值
  • 高亮仍然使用自制实现——Lucene 的 Highlighter/UnifiedHighlighter 更成熟,留给第 16 篇
  • 没有利用 Lucene 的 facet、suggest 等高级功能——后续章节按需引入

练习

  1. 对 5 篇 fixture 文档,比较自制分析器和 Lucene 分析器的分词结果,记录差异
  2. 用同一个两词查询,打印两个后端每篇文档的 BM25 分数,比较精度
  3. 找到一个因 norm 量化导致排序不同的案例——构造两篇长度只差 1 词的文档
  4. 将 Lucene 的 BM25 参数改为 k1=2.0, b=0.5,观察排序变化
  5. 测量两个后端的索引构建时间和查询延迟,比较性能差异

延伸阅读

  • Apache Lucene 10.5.1 官方文档
  • Lucene 源码:org.apache.lucene.search.similarities.BM25Similarity
  • Lucene 源码:org.apache.lucene.analysis.standard.StandardAnalyzer
  • Lucene 源码:org.apache.lucene.index.IndexWriter