Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

时间:2023-12-25 10:19:55

1. 定长编码

最容易想到的方式就是常用的普通二进制编码,每个数值占用的长度相同,都占用最大的数值所占用的位数,如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

这里有一个文档ID列表,254,507,756,1007,如果按照二进制定长编码,需要按照最大值1007所占用的位数10位进行编码,每个数字都占用10位。

和词典的格式设计中顺序列表方式遇到的问题一样,首先的问题就是空间的浪费,本来254这个数值8位就能表示,非得也用上10位。另外一个问题是随着索引文档的增多,谁也不知道最长需要多少位才够用。

2. 差值(D-gap)编码

看过前面前端编码的读者可能会想到一个类似的方法,就是我们可以保存和前一个数值的差值,如果Term在文档中分布比较均匀(差不多每隔几篇就会包含某个Term),文档ID之间的差别都不是很大,这样每个数值都会小很多,所用的位数也就节省了,如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

还是上面的例子中,我们可以看到,这个Term在文档中的分布还是比较均匀的,每隔差不多250篇文档就会出现一次这个Term,所以计算差值的结果比较理想,得到下面的列表254,253,249,251,每个都占用8位,的确比定长编码节省了空间。

然而Term在文档中均匀分布的假设往往不成立,很多词汇很可能是聚集出现的,比如奥运会,世界杯等相关的词汇,在一段时间里密集出现,然后会有很长时间不被提起,然后又出现,如果索引的新闻网页是安装抓取先后来编排文档ID的情况下,很可能出现图所示的情况。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

在很早时间第10篇文档出现过这个Term,然后相关的文档沉寂了一段时间,然后在第1000篇的时候密集出现。如果使用差值编码,得到序列10,990,21,1,虽然很多值已经被减小了,但是由于前两篇文档的差值为990,还是需要10位进行编码,并没有节省空间。

3. 一元编码(unary)

有人可能会问了,为什么要用最大的数值所占用的位数啊,有几位咱们就用几位嘛。这种思想就是变长编码,也即每个数值所占用的位数不同。然而说的容易做起来难,定长编码模式下,每读取b个bit就是一个数值,某个数值从哪里开始,读取到哪里结束,都一清二楚,然而对于变长编码来说就不是了,每个数值的长度都不同,那一连串二进制读出来,读到哪里算一个数值呢?比如上面的例子中,10和990的二进制连起来就是10101111011110,那到底1010是一个数值,1111011110是另一个数值呢,还是1010111是一个数值,剩下的1011110算另一个数值呢?另外就是会不会产生歧义呢?比如一个数值A=0011是另一个数值B=00111的前缀,那么当二进制中出现00111的时候,到底是一个数值A,最后一个1属于下一个数值呢,还是整个算作数值B呢?这都是变长编码所面临的问题。当然还有错了一位,多了一位,丢掉一位导致整个编码都混乱的问题,可以通过各种校验方法来保证,不在本文的讨论范围内,本文仅仅讨论假设完全正确的前提下,如何编码和解码。

最简单的变长编码就是一元编码,它的规则是这样的:对于正整数x,用x-1个1和末尾一个0来表示。比如5,用11110表示。这种编码方式对于小数值来讲尚可,对于大数值来讲比较恐怖,比如1000需要用999个1外加一个0来表示,这哪里是压缩啊,分明是有钱没处使啊。但是不要紧,火车刚发明出来的时候还比马车慢呢。这种编码方式虽然初级,但是很好的解决了上面两个问题。读到哪里结束呢?读到出现0了,一个数值就结束了。会不会出现歧义呢?没有一个数值是另一个数值的前缀,当然没有歧义了。所以一元编码成为很多变长编码的基础。

4. Elias Gamma编码

Gamma编码的规则是这样的:对于正整数x,首先对于Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行一元编码,然后用Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计个bit对Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行二进制编码。

比如对于正整数10,Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,对于4进行一元编码1110,计算Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,用3个bit进行编码010,所以最后编码为1110010。

可以看出,Gamma编码综合了一元编码和二进制编码,对于第一部分一元编码的部分,可以知道何时结束并且无歧义编码,对于二进制编码的部分,由于一元编码部分指定了二进制部分的长度,因而也可以知道何时结束并无歧义解码。

比如读取上述编码,先读取一系列1,到0结束,得到1110,是一元编码部分,值为4,所以后面3个bit是二进制部分,读取下面的3个bit(即便这3个bit后面还有些0和1,都不是这个数值的编码了),010,值为2,最后Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,解码成功。

Gamma编码比单纯的一元编码好的多,对于小的数值也是很有效的,但是当数值增大的情况下,就暴露出其中一元编码部分的弱势,比如1000经过编码需要19个bit。

5. Elias Delta编码

我们在Gamma编码的基础上,进一步减少一元编码的影响,形成Delta编码,它的规则:对于正整数x,首先对于Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行Gamma编码,然后用Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计个bit对Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行二进制编码。

比如对于正整数10,Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,首先对于4进行Gamma编码,Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,对于3进行一元编码110,然后用2个bit对Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行二进制编码00,所以4的Gamma编码为11000,然后计算Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,用3个bit进行编码010,所以最后编码为11000010。

Delta是Gamma编码和二进制编码的整合,也是符合变长编码要求的。

如果读取上述编码,先读入一元编码110,为数值3,接着读取3-1=2个bit为00,为数值0,所以Gamma编码为Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,然后读取三个bit位010,二进制编码数值为2,所以Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,解码成功。

尽管从数值10的编码中,我们发现Delta比Gamma使用的bit数还多,然而当数值比较大的时候,Delta就相对比较好了。比如1000经过Delta编码需要16个bit,要优于Gamma编码。

6. 哈夫曼编码

前面所说的编码方式都有这样一个特点,“任尔几路来 ,我只一路去”,也即无论要压缩的倒排表是什么样子的,都是一个方式进行编码,这种方法显然不能满足日益增长的多样物质文化的需要。接下来介绍的这种编码方式,就是一种看人下菜碟的编码方式。

前面也说到变长编码无歧义,要求任何一个编码都不是其他编码的前缀。学过数据结构的同学很可能会想到——哈夫曼编码。

哈夫曼编码是如何看人下菜碟的呢?哈夫曼编码根据数值出现的频率不同而采用不同长度进行编码,出现的次数多的编码长度短一些,出现次数少的编码长度长一些。

这种方法从直觉上显然是正确的,如果一个数值出现的频率高,就表示出现的次数多,如果采用较短的bit数进行编码,少一位就能节约不少空间,而对于出现频率低的数值,比如就出现一次,我们就用最奢侈的方法编码,也占用不了多少空间。

当然这种方法也是符合信息论的,信息论中的香农定理给出了数据压缩的下限。也即用来表示某个数值的bit的数值的下限和数值出现的概率是有关的:Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计。看起来很抽象,举一个例子,如果抛硬币正面朝上概率是0.5,则至少使用1位来表示,0表示正面朝上,1表示没有正面朝上。当然很对实际运用中,由于对于数值出现的概率估计的没有那么准确,则编码达不到这个最低的值,比如用2位表示,00表示正面朝上,11表示没有正面朝上,那么01和10两种编码就是浪费的。对于整个数值集合s,每个数值所占用的平均bit数目,即所有Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计的平均值Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,称为墒。

要对一些数值进行哈夫曼编码,首先要通过扫描统计每个数值出现的次数,假设统计的结果如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

其次,根据统计的数据构建哈夫曼树,构建过程是这样的,如图:

1) 按照次数进行排序,每个文档ID构成一棵仅包含根节点的树,根节点的值即次数。

2) 将具有最小值的两棵树合并成一棵树,根节点的值为左右子树根节点的值之和。

3) 将这棵新树根据根节点的值,插入到队列中相应的位置,保持队列排序。

4) 重复第二步和第三步,直到合并成一棵树为止,这棵树就是哈夫曼树。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

最终,根据最后形成的哈夫曼树,给每个边编号,左为0,右为1,然后从根节点到叶子节点的路径上的字符组成的二进制串就是编码,如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

