注:本文基于Lucene 8.2.0 版本。
搜索是使用Lucene的根本目的,本文介绍Lucene提供的常用查询。下面的讲述中,会以之前《Lucene系列(2)——代码实践》文章中4首短诗的索引数据为例进行查询,你可以先阅读那篇文章构建索引。在Lucene中,Term是查询的基本单元(unit),所有查询类的父类是org.apache.lucene.search.Query
,本文会介绍下图中这些主要的Query
子类:
DisjunctionMaxQuery主要用于控制评分机制,SpanQuery代表一类查询,有很多的实现。这两类查询不是非常常用,放在以后的文章单独介绍。本文所有、示例的完整代码见这里。
TermQuery
TermQuery是最基础最常用的的一个查询了,对应的类是org.apache.lucene.search.TermQuery
。其功能很简单,就是查询哪些文档中包含指定的term。
看下面代码:
/**
* Query Demo.
*
* @author NiYanchun
**/
public class QueryDemo {
/**
* 搜索的字段
*/
private static final String SEARCH_FIELD = "contents";
public static void main(String[] args) throws Exception {
// 索引保存目录
final String indexPath = "indices/poems-index";
// 读取索引
IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
IndexSearcher searcher = new IndexSearcher(indexReader);
// TermQuery
termQueryDemo(searcher);
}
private static void termQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("TermQuery, search for 'death':");
TermQuery termQuery = new TermQuery(new Term(SEARCH_FIELD, "death"));
resultPrint(searcher, termQuery);
}
private static void resultPrint(IndexSearcher searcher, Query query) throws IOException {
TopDocs topDocs = searcher.search(query, 10);
if (topDocs.totalHits.value == 0) {
System.out.println("not found!\n");
return;
}
ScoreDoc[] hits = topDocs.scoreDocs;
System.out.println(topDocs.totalHits.value + " result(s) matched: ");
for (ScoreDoc hit : hits) {
Document doc = searcher.doc(hit.doc);
System.out.println("doc=" + hit.doc + " score=" + hit.score + " file: " + doc.get("path"));
}
System.out.println();
}
}
上面代码先读取索引文件,然后执行了一个term查询,查询所有包含death关键词的文档。为了方便打印,我们封装了一个resultPrint
函数用于打印查询结果。On Death一诗包含了death关键字,所以程序执行结果为:
TermQuery, search for 'death':
1 result(s) matched:
doc=3 score=0.6199532 file: data/poems/OnDeath.txt
后面的示例代码会基于上述代码结构再增加。
BooleanQuery
BooleanQuery用于将若干个查询按照与或的逻辑关系组织起来,支持嵌套。目前支持4个逻辑关系:
- SHOULD:逻辑或的关系,文档满足任意一个查询即视为匹配。
- MUST:逻辑与的关系,文档必须满足所有查询才视为匹配。
- FILTER:逻辑与的关系,与must的区别是不计算score,所以性能会比must好。如果只关注是否匹配,而不关注匹配程度(即得分),应该优先使用filter。
- MUST NOT:逻辑与的关系,且取反。文档不满足所有查询的条件才视为匹配。
使用方式也比较简单,以下的代码使用BooleanQuery查询contents字段包含love但不包含seek的词:
private static void booleanQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("BooleanQuery, must contain 'love' but absolutely not 'seek': ");
BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(new TermQuery(new Term(SEARCH_FIELD, "love")), BooleanClause.Occur.MUST);
builder.add(new TermQuery(new Term(SEARCH_FIELD, "seek")), BooleanClause.Occur.MUST_NOT);
BooleanQuery booleanQuery = builder.build();
resultPrint(searcher, booleanQuery);
}
Love's Secret和Freedom and Love两首诗中均包含了love一词,但前者还包含了seek一词,所以最终的搜索结果为Freedom and Love。
PhraseQuery
PhraseQuery用于搜索term序列,比如搜索“hello world”这个由两个term组成的一个序列。对于Phrase类的查询需要掌握两个点:
- Phrase查询需要term的position信息,所以如果indexing阶段没有保存position信息,就无法使用phrase类的查询。
- 理解slop的概念:Slop就是两个term或者两个term序列的edit distance。后面的FuzzyQuery也用到了该概念,这里简单介绍一下。
Edit distance
Edit distance用于描述两个字符串(词也是一种特殊的字符串)的相似度,其定义有多种,比较常用的是 Levenshtein distance 和其扩展 Damerau–Levenshtein distance。Lucene使用的就是这两种。Levenshtein distance是这样定义edit distance的:如果最少通过n个 增加(Insertion)/删除(Deletion)/替换(Substitution) 单个符号(symbol)的操作能使两个字符串相等,那这两个字符串的距离就是 n。这里有三个注意点:
- 只允许使用增、删、替换三种操作。
- 一次只能操作1个符号。如果是计算两个词的距离,那一个符号就代表一个字母;如果是计算两个句子的距离,那一个符号就代表一个词。
- 计算的是最少达到目标的操作数。
举几个例子:
- cat与cut的edit distance是1,因为通过将cat的a替换为u这1个字母替换操作就可以让cat和cut相等。
- cate与cut的edit distance是2,因为需要将cate的a替换为u,再将e删除两个操作才可以使得cate和cut相等。
- cat与cta的edit distance是2,因为需要将cat的a替换为t,t替换为a两个操作才可以使得cat和cta相等。
- "a bad boy"和"a good boy"的edit distance是1,因为需要将bad替换为good才能使两个句子相等(注意这里计算的是两个句子的distance,所以一个symbol就是一个词)。
- "good boy"和"boy good"的edit distance是2,因为最少需要两步操作才可以使两个句子相等。
Damerau–Levenshtein distance对Levenshtein distance做了一个扩展:增加了一个transposition操作,定义 相邻 symbol的位置交换为1次操作,即distance为1。 这样的话在Levenshtein distance中,cat和cta的距离为2,但在Damerau–Levenshtein distance中,它们的距离就是1了;同理,"good boy"和"boy good"的距离也就是1了。
这就是所谓的Edit distance,PhraseQuery使用的是Levenshtein distance,且默认的slop值是0,也就是只检索完全匹配的term序列。看下面这个例子:
private static void phraseQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("\nPhraseQuery, search 'love that'");
PhraseQuery.Builder builder = new PhraseQuery.Builder();
builder.add(new Term(SEARCH_FIELD, "love"));
builder.add(new Term(SEARCH_FIELD, "that"));
PhraseQuery phraseQueryWithSlop = builder.build();
resultPrint(searcher, phraseQueryWithSlop);
}
// 运行结果
PhraseQuery, search 'love that'
1 result(s) matched:
doc=2 score=0.7089927 file: data/poems/Love'sSecret.txt
Love‘s Secret里面有这么一句:"Love that never told shall be",是能够匹配"love that"的。我们也可以修改slop的值,使得与搜索序列的edit distance小于等于slop的文档都可以被检索到,同时距离越小的文档评分越高。看下面例子:
private static void phraseQueryWithSlopDemo(IndexSearcher searcher) throws IOException {
System.out.println("PhraseQuery with slop: 'love <slop> never");
PhraseQuery phraseQueryWithSlop = new PhraseQuery(1, SEARCH_FIELD, "love", "never");
resultPrint(searcher, phraseQueryWithSlop);
}
// 运行结果
PhraseQuery with slop: 'love <slop> never
1 result(s) matched:
doc=2 score=0.43595996 file: data/poems/Love'sSecret.txt
MultiPhraseQuery
不论是官方文档或是网上的资料,对于MultiPhraseQuery讲解的都比较少。但其实它的功能很简单,举个例子就明白了:我们提供两个由term组成的数组:["love", "hate"], ["him", "her"],然后把这两个数组传给MultiPhraseQuery,它就会去检索 "love him", "love her", "hate him", "hate her"的组合,每一个组合其实就是一个上面介绍的PhraseQuery。当然MultiPhraseQuery也可以接受更高维的组合。
由上面的例子可以看到PhraseQuery其实是MultiPhraseQuery的一种特殊形式而已,如果给MultiPhraseQuery传递的每个数组里面只有一个term,那就退化成PhraseQuery了。在MultiPhraseQuery中,一个数组内的元素匹配时是 或(OR) 的关系,也就是这些term共享同一个position。 还记得之前的文章中我们说过在同一个position放多个term,可以实现同义词的搜索。的确MultiPhraseQuery实际中主要用于同义词的查询。比如查询一个“我爱土豆”,那可以构造这样两个数组传递给MultiPhraseQuery查询:["喜欢",“爱”], ["土豆","马铃薯","洋芋"],这样查出来的结果就会更全面一些。
最后来个例子:
private static void multiPhraseQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("MultiPhraseQuery:");
// On Death 一诗中有这样一句: I know not what into my ear
// Fog 一诗中有这样一句: It sits looking over harbor and city
// 以下的查询可以匹配 "know harbor, know not, over harbor, over not" 4种情况
MultiPhraseQuery.Builder builder = new MultiPhraseQuery.Builder();
Term[] termArray1 = new Term[2];
termArray1[0] = new Term(SEARCH_FIELD, "know");
termArray1[1] = new Term(SEARCH_FIELD, "over");
Term[] termArray2 = new Term[2];
termArray2[0] = new Term(SEARCH_FIELD, "harbor");
termArray2[1] = new Term(SEARCH_FIELD, "not");
builder.add(termArray1);
builder.add(termArray2);
MultiPhraseQuery multiPhraseQuery = builder.build();
resultPrint(searcher, multiPhraseQuery);
}
// 程序输出
MultiPhraseQuery:
2 result(s) matched:
doc=0 score=2.7032354 file: data/poems/Fog.txt
doc=3 score=2.4798129 file: data/poems/OnDeath.txt
PrefixQuery, WildcardQuery, RegexpQuery
这三个查询提供模糊模糊查询的功能:
- PrefixQuery只支持指定前缀模糊查询,用户指定一个前缀,查询时会匹配所有该前缀开头的term。
- WildcardQuery比PrefixQuery更进一步,支持 *(匹配0个或多个字符)和 ?(匹配一个字符) 两个通配符。从效果上看,PrefixQuery是WildcardQuery的一种特殊情况,但其底层不是基于WildcardQuery,而是另外一种单独的实现。
- RegexpQuery是比WildcardQuery更宽泛的查询,它支持正则表达式。支持的正则语法范围见
org.apache.lucene.util.automaton.RegExp
类。
需要注意,WildcardQuery和RegexpQuery的性能会差一些,因为它们需要遍历很多文档。特别是极力不推荐以模糊匹配开头。当然这里的差是相对其它查询来说的,我粗略测试过,2台16C+32G的ES,比较简短的文档,千万级以下的查询也能毫秒级返回。最后看几个使用的例子:
private static void prefixQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("PrefixQuery, search terms begin with 'co'");
PrefixQuery prefixQuery = new PrefixQuery(new Term(SEARCH_FIELD, "co"));
resultPrint(searcher, prefixQuery);
}
private static void wildcardQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("WildcardQuery, search terms 'har*'");
WildcardQuery wildcardQuery = new WildcardQuery(new Term(SEARCH_FIELD, "har*"));
resultPrint(searcher, wildcardQuery);
}
private static void regexpQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("RegexpQuery, search regexp 'l[ao]*'");
RegexpQuery regexpQuery = new RegexpQuery(new Term(SEARCH_FIELD, "l[ai].*"));
resultPrint(searcher, regexpQuery);
}
// 程序输出
PrefixQuery, search terms begin with 'co'
2 result(s) matched:
doc=0 score=1.0 file: data/poems/Fog.txt
doc=2 score=1.0 file: data/poems/Love'sSecret.txt
WildcardQuery, search terms 'har*'
1 result(s) matched:
doc=0 score=1.0 file: data/poems/Fog.txt
RegexpQuery, search regexp 'l[ao]*'
2 result(s) matched:
doc=0 score=1.0 file: data/poems/Fog.txt
doc=3 score=1.0 file: data/poems/OnDeath.txt
FuzzyQuery
FuzzyQuery和PhraseQuery一样,都是基于上面介绍的edit distance做匹配的,差异是在PhraseQuery中搜索词的是一个term序列,此时edit distance中定义的一个symbol就是一个词;而FuzzyQuery的搜索词就是一个term,所以它对应的edit distance中的symbol就是一个字符了。另外使用时还有几个注意点:
- PhraseQuery采用Levenshtein distance计算edit distance,即相邻symbol交换是2个slop,而FuzzyQuery默认使用Damerau–Levenshtein distance,所以相邻symbol交换是1个slop,但支持用户使用Levenshtein distance。
- FuzzyQuery限制最大允许的edit distance为2(
LevenshteinAutomata.MAXIMUM_SUPPORTED_DISTANCE
值限定),因为对于更大的edit distance会匹配出特别多的词,但FuzzyQuery的定位是解决诸如美式英语和英式英语在拼写上的细微差异。 - FuzzyQuery匹配的时候还有个要求就是搜索的term和待匹配的term的edit distance必须小于它们二者长度的最小值。比如搜索词为"abcd",设定允许的maxEdits(允许的最大edit distance)为2,那么按照edit distance的计算方式"ab"这个词是匹配的,因为它们的距离是2,不大于设定的maxEdits。但是,由于
2 < min( len("abcd"), len("ab") ) = 2
不成立,所以算不匹配。
最后看个例子:
private static void fuzzyQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("FuzzyQuery, search 'remembre'");
// 这里把remember拼成了remembre
FuzzyQuery fuzzyQuery = new FuzzyQuery(new Term(SEARCH_FIELD, "remembre"), 1);
resultPrint(searcher, fuzzyQuery);
}
// 程序输出
FuzzyQuery, search 'remembre'
1 result(s) matched:
doc=1 score=0.4473783 file: data/poems/FreedomAndLove.txt
PointRangeQuery
前面介绍Field的时候,我们介绍过几种常用的数值型Field:IntPoint、LongPoint、FloatPoint、DoublePoint。PointRangeQuery就是给数值型数据提供范围查询的一个Query,功能和原理都很简单,我们直接看一个完整的例子吧:
/**
* Point Query Demo.
*
* @author NiYanchun
**/
public class PointQueryDemo {
public static void main(String[] args) throws Exception {
// 索引保存目录
final String indexPath = "indices/point-index";
Directory indexDir = FSDirectory.open(Paths.get(indexPath));
IndexWriterConfig iwc = new IndexWriterConfig(new StandardAnalyzer());
iwc.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
IndexWriter writer = new IndexWriter(indexDir, iwc);
// 向索引中插入10条document,每个document包含一个field字段,字段值是0~10之间的数字
for (int i = 0; i < 10; i++) {
Document doc = new Document();
Field pointField = new IntPoint("field", i);
doc.add(pointField);
writer.addDocument(doc);
}
writer.close();
// 查询
IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
IndexSearcher searcher = new IndexSearcher(indexReader);
// 查询field字段值在[5, 8]范围内的文档
Query query = IntPoint.newRangeQuery("field", 5, 8);
TopDocs topDocs = searcher.search(query, 10);
if (topDocs.totalHits.value == 0) {
System.out.println("not found!");
return;
}
ScoreDoc[] hits = topDocs.scoreDocs;
System.out.println(topDocs.totalHits.value + " result(s) matched: ");
for (ScoreDoc hit : hits) {
System.out.println("doc=" + hit.doc + " score=" + hit.score);
}
}
}
// 程序输出
4 result(s) matched:
doc=5 score=1.0
doc=6 score=1.0
doc=7 score=1.0
doc=8 score=1.0
完整代码见这里。
TermRangeQuery
TermRangeQuery和PointRangeQuery功能类似,不过它比较的是字符串,而非数值。比较基于org.apache.lucene.util.BytesRef.compareTo(BytesRef other)
方法。直接看例子:
private static void termRangeQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("TermRangeQuery, search term between 'loa' and 'lov'");
// 后面的true和false分别表示 loa <= 待匹配的term < lov
TermRangeQuery termRangeQuery = new TermRangeQuery(SEARCH_FIELD, new BytesRef("loa"), new BytesRef("lov"), true, false);
resultPrint(searcher, termRangeQuery);
}
// 程序输出
TermRangeQuery, search term between 'loa' and 'lov'
1 result(s) matched:
doc=0 score=1.0 file: data/poems/Fog.txt // Fog中的term 'looking' 符合搜索条件
ConstantScoreQuery
ConstantScoreQuery很简单,它的功能是将其它查询包装起来,并将它们查询结果中的评分改为一个常量值(默认为1.0)。上面FuzzyQuery一节里面最后举得例子中返回的查询结果score=0.4473783,现在我们用ConstantScoreQuery包装一下看下效果:
private static void constantScoreQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("ConstantScoreQuery:");
ConstantScoreQuery constantScoreQuery = new ConstantScoreQuery(
new FuzzyQuery(new Term(SEARCH_FIELD, "remembre"), 1));
resultPrint(searcher, constantScoreQuery);
}
// 运行结果
ConstantScoreQuery:
1 result(s) matched:
doc=1 score=1.0 file: data/poems/FreedomAndLove.txt
另外有个知识点需要注意:ConstantScoreQuery嵌套Filter和BooleanQuery嵌套Filter的查询结果不考虑评分的话是一样的,但前面在BooleanQuery中介绍过Filter,其功能与MUST相同,但不计算评分;而ConstantScoreQuery就是用来设置一个评分的。所以两者的查询结果是一样的,但ConstantScoreQuery嵌套Filter返回结果是附带评分的,而BooleanQuery嵌套Filter的返回结果是没有评分的(score字段的值为0)。
MatchAllDocsQuery
这个查询很简单,就是匹配所有文档,用于没有特定查询条件,只想预览部分数据的场景。直接看例子:
private static void matchAllDocsQueryDemo(IndexSearcher searcher) throws IOException {
System.out.println("MatchAllDocsQueryDemo:");
MatchAllDocsQuery matchAllDocsQuery = new MatchAllDocsQuery();
resultPrint(searcher, matchAllDocsQuery);
}
// 程序输出
MatchAllDocsQueryDemo:
4 result(s) matched:
doc=0 score=1.0 file: data/poems/Fog.txt
doc=1 score=1.0 file: data/poems/FreedomAndLove.txt
doc=2 score=1.0 file: data/poems/Love'sSecret.txt
doc=3 score=1.0 file: data/poems/OnDeath.txt
数值类型的termQuery被转换成了pointRangeQuery,性能会受影响吗?要不要把int存成keyword避免这种情况发生?ES7.10
不要,即使性能会受影响,那也几乎是忽略不计的。但如果把int存成keyword,带来的弊端绝对大于那几乎可以忽略的性能收益。