假设你要从一千万篇文章中找出包含"Java 线程池"的内容。最直接的办法是逐篇读取正文,再判断关键词是否出现。但文章越多,读取和比较的成本就越高。
Lucene 换了一个思路:**写入文章时,先整理好"哪个词出现在哪些文章里";搜索时,直接查这份目录,这就是倒排索引。**那么它底层究竟是怎样的,如何一步一步实现呢?
一、Lucene 到底是什么?
Lucene 是一个用 Java 写的开源全文检索库 (Apache 顶级项目)。应用可以把它作为依赖引入,在进程内完成建索引、文本检索和相关性排序。它不是一个独立运行的"搜索服务器"或数据库,而是一个你嵌入到自己程序里的搜索引擎内核。提供的主要能力:
- 分词(Analyzer):把文本切成词,处理大小写、停用词、词干提取;
- 多种查询:关键词、短语、布尔组合、通配符、模糊匹配、范围查询;
- 相关性打分:默认使用 BM25 算法,按词频、文档长度等因素排序
- 高亮、排序、分面统计等辅助功能
它和 Elasticsearch、Solr 的关系:
| 定位 | |
|---|---|
| Lucene | 底层库,只负责单机的索引与检索,没有网络接口、没有集群 |
| Elasticsearch / Solr / OpenSearch | 基于 Lucene 封装的搜索服务,提供 REST API、分布式、分片副本、运维管理等 |
二、从文章到倒排表:把查询工作提前做
假设经过分词和规范化之后,有三篇文章:
| 文档编号 | 词序列 |
|---|---|
| 0 | java、线程池、原理 |
| 1 | java、内存、原理 |
| 2 | 线程池、参数、配置 |
按文档寻找词,是我们自然理解文章的方式。倒排索引则反过来组织:
| 词项(Term) | 文档编号列表 |
|---|---|
| java | 0、1 |
| 线程池 | 0、2 |
| 原理 | 0、1 |
| 内存 | 1 |
| 参数 | 2 |
| 配置 | 2 |
这个时候,如果搜索"同时包含 java 和线程池",就变成两个有序列表求交集:
java → 0, 1
线程池 → 0, 2
交集 → 0
搜索过程中无需先读三篇文章的完整正文。匹配完成之后,再读取命中文档中需要展示的字段。
三、分词决定了你实际上在搜索什么
Lucene 接收到一段文本后,会通过 Analyzer(分词器) 得到 Token 流。典型流程是:
原始字符
→ CharFilter:可选的字符预处理
→ Tokenizer:切分文本
→ TokenFilter:大小写规范化、停用词等处理
→ 用处理后的词建立索引
例如把 Java Thread Pool 处理成 java、thread、pool。中文还涉及词语边界,"线程池"是否成为一个词,取决于采用的分析器。
四、词这么多,Lucene 怎么快速找到一个词?
倒排索引实际需要解决两个问题:先找到词,再找到它对应的文档列表。在源码BlockTree的实现中,共享前缀的词被组织到块中,并用 FST 保存用于定位这些块的前缀索引。例如 cat、car共享前缀ca,不必把每个词都当成完全无关的字符串处理。FST 可以理解为一种紧凑的路径结构:沿输入字符向前走,最终获得定位信息。如下图所示:

查找入口LeafReader.postings(Term term, int flags) 的核心路径,源码如下:
java
final Terms terms = Terms.getTerms(this, term.field());
final TermsEnum termsEnum = terms.iterator();
if (termsEnum.seekExact(term.bytes())) {
return termsEnum.postings(null, flags);
}
return null;
这几行代码分别做了三件事:
- 找到指定字段的词典
- 在词典中精确定位目标词项
- 返回该词项的倒排迭代器
五、倒排表为什么既省空间,又查得快?
假设一个词出现在这些文档中:
原始编号:100、103、104、110
相邻差值:100、3、1、6
编号递增,就可以保存差值。差值通常比完整编号小,更适合紧凑编码。Lucene 还会把整数分块编码,兼顾压缩率和批量解码效率。查询时也不必从头逐个扫完。迭代器的 advance(target) 可以前进到第一个大于等于目标编号的位置,底层利用跳跃信息减少不必要的解码。因此,两个列表求交集类似于下面伪代码:
a = A.nextDoc()
b = B.nextDoc()
while a 和 b 都未结束:
if a < b: a = A.advance(b)
else if b < a: b = B.advance(a)
else:
收集 a
a = A.nextDoc()
b = B.nextDoc()
六、写入索引:先积累,再生成 Segment
Lucene 不会每新增一篇文章,就把所有历史倒排表重新写一遍。典型写入路径可以大致概括为:
Document → 字段处理与分词 → 内存索引缓冲 → flush → 生成新的Segment
Segment,中文常称"段",可以理解为一份独立的小索引,拥有自己的词典、倒排表以及其他字段数据。一个 Lucene 索引通常包含多个段,搜索时分别查询这些段,再统一收集结果。flush、刷新 Reader、commit 有什么区别?
| 操作 | 主要解决的问题 | 是否意味着持久提交 |
|---|---|---|
| flush | 把缓冲中的索引数据写到 Directory | 否 |
| 从 Writer 打开或刷新近实时 Reader | 让新的 Reader 看到尚未提交的变化 | 否 |
| commit | 发布提交点并同步相关文件 | 是,依赖存储正确实现同步语义 |
七、删除与更新:旧文章为什么还占空间?
如果一篇文章出现在很多词的倒排表里,删除时逐个修改这些列表会非常昂贵。普通硬删除的处理方式,是记录文档已失效,搜索时排除它。空间通常等到后续合并时再回收。所以"搜索不到了"不代表"文件立刻变小了"。
旧文档 → 标记删除 → 后续合并清理
新文档 → 正常建立索引
八、用一个小例子简单串一下
java
// 方法声明需 throws IOException。
try (Directory directory = new ByteBuffersDirectory();
//分词器
Analyzer analyzer = new StandardAnalyzer();
//建索引
IndexWriter writer = new IndexWriter(
directory, new IndexWriterConfig(analyzer))) {
//文档写入
Document doc = new Document();
doc.add(new StringField("id", "article-1", Field.Store.YES));
doc.add(new TextField("body", "Java thread pool", Field.Store.YES));
writer.addDocument(doc);
//根据条件搜索
try (DirectoryReader reader = DirectoryReader.open(writer)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query query = new TermQuery(new Term("body", "java"));
TopDocs hits = searcher.search(query, 10);
StoredFields storedFields = searcher.storedFields();
for (ScoreDoc hit : hits.scoreDocs) {
Document found = storedFields.document(hit.doc);
System.out.println(found.get("id") + ": " + found.get("body"));
}
}
}
整个过程大概如下:
原文:Java thread pool
│分词并建立索引
▼
词项:java、thread、pool
│TermQuery 搜索 java
▼
命中文档,获得内部编号和得分
│StoredFields 根据编号取值
▼
输出:article-1: Java thread pool