最终形成的编码,我们通过观察可以发现,没有一个文档ID的编码是另外一个的前缀,所以不存在歧义,对于二进制序列1001110,唯一的解码是文档ID “123”和文档ID “689”,不可能有其他的解码方式,对于Gamma和Delta编码,如果要保存二进制,则需要通过一元编码或者Gamma编码保存一个长度,才能知道这个二进制到底有多长,然而在这里连保存一个长度的空间都省了。

当然这样原始的哈夫曼编码也是有一定的缺点的:

1) 如果需要编码的数值有N个,则哈夫曼树的叶子节点有N个,每个都需要一个指向数值的指针,内部节点个数是N-1个,每个内部节点包含两个指针,如将整棵哈夫曼树保存在内存中,假设数值和指针都需要占用M个byte,则需要(N+N+2(N-1))*M=(4N-2)*M的空间,耗费还是比较大的。

2) 哈夫曼树的形成是有一定的不稳定性的,在构造哈夫曼树的第3步中,将一棵新树插入到排好序的队列中的时候,如果遇到了两个相同的值,谁排在前面?不同的排列方法会产生不同的哈夫曼树,最终影响最后的编码,如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

为了解决上面两个问题,大牛Schwartz在论文《Generating a canonical prefix encoding》中,对哈夫曼编码做了一定的规范(canonical),所以又称为规范哈夫曼编码或者范式哈夫曼编码。

当然哈夫曼树还是需要建立的,但是不做保存,仅仅用来确定每个数值所应该占用的bit的数目,因为出现次数多的数值占用bit少,出现次数少的数值占用bit多,这个灵魂不能丢。但是如果占用相同的bit,到底你是001,我是010,还是倒过来,这倒不必遵循左为0,右为1,而是指定一定的规范,来消除不稳定性,并在占用内存较少的情况下也能解码。

规范具体描述如下:

1) 所有要编码的数值或者字符排好队,占用bit少的在前,占用bit多的在后,对于相同的bit按照数值大小排序,或者按照字典顺序排序。

2) 先从占用bit最少的数值开始编码,对于第一个数值,如果占用i个bit,则应该是i个0。

3) 对于相同bit的其他数值,则为上一个数值加1后的二进制编码

4) 当占用i个bit的数值编码完毕,接下来开始对占用j个bit的数值进行编码,i < j。则j的第一个数值的编码应该是i个bit的最后一个数值的编码加1,然后后面再追加j-i个0

5) 充分3和4完成对所有数值的编码。

按照这个规范,图中的编码应该如图:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

根据这些规则,不稳定性首先得到了解决,无论同一个层次的节点排序如何,都会按照数值或字符的排序来决定编码。

然后就是占用内存的问题,如果使用范式哈夫曼编码,则只需要存储下面的数据结构,如图:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

当然原本数值的列表还是需要保存的,只不过顺序是安装占用bit从小到大,相同bit数按照数值排序的,需要N*M个byte。

另外三个数组就比较小了,它们的下标表示占用的bit的数目,也即最长的编码需要多少个bit,则这三个数组最长就那么长,在这个例子中,最长的编码占用5个bit,所以,它们仅仅占用3*5*M个byte。

第一个数组保存的是占用i个bit的编码中,起始编码是什么,由于相同bit数的编码是递增的,因而知道了起始,后面的都能够推出来。

第二个数组保存的是占用i个bit的编码有几个,比如5个bit的编码有5个,所以Number[5]=5。

第三个数组保存的是占用i个bit的编码中,起始编码在数值列表中的位置,为了解码的时候快速找到被解码的数值。

如果让我们来解析二进制序列1110110,首先读入第一个1,首先判断是否能构成一个1bit的编码,Number[1]=0,占用1个bit的编码不存在;所以读入第二个1,形成11,判断是否能构成一个2bit的编码,Number[2]=3,然后检查FirstCode[2]=00 < 11,然而11 – 00 + 1 = 4 > Number[2],超过了2bit编码的范围;于是读入第三个1,形成111,判断是否能构成一个3bit的,Number[3]=1,然后检查FirstCode[3]=110<111,然而111 – 110 + 1 = 2> Number[3],超过了3bit的编码范围;于是读入第四个0,Number[4]=0,再读入第五个1,判断是否能构成一个5bit的编码,Number[5]=4,然后检查FirstCode[5]=11100 < 11101,11101 – 11100 + 1 = 2<4,所以是一个5bit编码,而且是5bit编码中的第二个,5bit编码的第二个在位置Position[5]=5,所以此5bit编码是数值列表中的第6项,解码为value[6]=345。然后读入1,不能构成1bit编码,11不能构成2bit编码,110,Number[3]=1,然后检查FirstCode[3]=110=110,所以构成3bit编码的第一个Position[3]=4,解码为value[4]=789。

如果真能像理想中的那样,在压缩所有的倒排表之前,都能够事先通过全局的观测来统计每个文档ID出现的概率,则能够实现比较好的压缩效果。

在这个例子中,我们编码后使用的bit的数目为:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

我们再来算一下墒:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

按照香农定理最低占用的bit数为Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

可以看出哈夫曼编码的压缩效果相当不错。然而在真正的搜索引擎系统中,文档是不断的添加的,很难事先做全局的统计。

对于每一个倒排表进行局部的统计和编码是另一个选择,然而付出的代价就是需要为每一个倒排表保存一份上述的结构来进行解码。很不幸上述的结构中包含了数值的列表,如果一个倒排表中数值重复率很高,比如100万的长的倒排表只有10种数值,为100万保存10个数值的列表还可以接受,如果重复率不高,那么数值列表本身就和要压缩的倒排表差不多大了,根本起不到压缩作用。

7. Golomb编码

如果我们将倒排表中文档ID的概率分布假设的简单一些,就没必要统计出现的所有的数值的概率。比如一个简单的假设就是:Term在文档集集合中是独立随机出现的。

既然是随机出现的,那么就有一个概率问题,也即某个Term在某篇文档中出现的概率是多少?假设我们把整个倒排结构呈现如图的矩阵的样子,左面是n个Term,横着是N篇文档,如果某个Term在某篇文档中出现,则那一位设为1,假设里面1的个数为f,那么概率Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

正如在差值编码一节中论述的那样,我们在某个Term的倒排表里面保存的不是文档ID,而是文档ID的间隔的数值,我们想要做的事情就是用最少的bit数来表示这些间隔数值。

如果所有的间隔组成的集合是已知的,则可用上一节所述的哈夫曼编码。

我们在这里想要模拟的情况是:间隔组成的集合是不确定的,甚至是无限的,随着新的文档的不断到来进行不断的编码。

可以形象的想象成下面的情形,一个Term坐在那里等着文档一篇篇的到来,如果文档包含自己,就挂在倒排表上。

如果文档间隔是x,则表示的情形就是,来一篇文档不包含自己,再来一篇还是不包含自己,x-1篇都过去了,终于到了第x篇,包含了自己。如果对于一篇文档是否包含自己的概率为p,则文档间隔x出现的概率就是Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

假设编码当前的文档间隔x用了n个bit,这个Term接着等下一篇文档的到来,结果这次更不幸,等了x篇还包含自己,直到等到x+b篇文档才包含自己,于是要对x+b进行编码,x+b出现的概率为Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,显然比x的概率要低,根据信息论,如果x用n个bit,则x+b要使用更多的bit,假设Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,则最优的情况应该多用1个bit。

这样我们就形成了一个递推的情况,假设已知文档间隔x用了n个bit,对于Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计来说,x+b就应该只用n+1个bit,这样如果有了初始的文档间隔并且进行了最优的编码,后面的都能达到最优。

于是Golomb编码就产生了,对于参数b(当然是根据文档集合计算出的概率产生的),对于数值x的编码分为两部分,第一部分计算Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,然后将q+1用一元编码,第二部分是余数,r=x-1-qb,由于余数一定在0到b-1之间,则可以用Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计或者Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计进行无前缀编码(哈夫曼编码)。

