Lucene Search流程之二

一、前言

上一篇文章介绍如何通过TermsDict定位Term对应的Postings的所在位置并读取,属于Query跟索引(IndexReader)交互部分,我们将它定义为搜索流程的后端。开始之前,先回顾搜索主流程,它包含如下几个步骤,其中被标记为删除的部分表示已经介绍的后端部分内容。其它的则今天要探讨的内容,也是将它们归前端的部分。

  1. 解析查询条件生成一棵逻辑语法树
  2. 提取基于Term的原子查询
  3. 通过字典信息定位Term的Postings位置
  4. 读取Posting用于文档匹配
  5. 构建评分器对文档评分
  6. 按语法树的定义执行逻辑运算
  7. 通过Collector收集目标文档集合

二、Query

IndexSearch在接到用户的搜索请求之后,首先解析用户查询条件生成语法树。语法树的所有叶子结点都是为这次搜索请求的原子搜索条件,如TermQuery,即需要Query与IndexReader交互,通过IndexReader获取Term对应的Postings信息。非叶子点,即非原子查询,则无须与IndexReader交互,由它的子节点提供的Postings运算所得。

TermQuery属於单Term查询,所以查询得到Posting List。但是近似查询属于多Term查询,那它将得到Posting Lists。

但如果用户提交的查询,原本就是原子查询的话,IndexSearch实际上就没有执行解析成语法树的过程。

2.1. TermQuery

原子查询是最基础、常用的查询类型。TermQuery具有一般原子查询必须经过的流程,同时它又足够简单,因而选择从TermQuery开始剖析Lucene搜索流程以及处理机制。

TermQuery按下面流程得到Postings信息,并构建成可迭代的链表(PostingsEnum),也称它为迭代器。它依然保持按DocID从小到大有序,并且提供advance(target)操作实现将迭代器前进到不小于target的最小DocID的位置且返回。advance(traget)是非常重要的操作后续还会用到。这是上一篇博客长篇大论介绍的内容。

Lucene将Postings相关信息打包封装在PostingsEnum对象里,因此在全文中PostingsEnum与Postings可以认为同意。

此步骤在《Lucene Search流程之一》已经详细的介绍过了从Query到获得PostingsEnum的过程,同时也会把Term的相关统计信息(如TTF,TDF等)记得在环境中以便后续评分过程中如必要时可以直接使用。

a. Weight

接着IndexSearch利用Query创建一个Weight用于计算原子查询类型中的自身权重,也在复合查询中承载着子查询间的逻辑运算。由于Query在上一个步骤获取的PostingsEnum也会交付给它,使得直接能够访问Postings,因此也有为候选文档计算相似度的条件。

实际上Weight并没有真实的计算Query的权重,Weight只是拥有访问Postings的能力,一方面创建一个与它对应的Scorer评分器,由它负责文档的评分方面功能(这部分内容后续还会介绍到)。另一方面,通过matches接口向外部提供访问Term的Postings信息功能,即指定的Term出现在哪些文档上,在其中每个文档出现次数,以及位置和每个位置附带额外信息。

Weight还提供的访问Terms在候选文档上位置信息和Payload信息的matches接口,在Explain时会用到它。当然也可以用它来实现类似PharseQuery的功能,虽然PharseQuery没有直接它,不过也是采用雷同的方式。

Weight在搜索流程中,主要负责依据Query的查询条件创建对应Scorer,以及提供对评分结果进行解释的explain的方法。

b. Scorer

Scorer是Lucene在搜索流程用于计算Query与文档相似度计算的外围组件,它实际上并不负责文档得分的计算,这部分工作是委托给Similarity去做的。Similarity才是真正的评分器,而Scorer只是负责评分外围的工作。比如它为文档评分提供必须参数,决定文档是否需要评分,哪部分文档进行评分,哪些文档不用评分。也就说它决定了读取Postings信息的种类和模式,是否启用跳表优化等。

Query与文档的相似度代表文档在这次查询的得分,得分越高,相似度越高。博文出现文档得分与相似度属于相同意思。

虽然Scorer不负责文档得分的计算,但它却是能够给出文档最终得分的组件。关于真正得分计算是Similarity插件完成,Lucene实现了两种常用的计算模型,空间向量模型BM25概率模型。这两种模型都依赖于TF-IDF,它是一种统计方法,可以粗略的将二者关系理解为:TF-IDF用于计算权重,两模型通过权重计算相似度。

