Lucene倒排索引简述 之倒排表

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zteny/article/details/82980725

一、前言

上一篇《Lucene倒排索引简述 之索引表》,已经对整个倒索引的结构进行大体介绍,并且详细介绍了索引表(TermsDictionary)的内容。同时还详细介绍了Lucene关于索引表的实现,相关文件结构详解,以及对索引表采用的数据结构进行剖析解读。

本篇博客将继续剖析Lucene关于倒排索引实现有另一个核心内容,倒排表(Postings)。我一直觉得Postings内容相对而言是比较简单,虽然内容很多,但是Lucene的官方文档讲得也非常详细了。如果对Lucene文档上的描述文件结构的表达方式不太熟悉的话,个人觉得可以参考前面的图自行画出文件结构示意图,或者直接在网络搜索相关的图,只要能整出索引文件的结构示意图,那么理解起来就应该不会太困难。

二、Postings编码

开始之前先介解Lucene在Postings采用了两个关键的编码格式,PackedBlock和VIntBlock。PackedBlock是在Lucene4.0引入,带来向量化优化。

1. VIntBlock

VIntBlock是能够存储复合数据类型的数据结构,主要通过变长整型(Variable Integer)编码达到压缩的目的。此外VIntBlock还能够存储byte[],比如.pay用VIntBlock存储了payloads数据等。

值得一提的是,VIntBlock可以存储变长数据结构,如.doc用它存储DocID和TermFreq时,由于在特定条件下(TermFreq=1),Lucene会省略TermFreq以提高空间占用率。我知道Lucene用一个VInt来表示DocID,VInt则用每个Byte左边第一个Bit来表示是否需要读取顺续到下个Byte。也就是说一个VInt有效位是28bit,这就说明VInt头部是有特殊含义的,因此Lucene只能在VInt最右边的一个bit下功夫。让VInt的右边第一Bit来表示是否有下个数据。

具体用法会在介绍.doc文件格式时介绍。

2. PackedBlock

PackedBlock只能存储单一结构,整数数组(Integer/Long)。这里主要是介绍PackedInts,即是将一个int[]打包成一个Block。PackedBlock只能能够存储固定长度的数组(Lucene规定其长度为128个元素),它压缩方式是将每个元素截断为预算的长度(length,单位是bit)压缩的。所以当长度length不是8的倍数时,会出现一个byte被多个元素占用。

PackedBlock需要把整个int[]的所有条目指定长度编码,所以PackedBlock只能选择int[]最大的数还来计算长度,否则会让大数失真。反过来,PackedBlock都选择64位,则会浪费空间,不能达到压缩的目的。

Lucene预先编译了64个PackedFormat编码器和解码器,即针对Long以内的每种长度都数据都有自己的解码和编码器,以提高编解码的性能。

PackedPosDeltaBlock与PackedDocDeltaBlock和PackedFreqBlock一样采用PackedInts结构,它能存储的信息实际上是很有限的,只能存储Int的数组。所以在PackedPosDeltaBlock的时候,只能存储position信息,在VIntBlock则会存储更多必要的信息,减少搜索时的IO操作。

这也是为什么需要将DocId和TermFreq拆分成PackedDocDeltaBlock和PackedFreqBlock两个Block存储的原因了。

定长是指PackedBlock限定了一个Block仅允许存储长度128的整型数组,而不是限定Block用多少个Bytes来存储编码后的结果。另外Block存储占用的大小,是按数组中最大那个数的有效bits长度来计算整个Block需要占用多大的Bytes数组的。也就是Block的每个数据的长度都是一样,都按最长bits的来算。

比如:(我们定义一个函数,bit(num)用来计算num占用多少个bits)

  1. 数组中最大的是1,那么PackedBlock的长度仅是16Bytes。bit(1) * 128 / 8 = 16
  2. 数组中最大的是128,PackedBlock长度则是144个Bytes。bit(128) * 128 / 8 = 144
  3. 数组中最大的是520,PackedBlock则需要160Bytes。bit(520) * 128 / 8 = 160

小结,PackedBlock相当于是实现了向量化优化,Lucene通常会将整个PackedBlock加载到内在,既可以减少IO操作数,又能提高解码的性能。相对而言VIntBlock则能够更丰富数据类型,比较适合存储少量数据。

三、Postings文件结构说明

进入正题,我们知道整个Postings被拆成三个文件分别存储,实际上它们之间相对也是比较独立的。基本所有的查询都会用.doc,且一般的Query也仅需要用到.doc文件就足够了;近似查询则需要用.pox;.pay则是用于Payloads搜索(关于这个之前写一篇博客《Solr 迟到的Payloads》,介绍了Payloads用法和场景)。

1. Frequencies And Skip Data(.doc文件)

