多个MUST的倒排表合并
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 > 6且7 < 8。所以回到6节点并下沉,前进一个节点之后发现7 = 7,成功找到并返回。
Lucene的SkipList仅多花费n/8的存储空间,便将Block的随机查询的性能提到O(logn)的时间复杂度。PostingsEnum的advance(target)是SkipList主要应用场景,它除了应用于TOP_SCORE,还能用在多个结果集间做析取和合取运算上。
BooleanQuery
在真实的运用情景下,并非全是单个查询条件的,它更多的往往是多个条件的复合查询。布尔查询(BooleanQuery)是检索模型中最简单且使用广泛的模型,通过布尔代数的连接词(与或)将复杂的查询集合串联成布尔表达式,最终通过布尔代数计算查询与文档之间的相似度的。
其所有叶子节点都是原子查询,它需要读取Postings信息,但非叶子节点都通过对叶子节点的Postings进行谓词运算获得。
布尔查询由与、或两种连接词串联起来的表达式,在查询场景下考虑的是如何将每个查询条件查询得到的Postings实现布尔表达式的运算呢?换言之,换成数学问题中如何实现DocID集合进行交集、并集运算。对于与运算,是需要如何找出所有集合共同出现的子集——取交集运算;或运算,需要考虑的则是如何去重——取并集运算。
Lucene为Postings的遍历设计了一个叫advance(traget)的方法,含义是前进到Postings中不小于target的最小的文档编码(DocID)。如果不存在满足条件的文档时,返回NO_MORE_DOCS。其隐含含义是Postings迭代器中没更多的文档,遍历结束。
随着搜索引擎索引索的文档越来越多,一次查询中某些Term的Postings的长度可能会很长,尤其是一个Term(常用词)出现在非常普遍的文档中。此时对整个Postings的所有文档都进评分的代价也会随之增高,因此根据集合的布尔运算的特点设计如下两种算法。
以下描述和理解;有冲突:个人理解计算并集是依次遍历每个termsocre, 然后再遍历每个termsocre下的倒排表中的每个doc内容,然后计算部分得分,相同文档好的得分做累加;
布尔运算的与运算,要求所有的查询关键词(查询条件)共同命中候选文档,即候选文档同时出现了所有查询条件的关键词。也就是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优化。
private int doNext(int doc) throws IOException {
for(;;) {
// doc may already be NO_MORE_DOCS here, but we don't check explicitly
// since all scorers should advance to NO_MORE_DOCS, match, then
// return that value.
advanceHead: for(;;) {
for (int i = 1; i < docsAndFreqs.length; i++) {
// invariant: docsAndFreqs[i].doc <= doc at this point.
// docsAndFreqs[i].doc may already be equal to doc if we "broke advanceHead"
// on the previous iteration and the advance on the lead scorer exactly matched.
if (docsAndFreqs[i].doc < doc) {
//如果对比组的doc号小于lead的doc号,则取对比组的下一doc号
docsAndFreqs[i].doc = docsAndFreqs[i].scorer.advance(doc);
if (docsAndFreqs[i].doc > doc) {
//如果对比组的下一doc号大于lead的doc号,表明对比组的倒排表不拥有该doc号,则跳到方法最后一条语句doc = lead.doc = lead.scorer.advance(doc);比对lead中的下一doc号
// DocsEnum beyond the current doc - break and advance lead to the new highest doc.
doc = docsAndFreqs[i].doc;
break advanceHead;
}
}
}
// success - all DocsEnums are on the same doc
// 找到了包含所有关键字的文档号
return doc;
}
// advance head for next iteration
//当前leader中的doc不满足,找leader中不小于doc号的doc,即下一doc
doc = lead.doc = lead.scorer.advance(doc);
}
}