用上面的理论来看Golomb编码就容易理解了,第一部分是用来保持上面的递推性质的,一元编码的性质可以保证,数值增加1,编码就多用1位,递推性质要求数值x增加b,编码增加1位,于是有了用数值x除以b,这样Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计。第二部分的长度对于每个编码都是一样的,最多不过差一位,与数值x无关,仅仅与参数b有关,其实第二部分是用来保证初始的文档间隔是最优的,所以哈夫曼编码进行无前缀编码。

例如x=9,b=6,则Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,对q+1用一元编码为10,余数r=2,首先对于所有的余数进行哈夫曼编码,形成如图的哈夫曼树,从最小的余数开始进行范式哈夫曼编码,0为00,1为01,2占用三个bit,为01 + 1补充一位,为100,3为101,4为110,5为111。所以x=9的编码为10100。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

接下来我们试图编码x=9+6=15,b=6,则Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,对q+1用一元编码为110,余数r=2,编码为100,最后编码为110100,果真x增大b,编码多了1位。

接下来要解决的问题就是如何确定b的值,按照咱们的理论推导Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,计算起来有些麻烦,我们先来计算分母部分Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,当p接近于0的时候,由著名的极限公式Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,所以分母约为p,于是公式最后为Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

由于Golomb编码仅仅需要另外保存一个参数b,所以既可以基于整个文档集合的概率进行编码,这个时候Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,也可以应用于某一个倒排表中,对于一个倒排表进行局部编码,以达到更好的效果,对于某一个倒排表,term的数量n=1,f=词频Term Freqency,Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计,这样不同的倒排表使用不同的参数b,达到这样一个效果,对于词频高的Term,文档出现的相对紧密,用较小的b值来编码,对于词频低的Term,文档出现的相对比较松散,用较大的b来进行编码。

8. 插值编码(Binary Interpolative Coding)

前面讲到的Golomb编码表现不凡,实现了较高的压缩效果。然而一个前提条件是,假设Term在文档中出现是独立随机的,在倒排表中,文档ID的插值相对比较均匀的情况下,Golomb编码表现较好。

然而Term在文档中却往往出现的不那么随机,而往往是相关的话题聚集在一起的出现的。于是倒排表往往形成如下的情况,如图.

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

我们可以看到,从文档ID 8到文档ID 13之间,文档是相对比较聚集的。对于聚集的文档,我们可以利用这个特性实现更好的压缩。

如果我们已知第1篇文档的ID为8,第3篇文档的ID为11,那么第2篇文档只有两种选择9或者10,所以可以只用1位进行编码。还有更好的情况,比如如果我们已知第3篇文档ID为11,第5篇文档ID为13,则第6篇文档别无选择,只有12,可以不用编码就会知道。

这种方法可以形象的想象成为,我们从1到20共20个坑,我们要将文档ID作为标杆插到相应的坑里面,我们总是采用限制两头在中间找坑的方式,还是上面的例子,如果我们已经将第1篇文档插到第8个坑里,已经将第3篇文档插到第11个坑里,下面你要将第2篇文档插到他们两个中间,只有两个坑,所以1个bit就够了。当然一开始一个标杆还没有插的时候,选择的范围会比较的大,所以需要较多的bit来表示,当已经有很多的标杆插进去了以后,选择的范围会越来越小,需要的bit数也越来越小。

下面详细叙述一下编码的整个过程,如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

最初的时候,我们要处理的是整个倒排表,长度Length为7,面对的从Low=1到High=20总共有20个坑。还是采取限制两头中间插入的思路,我们先找到中间数值11,然后找一坑插入它,那两头如何限制呢?是不是从1到20都可以插入呢?当然不是,因为数值11的左面有三个数值Left=3,一个数值一个坑的话,至少要留三个坑,数值11的右面也有三个数值Right=3,则右面也要留三个坑,所以11这根标杆只能插到从4到17共14个坑里面,也就是说共有14中选择,用二进制表示的话,需要Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计bit来存储,我们用4位来编码11-4=7为0111。

第一根标杆的插入将倒排表和坑都分成了两部分,我们可以分而治之。左面一部分我们称之<Length=3, Low=1, High=10>,因为它要处理的倒排表长度为3,而且一定是放在从1到10这10个坑里面的。同理,右面一部分我们称之<Length=3, Low=12, High=20>,表示另外3个数值组成的倒排表要放在从12到20这些坑里。

先来处理<Length=3, Low=1, High=10>这一部分,如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

同样选取中间的数值8,然后左面需要留一个坑Left=1,右面需要留一个坑Right=1,所以8所能插入的坑从2到9共8个坑,也就是8中选择,用二进制表示,需要Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计bit来存储,于是编码8-2=6为110。

标杆8的插入将倒排表和坑又分为两部分,还是用上面的表示方法,左面一部分为<Length=1,Low=1,High=7>,表示只有一个值的倒排表要插入从1到7这七个坑中,右面一部分为<Length=1,Low=9,High=10>,表示只有一个值的倒排表要插入从9到10这两个坑中。

我们来处理<Length=1,Low=1,High=7>部分,如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

只有一个数值3,左右也不用留坑,所以可以插入从1到7任何一个坑,共7中选择,需要3bit,编码3-1=2为010。

对于<Length=1,Low=9,High=10>部分,如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

只有一个数值9,可以选择的坑从9到10两个坑,共两种选择,需要1bit,编码9-9=0为0。

再来处理<Length=3, Low=12, High=20>部分,如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

选择插入中间数值13,左面需要留一个坑Left=1,右面需要留一个坑Right=1,所以13可以插入在从13到19这7个坑里,共7种选择,需要3bit,编码13-13=0为000。

数值13的插入将倒排表和坑分为两部分,左面<Length=1, Low=12, High=12>,只有一个数值的倒排表要插入唯一的一个坑,右面<Length=1,Low=14,High=20>,只有一个数值的倒排表插入从14到20的坑。

对于<Length=1, Low=12, High=12>,如图,一个数一个坑,不用占用任何bit就可以。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

对于<Length=1,Low=14,High=20>,如图,只有一个值17,放在14到20之间7个坑中,有7中选择,需要3bit,编码17-14=3为011。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

综上所述,最终的编码为0111 110 010 0 000 011,共17位。如果用Golomb编码差值<3,5,1,2,1,1,4>,经计算b=2,则编码共18位。差值编码表现更好。

那么解码过程应该如何呢?初始我们知道<Length=7,Low = 1,High=20>,首先解码的是中间的也即第3个数值,由于Left=3,Right=3,则可这个数值必定在从4到17,表示这14种选择需要4位,因而读取最初的4位0111为7,加上Low + Left = 4,第3个数值解码为11。

已知第3个数值为11后,则左面应该有三个数值,而且一定是从1到10,表示为<Length=3, Low=1, High=10>,右面的也应该有三个数值,而且一定是从12到20,表示为<Length=3, low=12, high=20>。

先解码左面<Length=3, Low=1, High=10>,解码中间的数值,也即第1个数值,由于Left=1,Right=1,则这个数值必定从2到9,表示8种选择需要3位,因而读出3位110,为6,加上Low+Left=2,第1个数值解码为8。

数值8左面还有一个数值,在1到7之间,表示7种选择需要3位,读出3位010,为2,加上Low=1,第0个数值解码为3。

数值8右面还有一个数值,在9到10之间,表示2种选择需要1位,读出1位0,为0,加上Low=9,第2个数值解码为9。

然后解码<Length=3, low=12, high=20>,解码中间的数值,也即第5个数值,由于Left=1,Right=1,则这个数值必定从13到19,表示7中选择需要3位,读出3位000,为0,加上low=13,第5个数值解码为13。

数值13左面还有一个数值,在12到12之间,必定是12,无需读取,第4个数值解码为12。

数值13右面还有一个数值,在14到20之间,表示7种选择需要3位,读出3位011,为3,加上low=14,则第6个数值解码为17。

