Lucene 全文检索引擎
Apache Lucene是一款高性能、可扩展的Java全文检索框架,它也是目前最流行的开源搜索引擎核心实现,我们熟知的Elasticsearch、Solr等分布式搜索服务底层核心均是基于Lucene构建的。如果你需要一个分布式高可用的搜索引擎服务,那么可能没必要直接用Lucene,用ES、Solr即可,但如果你打算写一个类似的搜索引擎服务,或只是想在单体Java应用中嵌入全文检索功能,那么直接引入Lucene是更好的选择。
这篇笔记我们将从Lucene的基本概念出发,简要介绍Lucene 9.x版本中的索引构建、查询API、分词机制、向量搜索、性能优化等核心内容。
Lucene核心概念
Lucene vs 数据库LIKE查询
日常开发中,数据检索最常见的实现方式是使用关系型数据库的LIKE模糊查询,但这种方案在面对海量文本和复杂检索需求时有很多局限性。
性能极差:当使用%keyword%模糊查询时,数据库无法利用B+树索引只能全表扫描,在百万级以上数据量下查询延迟会达到秒级甚至分钟级。
匹配能力有限:仅支持简单的字符串模糊匹配,无法支持分词、同义词、语义匹配等高级功能,例如搜索“苹果手机”无法匹配到包含“iPhone”的文档。
无相关性排序:查询结果仅能按数据库固有字段排序,无法根据关键词的匹配程度等因素返回最相关的结果。
这也是全文检索要解决的问题。全文检索(Full-Text Search)是一种对文本内容建立索引,并支持基于词语进行快速查询的技术。它不是逐字符匹配,而是先对文本进行分词,再基于词项建立索引结构,查询时也对关键词进行同样的分词处理,最终通过索引快速定位到相关文档,并按照相关性评分排序返回结果。
倒排索引
不过和数据库的正向索引不同,全文检索的核心是构建倒排索引。假设我们有3篇文档,内容分别是Lucene是Java搜索库、Java是编程语言、Lucene支持向量搜索,它生成的倒排索引可能如下。
graph LR
A[词项] --> B[Lucene] --> B1[文档1, 文档3]
A --> C[Java] --> C1[文档1, 文档2]
A --> D[搜索] --> D1[文档1, 文档3]
A --> E[编程语言] --> E1[文档2]
当用户搜索“Lucene 搜索”时,检索引擎会直接从倒排索引中找到两个词对应的文档列表,取交集后得到文档1和文档3,再计算相关性排序后返回,整个过程无需扫描所有文档,性能远强于LIKE全表扫描。
Lucene架构
Lucene中有一些基本概念,它的API也是围绕这些基本概念设计的,这里我们简单了解一下。
文档 Document:Lucene中的记录被称为Document,它类似数据库中的数据行,一个Document由多个Field组成。
字段 Field:Field是Document中的一个字段,它类似数据库的列。不同类型的Field控制数据如何被索引和存储。
词项 Term:Term是构建索引的最小单元。一个Term由字段名和词语组成,例如title:lucene。
段 Segment:Segment是索引文件的基本单元。Lucene的索引由多个Segment组成,每个Segment都是独立的小索引。Segment是不可修改的,追加写入的数据会新建Segment存储,但Lucene会定期将小Segment合并成大Segment,这一过程被称为段合并。
分析器 Analyzer:分析器负责将文本转换为Term序列,分词策略直接决定搜索质量,因此围绕分析器的配置也是Lucene检索效果优化中最关键的一环。
IndexWriter:写入索引的入口,负责新增、更新、删除文档,以及控制commit、flush等操作。
IndexSearcher:查询索引的入口,接受Query对象,返回匹配的文档列表及评分。
Lucene使用
引入Maven依赖
在Java工程中使用Lucene需要引入以下依赖,其中lucene-core是Lucene中最核心的包,它负责维护索引、处理文档和执行搜索,lucene-queryparser是Lucene的查询解析器,它负责将搜索关键词和查询语法解析为Lucene能识别的查询对象,lucene-analysis-common包含了常用的Analyzer。
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>9.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>9.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
<version>9.10.0</version>
</dependency>
实际开发中,在中文领域Lucene内置的Analyzer可能无法满足我们的需求,工业级的常用方案是IK分析器。不过IK原作者的Lucene插件已经停止维护了,truenewx是社区作者维护的Fork版本,它支持最新的Lucene 9.x。
<dependency>
<groupId>org.truenewx</groupId>
<artifactId>ik-analyzer-lucene</artifactId>
<version>5.1.0</version>
</dependency>
索引创建和查询
Lucene的API还是比较繁琐的,下面我们直接给出一个完整的例子,演示Lucene中索引的创建和检索。
package com.gacfox.demo;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.wltea.analyzer.lucene.IKAnalyzer;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class Main {
public static void main(String[] args) {
try {
Path indexPath = Files.createTempDirectory("lucene");
try (Directory directory = FSDirectory.open(indexPath);
Analyzer analyzer = new IKAnalyzer()) {
// 写入索引
try (IndexWriter indexWriter = new IndexWriter(directory, new IndexWriterConfig(analyzer))) {
Document doc1 = new Document();
doc1.add(new TextField("title", "Apache Lucene简介", Field.Store.YES));
doc1.add(new TextField("content", "Lucene是一个强大的Java搜索框架。", Field.Store.YES));
doc1.add(new StringField("category", "tech", Field.Store.YES));
indexWriter.addDocument(doc1);
Document doc2 = new Document();
doc2.add(new TextField("title", "Elasticsearch简介", Field.Store.YES));
doc2.add(new TextField("content", "Elasticsearch是基于Lucene构建的搜索引擎服务。", Field.Store.YES));
doc2.add(new StringField("category", "tech", Field.Store.YES));
indexWriter.addDocument(doc2);
indexWriter.commit();
}
// 查询索引
try (DirectoryReader directoryReader = DirectoryReader.open(directory)) {
IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
QueryParser queryParser = new QueryParser("content", analyzer);
Query query = queryParser.parse("搜索引擎");
TopDocs topDocs = indexSearcher.search(query, 10);
System.out.println("命中: " + topDocs.totalHits.value);
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
Document document = indexSearcher.storedFields().document(scoreDoc.doc);
System.out.println("[" + scoreDoc.score + "] " + document.get("title"));
}
}
}
} catch (IOException | ParseException e) {
throw new RuntimeException(e);
}
}
}
Lucene中,Directory抽象类是索引的存储抽象,它最常用的实现是FSDirectory,该实现会将索引文件存储到本地文件系统中。IndexWriter是写索引的核心入口,创建时需要指定存储和分析器,我们这里使用IK分析器。具体的数据需要封装为Document和Field,然后使用IndexWriter写入索引。DirectoryReader是索引读取的核心接口,它会打开指定目录的索引文件,生成索引的只读快照。IndexSearcher封装了所有检索逻辑,它能基于DirectoryReader实现查询。QueryParser用于将用户输入的查询字符串解析为Lucene的Query对象,它需要指定查询字段和分析器,查询执行后会返回TopDocs对象,包含了匹配的总文档数、得分最高的前n个文档信息。注意写入和查询的分析器必须是同一个,否则分词结果不一致,整个检索逻辑就混乱了。
代码总体比较简单,出于演示目的,我们在临时文件目录中创建索引后就立刻对其进行了查询,不过生产环境中我们通常需要在指定的持久化目录下维护索引,维护索引和查询的代码通常也是分开的。
运行程序后,我们会看到控制台输出匹配到的文档以及对应的相关性得分。
Lucene工作流程
通过上面的示例,我们可以总结出Lucene完整的工作流程,流程大致分为写入和查询两大阶段:
写入阶段:原始文档经过分词器拆分为Term,基于Term构建倒排索引生成Segment段,最终持久化到磁盘
查询阶段:用户查询字符串经过相同的分词器处理,在倒排索引中匹配对应的文档并计算相关性得分,最终排序后返回结果
Analyzer与分词机制
分词是全文检索的核心环节,检索效果的好坏绝大部分取决于分词器的选择与配置是否合理。Lucene的全文检索本质其实就是Term匹配,写入时文档被拆分为哪些Term,查询时就只能匹配到对应的Term,在Lucene中,Analyzer就是负责这些处理的核心,这也是之前我们说“写入和查询时使用的Analyzer必须一致”的原因,且分词粒度必须符合业务的检索需求,否则会出现应该匹配的文档匹配不到,不该匹配的文档却匹配到了的问题。
Analyzer的本质是一个文本处理流水线,它包含3个核心组件:
CharFilter:字符过滤器,用于在分词前对原始文本进行预处理,例如去除HTML标签、替换特殊字符等,一个Analyzer可以配置多个CharFilter。
Tokenizer:分词器,将连续的文本字符串拆分为一个个独立的Token(词项),分词器是分词的核心环节,一个Analyzer有且只有一个Tokenizer。
TokenFilter:词项过滤器,对分词后的Token进行二次处理,例如转小写、去除停用词(的、是、a、the等无意义词)、同义词扩展、词干提取(例如将running转为run)等,一个Analyzer可以配置多个TokenFilter。
除了我们前面使用的用于中文的IK分析器,常用的内置Analyzer有以下几个:
StandardAnalyzer:Lucene默认分析器,按空格、标点、Unicode文本边界等自动切词,支持自动转小写、去除英文停用词。对英文效果良好,但对中文的处理是按单个汉字切分,高频出现的汉字会导致不相关的内容排在前面。
WhitespaceAnalyzer:以空白字符为分隔符切词,不做任何其他处理,适合字段值本身已经是词语列表的场景。
KeywordAnalyzer:不做任何切分,将整个字段值作为一个Term,等价于StringField的行为。
Field类型
Document中,Field的核心作用是控制字段的存储策略、索引方式、检索能力、排序聚合支持。不同业务场景需要正确选择对应的Field类型,错误的Field选型可能会导致检索失效、排序报错或性能问题,下面我们了解下Lucene中的常用Field。
TextField
TextField中文本会被Analyzer分词并构建倒排索引,它适合需要全文检索的长文本,例如文章标题、正文等。不过“被索引”并不默认意味着存储“原始值”,TextField中,我们需要通过Field.Store.YES或Field.Store.NO控制是否存储原始值,如果不存储原始值,那么字段可以被全文检索,但返回的记录中不能获取原始文本值。
doc.add(new TextField("title", "分布式系统设计原理", Field.Store.YES));
StringField
StringField与TextField不同,它不经过Analyzer处理,而是将整体作为Term构建倒排索引,适合需要精确匹配的字段。对于原始值,也需要使用Field.Store.YES或Field.Store.NO是否存储。
doc.add(new StringField("category", "tech", Field.Store.YES));
StringField搜索会表现为大小写敏感,由于它不经过Analyzer处理,因此构建索引时文本也不会自动变为小写,如果索引的文本和搜索的文本大小写不一致就可能会查不到,这需要我们手动处理。而TextField会经过Analyzer处理,默认的StandardAnalyzer和IKAnalyzer都会做小写归一化,因此使用这些Analyzer处理时表现为搜索时的大小写不敏感,但如果选择不做小写归一化的Analyzer,也能让其变为大小写敏感。
StoredField
前面我们提到过Lucene中“被索引”并不默认意味着存储“原始值”,TextField和StringField可以通过参数指定索引的同时是否被存储,而StoredField则专用于仅存储不索引的信息。它适用于不需要检索但查询结果需要返回的字段,如文章原始HTML、图片URL、冗余业务字段等。
doc.add(new StoredField("rawJson", jsonString));
Point系列数值字段
Lucene7引入了Point系列数值字段替代了旧版的NumericField,Point底层基于BKD树索引以支持高性能的数值精确匹配、范围查询。注意Point字段默认不存储原始值,它需要配合StoredField才能在查询时返回数值。
| 字段类型 | 适用场景 | 示例 |
|---|---|---|
IntPoint |
32位整数的查询 | doc.add(new IntPoint("price", 99)); |
LongPoint |
64位长整数、时间戳的查询 | doc.add(new LongPoint("create_time", System.currentTimeMillis())); |
FloatPoint |
单精度浮点数查询 | doc.add(new FloatPoint("score", 4.8f)); |
DoublePoint |
双精度浮点数查询 | doc.add(new DoublePoint("weight", 1.56)); |
DocValues
DocValues是一种面向列的存储结构,它主要用于两个场景:
排序:按某个字段排序时,Lucene需要快速获取每个文档的对应字段值。如果没有DocValues就要对每个命中文档逐一读取存储值,这样做效率非常低。而有了列式存储的DocValues,排序所需的值是连续的,读取速度会快几个数量级。
聚合:统计字段的最大值、最小值、平均值等操作同样依赖高速的列式存储DocValues。
NumericDocValuesField用于数值排序和聚合。
doc.add(new NumericDocValuesField("price", 299));
SortedDocValuesField用于字符串字段的排序。
doc.add(new SortedDocValuesField("category", new BytesRef("tech")));
注:Lucene底层出于性能考虑不直接使用String类型,BytesRef是Lucene中对字节数组的封装,它也是所有Term、DocValues的底层基础类。
SortedSetDocValuesField用于多字符串字段的排序,它常用于处理多对多类型标签、权限等数据。
doc.add(new SortedSetDocValuesField("tags", new BytesRef("electronics")));
doc.add(new SortedSetDocValuesField("tags", new BytesRef("sale")));
doc.add(new SortedSetDocValuesField("tags", new BytesRef("new")));
DocValues 必须与对应的索引字段同时添加,它们是独立的数据结构,不能互相替代。
KnnVectorField
KnnVectorField是Lucene 9.x原生支持的向量字段,它底层基于HNSW算法构建向量索引并支持高维向量的近邻检索,是语义搜索、推荐系统的核心能力,后续向量搜索章节会展开介绍。
doc.add(new KnnVectorField("content_vector", vector, VectorSimilarityFunction.COSINE));
文档增删改
Lucene的Segment是不可变的,表面上看是文档增删改查,底层实际是创建Segment、标记删除和定期段合并。
新增文档
新增文档使用IndexWriter的addDocument()方法,这在前面的示例中已经展示过。
indexWriter.addDocument(document);
删除文档
删除操作基于Term Query,下面例子代码删除id为article-123的文档。
indexWriter.deleteDocuments(new Term("id", "article-123"));
Lucene的Segment是不可修改的,删除操作并不会立即从Segment文件中移除数据,而是在一个独立的.liv文件中标记该文档为“已删除”,被标记的文档在下次段合并之前仍会占用磁盘空间,只是查询时会被过滤掉,真正的物理删除在段合并时才会发生,高频的写入删除操作会导致频繁段合并,这也是Lucene/ES不适合高频做删除操作的原因。
更新文档
Lucene的更新其实不是标准意义上的更新,而是删除重新插入文档。下面例子代码中,我们实现了先删除id为article-123的文档,然后插入新文档。
indexWriter.updateDocument(new Term("id", "article-123"), newDocument);
由于更新的本质是删除加插入,因此有和删除同样的局限性。
commit与flush
Lucene中对数据做增删改后,最终需要调用IndexWriter的commit()方法,这其中实际上涉及两个概念。
flush:将内存中的IndexWriter缓冲区写入文件系统生成新的Segment文件,但文件可能还在操作系统的页高速缓存中没真正物理落盘,此外flush这一步还没更新索引的提交点segment_N,segment_N类似于Segment的目录清单,没更新时新的Segment对其他DirectoryReader是不可见的。flush操作由Lucene内部自动触发,也会在commit之前触发,一般不需要手动通过代码调用。
commit:在flush的基础上,使用操作系统的fsync系统调用确保数据真正落盘,同时更新提交点文件segment_N,使得变更对新打开的DirectoryReader可见。我们写入数据后需要手动调用commit,不过commit是昂贵的IO操作,开发中我们应该批量写入后统一commit,不要每条文档都commit。
如果你刚调用addDocument()写入了一篇文章,然后立刻用DirectoryReader.open(directory)查询却查不到这篇文章,这是因为写入还没有commit,自然不可见。此外另一个要注意的是,如果你的DirectoryReader先打开了,然后做了写入并commit,已经打开的DirectoryReader仍看不到数据,因为DirectoryReader打开的是一个只读、静态的快照,只有重新打开DirectoryReader才能看到这条数据。
文档检索
Lucene中,Query是所有检索查询的顶层抽象类,多个Query可以通过BooleanQuery组合成更复杂的Query,我们构建查询逻辑其实就是构建各种Query。前面我们曾使用过类似下面的写法,它基于IK分析器构建了一个针对content字段的全文检索。
QueryParser queryParser = new QueryParser("content", analyzer);
Query query = queryParser.parse("搜索引擎");
QueryParser底层实际做的是调用IKAnalyzer将我们的检索逻辑转换成了具体的Query实现,它在底层可能会转换成类似下面的结构。
BooleanQuery(
TermQuery(Term("content","hello")),
TermQuery(Term("content","world"))
)
实际开发中,除了直接基于QueryParser和Analyzer生成全文检索Query检索内容,有时我们也会单独使用基础Query或手动组合Query实现更复杂的查询逻辑。
Term Query
Term Query是最基础的查询,它对某个字段精确匹配一个Term。Term Query一般都用于StringField。对于StringField,Term Query就是精确的匹配查询与索引值相等,对于TextField,Term Query比较的是倒排索引中的Term,而倒排索引中的Term是经过Analyzer处理的,其中包括转小写、去除停用词等操作,直接手动对其进行Term Query结果通常不是我们想要的。
Query query = new TermQuery(new Term("category", "tech"));
Boolean Query
Boolean Query非常实用,它用于将多个Query按逻辑组合起来,下面例子代码我们组合了多个Term Query实现了一个较为复杂的检索逻辑。
BooleanQuery boolQuery = new BooleanQuery.Builder()
// 必须匹配:内容包含“搜索”
.add(new TermQuery(new Term("content", "搜索")), BooleanClause.Occur.MUST)
// 过滤:只取分类为tech的文档,不参与计算相关性得分
.add(new TermQuery(new Term("category", "tech")), BooleanClause.Occur.FILTER)
// 可选匹配:标题包含“Lucene”,包含可提升得分
.add(new TermQuery(new Term("title", "Lucene")), BooleanClause.Occur.SHOULD)
// 排除:不能包含“测试”
.add(new TermQuery(new Term("content", "测试")), BooleanClause.Occur.MUST_NOT)
.build();
Boolean Query支持四种逻辑组合规则:
MUST:必须匹配,参与相关性得分计算SHOULD:可选匹配,参与相关性得分计算,匹配越多得分越高MUST_NOT:必须不匹配,不参与得分FILTER:必须匹配,适合纯过滤场景
其中要注意的是FILTER和MUST的区分,虽然两者看上去意义类似,但MUST参与相关性得分计算而FILTER不参与,后者的结果是可缓存的,性能要比MUST高,适合纯过滤场景。
Phrase Query
Phrase Query用于匹配有多个词项按指定顺序、指定间隔出现的文档,它支持slop指定词之间的最大间隔数,适合精确短语匹配。下面例子中,我们搜索content字段中包含search library这个短语的文档。
PhraseQuery query = new PhraseQuery("content", "search", "library");
下面例子在之前基础上允许词语之间最多有1个其他词。
PhraseQuery query = new PhraseQuery(1, "content", "search", "library");
Prefix Query
Prefix Query是前缀查询,它匹配所有以指定前缀开头的Term。下面例子代码匹配title中所有以java开头的词,例如java、javascript、javaee。
Query query = new PrefixQuery(new Term("title", "java"));
Wildcard Query
Wildcard Query是通配符查询,其中*匹配任意字符序列,?匹配单个字符。
Query query = new WildcardQuery(new Term("title", "java*"));
Fuzzy Query
Fuzzy Query是模糊查询,它允许拼写有一定的编辑距离误差(编辑指插入、删除、替换三种操作)。下面例子模糊匹配lucen,但指定最大编辑距离为2,因此Lucene也会被匹配,编辑距离等于2。
Query query = new FuzzyQuery(new Term("title", "lucen"), 2);
Range Query
Range Query是数值范围查询,不过Range Query的写法和之前有些区别,它使用的是Point类提供的静态方法。下面例子查询price字段值在100 <= price <= 500之间记录。
Query priceQuery = IntPoint.newRangeQuery("price", 100, 500);
QueryParser
前面我们介绍过QueryParser可用于基于Analyzer生成全文检索的Query。实际上,QueryParser允许用户用字符串表达式描述查询,它支持AND、OR、NOT、短语、通配符等语法,下面是一些例子。
title:lucene AND category:tech
title:"getting started"
price:[100 TO 500]
然而,这个功能存在注入风险,如果输入是完全由用户控制的,用户输入的特殊字符也会被解析为查询语法,这可能导致解析异常或意外的查询行为。实际开发中,对于结构化的查询条件还是推荐直接用API构建Query,输入给QueryParser的内容统一做Escape,这样更安全也更可控。
String userInput = "title:lucene AND category:tech";
String escaped = QueryParser.escape(userInput);
QueryParser parser = new QueryParser("content", analyzer);
Query query = parser.parse(escaped);
排序
默认情况下Lucene按相关性评分降序返回结果。如果需要自定义排序,需要使用Sort和SortField指定。下面例子我们按createTime字段降序排序。注意排序字段需要有对应的DocValues,否则会抛出异常。
Sort sort = new Sort(new SortField("createTime", SortField.Type.LONG, true));
TopDocs topDocs = searcher.search(query, 10, sort);
排序条件可以有多个,Sort的构造函数支持传入多个SortField,当有多个排序条件时,Lucene会先比较第一个排序字段,如果相同再比较第二个,以此类推。
BM25相关性评分
Lucene检索时,默认是按相关性排序的,相关性评分是搜索引擎考虑“文档对这次查询有多重要”的量化估计,分数越高排越靠前。Lucene的全文检索中,相关性分数默认使用BM25算法计算,我们这里不展开介绍BM25的细节,但需要理解它有以下几个核心概念:
词频(TF):文档中查询词出现次数越多相关性越高,但词频和得分不是线性关系,词频的影响有上限,重复较多次后差异会被压缩。
逆文档频率(IDF):一个词在越少的文档中出现它越有区分度,贡献的评分越高。的、是这类词几乎所有文档都有,价值接近零;倒排索引只出现在少数文档中,命中它意味着文档非常相关。
字段长度归一化:同样出现了查询词,短文档的评分高于长文档。短文章中出现Lucene比10000字的长文章中出现一次Lucene相关性显然更高。
boost
有时我们需要人为干预评分,让某个字段的评分权重更高,Lucene 9.x支持在查询时做boost操作。下面例子代码中,title命中的评分权重是content是3倍。
Query titleQuery = new BoostQuery(new TermQuery(new Term("title", "lucene")), 3.0f);
Query contentQuery = new TermQuery(new Term("content", "lucene"));
BooleanQuery query = new BooleanQuery.Builder()
.add(titleQuery, BooleanClause.Occur.SHOULD)
.add(contentQuery, BooleanClause.Occur.SHOULD)
.build();
Explain调试
当你不理解为什么某篇文档排名靠前或靠后时,可以用Lucene内置的调试API查看评分的详细计算过程。
TopDocs topDocs = searcher.search(query, 10);
for (ScoreDoc sd : topDocs.scoreDocs) {
Explanation explanation = searcher.explain(query, sd.doc);
System.out.println(explanation.toString());
}
输出的Explanation是树状结构,会逐层展示每个Query子句对最终评分的贡献。
NRT(Near Real-Time)近实时搜索
Lucene中,NRT主要解决的是索引更新之后,多久能被搜索到的问题。在没有NRT机制时,Lucene的典型使用流程是Writer写入文档 -> commit -> 新打开Reader -> 检索新数据,这个流程的问题是commit和重新打开Reader的成本极高,但不这样做又搜索不到最新的文档。而NRT允许不commit也能搜索到新数据,写入后通常都能做到毫秒至秒级可见。NRT在生产级别的代码中非常常见,ES/Solr默认都是基于NRT近实时查询的。
Lucene的NRT本质是通过不断的重新开启Reader让搜索快速看到写入变化,写入数据仍然先进入IndexWriter的内存缓冲区并flush成Segment,但我们不再等待commit后再开启Reader搜索了,而是搜索端通过定期重开Reader并直接加载最新生成的Segment,从而实现近实时可见。在生产环境中,我们通常不手动管理Reader的生命周期,Lucene提供了SearcherManager来简化这一过程,下面是一个例子。
// 初始化NRT搜索管理器,它负责维护Reader的生命周期
SearcherManager searcherManager = new SearcherManager(writer, new SearcherFactory());
// 执行搜索
IndexSearcher searcher = searcherManager.acquire();
try {
TopDocs topDocs = searcher.search(query, 10);
} finally {
searcherManager.release(searcher);
}
// 触发刷新(可以放到定时任务里,例如每秒执行1次)
searcherManager.maybeRefresh();
SearcherManager负责线程安全的Reader复用与刷新,acquire()和release()分别用于获取和释放Reader。
向量检索
传统全文检索基于倒排索引实现词项匹配,它虽然解决了数据库LIKE查询的性能问题,但本质仍然是“字符串匹配”,检索本身不会理解和考虑文本的语义,而向量检索突破了这一限制。向量检索中,文本、图片等非结构化数据会被通过深度学习模型(如BERT、Sentence-BERT)转换为固定维度的数值向量,语义相近的内容在向量空间中距离更近,检索时通过计算向量之间的相似度就找到与查询内容语义最接近的文档。Lucene 9.x开始原生集成了基于HNSW算法的向量检索能力,我们无需额外插件即可实现高性能的近似最近邻(ANN)检索,这也让Lucene具备了轻量级的向量检索能力。
向量检索核心概念
HNSW:向量检索的核心是计算数据记录和查询的向量表示之间的距离,但做这个计算时我们不可能逐条数据去算,这种暴力检索方式效率极为低下,对于向量数据我们也需要构建一种合适的索引。Lucene的向量检索底层基于HNSW(Hierarchical Navigable Small World,分层导航小世界)算法实现,这是目前工业界最主流的近似最近邻检索算法,它通过构建多层的有向图结构,将高维向量的检索复杂度从O(n)降低到对数级别,在保证90%以上召回率的同时,检索延迟可以控制在毫秒级。
相似度函数:Lucene提供了4种向量相似度计算函数:
- COSINE(余弦相似度):最常用的文本向量相似度函数,适合语义匹配场景。
- DOT_PRODUCT(点积):直接计算两个向量的内积。
- EUCLIDEAN(欧氏距离):衡量向量空间中的绝对距离,适合图像、推荐系统等场景。
- MAX_INNER_PRODUCT(最大内积):针对未归一化向量的内积优化,适合特定模型输出的向量。
注意写入和查询时必须使用完全一致的相似度函数,否则检索结果会出现偏差。
创建向量字段
要使用向量检索,首先我们需要在文档中添加KnnVectorField字段,和其他Field一样,它需要指定字段名、向量数组和相似度函数。注意Lucene本身不负责生成向量,你需要提前通过BERT等模型将文本转化为float[]数组,向量维度必须固定,Lucene 9.x出于性能考虑默认支持最大1024维的向量。
doc.add(new KnnVectorField("content_vector", contentVector, VectorSimilarityFunction.COSINE));
和数值字段类似,KnnVectorField默认不存储原始向量值,如果需要在查询结果中返回向量,需要同时添加StoredField存储原始向量。
向量检索
Lucene提供了KnnVectorQuery实现向量检索,它的核心参数是向量字段名、目标查询向量、返回的TopK数量。下面例子代码中,我们使用向量检索获取与输入最接近的10个结果。
// queryVector是Embedding模型生成的向量
KnnVectorQuery vectorQuery = new KnnVectorQuery("content_vector", queryVector, 10);
TopDocs topDocs = searcher.search(vectorQuery, 10);
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
Document doc = searcher.storedFields().document(scoreDoc.doc);
System.out.println("[" + scoreDoc.score + "] " + doc.get("title"));
}
实际业务中,我们通常需要先过滤掉不符合条件的文档再在剩余文档中做向量检索,这可以大幅缩小检索范围并提升性能。Lucene的KnnVectorQuery支持传入过滤Query,实现先过滤后检索。
Query filterQuery = new TermQuery(new Term("category", "tech"));
KnnVectorQuery vectorQuery = new KnnVectorQuery("content_vector", queryVector, 10, filterQuery);
实际开发中,向量检索和传统全文检索不是替代关系,而是互补关系。工业级搜索通常会同时使用两种检索方式,兼顾精确匹配和语义匹配,这被称为Hybrid Search(混合检索)。目前,业界广泛认可的混合检索实现主流有这样几种:
- 线性加权:将BM25分数和向量相似度分数简单相加是最早和最简单的混合检索融合方式,然而BM25和向量相似度分数的量纲不同,直接相加其实没太大意义,它也是所有融合方式里最弱鸡的版本,Lucene默认仅支持该方式。
- RRF融合:RRF(Reciprocal Rank Fusion,倒数排名融合)是最常用的融合算法之一,RRF的核心思想其实很简单,它根据各自排名位置计算融合分数,实际开发中较为常用。
- Rerank模型重排序:Rerank模型也是一种基于大量文本预训练的模型,但专用于基于语义相似度排序,相比RRF用Rerank模型在某些场景下效果更好,但显然调用模型的计算开销也大得多。
- 基于LLM Agent的筛选和排序:基于大语言模型Agent做排序是成本最高的方式,它的效果取决于使用的模型能力和提示词,输出结果大概率是不稳定的,而且效果也未必最优,但Agent还具备自主规划、主动搜索等扩展能力,因此这种方式和前面几种并不是同一层级的方案。
下面例子代码使用Lucene内置支持的简单线性加权实现混合检索。
Query keywordQuery = new TermQuery(new Term("content", "Lucene"));
KnnVectorQuery vectorQuery = new KnnVectorQuery("content_vector", queryVector, 10);
BooleanQuery hybridQuery = new BooleanQuery.Builder()
.add(new BoostQuery(keywordQuery, 2.0f), BooleanClause.Occur.SHOULD)
.add(new BoostQuery(vectorQuery, 1.0f), BooleanClause.Occur.SHOULD)
.build();
HNSW性能调优
HNSW算法的性能和精度主要由3个核心参数控制,Lucene允许通过KnnVectorConfig自定义这些参数。
KnnVectorConfig vectorConfig = new KnnVectorConfig(
VectorSimilarityFunction.COSINE,
16, // M:每层的最大邻居数,默认16
100 // efConstruction:索引构建时的探索邻居数,默认100
);
doc.add(new KnnVectorField("content_vector", contentVector, vectorConfig));
M(邻居数):取值通常在12~48之间。M越大索引精度越高,检索速度越快,但索引构建速度越慢、内存占用越高。768维文本向量推荐设置为16~24。
efConstruction:取值通常在100~500之间。值越大,索引构建精度越高,但构建速度越慢,生产环境推荐设置为100~200。
efSearch:查询时的探索邻居数,默认100,可在查询时动态调整。值越大,召回率越高,但查询延迟越高,可根据业务对精度和延迟的要求在50~200之间调整。