显然是反向索引。
好了,现在我们开始介绍反向索引部分。
0. 一些说明
反向索引分成两部分,如下图(图来自《信息检索导论》):
左面是词项词典(Term Dictionary),右边是倒排记录表(Posting)。
在Lucene中,词典和倒排是分开存储的,词典存储在.tii和.tis文件中,而倒排又分为两部分存储,第一部分是文档号和词频信息,存储在.frq中;另一部分是词的位置信息,存储在.prx文件中。
本章节将介绍.tii和.tis文件。
1. 索引文档集
在分析索引之前,需要对几篇文档进行索引。
去ChinaDaily上扒了三篇英文新闻,然后重新用索引程序对它们进行索引。
和前面几篇文章介绍的一样,也是对它们分成path,modified和contents三个域进行索引,分别索引他们的路径,最后修改时间以及内容。具体代码如下:
doc.add(new Field("path", f.getPath(), Field.Store.YES, Field.Index.NOT_ANALYZED,Field.TermVector.WITH_POSITIONS_OFFSETS ));
doc.add(new Field("modified",
DateTools.timeToString(f.lastModified(), DateTools.Resolution.MINUTE),
Field.Store.YES, Field.Index.NOT_ANALYZED,Field.TermVector.WITH_POSITIONS_OFFSETS));
doc.add(new Field("contents", br));
2. 词项词典索引(.tii,.tis)结构分析
2.1 作用
凡是存在的,必是合理的。
在分析结构之前,还是和之前一样,分析分析词典索引的作用吧。
其实,在这儿也没啥好分析的,囧。。因为要没词典的话,你还搜个毛。。。
只要但凡对搜索引擎索引的倒排结构稍有了解,就知道和搜索最相关的部分是反向索引,也即<词项ID->文档ID列表>的这么一个映射。
很难想象一个搜索引擎接到一个query词之后,对所有文档grep一遍来取出命中的文档,这得多慢。
当用户输入Query后,经过诸如大小写处理、分词、同义词处理、停用词去除等等等等一系列Query变换之后,最终会到词典中(此时词典已经由Lucene加载到内存了)中查询有无命中,如果有命中,就会取出它的词频和位置信息,这两个信息分别是存储在.frq和.prx的,而词典就会保存这个词的词频信息、位置信息在这两个文件中的地址。
这样,程序就能很快的从.frq和.prx倒排文件中取出该词项的信息,然后就可以开始根据这些信息进行打分、排序什么的了,最后将一系列匹配上的文档返回。完成检索。
2.2 词项词典(Term Dictionary)索引文件结构分析
它由两个文件组成,
1).tis文件:Term infos file,这个文件保存了这个段(segment)中所有词项的信息,可以通过查询这个文件,来找到某个词项在.frq和.prx中的偏移,查到这个词项的文档频率,属于哪一个域。在词典中,所有的词项都是按照字典序排序的。
2).tii文件:Term infos index file。可以把这个文件理解成.tis文件的"跳表",它按一定的间隔来记录tis中的词项,用来加速词项信息的查找。
例如tis中有如下词汇
abandon -> bus -> chinese -> dictionary -> email -> fine -> god
如果要查询dictionary,就必须挨个遍历(因为tis中的记录不是定长,所以必须遍历)。
而如果以2为间隔来搞一张跳表,跳表里是保存着这个词在tii中的偏移的。
abandon->chinese->email->..
这样就可以先查跳表,当查到email的时候,发现email>dictionary,不能继续了。就取出跳表前一项(chinese)的位置,从tis的这个位置开始遍历,从而避免了在tis中遍历abandon->chinese这段。
当然跳表间隔不可能是2那么小,这样遍历跳表也会很花时间。间隔肯定会是一个折中。
总而言之,tis文件包括所有词项的信息,而tii文件加快程序在tis中查询词项信息的速度。
它们的结构如下图(图较大,请右键另存或在新标签看):
那么接下来就分析一下各个字段的意义吧,
1) .tis文件:
a) TIVersion: UInt32, 没啥好说了,就一版本号。
b) TermCount:UInt64,词典中包含的词项总数。
c) IndexInterval:UInt32,这就是前面说的tii文件保存词项的'间隔',tii文件保存第IndexInterval,2*IndexInterval,3*IndexInterval,....个词项。
d) SkipInterval : UInt32, 倒排表无论是文档号及词频,还是位置信息,都是以跳跃表的结构存在的,SkipInterval是跳跃的步数。
e) MaxSkipLevels : UInt32, 倒排的跳表是多层的结构(多级加速嘛),MaxSkipLevels指的就是.frq索引的跳表的最大层数。这个值越小,显然索引占用的空间也越小,但是加速较小;如果层数多,占用空间当然更大,但是加速效果也更快。这也是个折中,后面分析.frq的时候再议。
f) TermInfo(<Term(<PrefixLen,Suffix,FieldNum>) , DocFrequency, FrequencyDelta, ProxyDelta,SkipDelta>),一共有TermCount个
Term(<PrefixLen,Suffix,FieldNum>) : 这个三元组是描述构成这个词项的字符串。
PrefixLen:Vint, 同样这儿也用了前缀压缩算法,PrefixLen表示取出前一个词的前PrefixLen个字符,来和Suffix拼接,得到这个词项。
Suffix : String(Vint,Chars),长度+字符。前面的文章说了很多次了,不解释。
FieldNum:VInt,区分这个词项是哪个域的。比如tilte和contents都含有"Jike",这就得通过这个域来区分了。严格来说,一个词项是"词项字符.域ID"的二维信息,不同域的词项即便字符相同,也不是同一个词。这里相当于把域信息放在词典中,和《信息检索导论》上介绍的放在倒排中的方式不一样。
DocFreq, VInt,词项的文档频率,也即包含这个词项的文档数量。
FreqDelta,VInt,记录词项的频率信息(TermFreqs)在.frq中的偏移。注意这也是用间隔来压缩,保存的值是这个词项的地址和上一词项的地址之差。
ProxDelta,VInt, 记录词项的位置信息(TermPositions)在.prx中的偏移,也是用了间隔压缩,保存的是差值。
SkipDelta,VInt,,此词的倒排表的跳跃表在frq中的偏移量(SkipDelta)。
2) .tii文件:
a) TIVersion: UInt32, 版本号,不解释。
b) IndexTermCount:UInt64, 这是跳表中包含的词项个数。IndexTermCount=TermCount / IndexInterval + 1(因为有一个虚拟TermInfo在第一个)。
c) IndexInterval,UInt32,同.tis中的IndexInterval。
d) SkipInterval, UInt32,同.tis中的SkipInterval。
e) MaxSkipLevels,UInt32,同.tis中的MaxSkipLevels。
f) TermIndex(<TermInfo,IndexDelta>),一共有IndexTermCount个。
Term(<PrefixLen,Suffix,FieldNum>) : 这个三元组是描述构成这个词项的字符串。
PrefixLen,Suffix,FieldNum都在上面解释过了,这儿不解释了。
IndexDelta,VLong,可变长类型,指向跳表中的这个词项的下一个词项TermInfo在tis中的开始位置。
比如,tis为abandon -> bus -> chinese -> dictionary -> email -> fine -> god
tii为 abandon->chinese->email->god
那么chinese的IndexDelta指向的实际上是dictionary在tis中的起始位置,仔细想想跳表是如何工作的,相信你很好理解Lucene为什么这么做。
需要注意的是,IndexDelta也是个利用间隔压缩编码,存储的实际上是差值。
特别提醒,这个差值是TermIndex和跳表中上一个TermIndex指向位置的差值(比如chinese和abandon指向的地址的差值),而不是被指向的tii中的dictionary和chinese的地址差。
3. 深入.tis,.tii的内部
如下图,就是UE打开索引后,图较大,请右键另存或新标签里查看。
中间的省略号表示没有把.tis文件完整显示出来,因为IndexInterval比较大,因此跳表第二项就跳到了比较大的地址(0x052E)上,要是把0到这个地址的截图都放上来,那也太长了。。。
1).tii文件:
a) TIVersion: 取值为FF FF FF FC,转成Int(注意,这可是带符号整数)后,为-3。这和TermInfosWriter.FORMAT取值是一样的。
b) TermCount: 0x207,即字典里一共有519个词项。
c) IndexInterval为0x80,也就是每隔128,跳一下。因此跳表的数量即为IndexTermCount = TermCount / IndexInteval + 1 = 519/128 +1 =5个。
d) SkipInterval为0x10,也即16。
e) MaxSkipLevels,0x0A,即10。
可看到IndexInterval,SkipInterval和MaxSkipInterval的默认值是在TermInfosWriter类里定义的。
f) PrefixLen,为0,这可是第1个词项,还有毛前缀,故为0。
g) Suffix长为5,读出来就是10.35。
h) FieldNum为2,也就是contents域(0是path,1是modified)。
i) DocFreq,为1,只出现在一篇文档里。
j) FreqDelta和ProxDelta都是0,表示10.35这个词项在.frq和.prx文件中的偏移都是0.
之后又开始了下一个词项,现在可以看出前缀压缩了。前缀是1,表示取"10.35"的前1位,和后缀"1.8"拼接,即第2个词项为"11.8"。
去被索引的文本文件里看了下,的确存在"10.35"和"11.8"。
他们的文档频率和出现的域,都和.tis里的对应字段一致。
2).tis文件
a) TIVersion: 取值为FF FF FF FC,转成Int(注意,这可是带符号整数)后,为-3。这和TermInfosWriter.FORMAT取值是一样的。
b) IndexTermCount ,UInt64, 为5。具体怎么来的上面刚刚解释过,不再重复。
c) 之后的IndexInternal,SkipInternal,MaxSkipLevels都和上面一样,不再重复。
接下来就是跳表的TermIndex部分了,一共有IndexTermCount个TermIndex,每个TermIndex包含词项信息TermInfo,以及在词典中它下一个词项的偏移IndexDelta。
d) 第一个TermIndex是一个虚拟的"词项",插入一个虚拟节点的目的很简单,因为虚拟节点的IndexDelta是要指向它在词典中的下一词项,这里插入一个虚拟节点在第一个节点之前,使得整个处理逻辑统一了。这和数据结构课程里学到的给链表加一个"头结点"的目的是一样的。
虚拟词项的PrefixLen是0,Suffix长度也是0,出现的FiledNum为FF FF FF FF 0F, Vint类型,转成int,那就是-1,表示不在任何一个域里出现(域的ID从0开始)。
它的DocFreq,FreqDelta,ProxDelta都设成0。
最后我们看看这个虚拟词项的IndexDelta,为0x18。
加上偏移地址0x0 + 0x18 = 0x18
看看tis文件中0x18是什么?见蓝色虚线箭头。对了,那不就是第1个词项在词典中的初始地址嘛!
e) 然后接着分析后一个节点,根据Prefix(为0)和Suffix可以看出这个词项是declaration,它的IndexDelta为96 0A(Vint) = 22 + 10*128=1302=0x516,
0x18 + 0x516 = 0x52E
看看紫色虚线箭头,指向的位置就是.tis的0x52E位置。可以看出这个位置上跟着的词项就是由declaration的前6个字母(PrefixLen=2)和Suffix "ed"组成的词项,即"declared"。
由此也可以看出跳表对于词项词典查找的加速速度了,如果要查找declared的词项信息,显然用不着从词典的第一项10.35一直遍历到declared,而是利用跳表,和跳表里的词项比较,当比到跳表的N词项时,发现跳表的N词项比目标词项"大",那就找跳表的N-1词项的IndexDelta(当然是一路累加的)得到N-1词项在词典中的下一项的地址,直接从这个地址开始遍历,大大提升了在词典中查找的时间。
4.Reference
[1] Apache Lucene - Index File Formats
http://lucene.apache.org/core/old_versioned_docs/versions/2_9_0/fileformats.html#File Naming
[2] forfuture1978的专栏
http://blog.csdn.net/forfuture1978
[3] a276202460的专栏--边学边记(七) lucene索引结构四(_N.fdx,_N.fdt)
http://blog.csdn.net/a276202460/article/details/5650026
[4] Annotated Lucene