解码完毕。

9. Variable Byte编码

上述所有的编码方式有一个共同点,就是需要一位一位的进行处理,称为基于位的编码(bitwise)。这样一分钱一分钱的节省,的确符合咱们勤俭持家的传统美德,也能节约不少存储空间。

然而在计算机中,数据的存储和计算的都是以字(Word)为单位进行的,一个字中包含的位数成为字长,比如32位,或者64位。一个字包含多个字节(Byte),字节成为存储的基本单位。如果使用bitwise的编码方法,则意味着在编码和解码过程中面临者大量的位操作,从而影响速度。

对于信息检索系统来讲,相比于存储空间的节省,查询速度尤为重要。所以我们将目光从省转移到快,基于字节编码(Bytewise)是以一个或者多个字节(Byte)为单位的。

最常见的基于字节的编码就是变长字节编码(Variable Byte),它的规则比较简单,一个Byte共8个bit,其中最高位的1个bit表示一个flag,0表示这是最后一个字节,1表示这个数还没完,后面还跟着其他的字节,另外7个bit是真正的数值。

如图所示,比如编码120,表示成二进制是1111000没有超过7个bit,所以用一个byte就能保存,最高位置0。如果编码130,表示成二进制是10000010,已经有8个bit了,所以需要用两个byte来保存,第一个byte保存第一个bit,最高位置1,接下来的一个byte保存剩下的7个bit,最高位置0。如果数值再大一些,比如20000,则需要三个byte才能保存。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

变长字节编码的解码也相对简单,每次读一个byte,然后判断最高位,如果是0则结束,如果是1则再读一个byte,然后再判断最高位,直到遇到最高位为0的,将几个byte的数据部分拼接起来即可。

从变长字节编码的原理可以看出,相对于基于位的编码,它是一次处理一个字节的,相应付出的代价就是空间有些浪费,比如130编码后的第一个字节,本来就保存一个1,还是用了7位。

变长字节编码作为基于字节的编码方式,的确比基于位的编码方式表现出来较好的速度。在Falk Scholer的论文《Compression of Inverted Indexes For Fast Query Evaluation》中,很好的比较了这两种类型的编码方式。

如图所示,图中的简称的意思是Del表示Delta编码,Gam表示Gamma编码,Gol表示Golomb编码,Ric表示Rice编码,Vby表示Variable Bytes编码,D表示文档ID,F表示Term的频率Term Frequency,O表示Term在文档中的偏移量Offset。GolD-GamF-VbyO表示用Golomb编码来表示文档ID,用Gamma编码来表示Term频率,用Vby来表示偏移量。

文中对大小两个文档集合进行的测试,从图中我们可以看出变长字节编码虽然在空间上有所浪费,然而在查询速度上却表现良好。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

10. PFORDelta编码

变长字节编码的一个缺点就是虽然它是基于byte进行编码的,但是每解码一个byte,都要进行一次位操作。

解决这个问题的一个思路就是,将多个byte作为一组(Patch)放在一起,将Flag集中起来,作为一个Header保存每个数值占用几个byte,一次判断一组,我们称为Signature block,如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

对于Header中的8个bit,分别表示接下来8个byte的flag,前三个0表示前三个byte各编码一个数值,接下来1表示下一个byte属于第四个数值,然后接下来的1表示下一个byte也属于第四个数值,接下来0表示没有下一个byte了,因而110表示的三个byte编码一个数值。最后10表示最后两个byte编码第五个数值。

细心的同学可能看出来了,Header里面就是一元编码呀。

那么再改进一下,在Header里面咱们用二进制编码,每两位组成一个二进制码,这个二进制数表示每一个数值的长度,长度的单位是一个byte,这样两位可以表示32个bit,基本可以表示所有的整数。00表示一个byte,01表示2个byte,10表示3个byte,11表示4个byte,这种方式称为长度编码(Length Encoding),如图。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

如果数比较大,32位不够怎么办?用三位,那总共8位也不够分的啊?于是有人改变思路,Header里面的8位并不表示长度,而是8个flag,每个flag表示是否能够压缩到n个byte,n是一个参数,0表示能,则压缩为n个byte,1表示不能,则用原来的长度表示。这种方法叫做Binary Length Encoding。如同所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

这里参数n=2,也即如果一个32位整数能压缩为2个byte,则压缩,否则就用全部32位表示。比如第三个数字,其实用三位就能够表示的,但是由于不能压缩成为2个byte,也是用完整的32位来表示的。

Binary Length Encoding已经为将数值分组打包(Patch)压缩提供了一个很好的思路,就是将数值分为两大部分,可以压缩的便打包处理,太大不能压缩的可单独处理。这种思想成为PForDelta编码的基础。

然而Binary length Encoding是将能压缩的和不能压缩的混合起来存储的,这其实是不利于我们批量压缩和解压缩的,必须一个数值一个数值的判断。

而PForDelta做了改进,将两部分的分开存储。试想如果m个数值都是压缩成b个bit的,就可以打包在一起,这样批量读出m*b个bit,一起解压便可。而其他不可压缩的,我们放在另外的地方。只要我们通过参数,控制b的大小,使得能压缩的占多数,不能压缩的占少数,批量处理完打包好的,然后再一个个料理不能打包的残兵游勇。可压缩部分我们成为编码区域(Code Section),不可压缩的部分我们成为异常区域(Excepton Section)。

当然分开存储有个很大的问题,就是不能保持原来数值列表的顺序,而我们要压缩的倒排表是需要保持顺序的。如同所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

一个最直接的想法是,如图(a),在原来异常数值(Exception Value)出现的位置保留一个指针,指向异常区域中这个数值的位置。然而一个很大的问题就是这个指针只能占用b个bit,往往不够一个指针的长度。

另外一个替代的方法是,如图(b),我们如果知道第一个异常数值出现的位置(简称异常位置),并且知道异常区域的起始位置,我们可以在b个bit里面保存下一个异常位置的偏移量(因为偏移量为0没有意义,所以存放0表示距离1,存放i表示距离i+1),由于编码区域是密集保存的,所以b个bit往往够用。解压缩的时候,我们先批量将整个编码区域解压出来,然后找到第一个异常位置,原本放在这个位置的数值应该是异常区域的第一个值,然后异常位置里面解压出3,说明第二个异常位置是当前位置加4,找到第二个异常位置后,原本放在这个位置的数值应该是异常区域的第二个值,以此类推。这个将异常位置串起来的链表我们称为异常链。

然而如果很不幸,b个bit不够用,下一个异常位置的偏移量超过了2b个bit。如图(c),b=2,然而下一个异常位置距离为7,2位放不开,我们就需要在距离为4的位置人为插入一个异常位置,当前位置里面写11,异常位置里面写7-4-1=2,当然异常区域中也需要插入一个不存在的数值。这样做的缺点是增加了无用的数值,降低了压缩率,为了减少这种情况的出现,所以实践中b不要小于4。

这就是PForDelta的基本思路。PForDelta,全称Patched Frame Of Reference-Delta,其中压缩后的n个bit,称为一个Frame,Patched就是将这些Frame进行打包,Delta表示我们打包压缩的是差值。

PForDelta是将数值分块(Block)存储的,一块中可以包含百万个数值,大小可以达到几个Megabyte,一般的方法是,在内存中保存一个m个Megabyte的缓存区域(Buffer),然后不断的读取数据,将这个缓存区域按照一定的格式填满,便可以写入硬盘,然后再继续压缩。

块内部的格式如图所示。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

块内部分为四个部分,在图中这四个部分是分开画的,其实是一个部分紧接着下一个部分的。编码区域和异常区域之间可能会有一些空隙,下面介绍中会讲到为什么。在图中的例子里面,我们还假设32bit足够保存原始数值。

第一部分是Header,里面保存了这个块的大小,比如1M,大小应该是32或者64的整数倍。另外保存了压缩的数值所用的bit数为b。

