Lucene Search流程之一

一、 搜索印象

Lucene的搜索流程大概的步骤是,先将用户搜索条件改写一系列的原子查询条件,将查询条件向量化。进而通过字典(TermsDict)定位Term的posting信息所在的位置,读取posting的信息是对文档的评分的依据,最后collector收集所期望的目标结果集,最终召回文档。

如果我们把查询流程切分,前端是Query处理用户搜索条件逻辑的关系,后端是主要通过TermsDict找到对应Postings信息返回给前端,前端再依据Query的查询逻辑处理Postings得到最终希望得到查询结果集。

也就说用户编写的查询条件是属于前端的部分,需要转化成后端IndexReader能懂读懂的查询语言。为了方便用户编写复杂的查询,Lucene查询前端提供丰富的Query类型,以帮助用户实现多个查询条件组合实现搜索需求。如何将查询条件的组织关系影响结果集,即如何评分和如何合并Postings呢(每个查询都会返回一个Postings,如果Term在Segment没有文档,则为空)。

二、Query

后端主要涉及如何在TermsDict找到目标Term对应的Postings信息,而Postings含有那么多种信息,这些信息并非所有的查询都需要所有信息的。因此可以在这两个维度上对Query分类,然后继续剖析搜索流程中如何读取和利用这些为搜索加速。

搜索流程后端的第一步是,如何在字典找到目标Postings位置信息,第二步才是继续利用这些信息对文档进行筛选过滤和评分。

A. 获取Postings位置信息

开始之前先回顾一下TermsDict在存储结构,分两部分,TermsDict的索引部分FST和索引信息block列表。它们组成了Burst-Trie结构,FST将以TermsDict的block的共同前缀构建的,可以将其理解为Trie。每个block将记录每个Term以及其索引信息的位置。

TermsDict在构建Burst-Trie结构时,将所有Terms排序之后,在从小到大遍历的过程中,一步一步将共同前缀合并。合并的意思是将共同前缀的每个字符变成一个节点,当一节点超过25个子节点时,TermsDict将这个节点的所有子节点变换成一个Block写入Block列表中。
每当一个节点的子节点数超过25个时,就会改写成Block并这些节点从Tree中删除。
且当一个节点的子节点数据超过48个时,会拆分成多个Block,从而保证每个Block的Terms数目在25-48个范围内。

最终就是会如上图所示,一个RoodCode(根节点)和一系列Block。这里以2个节点为一个Block,虚线框表示非叶子节点。

在内存中,TermsDict最终由SegmentTermsEnum或者IntersectTermsEnum表示,它们分别代表两种查询TermsDict的方式,精确查询和近似查询。此外还有区间查询,但它情况比较特殊,需要区间是否是points类型,若是则采用结构特性直接进行区间查询;否则与近似查询的流程基本雷同。

SegmentTermsEnum/IntersectTermsEnum都是IndexReader读取TermsDict的介质,Frame自然就对应TermsDict中的Block部分。Frame加载过程分两个步骤,第一步是初始化过程中读取元数据,第二步是才会读取索引数据。在真正需要用到数据的时候,Frame才会去发生第二个步骤去读取索引信息。

已然知道,Search过程得先确认Terms在Segment的词典(TermsDict)中存在,确认存在之后才能继续下一步的操作。就Query如何确认搜索目标词是否存在以及如何定位它的Postings相关信息的位置,将Query分为三大类,分别是:精确查询、近似查询和区间查询。当然,还有不需要访问TermsDict的MatchAllQuery等特殊的查询这里暂时先不展开讨论。

2. SegmentTermsEnum - 精确查询

SegmentTermsEnum查询时,支持利用TermsDict的索引FST加速TermsDict的查询。多次提到FST是由每个Block的共同前缀构建的,因此通过FST只能定位Term可能存在的Block。当某个节点的子节点数量比较多时,还可能出现拆分多个Block存储的情况,此时SegmentTermsEnum需要继续读取多个Block才能确定Terms是否真正存储,以及存在的时候Postings所在的位置信息。

SegmentTermsEnum只能为精确查询提供服务,其实查询的代价很低、性能也很好。

3. IntersectTermsEnum - 近似查询

IntersectTermsEnum除了支持通过FST定位Term的Postings所在位置之外,它还支持有限决定状态机的查询获取所有满足表达式的Terms以及其Postings信息的位置。这也就说,IntersectTermsEnum支持搜索条件为正则表达式(区间查询也是将查询条件改写成正则表达式),通过正则表达式筛选TermsDict,最终返回所以符合正则表达式的Terms集合,Query依据Query潜在逻辑关系将多个Terms的查询改成复合条件查询,如BooleanQuery等。

IntersectTermsEnum是支持在近似查询时,支持IntersectTermsEnum从在哪里和在哪里的终止,从而减少SegmentTermsEnum遍历TermsDict的Block列表的区间提升搜索的性能。也说是IntersectTermsEnum也是支持FST快速Block的定位的,通过开始条件找到IntersectTermsEnum满足条件的的Block之后开始遍历。

当前版本Lucene7.6,在做前缀查询和区间查询时,并没有启用startTerm跳过不必要的Block,减少遍历Block列表的范围。

  1. 近似查询
    近似查询的查询关键词可能是通配符查询、前缀查询、甚至是正则表达式,总之查询涉及的Term不是唯一确定的。

  2. 区间查询
    区间查询的Terms也是不确定的,它只指定一个范围,需要先通过TermsDict找到所有在Query指定区间内的所有Terms,再转成BooleanQuery进行查询。