Lucene6.0之前的版本中,Lucene默认使用空间向量模型实现作为评分器,之后改用BM25概率模型作为默认实现。

TF-IDF用于计算查询词Term文档或者Query的权重,那么文档与Query的相似度如何用这两种权重来表示呢?以空间向量模型为例,Query中的Term系列,可以计算得每个Term的文档权重,得到文档权重系列,它也是文档权重的向量。同样的方式也可以得到Query权重的向量,那么Query与文档的最终相似度便可以表示为两向量的距离

I. Score_Mode

至于如何决定文档是否需要评分,Lucene定义了三种模式分别如下:

  1. TOP_SCORE,最常用的方式,即按文档得分取查询结果集的TopK
  2. COMPLETE,则需要为所有候选文档都进行评分
  3. NOT_COMPLETE,与COMPLETE相反,它表示完全不需要评分

关于评分模式,通常由collector决定的,如在大部分的facet查询的collector便是完全无须评分的。但也有由排序的条件决定,如按字段排序。NOT_COMPLETE表示

在默认情况下,即没有指定Collector和排序条件,此时IndexSearcher采用TOP_SCORE的评分模式,仅给用户返回TopK文档。显然Lucene仅需要对头部分文档进行评分即可,即跳过部分候选文档不用为之计算就可以淘汰的。

Lucene为什么可以直接淘汰跳过部分文档,而不需要为之计算评分,且最终不影响召回的结果集呢?

首先从评分角度看,哪些因素会最终影响了评分呢?下面是对Lucene官方文档给出的TFIDFSimlarity相似度评分公式展开之后得到的式子:

scorer(q,d)=t in q((1+logdoc_count+1doc_freq+1)2×frequency(t,d)×norm(t,d)) scorer(q,d) = \sum_{t\ in\ q}{((1 + \log{\frac{doc\_count+1}{doc\_freq+1}})^2 \times frequency(t,d) \times norm(t,d))}

doc_freq表示搜索关键词Term出现在多少个文档中
doc_count表示整个Segment的总文档个数
frequency(t,d)表示词条t在文档d中出现频次
norm(t,d)表示词条t在文档d归一化因子
其中这两个函数norm(t,d)和frequency(t,d)的值与本文中的上下文均用normfreq表示

结论是,通过freq和norm便能够进行文档得分的比较,而不需要先计算文档得分。

由此可见,其中只有norm(t,d)frequency(t,d)是随文档变化的,其它参数都在segment内确定不变的固定值。

其次是与Postings的存储结构相关,Postings是有序且分块存储的。为了Postings能达到非顺序查找,Lucene为此构建了多层SkipList,且在构建的时候,为每个节点记下当前以及之前所有Block的freqnorm信息。此时,Lucene便可以启用SkipList的优化,直接跳过低的文档。

已知Postings是有序的链表结构,且它是分块(Block)存储的。
Postings并不一次全部加载到内存,那样会那样非常占用heap内存,可能加剧JVM的GC,甚至是OOM,导致qps受到限制。总之会影响搜索引擎的性能。

所以Postings在存储时,也是分块存储的,使得它对很长的Postings也非常友好。分块之后,为了能够快速的访问某个块,实际上也是快速试某个文档是否存在的,Lucene对此需求做了优化。即在写Postings的数据块时,为它们创建索引——多层跳表。

关于倒排表的内容曾在《Lucene倒排索引简述 之倒排表》中介绍过详细倒排表的存储结构,顺带简单介绍了引入SkipList的提前和带来的好处。这里从使用的角度重新回顾SkipList的结构,然后进一步介绍它应用在搜索过程中哪些步骤和情景、以及如何能够提升搜索性能。

II. SkipList

SkipList本质上是在有序的链表上实现实现二分查找,它能有效的提升链表的查找效率,其时间复杂度为O(logn)(其中n为链表长度)。简单说SkipList优化了Postings的随机查找的性能问题。

SkipList的节点存储了三部分数据,分别是当前节点指向Block的信息,是关于Block本身的信息;指向下层的索引;最后是存储freq和norm的信息,它被封装在Impact里面。

Impact结构仅是<freq, norm>的键值对,与文档无关,在SkipList的索引节点中。Impacts表示一系列Impact结构,用有序的TreeSet存储。这里强调的是Impact并没有与具体文档关联,其次按freq和norm作为主键去重。也就是Impacts代表了该索引节点指向数据点以及之前所有数据节点所包含的文档得分的分布。
如果此索引节点中最大的Impact都小于Scorer的水位线,那么此节点的范围内的所有节点都不需要再进入Scorer评分程序,在TOP_SCORE模式下。