第二部分是Entry point的数组,有N项,每一个Entry管理128个数值。每一项32位,其中前7位表示这个Entry管理的128个数值中第一个异常位置,后25位保存了这128个数值的异常区域的起始位置。这个数组的存在是为了在一个块中随机访问。

第三部分是编码区域(Code Section),存放了一系列压缩为b个bit的数值,每128个被一个entry管理,总共有128*N个数值,数值是从前向后依次排放的。在这个部分中,异常位置是以异常链的形式串起来的。

第四部分是异常区域(Exception Section),存放不能压缩,以32个bit存储原始数值的部分。这一部分的数值是从后往前排放的。由于每128个数值中异常数值的个数不是固定的,所以仅仅靠这部分不能确定哪些属于哪个entry管理。在一个entry中,有指向起始位置的指针,然后根据编码区域中的异常链,依次一个一个找异常数值。

编码区域是从前往后增长的,异常区域是从后往前增长的,在缓存块中,当中间的空间不足的时候,就留下一段空隙。为了提高效率,我们希望解压缩的时候是字对齐(word align)的,也即希望一次处理32个bit。假设Header是占用32个bit,每个Entry也是32个bit,异常区域是32个bit一个数值的,然而编码区域则不是,比如5个bit一个数值,假设一共有100个数值,则需要500个bit,不是32的整数倍,最后多余20个bit,则需要填充12个0,然后使得编码区域字对齐后再存放异常区域。索性我们的设计中,一个entry是管理128个数值的,所以最后一定会是32的整数倍,一定是字对齐的,不需要填充,可以保证写入硬盘的时候,编码区域和异常区域是紧密相邻的。

PForDelta的字对齐和批量处理,意味着我们已经从一个bit一个bit处理的个人手工业时代,到了机械大工业时代。如图。在硬盘上是海量的索引文件,是由多个PForDelta块组成的,在压缩和解压过程中,需要有一部分缓存在内存中,然后其中一个块可以进入CPU Cache,每块的结构都是32位对齐的,对于32位机器,寄存器也是32位的。于是我们可以想象,CPU就像一个卓别林扮演的工人,来了32个bit,处理完毕,接着下一个32位,流水作业。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

下面咱们就通过一个例子,具体看一下PForDelta的压缩和解压方法。

我们假设有以下266个数值:

Input = [26, 24, 27, 24, 28, 32, 25, 29, 28, 26, 28, 31, 32, 30, 32, 26, 25, 26, 31, 27, 29, 25, 29, 27, 26, 26, 31, 26, 25, 30, 32, 28, 23, 25, 31, 31, 27, 24, 32, 30, 24, 29, 32, 26, 32, 32, 26, 30, 28, 24, 23, 28, 31, 25, 23, 32, 30, 27, 32, 27, 27, 28, 32, 25, 26, 23, 30, 31, 24, 29, 27, 23, 29, 25, 31, 29, 25, 23, 31, 32, 32, 31, 29, 25, 31, 23, 26, 27, 31, 25, 28, 26, 27, 25, 24, 24, 30, 23, 29, 30, 32, 31, 25, 24, 27, 31, 23, 31, 29, 28, 24, 26, 25, 31, 25, 26, 23, 29, 29, 27, 30, 23, 32, 26, 31, 27, 27, 29, 23, 32, 28, 28, 23, 28, 31, 25, 25, 26, 24, 30, 25, 28, 26, 28, 32, 27, 23, 31, 24, 25, 31, 27, 31, 24, 24, 24, 30, 27, 28, 23, 25, 31, 27, 24, 23, 25, 30, 23, 24, 32, 26, 31, 28, 25, 24, 24, 23, 28, 28, 28, 32, 29, 27, 27, 29, 25, 25, 32, 27, 31, 32, 28, 27, 32, 26, 23, 26, 31, 24, 32, 29, 27, 27, 25, 31, 31, 24, 23, 32, 30, 28, 29, 29, 28, 32, 26, 26, 27, 27, 29, 24, 25, 31, 27, 30, 28, 29, 27, 31, 25, 26, 26, 30, 31, 29, 30, 31, 26, 24, 29, 28, 25, 30, 24, 25, 23, 24, 32, 23, 32, 24, 27, 28, 29, 27, 31, 28, 29, 29, 32, 25, 26, 27, 29, 23, 26]

根据上面说过的原理,足够需要三个entry来管理。

首先在索引过程中,这些数值是一个个到来的,经过初步的统计,发现数值32是最大的,并且占到总数的10%多一点,所以我们可以将32作为异常数值。其他的数值都在0-31之间,用5个bit就可以表示,所以b=5。

下面我们就可以开始压缩了,我们是一个entry一个entry的来压缩的,所以128个数值为一组,代码如下:

//存放编码区域压缩前数值

int[] codes = new int[128];

//记录每个异常数值的位置,miss[i]表示第i个异常数值的位置

int[] miss = new int[numOfExceptions];

int numOfCodes = 0;

int numOfExcepts = 0;

int numOfJump = 0;

//第一个循环,构造编码区,并且统计异常数值位置

//每128个数值一组,或者不够128则剩下的一组

while(from < input.length && numOfCodes < 128){

//统计从上次遇到异常数值开始,遇到的普通数值的个数

numOfJump = (input[from] > maxCode)?0:(numOfJump+1);

//如果两个异常数值之间的间隔太大,则必须认为插入一个异常数值。maxJumpCode是指b=5的情况下能表示的最大间隔31。之所以判断numOfExcepts > 0,是因为第一个异常位置用7个bit保存在entry里面,所以在哪里都可以。

if(numOfJump > maxJumpCode && numOfExcepts > 0){

codes[numOfCodes] = -1;

miss[numOfExcepts] = numOfCodes;

numOfCodes++;

numOfExcepts++;

numOfJump = 0;

}

//编码区域的构造。这个地方是最简单的情况,就是input的数值直接进入编码区域,这里还可以用其他的编码方式(比如用Golomb)进行一次编码。

codes[numOfCodes] = input[from];

//只有遇到异常数值的时候numOfExcepts才加一

miss[numOfExcepts] = numOfCodes;

numOfExcepts += (input[from] > maxCode)?1:0;

numOfCodes++;

from++;

}

//构造完编码区域后,可以对entry进行初始化,7位保存第一个异常位置,25位保存异常区域的起始位置。

int prev = miss[0];

entries[curEntry++]=prev << 25 | (curException & 0x1FFFFFF);

//第二个循环,构造异常链和异常区域

exceptionSection[curException--] = codes[prev];

for(int i=1; i < numOfExcepts; i++){

int cur = miss[i];

codes[prev] = cur - prev - 1;

prev = cur;

exceptionSection[curException--] = codes[cur];

}

codes[prev] = numOfCodes - prev - 1;

//最后将编码区域压缩,其中codes是压缩前的数值,numOfCodes是数值的个数,codeSection是一个int数组,用于存放压缩后的数值,curCode是当前codeSection可以从哪个位置开始写入,bwidth=5

curCode += pack(codes, numOfCodes, codeSection, curCode, bwidth);

整个过程是两次循环构造未压缩的编码区域和异常区域,如下面的表格所示。表格中每一列中上面的数值是input,下面的数值是未压缩编码区域数值,其中黄色的部分便是异常位置:

Entry 1的未压缩编码区域

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Entry 2的未压缩编码区域,其中第214个异常位置和第248个异常位置中间隔了33个位置,无法用5个bit表示,于是在第216个位置人为插入一个异常位置,就是红色的部分。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Entry 3的未压缩编码区域,本来input中只有266个数值,这里又添加两个0数值(绿色的部分)是为什么呢?因为每个数值压缩后将占用5个bit,如果只有11个数值的话共55位,而要求字对齐的话,需要64位,因而需要人为添加9个0.

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

下面应该对编码区域进行压缩了,在大多数的实现中,压缩代码多少有些晦涩难懂。一般来说,会对每一种b有一个代码实现,在这里我们举例列出的是b=5的代码实现。