在Lucene倒排索引中,只有.doc是Postings必要文件,即是它是不能被省略。除此之外的两个文件都是通过配置,然后将其省略的。那么.doc到底是存储哪些不可告人的秘密呢?直接上图,开始剖析吧!

这里画得不够清晰,每个Term都有成对的TermFreqs和SkipData的。换言之,SkipData是为TermFreqs构建的跳表结构,所以它们是成对出现的。

1.1. TermFreqs – Frequencies

TermFreqs存储了Postings最核心的内容,DocID和TermFreqs。分别表示文档号和对应的词频,它们是一一对应的,Term出现在文档上,就会有Term在文档中出现次数(TermFreqs)。

Lucene早期的版本还没有PackedBlock结构,所以DocID与TermFreq是以一个二元组的方式存储的。这个结构非常好,是因为它好理解,之所以好理解是因为它贴近我们的心中的预想。但实际上这个结构并不太准确,只不过我们先简单这么理解也无伤大雅。既然是想深入剖析,还是有必要还原真相的。

TermFreqs采用的是混合存储,由Packed Blocks和VInt Blocks两种结构组成。由于PackedBlock是定长的,当前Lucene默认是128个Integers。所以在不满128的时候,Lucene则采用VIntBlocks结构还存储。需要注意的是当用Packed Blocks结构时,DocID和TermFreq是分开存储的,各自将128个数据写入到一个Block。

当用VIntBlocks结构时,还是沿用旧版本的存储方式,即上面描述的二元组的方式存储。所以说,将DocID和TermFreq当成一条数据的说法是不完全正确的。

在Lucene4.0之前的版本,还没有引入PackedBlock时,DocID和TermFreq确定完全是成对出现,当时只有VIntBlock一种结构。

Lucene尽可能优先采用PackedBlocks,剩余部分(不足128部分)则用VIntBlocks存储。引入PackedBlock之后,PackedDocDeltaBlock跟PackedFreqBlock是成对的,所以它的写出来的示意图应该是如下:

每个PackedBlock由一个PackedDocDeltaBlock和一个PackedFreqBlock构成,它们都采用PackedInts格式。

例如,在同一个Segment里,某一个Term A在259个文档同一个字段出现,那么Term A就需要把这259个文档的文档编号和Term A在每个文档出现的频率一同记下来存储在.doc。此时,Lucene需要用到2个PackedBlocks和3个VIntBlocks来存储它们。

VIntBlock结构相对而言就高级很多了,它能够以一种巧妙的方式存储复杂的多元组结构。在.doc,用VIntBlock存储DocID和TermFreqs,是二元组。后面将介绍的Positions则用VIntBlock存储了Postition、Payload和Offset多元组,
byte[]和VInt多种数据类型。

这里每一个PackedBlock结构都包含了一个PackedDocDeltaBlock和一个PackedFreqBlock,如果没有省略Frequencies(TermFreq)的话;如果用户配置了不存储词频(TermFreq)的话,此时一个PackedBlock仅含有一个PackedDocDeltaBlock。PackedFreqBlock(TermFreq)的存储方式跟PackedDocDeltaBlock(DocID)完全一致,包括后面要讲的pos/pay也都一样的。也都是使用Packed Block这种编码方式。

在VIntBlock上如何存储DocDelta和TermFreq的呢,当设置为不存储TermFreq时,Lucene将所有DocDelta以Variable Integer的编码方式直接写文件上。

但当DocDelta和TermFreq两者都存储时,官方文档给出一个比较完整且复杂的计算说明。反正是我觉得有点复杂,所以没有用直接官方的上说明,我们来点简单的。

首先需要换算的原因是,Lucene做一个小优化,即是当TermFreq=1时,TermFreq将不被存储。那么原本DocDelta(DocID的增量)后面紧跟一个Frequencies的情况变得不再确定,我压根就不知道我读的DocDelta后面有没有TermFreq的信息。

那么问题就变成怎么标记存储还是没有存储TermFreq,Lucene先把数值向左移动一位,然后用最右的一个Bit的标记是否存储TermFreq。最后右边的一个bit1表示没有存储,0作为有存储TermFreq。实际上这已经是Lucene的惯用手段了。

左移一位,实际上等同于X2,当最后一个bit是0,此时是一定是偶数,表示后面还存 储了TermFreq;
左移一位再+1,相当于偶数+1,那就是奇数,此时最后一个bit是1,表示TermFreq=1,所以后面没有存储TermFreq。

这基本上就是官方文档上的大体意思了。

DocFreq=1时,Lucene做一个叫Singletion(仅出现在一个文档)的优化,当时就没有TermFreq和SkipData。因为TermFreq就等同于TotalTermFreq(上篇文章介绍过,存储在.tim的FieldMetadata上)。

