要飞起来了,Lucene 高阶查询技巧
- 2019 年 10 月 4 日
- 筆記
在前面的章节中我们使用了最基础的关键词查询 TermQuery 和 复合查询 BooleanQuery,本节我们来尝试 Lucene 内置的其它高级查询功能。
字符串前缀查询 PrefixQuery
同关系数据库索引一样,得益于 FST 的前缀共享属性,Lucene 也支持前缀查询。它会将共享同样前缀的一系列关键词都找出来,然后再 merge 它们的文档列表。
var query = new PrefixQuery(new Term("title", "动"));
查询结果如下,可以看到 「动物」和「动作」等词汇都搜索出来了
图片
但是默认的 PrefixQuery 不会对搜索的结果进行排序,它对所有被搜索出来的文档统一打分 1.0,在实现上可以让查询效率快很多,直接省去了收集所有文档进行排序的过程。如果你希望结果可以排序,可以给 PrefixQuery 设置一个选项,查询的结果可以观察 hit.score 值的大小。
var query = new PrefixQuery(new Term("content", "动")); query.setRewriteMethod(MultiTermQuery.SCORING_BOOLEAN_REWRITE); var hits = searcher.search(query, 10).scoreDocs; for (var hit : hits) { var doc = searcher.doc(hit.doc); System.out.printf("%s:%.2f => %sn", doc.get("url"), hit.score, doc.get("title")); }
图片
PrefixQuery 为什么要默认关闭排序呢?这是因为前缀查询能匹配到的关键词可能会很多,merge 所有的文档列表并排序将会是一个非常耗费性能的过程。
数字范围查询 NumericRangeQuery
数字查询和字符串查询不太一样,在内部实现结构上它并不是像字符串那样使用 FST 来组织关键词。如果每一个数字都是关键词,那么使用数字范围定位出的关键词会非常多,需要 merge 的文档列表也会非常多,这样的查询效率就会很差。如果是浮点型数字,那这个问题就更加突出了,可能每个浮点数字只会关联一个文档,而浮点数关键词将会太多太多。
Lucene 将数字进行特殊处理,内部使用 BKD-Tree 来组织数字键。BKD 树也是一个较为复杂的数据结构,简单理解就是将数值比较接近的一些数字联合起来作为一个叶节点共享一个 PostingList,同时在 PostingList 的元素需要存储数字键的值,在查询时会额外多出一个过滤步骤。粗略来看,BKD 树会将一个大的数值范围进行二分,然后再继续二分,一直分到几层之后发现关联的文档数量小于设定的阈值,就不再继续拆分了。BKD 树还可以索引多维数值,这样它就可以应用于地理位置查询。
下面我们给前面的文章索引增加一个字段,文章的 ID,我们需要将目录里的所有文件全部删除,然后运行 Indexer.java 重新构建索引。
var doc = new Document(); doc.add(new TextField("title", title, Field.Store.YES)); doc.add(new TextField("content", content, Field.Store.YES)); doc.add(new IntPoint("article_id", Integer.parseInt(id))); doc.add(new StoredField("url", url)); indexWriter.addDocument(doc);
IntPoint 类型只索引,不存储,对应的选项默认是 Field.Store.NO,Lucene 只会将它的值放入 PostingList 的元素中。如果需要存储还需要单独增加一个 StoreField 类型,在本例中我们不需要存储。之所以叫 Point 类型是因为它可以支持多维数值,IntPoint 的构造器是一个可变参数,可以填入多个整数形成的数值对。下面我们尝试查询
var query = IntPoint.newRangeQuery("article_id", 400000, 450000); var hits = searcher.search(query, 10).scoreDocs;
查询结果如下
图片
对数值型的查询结果进行排序是没有意义的,返回的文档 score 值都是默认的 1.0。
关于 BKD 树的更多细节,我们后面再继续讨论。
字符串范围查询 TermRangeQuery
同数字范围查询类似,字符串也有范围查询,它是通过遍历关键词前缀树 FST 来实现的,它会按照字典序将范围内的所有词汇都列出来,然后 merge 所有关键词的文档列表。如果这个范围覆盖的关键词很多,可以预见性能是很差的。不过,字典序对于中文来说是没有意义的,所以通常我们不会去使用它。
var query = TermRangeQuery.newStringRange("level", "level1", "level9", true, true);
上面的查询表示等级范围在 [level1, level9] 之间的所有数据,参数中的两个 bool 值表示是否包含边界值。如果是两个 false,那么等级范围就是 (level1, level9)。
短语查询 PhraseQuery
「北京大学」是一个词汇,我们可以使用 TermQuery 关键词查询来快速定位所有包含「北京大学」的内容。比如下面这个查询就可以查出很多文章,因为「北京大学」上镜率很高。
var query = new TermQuery(new Term("content", "北京大学"));
那如何定位「北京xx大学」这样的内容呢?它可以是「北京科技大学」、「北京交通大学」、「北京化工大学」等词,但是不可以匹配「我是北京人,我没上过大学」这样的语句。这时候就可以用到短语查询 PhraseQuery。
var query = new PhraseQuery.Builder() .add(new Term("content", "北京")) .add(new Term("content", "大学")) .setSlop(2) .build();
这里有一个关键参数 Slop 表示两个词汇之间最多允许间隔的单词数量,注意这里是「单词」数量,而不是「字符」数量。如此它就可以匹配「北京工业大学」,同时还可以匹配「北京大学」。下面我们来看看它的查询结果
图片
奇怪的是它只搜寻到了三篇文章,点开链接进去后发现它只匹配到了「北京林业大学」、「北京体育大学」,就是没有匹配到「北京大学」,难道 PhraseQuery 的 Slop 字段含义另有解释? 我也被这个问题愣了一段时间,结果发现是因为在构建索引的时候使用的中文切词分析器的问题。我们用的是 HanLPAnalyser,它会将「北京大学」切成单个词汇,而不是切成三个词汇「北京」、「大学」和「北京大学」,这样就会导致相关文章只会收录到倒排索引中的「北京大学」这个词,而不会索引到「北京」和「大学」这两个词上面。那该如何解决这个问题呢?这需要我们在构建索引时对分词器进行适当的修改。
var analyser = new Analyzer() { protected TokenStreamComponents createComponents(String fieldName) { var tokenizer = new HanLPTokenizer(HanLP.newSegment().enableOffset(true).enableIndexMode(true), null, false); return new TokenStreamComponents(tokenizer); } };
将 HanLP 的 indexMode 打开,如此就可以将 「北京大学」分词成三个词汇「北京」、「大学」和「北京大学」。重新建立索引后,再次尝试查询,就可以看到期望的搜寻结果。
图片
从结果中我们可以注意到文章是携带排序分值信息的,「北京」和「大学」词汇越接近,出现的越频繁,文章的评分就越高。同时我们还要注意到它是携带顺序的,它不能匹配「大学xx北京」这样的内容。
正则查询 WildcardQuery
查询「北京xx大学」的方式除了上面的短语查询之外,Lucene 还提供了正则查询。比如上面的查询可以改写成
var query = new WildcardQuery(new Term("content", "北京*大学"));
它可以匹配「北京大学」和「北京林业大学」,* 号表示 0 到多个字符。但是如果你仔细查看查询结果的内容你会发现,所有的内容都是包含「北京大学」这样的词汇,「北京xx大学」这样的文章却没有找出来。这是为什么呢?因为正则匹配的对象是倒排索引的关键词。使用上面的分词器是不会得到「北京xx大学」这样的词汇的,分词的结果是「北京」、「xx」和「大学」这三个词汇。从这点来说它和 PhraseQuery 差异还是很大的。除了 * 号之外还有 ? 号表示单个字符,它不能使用任意的复杂正则表达式。注意如果 * 号位于词汇开头,查询将会尝试扫描所有关键词来寻找出匹配的候选词,这对性能将是一个很大的伤害。所以我们要尽可能避免使用首字母 * 号的正则查询,词汇的前缀越长查询性能越好。
模糊查询 FuzzyQuery
最后我们来看一个更加高级的查询模式 —— 模糊查询。它跟上面的查询方式都不太一样,它是基于「编辑距离」的查询。当我们目标查询是「北京大学」时它可以匹配「北方大学」,还可以匹配「北京中学」,它的性能不怎么样,因为和指定词汇相似的词汇会有很多选择,如此就会匹配非常多的词汇,需要 merge 非常多的文档列表,然后还需要根据编辑距离和词汇的频率进行评分排序。除了 merge 文档列表和排序的代价之外,寻找到相似的词汇也需要一定的代价。它需要搜寻整个关键词的前缀树(FST),然后计算它们之间的编辑距离,再挑选出「最大编辑距离」范围内的词汇。
var query = new FuzzyQuery(new Term("content", "北京大学"));
为了加速关键词匹配的效率,减少相似的关键候选词,FuzzyQuery 给查询做了若干限制条件。它可以设置关键词的前缀数量 prefixLength,比如如果前缀数量为2,那么指定「北京大学」,就不会匹配「北方大学」,但是可以匹配「北京中学」。它可以设置编辑距离的最大值 maxEdits,跟关键词差距太大的词汇就不用考虑了。它还可以设置相似词汇候选集的最大数量 maxExpansions,避免需要 merge 太多的文档列表。这三个选项的默认值分别是
var defaultMaxEdits = 2 var defaultPrefixLength = 0 var defaultMaxExpansions = 50
因为默认的前缀长度是 0,所以它需要扫描整颗关键词树,这个性能很差。使用时务必注意,保险起见,还是别轻易使用 FuzzyQuery。
全表遍历 MatchAllDocsQuery
同关系数据库一样,Lucene 也提供了全表遍历查询 MatchAllDocsQuery,它是不走倒排索引的,因为基数太大,所以默认不评分不排序。如果数据量很大,进行全量评分排序估计会把服务器卡死,最好也不要使用它。
var query = new MatchAllDocsQuery();
下一节我们来讲 Lucene 最高级的查询模式 —— 表达式查询 QueryParser,它可以将一个文本表达式字符串解析成上面各种 Query 的逻辑组合 BooleanQuery,有了 QueryParser 我们就可以非常方便地构造出一个十分复杂的组合逻辑查询。