.doc文件读取出来的SkipList如下,为了方便制图,把步长缩小为2。那么在第0层,每两个Block创建一个索引节点,第1层在第0层的基本上构建,依此类推。

常规多层跳表结构,每个索引节点两个指针,一个指向同层下一个节点,叫next指针;另一个指向下一层的down指针。在下图中,第1层的节点4指向第0层的节点4的指针即是down指针,而从节点4直接指向节点8的叫next指针

实际上SkipList的性能提升是通过在链表上加上多级索引获得的,所以说它属于空间换时间的做法,在索引时牺牲小量空间换取在搜索时的性能提升。而层级越高,索引的步长越短,构建索引的空间代价也会越高。这也解释了Lucene为什么要采用8个Block作为步长,虽然它的查询性能相比会差一些,但是需要的空间也缩减少n/8,是一种存储空间和性能的折中方案

查找过程:以查找第7个Block为例,与最上层第二层的第1个节点比较,7 < 8;通过down指针下沉到第一层,7 < 4,通过next指针找到下一个索引节点继续比较,7 < 8。所以回溯到节点4,然后下沉到第0层,7 > 67 < 8。所以回到6节点并下沉,前进一个节点之后发现7 = 7,成功找到并返回。

Lucene的SkipList仅多花费n/8的存储空间,便将Block的随机查询的性能提到O(logn)的时间复杂度。PostingsEnum的advance(target)是SkipList主要应用场景,它除了应用于TOP_SCORE,还能用在多个结果集间做析取和合取运算上。

2.2. BooleanQuery

在真实的运用情景下,并非全是单个查询条件的,它更多的往往是多个条件的复合查询。布尔查询(BooleanQuery)是检索模型中最简单且使用广泛的模型,通过布尔代数的连接词(与或)将复杂的查询集合串联成布尔表达式,最终通过布尔代数计算查询与文档之间的相似度的。

其所有叶子节点都是原子查询,它需要读取Postings信息,但非叶子节点都通过对叶子节点的Postings进行谓词运算获得。

布尔查询由与、或两种连接词串联起来的表达式,在查询场景下考虑的是如何将每个查询条件查询得到的Postings实现布尔表达式的运算呢?换言之,换成数学问题中如何实现DocID集合进行交集、并集运算。对于运算,是需要如何找出所有集合共同出现的子集——取交集运算;运算,需要考虑的则是如何去重——取并集运算。

Lucene为Postings的遍历设计了一个叫advance(traget)的方法,含义是前进到Postings中不小于target的最小的文档编码(DocID)。如果不存在满足条件的文档时,返回NO_MORE_DOCS。其隐含含义是Postings迭代器中没更多的文档,遍历结束。

随着搜索引擎索引索的文档越来越多,一次查询中某些Term的Postings的长度可能会很长,尤其是一个Term(常用词)出现在非常普遍的文档中。此时对整个Postings的所有文档都进评分的代价也会随之增高,因此根据集合的布尔运算的特点设计如下两种算法。

a. Conjunction

布尔运算的运算,要求所有的查询关键词(查询条件)共同命中候选文档,即候选文档同时出现了所有查询条件的关键词。也就是Postings中都出现的文档号才是最终结果集。

实际上就是在多个集合间取交集,易知最终结果集必然是任意集合的子集。因此,基于最小的集合开始遍历,可以避免不必须尝试。而Lucene通过二阶验证,可以进一步减小无效尝试。基本思想是,合并后的结果集中每个文档必须是每个Postings都存在。

Lucene实现比较巧妙,首先在Posting Lists中取出最短Postings命名为Lead1,接着取出次短Postings的命名Lead2,除此之外称为Others。然后遍历Lead1的每个文档的过程中,每个文档都在Lead2中做校验。假如在Lead2中不存在,则直接退出,否则到others中校验判断是否存在。简单说通过Lead1可以非常有效的减小尝试次数,通过Lead2则能进一步减小尝试的次数。总体思路就是避免到Others列表校验文档是否存在,流程如下。

在Others的校验的式子如下,一旦max(...)返回NO_MORE_DOCS退出循环,合并完成。