近似查询和区间查询首先在TermsDict中找到所有符合条件的Terms,最终会先得到一个Terms的集合,根据它的潜伏关系改成一系列精确查询的组合,如BooleanQuery/DisjunctionMaxQuery等,回到精确查询的流程。

近似查询的类型很多,实际上是用正规表达式来表示的,Lucene的底层也是如此实现的。通过有限决定自动机,它就是正规表达式的实现。在验证Term是否存在和查找Term的索引信息的位置时,排除points查询之外,区间查询与精确查询是差不多的,但近似查询需要构建DFA,然后查找满足近似查询表达式具体的Term退化成精确查询。

B. 需要用posting哪些信息

先重新认识Lucene的Postings的逻辑结构,由于Lucene真正的Postings存储结构理解起来确实比较晦涩,这里换种简单的、更直观的方式来理解它,有利于理解搜索流程背后的逻辑。

图中对述Lucene的索引结构描并不准确,但是它完全展现了整索引表包含的信息,以及各种信息之间的关系。 只是Lucene存储时,出于读写性能的考虑改变了存储结构。它说明了Postings包含的信息有DocIDTermFreqPositions,表示一个Term在文档DocId出现了TermFreq次,分别出现在Postition,这些位置以及在位置的附加信息。位置信息是指第几个词(Posititon),从第几个字段符到第几个字符(StartOffsetEndOffset),和附加信息Payload每个Position含有三部分信息,它们是一个三元组结构。

关于Lucene的倒排表存储结构内容,在《Lucene倒排索引简述 之倒排表》中有详细的介绍。

我们知道posting在Lucene分成三个文档存储,拆分是为了让不需要用这些信息的查询,不必浪费资源读取不用的信息。这里查询涉及索引信息的种类将Query重新划分,需要读取Postings的多少种信息才能支撑这次查询,从Search流程中Query需要读取Postings的信息情况又可以将Query分成三类,TermQueryPharseQueryPayloadQuery

关于读取postings,不管读多少种索引信息,实际影响可能并不明显的,当然读取的流程也并不复杂。因为在block中记录所有需要记录元数据了,同时Term被查找出来之后,其postings的所有元数据信息也已经被完全解析存在内存中。所以读取一种或者两种,三种实则性能影响并不大。

1. Basic : doc

首先,读取DocIDSet是所有查询都不可避免过程,包含Facet/Stats统计查询,但不包括MatchAllQuery。而涉及TF-IDF评分的查询,读取索引文件中的TermFreq信息也必不可少。Lucene也正是将TermFreqDocID存储在同一个索引文件(.doc)中,它也是Postings不可或缺的部分。

TermQuery是Lucene最基础的查询类型,查询过程中仅需要.doc文件的信息。在TermsDict找到Postings的位置之后,将元数据信息装载到BlockDocsEnum交给Scorer遍历所有命中的文档进行评分。Scorer是Query根据自己类型创建的,Query除了创建Score之外,还有用于计算Query权重的Weight。Scorer和Weight的计算公式由Simarity提供。最后由Collector收集所有命中的文档以及最终相似度的评分,收集过程可以加入额外的逻辑取出需要的部分结果集。

2. Postitions : doc+pos

短语查询和坡度查询,它要求关键词有连续出现,或者编辑距离小于指定长度才算命中,因此PhraseQuery/SpanQuery必须用到Position信息才能确定关键词在文档的位置是否满足查询条件的要求。它依赖Position过滤查询预设的位置要求的文档,然后将结果再由Query交给scorer进行对文档的评分。

在TokenStream中Position表示相对位置,相对于前一个Token而言。它是如下定义Position的:

  • 0表示是两词为同源词,或者采用了同义词表;
  • 1表示连续,有停顿词;
  • >1表示中间有停顿词,其数值表示停顿词的个数。

但在索引(.pos文件)中,它存储是绝对位置,即需要累加前所有Token的Position,相同数值表示同源词,或者同义词;两个Position差值为1,表示连续;在有停顿词的情况下,也会记录停顿词的个数。

Offset信息也是由TokenStream产出的,Offset分为StartOffsetEndOffset两种情况,分别代表Term的第一个字符的位置,以及最后一个字符的位置,它们表示Term在文档中绝对位置。也就是说StartOffsetEndOffset两个位置可以共同决定唯一个Term,与Postition不同,相同的Position可以有多个Term。

例如,索引时采用IK分词器的索引时分词策略时,IK可能会切出多个同源词,比如将“中国人民共和国”分成“中国”、“中国人”等多个同源词。此时,如果使用Postition来计算两个词之间的相对位置会很方便,比起使用StartOffsetEndOffset

从Position和Offset的区别上可知,正常来说并不需要PhraseQuery和SpanQuery两种类型的查询并不涉及Offset。另外.pos主要是存储Postition和一小部分Offset信息和Payload信息,Offset和Payload主要是存储于.pay文件中。

3. Anything : doc+pos+pay

PayloadScorerQuery允许用户在查询时,可以利用索引写入的额外信息用于影响查询评分,它是索引时的Boost升级版本,它支持更丰富信息和手段来干预文档的最终评分。关于PayloadQuery查询更详细的介绍、使用场景和用法,请参考《Solr Payloads》

该查询类型并不在Lucene核心发行包core中,而是在额外的发行包Queries包中。

三、总结

这里主要介绍了搜索后端流程主要两大步骤,如何在字典找到目标Postings所在位置信息,以及为需要读取哪些信息为查询提供筛选和评分的依据。以及每个步骤都以不同的角度将查询类型进行分类,且对它们做了简单的介绍。

下一篇将继续介绍Query前端部分内容,了解Query在拿到Posting信息之后,后续如何加工成最终的用户期望的结果集的呢?下回分析。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章