搜索引擎的设计(2):倒排表的设计上
1. 定长编码
最容易想到的方式就是常用的普通二进制编码,每个数值占用的长度相同,都占用最大的数值所占用的位数,如图所示。
这里有一个文档ID列表,254,507,756,1007,如果按照二进制定长编码,需要按照最大值1007所占用的位数10位进行编码,每个数字都占用10位。
和词典的格式设计中顺序列表方式遇到的问题一样,首先的问题就是空间的浪费,本来254这个数值8位就能表示,非得也用上10位。另外一个问题是随着索引文档的增多,谁也不知道最长需要多少位才够用。
2. 差值(D-gap)编码
看过前面前端编码的读者可能会想到一个类似的方法,就是我们可以保存和前一个数值的差值,如果Term在文档中分布比较均匀(差不多每隔几篇就会包含某个Term),文档ID之间的差别都不是很大,这样每个数值都会小很多,所用的位数也就节省了,如图所示。
还是上面的例子中,我们可以看到,这个Term在文档中的分布还是比较均匀的,每隔差不多250篇文档就会出现一次这个Term,所以计算差值的结果比较理想,得到下面的列表254,253,249,251,每个都占用8位,的确比定长编码节省了空间。
然而Term在文档中均匀分布的假设往往不成立,很多词汇很可能是聚集出现的,比如奥运会,世界杯等相关的词汇,在一段时间里密集出现,然后会有很长时间不被提起,然后又出现,如果索引的新闻网页是安装抓取先后来编排文档ID的情况下,很可能出现图所示的情况。
在很早时间第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,首先对于1+floor(logx)进行一元编码,然后用floor(logx)个bit对x-2^floor(logx)进行二进制编码。
比如对于正整数10,floor(log10)=3,对于4进行一元编码1110,计算10-2^floor(log10)=2,用3个bit进行编码010,所以最后编码为1110010。
可以看出,Gamma编码综合了一元编码和二进制编码,对于第一部分一元编码的部分,可以知道何时结束并且无歧义编码,对于二进制编码的部分,由于一元编码部分指定了二进制部分的长度,因而也可以知道何时结束并无歧义解码。
比如读取上述编码,先读取一系列1,到0结束,得到1110,是一元编码部分,值为4,所以后面3个bit是二进制部分,读取下面的3个bit(即便这3个bit后面还有些0和1,都不是这个数值的编码了),010,值为2,最后2+2^3=10,解码成功。
Gamma编码比单纯的一元编码好的多,对于小的数值也是很有效的,但是当数值增大的情况下,就暴露出其中一元编码部分的弱势,比如1000经过编码需要19个bit。
5. Elias Delta编码
我们在Gamma编码的基础上,进一步减少一元编码的影响,形成Delta编码,它的规则:对于正整数x,首先对于1+floor(logx)进行Gamma编码,然后用floor(logx)个bit对x-2^floor(logx)进行二进制编码。
比如对于正整数10,floor(log10)=3,首先对于4进行Gamma编码,floor(log4)=2,对于3进行一元编码110,然后用2个bit对4-2^floor(log4)=0进行二进制编码00,所以4的Gamma编码为11000,然后计算10-2^floor(log10)=2,用3个bit进行编码010,所以最后编码为11000010。
Delta是Gamma编码和二进制编码的整合,也是符合变长编码要求的。
如果读取上述编码,先读入一元编码110,为数值3,接着读取3-1=2个bit为00,为数值0,所以Gamma编码为0+2^2=4,然后读取三个bit位010,二进制编码数值为2,所以2+2^3=10,解码成功。
尽管从数值10的编码中,我们发现Delta比Gamma使用的bit数还多,然而当数值比较大的时候,Delta就相对比较好了。比如1000经过Delta编码需要16个bit,要优于Gamma编码。
6. 哈夫曼编码
前面所说的编码方式都有这样一个特点,“任尔几路来 ,我只一路去”,也即无论要压缩的倒排表是什么样子的,都是一个方式进行编码,这种方法显然不能满足日益增长的多样物质文化的需要。接下来介绍的这种编码方式,就是一种看人下菜碟的编码方式。
前面也说到变长编码无歧义,要求任何一个编码都不是其他编码的前缀。学过数据结构的同学很可能会想到——哈夫曼编码。
哈夫曼编码是如何看人下菜碟的呢?哈夫曼编码根据数值出现的频率不同而采用不同长度进行编码,出现的次数多的编码长度短一些,出现次数少的编码长度长一些。
这种方法从直觉上显然是正确的,如果一个数值出现的频率高,就表示出现的次数多,如果采用较短的bit数进行编码,少一位就能节约不少空间,而对于出现频率低的数值,比如就出现一次,我们就用最奢侈的方法编码,也占用不了多少空间。
当然这种方法也是符合信息论的,信息论中的香农定理给出了数据压缩的下限。也即用来表示某个数值的bit的数值的下限和数值出现的概率是有关的:I(s)=-logP(s)。看起来很抽象,举一个例子,如果抛硬币正面朝上概率是0.5,则至少使用1位来表示,0表示正面朝上,1表示没有正面朝上。当然很对实际运用中,由于对于数值出现的概率估计的没有那么准确,则编码达不到这个最低的值,比如用2位表示,00表示正面朝上,11表示没有正面朝上,那么01和10两种编码就是浪费的。对于整个数值集合s,每个数值所占用的平均bit数目,即所有I(s)的平均值H=Sum(-P(s)logP(s)),称为墒。
要对一些数值进行哈夫曼编码,首先要通过扫描统计每个数值出现的次数,假设统计的结果如图所示。
其次,根据统计的数据构建哈夫曼树,构建过程是这样的,如图:
1) 按照次数进行排序,每个文档ID构成一棵仅包含根节点的树,根节点的值即次数。
2) 将具有最小值的两棵树合并成一棵树,根节点的值为左右子树根节点的值之和。
3) 将这棵新树根据根节点的值,插入到队列中相应的位置,保持队列排序。
4) 重复第二步和第三步,直到合并成一棵树为止,这棵树就是哈夫曼树。
最终,根据最后形成的哈夫曼树,给每个边编号,左为0,右为1,然后从根节点到叶子节点的路径上的字符组成的二进制串就是编码,如图所示。
最终形成的编码,我们通过观察可以发现,没有一个文档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步中,将一棵新树插入到排好序的队列中的时候,如果遇到了两个相同的值,谁排在前面?不同的排列方法会产生不同的哈夫曼树,最终影响最后的编码,如图。
为了解决上面两个问题,大牛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完成对所有数值的编码。
按照这个规范,图中的编码应该如图:
根据这些规则,不稳定性首先得到了解决,无论同一个层次的节点排序如何,都会按照数值或字符的排序来决定编码。
然后就是占用内存的问题,如果使用范式哈夫曼编码,则只需要存储下面的数据结构,如图:
当然原本数值的列表还是需要保存的,只不过顺序是安装占用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的数目为:
按照香农定理最低占用的bit数为
可以看出哈夫曼编码的压缩效果相当不错。然而在真正的搜索引擎系统中,文档是不断的添加的,很难事先做全局的统计。
对于每一个倒排表进行局部的统计和编码是另一个选择,然而付出的代价就是需要为每一个倒排表保存一份上述的结构来进行解码。很不幸上述的结构中包含了数值的列表,如果一个倒排表中数值重复率很高,比如100万的长的倒排表只有10种数值,为100万保存10个数值的列表还可以接受,如果重复率不高,那么数值列表本身就和要压缩的倒排表差不多大了,根本起不到压缩作用。
7. Golomb编码
如果我们将倒排表中文档ID的概率分布假设的简单一些,就没必要统计出现的所有的数值的概率。比如一个简单的假设就是:Term在文档集集合中是独立随机出现的。
既然是随机出现的,那么就有一个概率问题,也即某个Term在某篇文档中出现的概率是多少?假设我们把整个倒排结构呈现如图的矩阵的样子,左面是n个Term,横着是N篇文档,如果某个Term在某篇文档中出现,则那一位设为1,假设里面1的个数为f,那么概率p=f/(N*n)。
正如在差值编码一节中论述的那样,我们在某个Term的倒排表里面保存的不是文档ID,而是文档ID的间隔的数值,我们想要做的事情就是用最少的bit数来表示这些间隔数值。
如果所有的间隔组成的集合是已知的,则可用上一节所述的哈夫曼编码。
我们在这里想要模拟的情况是:间隔组成的集合是不确定的,甚至是无限的,随着新的文档的不断到来进行不断的编码。
可以形象的想象成下面的情形,一个Term坐在那里等着文档一篇篇的到来,如果文档包含自己,就挂在倒排表上。
如果文档间隔是x,则表示的情形就是,来一篇文档不包含自己,再来一篇还是不包含自己,x-1篇都过去了,终于到了第x篇,包含了自己。如果对于一篇文档是否包含自己的概率为p,则文档间隔x出现的概率就是P(x)=((1-p)^(x-1)) * p。
假设编码当前的文档间隔x用了n个bit,这个Term接着等下一篇文档的到来,结果这次更不幸,等了x篇还包含自己,直到等到x+b篇文档才包含自己,于是要对x+b进行编码,x+b出现的概率为p(x+b)=(1-p)^b *(1-p)^(x-1) * p,显然比x的概率要低,根据信息论,如果x用n个bit,则x+b要使用更多的bit,假设(1-p)^b=1/2,则最优的情况应该多用1个bit。
这样我们就形成了一个递推的情况,假设已知文档间隔x用了n个bit,对于b=-ln2/ln(1-p)来说,x+b就应该只用n+1个bit,这样如果有了初始的文档间隔并且进行了最优的编码,后面的都能达到最优。
于是Golomb编码就产生了,对于参数b(当然是根据文档集合计算出的概率产生的),对于数值x的编码分为两部分,第一部分计算q=floor((x-1)/b),然后将q+1用一元编码,第二部分是余数,r=x-1-qb,由于余数一定在0到b-1之间,则可以用floor(logb)或者ceiling(logb)进行无前缀编码(哈夫曼编码)。
用上面的理论来看Golomb编码就容易理解了,第一部分是用来保持上面的递推性质的,一元编码的性质可以保证,数值增加1,编码就多用1位,递推性质要求数值x增加b,编码增加1位,于是有了用数值x除以b,这样q(x+b)=floor((x-1+b)/b)=floor((x-1)/b)+1=q(x)+1。第二部分的长度对于每个编码都是一样的,最多不过差一位,与数值x无关,仅仅与参数b有关,其实第二部分是用来保证初始的文档间隔是最优的,所以哈夫曼编码进行无前缀编码。
例如x=9,b=6,则q=floor((9-1)/6)=1,对q+1用一元编码为10,余数r=2,首先对于所有的余数进行哈夫曼编码,形成如图的哈夫曼树,从最小的余数开始进行范式哈夫曼编码,0为00,1为01,2占用三个bit,为01 + 1补充一位,为100,3为101,4为110,5为111。所以x=9的编码为10100。
接下来我们试图编码x=9+6=15,b=6,则q=floor((15-1)/6)=2,对q+1用一元编码为110,余数r=2,编码为100,最后编码为110100,果真x增大b,编码多了1位。
接下来要解决的问题就是如何确定b的值,按照咱们的理论推导b=-ln2/ln(1-p),计算起来有些麻烦,我们先来计算分母部分-ln(1-p) = p * (1/-p)ln(1+(-p))=p * ln(1+(-p))^(1/-p),当p接近于0的时候,由著名的极限公式lim(1+1/n)^n=e,所以分母约为p,于是公式最后为b=ln2/p=0.69 * (1/p)
由于Golomb编码仅仅需要另外保存一个参数b,所以既可以基于整个文档集合的概率进行编码,这个时候b=0.69 * (N*n/f),也可以应用于某一个倒排表中,对于一个倒排表进行局部编码,以达到更好的效果,对于某一个倒排表,term的数量n=1,f=词频Term Freqency,b=0.69 * N/tf,这样不同的倒排表使用不同的参数b,达到这样一个效果,对于词频高的Term,文档出现的相对紧密,用较小的b值来编码,对于词频低的Term,文档出现的相对比较松散,用较大的b来进行编码。
8. 插值编码(Binary Interpolative Coding)
前面讲到的Golomb编码表现不凡,实现了较高的压缩效果。然而一个前提条件是,假设Term在文档中出现是独立随机的,在倒排表中,文档ID的插值相对比较均匀的情况下,Golomb编码表现较好。
然而Term在文档中却往往出现的不那么随机,而往往是相关的话题聚集在一起的出现的。于是倒排表往往形成如下的情况,如图.
我们可以看到,从文档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数也越来越小。
下面详细叙述一下编码的整个过程,如图所示。
最初的时候,我们要处理的是整个倒排表,长度Length为7,面对的从Low=1到High=20总共有20个坑。还是采取限制两头中间插入的思路,我们先找到中间数值11,然后找一坑插入它,那两头如何限制呢?是不是从1到20都可以插入呢?当然不是,因为数值11的左面有三个数值Left=3,一个数值一个坑的话,至少要留三个坑,数值11的右面也有三个数值Right=3,则右面也要留三个坑,所以11这根标杆只能插到从4到17共14个坑里面,也就是说共有14中选择,用二进制表示的话,需要ceiling(log(17-4+1))=4 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>这一部分,如图。
同样选取中间的数值8,然后左面需要留一个坑Left=1,右面需要留一个坑Right=1,所以8所能插入的坑从2到9共8个坑,也就是8中选择,用二进制表示,需要ceiling(log(9-2+1))=3 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>部分,如图。
只有一个数值3,左右也不用留坑,所以可以插入从1到7任何一个坑,共7中选择,需要3bit,编码3-1=2为010。
对于<Length=1,Low=9,High=10>部分,如图。
只有一个数值9,可以选择的坑从9到10两个坑,共两种选择,需要1bit,编码9-9=0为0。
再来处理<Length=3, Low=12, High=20>部分,如图。
选择插入中间数值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就可以。
对于<Length=1,Low=14,High=20>,如图,只有一个值17,放在14到20之间7个坑中,有7中选择,需要3bit,编码17-14=3为011。
综上所述,最终的编码为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。
解码完毕。