boolean matches = (DocID == max(pe1.advance(DocID), pe2.advance(DocID), pe3.advance(DocID), ...);

通过如上流程中,都是通过PostingsEnum#advance(target)方法寻找离target最近且不小于target的DocID。而advance(target)在有SkipList的情况下,可能会启用SkipList优化。

b. Disjunction

布尔运算的运算,要求将每个查询条件的结果集进行并集运算。每个查询的结果集Postings,在Luecne中都被会表示为PostingsEnum。前面介绍PostingsEnum的最重要操作advance(target),这个方法是取Postings的文档号不小于target的最小文档号。所以用编程语言描述为:

DocID = min(pe1.advance(DocID), pe2.advance(DocID), pe3.advance(DocID), ...);

此过程用图示如下,这里简化的Lucene的运算流程。实际上就是将DocID++之后进行上面式子的运算,直至每个元素都至少会被触达一次,也是DocID恰为NO_MORE_DOCS时表示计算完成。

每一轮都会得到一条存在当前DocID的Postings数组,然后计算查询条件命中率,即拥有当前DocID的Postings占所有原子查询条件的比例。当它小于某个阀值时,该DocID被以为不匹配直接丢弃。而对于满足条件的DocID的Postings链表则会用于计算文档最终评分的计算,它有两种常用的计算策略,有SumScore和MaxScore,即是对每个子询的文档得分汇总和取所有原子查询中的最高得分。

Disjunction在计算文档得分时,针对TOP_SCORE模式采用一种剪枝算法,Weak-AND算法

三、Collector

收集目标文档集可以算是Search流程的最后一个步骤了,它是对候选文档集进行过滤。这里有多种策略,比如通过该查询的候选文档的评分取TopK,或者按文档的某些字段值取TopK等。此还有一些高级用法,如Group、Facet和Stats以统计分主的聚合运算。

这里主要讨论按文档评分取TopK为例,介绍Collector搜索过程的主路径中充当角色负责的任务,顺带介绍取TopK的常见方案。

按确切流程讲,实际上Search工作过程中,首先是Collector会为每个Segment分配一个属于的LeafCollector,针对它的Segment执行收集任务。然后它触发Scorer对文档进行评分,再收集文档的得分和文档编号。在LeafCollector在收集过程会不断更新它的最小得分,有利用于scorer更好过滤无评分的文档。

在Lucene中采用名为ScoreDoc的结构表,它由于DocID,score以及分片号三部分构成。

在收集过程中,由于最终只按文档得分的取TopK文档,所以Lucene并不需要保留过程中所有的文档。因此问题转为化如何取TopK方案,Lucene采用经典的做法,依赖于优先队列,它的插入的取出的复杂度均为O(logK),而需要的内存仅为O(K)

a. PriorityQueue

PriorityQueue的创建时,需要先指定期望获得结果集的长度,然后PriorityQueue创建一个指定长度的数组。PriorityQueue在数组上构建一棵完全二叉树,其中第0个元素留空,第1个元素作为树的根节点。

如上图可能看起来还不够直观,为此把它位置重新调整一下变成如下所示。注意,图中的数字表示数组下标,而非是数据的值。

比较特殊的是PriorityQueue并不保证任意一个父节点的左右两个子节点之间有序,它只保证父节点小于任意子节点。

它的根节点称为队首,也是整棵树中最小的节点。PriorityQueue如下几个操作,

  1. 定义节点比较器
  2. 取队首
  3. 出队
  4. 入队

PriorityQueue的出入队的时间复杂度为O(logK),其中K为队列的长度,而取队首是O(1)的时间复杂度。

入队可以区别为两种方式,第一种是列队仍不满时,将数据放入队列中,然后将它与父节点比较,如果小于父节点,则对换位置之后,继续比较直至不小于父节点,入队操作完成。
第二种是列队已经满时,如果入队的数据小于父节点,入队操作完成。否则,将队首置换为入队的节点,然后它不断与它的子节点比较,直到它不大于子节点。

相比之下,出队则比较简单,即将队尾置换队首,然后执行一下第二种入队操作。

不管是出队还是入队,都是被更新的节点是上浮或下沉两种操作。在下沉的过程,它还需要与两个节点都做一次操作,选择继续下沉的路径。

四、总结

接着上一篇文章介绍Lucene搜索流程中的评分模式和布尔查询中的合取操作的析取操作,顺带着介绍两个结构SkipList和PriorityQueue的原理和应用场景。从索引应用的角度进一步巩固Lucene索引构建流程,知其然也知其所以然。

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