整个过程我们可以想象成codeSection是一条条32位大小的袋子,而codes是一系列待压缩的32位的物品,其中货真价实的就5位,其他都是水分(都是0),下面要做的事情就是把待压缩的物品一件件拿出来,把有水分挤掉,然后往袋子里面装。

装的时候就面临一个问题,32不是5的整数倍,放6个还空着2位,放7个不够空间,这循环怎么写啊?所以只能以最小公倍数32*5=160位为一个处理批次,放在一个循环里面,也即每个循环处理5个袋子,32个物品,使得32个物品正好能放在5个袋子里面。

//bwidth=5

private static int pack(int[] codes, int numOfCodes, int[] codeSection,

int curCode, int bWidth) {

int cur = 0;

// suppose bwidth = 5

// bwidth不一定能被32的整除,所以每32个一组,保证处理完以后,32*bwidth个bit,一定是字对齐的。

while (cur < numOfCodes) {

codeSection[curCode + 0] = 0;

codeSection[curCode + 1] = 0;

codeSection[curCode + 2] = 0;

codeSection[curCode + 3] = 0;

codeSection[curCode + 4] = 0;

//curCode + 0是第一个袋子,先放codes里面从cur+0到cur+5六个物品后,还空着2位,于是把第七个物品前2位截出来,放进去。0x18二进制11000,作用就是最后5位保留前两位,然后右移3位,就把前2位放到了袋子的最后2位。

codeSection[curCode + 0] |= codes[cur + 0] << (32 - 5);

codeSection[curCode + 0] |= codes[cur + 1] << (32 - 10);

codeSection[curCode + 0] |= codes[cur + 2] << (32 - 15);

codeSection[curCode + 0] |= codes[cur + 3] << (32 - 20);

codeSection[curCode + 0] |= codes[cur + 4] << (32 - 25);

codeSection[curCode + 0] |= codes[cur + 5] << (32 - 30);

codeSection[curCode + 0] |= (codes[cur + 6] & 0x18) >> 3;

//curCode+1是第二个袋子。刚才第七个物品前2位被截了放在第一个袋子里,那么首先剩下的3位放在第二个袋子的开头,0x07就是00111,也就是截取后三位。然后再放5个物品,还空着4位,于是第十三个物品截取前四位(0x1E二进制11110)。

codeSection[curCode + 1] |= (codes[cur + 6] & 0x07) << (32 - 3);

codeSection[curCode + 1] |= codes[cur + 7] << (32 - 3 - 5);

codeSection[curCode + 1] |= codes[cur + 8] << (32 - 3 - 10);

codeSection[curCode + 1] |= codes[cur + 9] << (32 - 3 - 15);

codeSection[curCode + 1] |= codes[cur + 10] << (32 - 3 - 20);

codeSection[curCode + 1] |= codes[cur + 11] << (32 - 3 - 25);

codeSection[curCode + 1] |= (codes[cur + 12] & 0x1E) >> 1;

//curCode + 2第三个袋子。先放第十三个物品剩下的1位(0x01二进制00001),然后再放入6个物品,最后空着1位。将第二十个物品的第1位截取出来(0x10二进制10000)放入。

codeSection[curCode + 2] |= (codes[cur + 12] & 0x01) << (32 - 1);

codeSection[curCode + 2] |= codes[cur + 13] << (32 - 1 - 5);

codeSection[curCode + 2] |= codes[cur + 14] << (32 - 1 - 10);

codeSection[curCode + 2] |= codes[cur + 15] << (32 - 1 - 15);

codeSection[curCode + 2] |= codes[cur + 16] << (32 - 1 - 20);

codeSection[curCode + 2] |= codes[cur + 17] << (32 - 1 - 25);

codeSection[curCode + 2] |= codes[cur + 18] << (32 - 1 - 30);

codeSection[curCode + 2] |= (codes[cur + 19] & 0x10) >> 4;

//curCode + 3第四个袋子。先放第二十个物品剩下的4位(0x0F二进制位01111)。然后放5个物品,最后还空着3位。将第二十六个物品截取3位放入(0x1C二进制11100)。

codeSection[curCode + 3] |= (codes[cur + 19] & 0x0F) << (32 - 4);

codeSection[curCode + 3] |= codes[cur + 20] << (32 - 4 - 5);

codeSection[curCode + 3] |= codes[cur + 21] << (32 - 4 - 10);

codeSection[curCode + 3] |= codes[cur + 22] << (32 - 4 - 15);

codeSection[curCode + 3] |= codes[cur + 23] << (32 - 4 - 20);

codeSection[curCode + 3] |= codes[cur + 24] << (32 - 4 - 25);

codeSection[curCode + 3] |= (codes[cur + 25] & 0x1C) >> 2;

//curCode + 4第五个袋子。先放第二十六个物品剩下的2位。最后这个袋子还剩30位,正好放下6个物品。

codeSection[curCode + 4] |= (codes[cur + 25] & 0x03) << (32 - 2);

codeSection[curCode + 4] |= codes[cur + 26] << (32 - 2 - 5);

codeSection[curCode + 4] |= codes[cur + 27] << (32 - 2 - 10);

codeSection[curCode + 4] |= codes[cur + 28] << (32 - 2 - 15);

codeSection[curCode + 4] |= codes[cur + 29] << (32 - 2 - 20);

codeSection[curCode + 4] |= codes[cur + 30] << (32 - 2 - 25);

codeSection[curCode + 4] |= codes[cur + 31] << (32 - 2 - 30);

//处理下一组

cur += 32;

curCode += 5;

}

int numOfWords = (int) Math.ceil((double) (numOfCodes * 5) / 32.0);

return numOfWords;

}

经过压缩后,整个block的格式如下,整个block被组织成int数组,[]里面的数值为下标:

Header:这部分只占用32位,在这里包含四部分,第一部分5位,表示b=5。第二部分表示Entry部分的长度,占用了3个32位,也即有三个Entry。第三部分表示编码区域的长度,占用了42个

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Entry列表:包含3个entry,每个entry占用32位,前7位表示第一个异常位置,后25位表示这个entry在异常区域中的起始位置。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

编码区域。总共占用42个int,每5个int可以存放32个压缩后的编码(每个编码占用5位)。第三个entry共占用两个int,保存了11个数值占用55位,另外人为补充9个0.

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

异常区域。在块中,异常区域是从后向前延伸的。其中从74到60的红色部分属于Entry 1,从59到50的黄色部分属于Entry 2,绿色部分属于Entry 3。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

当需要读取这个快的时候,便需要对这个块进行解码。

首先通过解析Header来得到全局信息。

接下来需要读取Entry列表,来解析每个Entry中的信息。

然后对于每个Entry进行解码,代码如下:

//解析entry,得到全局信息

int entrycode = entries[i];

int firstExceptPosition = entrycode >> 25;

int curException = entrycode & 0x1FFFFFF;

//进行解压缩,将编码区域5位一个数值解压为一个int数组。Codes就是解压后的数组,tempCodeSection指向编码区域这个Entry的起始位置,numOfCodes是需要解压的数值的个数,bwidth=5.

Unpack(codes, numOfCodes, tempCodeSection, bwidth);

//第一个循环将异常数值还原

int cur = firstExceptPosition;

while (cur < numOfCodes && curException >= lastException) {

int jump = codes[cur];

codes[cur] = block[curException--];

cur = cur + jump + 1;

}

//第二个循环输出结果并且跳过人为添加的异常数值

for (int j = 0; j < codes.length; j++) {

if (codes[j] > 0) {

output[curOutput++] = codes[j];

}

}

对编码区域的解压方式也正好是压缩方式的逆向过程。是从袋子里面将物品拿出来的时候了。

