Apache Lucene 底层原理:从倒排索引到一次搜索的执行过程

假设你要从一千万篇文章中找出包含"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

相关推荐
HEJOO91 小时前
ElasticSearch 是什么?核心概念与应用场景详解
大数据·elasticsearch·搜索引擎
Elastic 中国社区官方博客1 天前
Jina AI:Elasticsearch 集成
大数据·运维·人工智能·elasticsearch·搜索引擎·全文检索·jina
顧棟2 天前
Elasticsearch Scroll :分页进度到底记在哪?
大数据·elasticsearch·搜索引擎
一枕槐安4872 天前
线上检索延迟 2 秒,我把向量库从 ES 换成了 Chroma
大数据·elasticsearch·搜索引擎
RD_daoyi2 天前
外贸独立站从0到1:平台选择 + 建站流程 + 新手避坑
大数据·服务器·网络·人工智能·搜索引擎·职场和发展
Flynt2 天前
我把真实的 ES 查询怼到 7059 星的 Rust 搜索引擎上:1366/1369 的兼容承诺,裂了 3 个缝
elasticsearch·搜索引擎·rust
漫步人生走在路上3 天前
用小语种SEO突破外贸B2B天花板:法语、德语市场的实测数据
开发语言·前端·javascript·人工智能·搜索引擎·ecmascript
Elastic 中国社区官方博客3 天前
教程:监控 Java 应用
java·开发语言·数据库·elasticsearch·搜索引擎·全文检索
芷栀夏3 天前
MonkeyCode本地部署后怎么远程用?从模型配置、VS Code到9999公网访问
大数据·elasticsearch·搜索引擎