1.2. Multi-level SkipList – SkipData

SkipData是.doc文件核心部件之一,Lucene采用的是多层次跳表结构,首先我们先预热一下了解SkipList的逻辑结构图,最后剖析Lucene存储SkipList的物理结构图。

跳表的原理非常简单,跳表其实就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。 ——— 来自百度百科

将搜索时耗时转嫁给索引时,空间换时间是索引的基本思想。为此Lucene为Postings构建SkipList
,并把按层级将它系列化存储。第一个SkipLevel是最高,拥有最少的索引数。

易知Lucene是在索引时构建了SkipList,在Segment中 每个Term都有自己唯一的Postings,每个Postings都有需要构建一个SkipList。这三者是一一对应的。所以画出来结构图如下:

除了第0层之外所有SkipLevel的每个跳表数据块(SkipDatum)会存储了指向下一个SkipLevel的指针。图中SkipChildLevelFPg带?的原因是在Level 0时,SkipDatum没有下一级可以记录。如果Postings有存储positions、payloads和offsets的话,在跳表数据块中也会记录它们的Block所有文件指针。

也就是说,通过SkipList可以找到DocID和TermFreq之外,还能找到Positions、Payloads和Offsets这三部分信息。所以在搜索时,通过SkipList的可以快速定位Postings的所有相关信息。

关于Lucene如何构建SkipList的诸多细节,Lucene规定SkipList的层级不超过10层。

  1. 第0层,SkipList为每个Block增加索引,所以VIntBlock不在SkipList上。
  2. 第9层,SkipList的第一个节点是在第89 (227)Block。(这个数确实有点大)
  3. 第n层,SkipList的第m个节点的位置是第 8 n m 8^n * m 个Block。

跳表的第一层是最密的,越高层越稀疏。按层级从低到高依次系列化为写入.doc的SkipData部分。换言之,SkipDatum的个数越来越多,SkipLevelLength会越来越大。

SkipLevelLength说明当前层次Skip系列化之后的长度,SkipLevel是包含该层的所有节点的数据SkipDatum。SkipDatum包含四部分信息,doc_id和term_freq、positions、payloads、以及下一层开始的位置(是第N层指向第N-1层的前一个索引)。

SkipList主要是搜索时的优化,主要是减少集合间取交集时需要比较的次数,比如在Query被分词器分成多个关键词时,搜索结果需要同时满足这些关键词的。即是需要将每个Term对应的DocId集合进行析取操作,通过跳表能够有效有减少比较的次数。

2. Postitions(.pos文件)

.pos文件存储所有Terms出现文档中的位置信息。为更好的搜索性能,Lucene还在VIntBlock上存储了部分payloads和offsets的信息。实际上因为只有VIntBlock才有能力来存储复杂的数据结构,而PackedBlock是不具备这样的能力的。具体请参考下面的示意图:

Lucene把同一个Term的所有position信息存储在同一个TermPositions上,并没有逻辑或者物理上的划分的。将在一个文档里出现的所有位置信息,按出现的先后顺序依次写入。
关键在于,position与TermFreq并不是在一维度上,TermFreq的数值就是position的个数。也就是通过.pos文件,无法知道每个position的具体含义的,PostingsReader通过.doc文件的DocID和TermFreq信息才能算出Postition的是在哪个文档上的那个位置的。

3. Payloads and Offsets(.pay文件)

Payloads,可以理解为Term的附加信息,它实际上是跟Term成对出现的,类似于Map。在用法上也是如此,Payloads的信息需要用byte数组存储,所以在TermPayloads并不能用PackedBlock结构来存储。但是TermOffsets是由2个int来表示Offet的开始位置和长度的,即是能将它们拆成两个等size的int[],故可以用PackedBlock存储。故有如下图:

四、总结

开篇先学习了Lucene用于存储Postings的两种结构,或者说编码方式,PackedBlock和VIntBlock。PackedBlock是Lucene4.0引用的,它就是int[],给Postings向量化优化。除之外,还有一原著民VIntBlock,也是一种很巧妙且优雅的结构,能存储复杂的类型。

而后,在介绍.doc文件格式的同时,又对上面的两大结构反复剖析。个人认为了解这两个结构之后,整个postings的理解应该不成问题。并且剖析了.doc文件上采用的SkipList数据结构,主要是搜索时集合间AND操作上的一个优化。所以在postings其它两个文件格式,仅用非常短的篇幅介绍。

Lucene倒排索引部分内容到这里全部结束,其它很多优雅的设计和巧妙的结构,其中蕴含的Lucene之美,值得我们反复研读。

猜你喜欢

转载自blog.csdn.net/zteny/article/details/82980725