我们在上节,lucene索引结构(四)中分析了lucene倒排索引的词典部分。
词典的作用就是让程序查询词项是否存在,将词项倒排(posting)记录的地址返回。
Lucene中,一个词项的倒排有词频信息和位置信息两部分。
其中词频信息记录存储了某词项在一系列文档中出现了多少次,位置信息记录的是词项在文档中出现的一系列位置。
他们分别被存储在.frq文件和.prx文件中。
本节就来分析一下.frq文件的结构。
1. 作用
还是和前几小节一样,这里先大致介绍一下词频倒排索引的作用是什么。
说白了,词频倒排记录的是,
这一映射 -> , ....
右边的一部分。
它记录了词项termID出现的一系列文档,以及在这些文档中分别出现了多少次。
词频信息是搜索引擎在判定query和文档相关程度以及打分排序过程中一个非常重要的参数。一个词在文档A中出现了1次,而在文档B中出现了10次,显然文档A和文档B关于它的相关程度是不一样的。(当然打分不可能只依赖于文档频率,文档集频率,逆文档频率等等都是非常重要的参数,再此不详细介绍了)
2 .frq文件格式分析
.frq文件结构请见下图,图中将词典也一起画进去了。目的是为了让读者能够更直观的看到搜索引擎是如何从词典得到词频的倒排地址的。
图片太大显示不完,请右键另存或拖到浏览器新标签查看。
可以看到.frq是由一个个TermPostingList构成的。一共有TermCount个TermPostingList,也即每个词项(Term)有一个倒排链表。
每个词项的TermPostingList又由TermFreqs和SkipData两部分组成,是按照Term来进行排序的(和tis文件中TermInfo的顺序一致)。
TermFreqs是词项完整的词频倒排记录,即这个词项在哪个文档中出现了多少次,都记录在这个结构中。
SkipData存储的是这个词项词频倒排记录的跳表,在合并倒排等操作中,可以起到提速作用。
下面分别介绍它们俩,
1) TermFreqs,词频倒排记录。
由DocFreq个TermFreq个结构组成,每一个TermFreq结构即是一个词项在一篇文档中的词频记录,它是按照文档ID升序排的。
每一个TermFreq结构由DocDelta[,Freq?]这样的结构组成。
下面要重点讲讲这个结构。
很多关于lucene的blog上讲到这个结构时,都使用了"或然跟随法则"这个字眼。不知道这个字眼是谁发明的,但我实在无力吐槽它的坑爹了。。显得高深dyb,其实就是一个很简单的压缩算法。
这种压缩算法的最主要的目的就是在"这个词项在文档中只出现1次"的情境下,将文档ID间距和词频(也即1)压缩在一个VInt中存储。从而节省了0000 0001(一个Byte)的存储空间。
例如,00011111(间距) 00000001(词频)
压缩后,00111111(DocFreq)
看懂了吗?
if(Freq==1) DocFreq = (间距<<1) | 1;
就是将间距左移1位,然后将词频1保存到它的最低位。保存为 "间距*2"
由于现实中的确不少词项在一些文档中只出现一次,所以这种压缩算法是可以省去不少空间的。
而当词频Freq不为1的时候,就将Freq单独保存为一个VInt,但是仍然将间距*2,即保存为"间距*2,Freq"。
例如一个词项在文档7中出现1次,在文档11中出现3次,那么TermFreqs就是如下:
15,8,3
15 = (0x111 << 1) | 1(间距7右移1位并最低位或上1)
8 = (0x100 << 1) (间距4右移1位,但由于词频为3,最低位不能或1)
3 = 0x11(词频3)
当解析的时候,
间距 = DocDelta/2;
if(DocDelta is odd) Freq = 1;
eles Freq = input.readVInt(); // 如果为偶,读入下一个VInt为词频
这是omitTF=false时的情况。
如果omitTF=true,就表示忽略词频和位置信息。那么此时TermFreq就简单的存储为“间距”。上面的例子就存为7,4。
2) SkipData,词频倒排记录的跳表。
关于跳表,有几点需要说明
a) 跳跃表可根据倒排表本身的长度(DocFreq)和跳跃的幅度(SkipInterval)而分不同的层次,层次数为NumSkipLevels = Min(MaxSkipLevels, floor(log(DocFreq/log(SkipInterval))))。也就是跳表最深不能超过MaxSkipLevels层(亲,还记得MaxSkipLevels存在哪儿吗,在tii和tis中都有)。
b) 第Level层的节点数为DocFreq/(SkipInterval^(Level + 1)),level从零计数。即每一层跳表在低一层跳表中每隔SkipInterval取一个节点记录,最低层跳表(0层)直接在倒排TermFreqs中取。
c) 除了最低层(0层)外,每层开头之处都有一个SkipLevelLength字段,描述该层跳表的二进制长度。这个字段很有用,当机器内存足够的时候,lucene在加载索引的时候,可以把高层跳表加载到内存。为啥最后一层没有呢?难道这台机器的内存就不能大到也可以装下它么?是可以的,不过此时已经将其他层的索引加载了,那么剩下的跳表数据,直到SkipData的EOF都是第0层的了,全读完就行了,所以不需要长度字段了。这也可以看出Lucene在索引空间上的细扣,不放过一点优化。
d) 每一个跳跃节点包含以下信息:文档号,payload的长度,文档号对应的倒排表中的节点在frq中的偏移量,文档号对应的倒排表中的节点在prx中的偏移量。
然后接下来大家就会注意到一点,咦?SkipData在磁盘上怎么是排在TermFreqs后面的,而TermFreq又是不定长的,难道要把前面的TermFreq挨个挨个IO一遍之后才能读跳表么?至少我第一次看到.frq的文档的时候有这个疑问。
显然不是这样的,要不这个索引得脑残到什么程度啊?
回过头来看看我们的词典tis,一个词项在tis中记录为一个TermInfo结构,而这个结构里有一个FreqDelta字段,通过在它之前的TermInfo,能够加和间距得到这个Term的词频倒排在.frq文件中的起始地址;然后注意到TermInfo还有一个字段SkipDelta,能够通过它得到这个Term的跳表起始位置。具体看图吧。
这就解答了上面的问题。也使我们能够在脑中走了一次lucene检索某个term的过程。这下子lucene是如何通过FreqDelta和SkipDelta字段将字典(tis)和词频倒排(frq)"连接"在一起的也很清楚了。
可以看到.frq是由一个个TermPostingList构成的。一共有TermCount个TermPostingList,也即每个词项(Term)有一个倒排链表。
每个词项的TermPostingList又由TermFreqs和SkipData两部分组成,是按照Term来进行排序的(和tis文件中TermInfo的顺序一致)。
TermFreqs是词项完整的词频倒排记录,即这个词项在哪个文档中出现了多少次,都记录在这个结构中。
SkipData存储的是这个词项词频倒排记录的跳表,在合并倒排等操作中,可以起到提速作用。
下面分别介绍它们俩,
1) TermFreqs,词频倒排记录。
由DocFreq个TermFreq个结构组成,每一个TermFreq结构即是一个词项在一篇文档中的词频记录,它是按照文档ID升序排的。
每一个TermFreq结构由DocDelta[,Freq?]这样的结构组成。
下面要重点讲讲这个结构。
很多关于lucene的blog上讲到这个结构时,都使用了"或然跟随法则"这个字眼。不知道这个字眼是谁发明的,但我实在无力吐槽它的坑爹了。。显得高深dyb,其实就是一个很简单的压缩算法。
这种压缩算法的最主要的目的就是在"这个词项在文档中只出现1次"的情境下,将文档ID间距和词频(也即1)压缩在一个VInt中存储。从而节省了0000 0001(一个Byte)的存储空间。
例如,00011111(间距) 00000001(词频)
压缩后,00111111(DocFreq)
看懂了吗?
if(Freq==1) DocFreq = (间距<<1) | 1;
就是将间距左移1位,然后将词频1保存到它的最低位。保存为 "间距*2"
由于现实中的确不少词项在一些文档中只出现一次,所以这种压缩算法是可以省去不少空间的。
而当词频Freq不为1的时候,就将Freq单独保存为一个VInt,但是仍然将间距*2,即保存为"间距*2,Freq"。
例如一个词项在文档7中出现1次,在文档11中出现3次,那么TermFreqs就是如下:
15,8,3
15 = (0x111 << 1) | 1(间距7右移1位并最低位或上1)
8 = (0x100 << 1) (间距4右移1位,但由于词频为3,最低位不能或1)
3 = 0x11(词频3)
当解析的时候,
间距 = DocDelta/2;
if(DocDelta is odd) Freq = 1;
eles Freq = input.readVInt(); // 如果为偶,读入下一个VInt为词频
这是omitTF=false时的情况。
如果omitTF=true,就表示忽略词频和位置信息。那么此时TermFreq就简单的存储为“间距”。上面的例子就存为7,4。
2) SkipData,词频倒排记录的跳表。
关于跳表,有几点需要说明
a) 跳跃表可根据倒排表本身的长度(DocFreq)和跳跃的幅度(SkipInterval)而分不同的层次,层次数为NumSkipLevels = Min(MaxSkipLevels, floor(log(DocFreq/log(SkipInterval))))。也就是跳表最深不能超过MaxSkipLevels层(亲,还记得MaxSkipLevels存在哪儿吗,在tii和tis中都有)。
b) 第Level层的节点数为DocFreq/(SkipInterval^(Level + 1)),level从零计数。即每一层跳表在低一层跳表中每隔SkipInterval取一个节点记录,最低层跳表(0层)直接在倒排TermFreqs中取。
c) 除了最低层(0层)外,每层开头之处都有一个SkipLevelLength字段,描述该层跳表的二进制长度。这个字段很有用,当机器内存足够的时候,lucene在加载索引的时候,可以把高层跳表加载到内存。为啥最后一层没有呢?难道这台机器的内存就不能大到也可以装下它么?是可以的,不过此时已经将其他层的索引加载了,那么剩下的跳表数据,直到SkipData的EOF都是第0层的了,全读完就行了,所以不需要长度字段了。这也可以看出Lucene在索引空间上的细扣,不放过一点优化。
d) 每一个跳跃节点包含以下信息:文档号,payload的长度,文档号对应的倒排表中的节点在frq中的偏移量,文档号对应的倒排表中的节点在prx中的偏移量。
然后接下来大家就会注意到一点,咦?SkipData在磁盘上怎么是排在TermFreqs后面的,而TermFreq又是不定长的,难道要把前面的TermFreq挨个挨个IO一遍之后才能读跳表么?至少我第一次看到.frq的文档的时候有这个疑问。
显然不是这样的,要不这个索引得脑残到什么程度啊?
回过头来看看我们的词典tis,一个词项在tis中记录为一个TermInfo结构,而这个结构里有一个FreqDelta字段,通过在它之前的TermInfo,能够加和间距得到这个Term的词频倒排在.frq文件中的起始地址;然后注意到TermInfo还有一个字段SkipDelta,能够通过它得到这个Term的跳表起始位置。具体看图吧。
这就解答了上面的问题。也使我们能够在脑中走了一次lucene检索某个term的过程。这下子lucene是如何通过FreqDelta和SkipDelta字段将字典(tis)和词频倒排(frq)"连接"在一起的也很清楚了。