private static void Unpack(int[] codes, int numberOfCodes, int[] codeSection,

int bwidth) {

int cur = 0;

int curCode = 0;

while (cur < numberOfCodes) {

//从第一个袋子中,拿出6个物品,还剩2位。

codes[cur + 0] = (codeSection[curCode + 0] >> (32 - 5)) & 0x1F;

codes[cur + 1] = (codeSection[curCode + 0] >> (32 - 10)) & 0x1F;

codes[cur + 2] = (codeSection[curCode + 0] >> (32 - 15)) & 0x1F;

codes[cur + 3] = (codeSection[curCode + 0] >> (32 - 20)) & 0x1F;

codes[cur + 4] = (codeSection[curCode + 0] >> (32 - 25)) & 0x1F;

codes[cur + 5] = (codeSection[curCode + 0] >> (32 - 30)) & 0x1F;

codes[cur + 6] = (codeSection[curCode + 0] << 3) & 0x18;

//第一个袋子中的2位和第二个袋子中的前3位组成第7个物品。然后接着从第二个袋子中拿出5个物品,剩下4位。

codes[cur + 6] |= (codeSection[curCode + 1] >> (32 - 3)) & 0x07;

codes[cur + 7] = (codeSection[curCode + 1] >> (32 - 3 - 5)) & 0x1F;

codes[cur + 8] = (codeSection[curCode + 1] >> (32 - 3 - 10)) & 0x1F;

codes[cur + 9] = (codeSection[curCode + 1] >> (32 - 3 - 15)) & 0x1F;

codes[cur + 10] = (codeSection[curCode + 1] >> (32 - 3 - 20)) & 0x1F;

codes[cur + 11] = (codeSection[curCode + 1] >> (32 - 3 - 25)) & 0x1F;

codes[cur + 12] = (codeSection[curCode + 1] << 1) & 0x1E;

//第二个袋子的最后4位和第三个袋子的前1位组成一个物品,然后在第三个袋子里面拿出6个物品,剩下1位。

codes[cur + 12] |= (codeSection[curCode + 2] >> (32 - 1)) & 0x01;

codes[cur + 13] = (codeSection[curCode + 2] >> (32 - 1 - 5)) & 0x1F;

codes[cur + 14] = (codeSection[curCode + 2] >> (32 - 1 - 10)) & 0x1F;

codes[cur + 15] = (codeSection[curCode + 2] >> (32 - 1 - 15)) & 0x1F;

codes[cur + 16] = (codeSection[curCode + 2] >> (32 - 1 - 20)) & 0x1F;

codes[cur + 17] = (codeSection[curCode + 2] >> (32 - 1 - 25)) & 0x1F;

codes[cur + 18] = (codeSection[curCode + 2] >> (32 - 1 - 30)) & 0x1F;

codes[cur + 19] = (codeSection[curCode + 2] << 4) & 0x10;

//第三个袋子的最后1位和第四个袋子的前4位组成一个物品,然后从第四个袋子中拿出5个物品,剩下3位。

codes[cur + 19] |= (codeSection[curCode + 3] >> (32 - 4)) & 0x0F;

codes[cur + 20] = (codeSection[curCode + 3] >> (32 - 4 - 5)) & 0x1F;

codes[cur + 21] = (codeSection[curCode + 3] >> (32 - 4 - 10)) & 0x1F;

codes[cur + 22] = (codeSection[curCode + 3] >> (32 - 4 - 15)) & 0x1F;

codes[cur + 23] = (codeSection[curCode + 3] >> (32 - 4 - 20)) & 0x1F;

codes[cur + 24] = (codeSection[curCode + 3] >> (32 - 4 - 25)) & 0x1F;

codes[cur + 25] = (codeSection[curCode + 3] << 2) & 0x1C;

//第四个袋子剩下的3位和第五个袋子的前2位组成一个物品,然后第五个袋子取出6个物品。

codes[cur + 25] |= (codeSection[curCode + 4] >> (32 - 2)) & 0x03;

codes[cur + 26] = (codeSection[curCode + 4] >> (32 - 2 - 5)) & 0x1F;

codes[cur + 27] = (codeSection[curCode + 4] >> (32 - 2 - 10)) & 0x1F;

codes[cur + 28] = (codeSection[curCode + 4] >> (32 - 2 - 15)) & 0x1F;

codes[cur + 29] = (codeSection[curCode + 4] >> (32 - 2 - 20)) & 0x1F;

codes[cur + 30] = (codeSection[curCode + 4] >> (32 - 2 - 25)) & 0x1F;

codes[cur + 31] = (codeSection[curCode + 4] >> (32 - 2 - 30)) & 0x1F;

cur += 32;

curCode += 5;

}

}

11. Simple Family

另一种多个数值打包在一起,并且是字对齐的编码方式,就是下面我们要介绍的Simple家族。

对于32位机器,一个字是32个bit,我们希望这32个bit里面可以放多个数值。比如1位的放32个,2位的放16个,3位放10个,不过浪费2位,4位放8个,5位放6个,6位放5个,7位和8位都是4个算一样,9位,10位都是放3个,11位,12位一直到16位都是放2个,32位放1个,共10种方法。那么来了32位,我们怎么区分里面是哪种方法呢?在放置真正的数据之前,需要放置一个选择器(selector),来表示我们是怎么存储的,10种方法4位就够了。

如果这4位另外存储,就不是字对齐的了,所以还是将这4位放在32位里面,这样数据位就剩了28位了。那这28位怎么安排呢?1位的28个,2位的14个,3位的9个,4位的7个,5位的5个,6位和7位的4个,8位和9位的3个,10位到14位的都是2个,15位到28位的都是1个,共9种。如果同样存储2个,当然选最长的位数14,所以形成以下的表格:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

由于一共9种情况,所以这种编码方式称为Simple-9。

4位selector来表示9,太浪费了,浪费了差不多一半的编码(24=16),如果少一种情况,8种的话,就少一位做selector,多一位保存数值了,比如去掉selector=e这种情况,所有5位的都保存成7位,这样保存数据就是29位了,很遗憾,29是个质数,除了1和本身不能被任何数整除,多出的一位也往往浪费掉。

于是我们再进一步,用30位来保存数值,这样会有10种情况,而且浪费的情况也减少了。如下面的表格所示:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

虽然看起来30比28要好一些,然而剩下的两位如何表示10种情况呢?我们假设文档编号之间的差值不会剧烈抖动,一会儿大一会儿小,而是维持在一个稳定的水平,就算发生突然的变化,变化完毕后也能保持较稳定的水平。所以我们可以采取这样的策略,首先对于第一个32位,假设selector是某一个数r,比如r=f,6位保存一个数值,那么下一个32位开始的两位表示四种情况:

1) 如果用的位数少,比如3位就够,则selector=r-1=e,也即用5位保存

2) 如果用的位数不变,还是用6位保存,则selector=r

3) 如果用的位数多 ,selector=r+1=g,也即用7位保存

4) 如果r+1=7位放不下,比如需要10位,说明了变化比较突然,于是r取最大值j,用30位来保存。

一旦出现了突然的较大变化,则会使用最大的selector j,然后随着突然变化后的慢慢平滑,selector还会降到一个文档的值。当然,如果事先经过统计,发现最大的文档间隔也不过需要15位,则对于第四种情况,可以使得r=i。

这种用相对的方式来表示10种情况,成为Relative-10。

既然selector只需要2位,那么上面的表格中selector=d和selector=g的没有用的两位,不是也可以成为下一个32位的selector么?这样下一个整个32位都可以来保存数据了。

另外上面表格中每个数值的编码长度一栏为什么会从7跳到10呢?是因为8位,9位,10位都只能保存3个,当然用最大的10位了。然而如果没有用的2位可以留给下一个32位做selector,那就有必要做区分了,比如一个值只需要9位,咱们就用9位,三九二十七,剩下的三位中的两位作为下一个32位的selector。另外14位和15位也是这个情况,如果两个14位,就可以剩下两位作为下一个的selector,如果是两个15位就没有给下一个剩下什么。

这样selector由10种情况变成了12种情况,如图下面的表格所示:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

如果上一个32位剩下2位作为selector,则当前的32位则可以全部用于保存数据。当然也会有剩余,可留给后来人。那么32位全部用来保存数据的情况下,selector应该如下面表格所示:

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

这样每个32位都分为两种情况,一种是上一个留下了2位做selector,本身32位都保存数据,另一种是上一个没有留下什么,本身2位做selector,30位做数据。

这种上一个32位为下一个留下遗产,共12种情况的,成为Carryover-12编码。

下面举一个具体的例子,比如我们想编码如下的输入:

int[] input = {5, 30, 120, 60, 140, 160, 120, 240, 300, 200, 500, 800, 300, 900};

首先定义上面的两个编码表:

class Item {

public Item(int lengthOfCode, int numOfCodes, boolean leftForNext) {

this.lengthOfCode = lengthOfCode;

this.numOfCodes = numOfCodes;

this.leftForNext = leftForNext;

}

int lengthOfCode;

int numOfCodes;

boolean leftForNext;

}

static Item[] noPreviousSelector = new Item [12];

static {

noPreviousSelector[0] = new Item(1, 30, false);

noPreviousSelector[1] = new Item(2, 15, false);

noPreviousSelector[2] = new Item(3, 10, false);

noPreviousSelector[3] = new Item(4, 7, true);

noPreviousSelector[4] = new Item(5, 6, false);

noPreviousSelector[5] = new Item(6, 5, false);

noPreviousSelector[6] = new Item(7, 4, true);

noPreviousSelector[7] = new Item(9, 3, true);

noPreviousSelector[8] = new Item(10, 3, false);

noPreviousSelector[9] = new Item(14, 2, true);

noPreviousSelector[10] = new Item(15, 2, false);

noPreviousSelector[11] = new Item(28, 1, true);

}

static Item[] hasPreviousSelector = new Item [12];

static {

hasPreviousSelector[0] = new Item(1, 32, false);

hasPreviousSelector[1] = new Item(2, 16, false);

hasPreviousSelector[2] = new Item(3, 10, true);

hasPreviousSelector[3] = new Item(4, 8, false);

hasPreviousSelector[4] = new Item(5, 6, true);

hasPreviousSelector[5] = new Item(6, 5, true);

hasPreviousSelector[6] = new Item(7, 4, true);

hasPreviousSelector[7] = new Item(8, 4, false);

hasPreviousSelector[8] = new Item(10, 3, true);

hasPreviousSelector[9] = new Item(15, 2, true);

hasPreviousSelector[10] = new Item(16, 2, false);

hasPreviousSelector[11] = new Item(28, 1, true);

}

形成的编码格式如图

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

假设约定的起始selector为6,也即表3-10的g行。对于第一个32位,是不会有前面遗传下来的selector的,所以前两位表示selector=1,也即就用原始值6,第g行,接下来应该是4个7位的数值,最后剩余两位作为下一个32位的selector。Selector=2,所以用6+1=7,也即表3-11的h行,下面的整个32位都是数值,保存了4个8位的,没有遗留下什么。接下来的32位中,头两位是selector=1,还是第7行,也即第h行,只不过是表3-10中的,所以接下来应该是3个9位,遗留下最后两位selector=2,也即是7+1=8行,也即表3-11的第i行,接下来应该是3个10位的。

整个解码的过程如下:

//block是编码好的块,defaultSelector是默认的selector

private static int[] decompress(int[] block, int defaultSelector, int numOfCodes) {

//当前处理到编码块的哪一个int

int curInBlock = 0;

//解码结果

int[] output = new int[numOfCodes];

int curInOutput = 0;

//前一个selector,用于根据相对值计算当前selector的值

int prevSelector = defaultSelector;

int curSelector = 0;

//最初的编码表用当然是没有遗留的

Item[] curSelectorTable = noPreviousSelector;

//尚未处理的bit数

int bitsRemaining = 0;

//当前int中每个编码的bit数

int bitsPerCode = curSelectorTable[curSelector].lengthOfCode;

//当前要解码的32位编码

int cur = 0;

//一个循环,当curInBlock > block.length的时候,编码块已经处理完毕,但是还需要等待最后一个32位处理完毕,当bitsRemaining大于等于bitsPerCode的时候,说明还有没处理完的编码

while (curInBlock < block.length || bitsRemaining >= bitsPerCode) {

//当bitsRemaining不足一个编码的时候,说明当前的32位处理完毕,应该读入下一个32位了。

if(bitsRemaining < bitsPerCode){

//当bitsRemaining小于2,说明当前32位剩下的不足2位,不够给下一个做selector的,因而下一个selector在下一个32位的头两位。

if(bitsRemaining < 2){

//取下一个32位

cur = block[curInBlock++];

//前两位为selector

int selector = (cur >> 30) & 0x03;

//根据selector的相对值计算当前的selector

if(selector == 0){

curSelector = prevSelector - 1;

} else if (selector == 1){

curSelector = prevSelector;

} else if (selector == 2) {

curSelector = prevSelector + 1;

} else {

curSelector = curSelectorTable.length - 1;

}

prevSelector = curSelector;

//当前32位中数据仅仅占30位

bitsRemaining = 30;

//使用编码表3-10

curSelectorTable = noPreviousSelector;

}

//如果bitRemaining大于等于2,足够给下一个做selector,则解析最后两位为selector。

else {

int selector = cur & 0x03;

if(selector == 0){

curSelector = prevSelector - 1;

} else if (selector == 1){

curSelector = prevSelector;

} else if (selector == 2) {

curSelector = prevSelector + 1;

} else {

curSelector = curSelectorTable.length - 1;

}

prevSelector = curSelector;

//取下一个32位,全部用于保存数值

cur = block[curInBlock++];

bitsRemaining = 32;

//使用编码表3-11

curSelectorTable = hasPreviousSelector;

}

bitsPerCode = curSelectorTable[curSelector].lengthOfCode;

}

//在bitRemaing中截取一个编码,解码到输出,更新bitsRemaining

int mask = (1 << bitsPerCode) - 1;

output[curInOutput++] = (cur >> (bitsRemaining - bitsPerCode)) & mask;

bitsRemaining = bitsRemaining - bitsPerCode;

}

return output;

}

12. 跳跃表

上面说了很多的编码方式,能够让倒排表又小又快的存储和解码。

但是对于倒排表的访问除了顺序读取,还有随机访问的问题,比如我想得到第31篇文档的ID。

第一点,差值编码使得每个文档ID都很小,是个不错的选择。第二点,上面所述的很多编码方式都是变长的,一个挨着一个存储的。这两点使得随机访问成为一个问题,首先因为第二点我们根本不知道第30篇文档在文件中的什么位置,其次就算是找到了,因为第二点,找到的也是差值,需要知道上一个才能知道自己,然而上一个也是差值,难不成每访问一篇文档整个倒排表都解压缩一遍?

看过前面前端编码的同学可能想起了,差值和前缀这不差不多的概念么?弄几个排头兵不就行啦。

如图,上面的倒排表被用差值编码表示成了下面一行的数值。我们将倒排表分成组,比如3个数值一组,每组有个排头兵,排头兵不是差值编码的,这样如果你找第31篇文档,那它肯定在第10组里面的第二个,只需要解压一个组的就可以了。

Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

有了跳跃表,根据文档ID查找位置也容易了,比如要在文档57,在排头兵中可以直接跳过17,34,45,肯定在52的组里,发现52+5=57,一进组就找到了。

当然排头兵如果比较大,也可以用差值编码,是基于前一个排头兵的差值,由于排头兵比较少,反正查找的时候要一个个看,都解压了也没问题。

如果链表比较长,导致排头兵比较多,没问题还可以有多级跳跃表,排头兵还有排头兵,排长上面是连长。这样查找的时候,先查找连长,找到所在的连再查找排长,然后再进组查找。

上面提到的PForDelta的编码方式可以和跳跃表进行整合,由于PForDelta的编码区域是定长的,基本可以满足随机访问,然而对于差值问题,可以再entry中添加排头兵的值的信息,使得128个数值成为一组。

我们姑且称这种方式为跳跃表规则。