Lucene倒排索引原理与实现:Term Dictionary和Index文件 (FST详细解析)

 

有限自动机算法(FST,Finite State Transducer):通过输入有序字符串构建最小有向无环图。通过共享前缀来节省空间,内存存放前缀索引,磁盘存放后缀词块

 1、紧凑的结构,通过对词典中单词前缀和后缀的重复利用,压缩了存储空间。

2、O(len(str))的查询时间复杂度。

lucene使用fst, 最主要的是解决了模糊搜索的问题

 

注:该过程是执行flush动作时将内存中缓存的索引写入磁盘的过程, 而非缓存索引过程

Term Dictionary文件的后缀名为tim,Term Index文件的后缀名是tip,格式如图所示。

image

 

Term Dictionary文件首先是一个Header,接下来是PostingsHeader,这两个的格式一致,但是保存的是不同的信息。SkipInterval是跳跃表的跳的幅度,MaxSkipLevels是跳跃表的层数,SkipMinimun是应用跳跃表的最小倒排表长度,接下来就是Term的部分了。

在tim文件中,Term是分成Block进行保存的,如何将Term进行分块,则需要和tip文件配合。Term Index文件对于每一个Field都保存一个FSTIndex来帮助快速定位tim文件中属于这个Field的Term的位置,由于FSTIndex的长度不同,为了快速定位某个Field的位置,则应用指针列表规则,为每一个Field保存了指向这个Field的FSTIndex的指针。

这里比较令人困惑的一点就是,FST是什么,如何利用他来分块呢?

FST全程是Finite State Transducers,是一个带输出的有限状态机,看过前面有限状态机规则的可以知道,有限状态机逻辑上来讲就是一颗树,就像图3-71中的那棵树,从初始状态输入字符a到达状态a,输入字符b到达状态b,输入字符d到达状态d,不同的是状态d有输出,所谓的输出就是一个指针,指向tim文件中的位置。

Tim文件中Term的分块就是按照FST来的,图3-71中,Block 0中的所有的Term都是以abd为前缀的,Block 1中所有的Term都是以abe为前缀的。每一个Block都有一个Block Header,里面指明这个Block包含几个Term,假设个数为N,Suffix里面包含了N个后缀,比如Block 0中包含Term “abdi”和”abdj”,则这里面保存”i”和”j”。Stats里面包含了N个统计信息,每个统计信息包含docFreq和totalTermFreq。Metadata里面包含了指向倒排表文件frq和prx文件的指针。

Tim和tip文件的写入是由org.apache.lucene.codecs.BlockTreeTermsWriter来负责的,在它的构造函数中,生成了两个OutputStream,并且写入除了Block和FSTIndex之外的所有信息。

image

Lucene40PostingsWriter的start函数如下:

image

image

下面咱们具体讨论,Term如何分块,Block如何写入,FSTIndex如何构造。

我们首先通过一个简单的例子,来看一下一个普通的FST是如何构造的,Lucene的文档里面给了类似下面这样一个例子。

image

这里InputValues是构造FST的输入,是根据这些字符串,构造出图3-71中的那棵树。

OutputValue是有限状态机的输出,由于在实际应用中,输出是一个指向tim文件的一个指针,一般是byte[]类型,所以我们也在这里弄了三个byte[]作为输出。

Builder就是有限状态机的构造器,它支持多种输出类型,我们这里用byte[]作为输出,所以输出类型我们选择BytesRef,这是对byte[]的一个封装。

下一步就是用Builder的add函数将输入和输出关联起来,由于builder的输入必须是IntsRef类型,所以需要从字符串转换成为IntsRef类型,输出也要将byte[]封装为BytesRef。

Builder的finish函数真正构造一个FST,在内存中形成一个二进制结构,通过它可以通过输入,快速查询输出,例如程序中的给出输入”acf”就能得到输出[5 6]。

从表面现象来看,我们甚至可以决定FST就是一个hash map,给出输入,得到输出。这就满足了作为Term Dictionary的要求,给出一个字符串,我马上能找到倒排表的位置。

Builder里面一个很重要的成员变量UnCompiledNode<T>[] frontier,在FST的构造过程中,它维护整棵FST树,其中里面直接保存的是UnCompiledNode,是当前添加的字符串所形成的状态节点,而前面添加的字符串形成的状态节点通过指针相互引用。

Builder.add函数主要包括四个部分:

image

当第一个字符串abd加入之后,frontier的结构如图3-72所示,图中蓝色的节点都是。

image

 

当新的字符串abe之后,首先(1)找出公共前缀ab,则prefixLenPlus1=3。然后调(2)用freezeTail将尾节点Sd进行冰封。为什么要进行冰封(一个形象的说法)呢?因为Sd节点不会再改变了。在实际应用中,字符串都是按照字母顺序依次处理的,上一次的字符串是abd,下一个字符串可能是abdm,再下一个字符串可能是abdn,这都会导致Sd这个节点的变化。然而当abe出现后,说明abd*都不可能出现了,状态Sd也不可能再有新的子节点了,所以Sd也就确定下来了,需要冰封。那么Sb节点要不要冰封呢?当然不行了,因为这次来了abe,下次还可能有abf, abg等等新的Sb的子节点出现,这就是为什么要计算公共前缀了,公共前缀之后的状态节点都是可以冰封的了,而这些冰封的节点都从尾部开始,所以这一步的函数叫freezeTail。

freezeTail的实现如下:

image

freezeTail主要有两个分支,在Builder构造的时候,用户可以传进自己的freezeTail,如果用户指定了,则调用它的freeze函数,如果没有指定,则执行else部分默认的行为。在这里,我们使用默认行为,在后面的代码分析中,我们还能看到使用自己的freezeTail的情况。

默认行为中,从尾部到公共前缀节点,对于每个状态节点,调用compileNode函数。在这之前,frontier里面保存的都是UnCompiledNode,经过compileNode函数后,就变成了CompiledNode,并从frontier摘下来,parent.replaceLast函数将父节点的指针指向新的CompiledNode。所谓compile过程,就是将内存中的数据结构变成二进制。

compileNode最终调用org.apache.lucene.util.fst.FST.addNode(UnCompiledNode<T>),代码如下:

image

image

然后(3)将新的input添加到frontier之后,变成如图3-73的数据结构。

image

 

依次类推,当添加acf之后,frontier变成如下的数据结构。

image

 

最后调用Builder的finish函数生成FST,代码如下:

image

image

形成的二进制数组如图3-75所示,由于有内容翻转,所以解析的时候需要从右向左解析。

image

 

了解了最基本的FST的原理之后,让我们来一步一步通过代码,了解tim和tip文件的block和FSTIndex是如何生成的。

我们以下图3-76为例子。默认情况下,BlockTreeTermsWriter有两个静态变量,DEFAULT_MIN_BLOCK_SIZE=25,DEFAULT_MAX_BLOCK_SIZE=48,MIN的意思是当某个状态节点的子节点个数超过25个的时候,可以写成一个Block,MAX的意思是当个数超过48的时候,则写成多个Block,多个Block构成一个层级Block。为了能够清晰的解析代码,我们设DEFAULT_MIN_BLOCK_SIZE=2,DEFAULT_MAX_BLOCK_SIZE=4。我们仅仅添加一篇文档,里面的Term依次为 abc abdf abdg abdh abei abej abek abel abem aben。所形成的状态树如图所示,根据MIN和MAX的设置,f, g, h会写成一个Block,i, j, k, l, m, n写成一个层级Block,c, d, e写成一个Block。我们之所以把从a到n的十进制和十六进制列在这里,是因为在eclipse中,有时候字符显示的是十进制,有时候是十六进制,当看到这些数值的时候,知道是这些字符即可。

image

 

重要: 写tim和tip文件的过程纷繁复杂,下面的流程图3-77作为一个线索

image

 

每来一个新的Term,都调用finishTerm。

image

image

finishTerm的blockBuilder是没有output的,这个blockBuilder是用来进行Term分块的,而不是用来生成FSTIndex的。blockBuilder.add函数的流程和上面的叙述过的FST基本原理中的过程基本一致,不同的是blockBuilder是被用户指定了freezeTail的,为org.apache.lucene.codecs.BlockTreeTermsWriter.TermsWriter.FindBlocks,所以freezeTail调用的是FindBlocks.freeze函数。这个freeze函数仅仅处理子节点的个数大于min的节点,调用writeBlocks函数将子节点写成block,对于不满足这个条件的节点,仅仅从frontier上摘下来,不做其他操作。

在整个过程中,维护两个成员变量,一个是List<PendingEntry> pending保存尚未处理的Term或者block,对于Term,里面保存这个Term的text,docFreq,totalTermFreq信息。另一个是pendingTerms,保存尚未处理的Term的freqStart和proxStart信息。

当加入abc,abdf,abdg,abdh之后,frontier成为如下的结构,在这个过程FindBlock.freeze什么都不做。这个时候的pending和pendingTerms也如图所示。

image

 

加入abei的时候,对Sd进行freeze的时候,发现Sd的出度为3,大于min,则开始调用BlockTreeTermsWriter.TermsWriter.writeBlocks(IntsRef, int, int)函数。

image

由于出度小于max,所以写成一个non floor的block。

写入一个Block的函数如下:

image

image

image

对于每一个写成的block,都要为这个block生成一个FSTIndex,这个过程由函数BlockTreeTermsWriter.PendingBlock.compileIndex实现。

image

image

Block也写入了,FSTIndex也生成了,这个时候frontier,pending和pendingTerms的结果如下图所示。

image

 

这里需要解释一下的BLOCK:abd的FSTIndex里面的映射关系[-38,2]是如何得出来的?这是由下面这个函数计算出来的。fp=86, hasTerm=true, isFloor=false,则二进制位101011010,表示成为VInt为11011010, 00000010,为[-38,2],其实-38是补码。

image

接下来添加abei, abej, abek, abel, abem, aben之后,这个时候frontier,pending和pendingTerms的结果如下图3-80所示。

image

 

当所有的Term添加完毕后,BlockTreeTermsWriter.TermsWriter.finish被调用。

image

image

调用freezeTail(0)的时候,还是调用FindBlocks.freeze函数,在freeze状态Se的时候,出度为6>min,所以调用writeBlocks,由于6>max,因而写入floor block。

image

image

image

写入firstBlock和floorBlocks的函数还是上面写non floor block时调用的writeBlock函数,下面列出一些主要的变量的值。

image

写入了层级block并且生成FSTIndex之后,frontier,pending和pendingTerms的结果如下图所示。

image

 

这里需要解释的是[-77,3,1,107,33]代表的什么呢?首先abe指向的是层级Block,其中firstBlock的起始地址为108,fp=108, hasTerm=true, isFloor=true,则二进制为110110011,表示成为VInt为 [10110011, 00000011],为[-77,3],接下来是floorblock信息。

在函数BlockTreeTermsWriter.PendingBlock.compileIndex中,有这样一段:

image

接着写入floorBlock的个数,为1。接着写这个floorBlock的首字符k(107)。最后写floorBlock的首地址和firstBlock的首地址的差,sub.fp=124, fp=108, sub.hasTerms=true,所以为33。所以[abe]的output为[-77,3,1,107,33]。

在freeze状态Se之后,下面应该freeze状态Sb了,它的出度为3,所以先调用writeBlock写入一个non floor block的,然后调用compileIndex来为这个block产生新的FSTIndex。

写入Block的时候,一些重要的变量如下表所示。

表3-17 freeze状态Sb时writeBlock的变量

image

 

在compileIndex生成当前block的FSTIndex的时候,除了添加prefix=ab所对应的output之外,还会将子block,BLOCK:abd和BLOCK:abe的FSTIndex都添加过来,形成一个整的FSTIndex。

Freeze完状态Sb之后,frontier,pending和pendingTerms的结果如下图所示。

image

 

这里pending只有一项,所有子Block的FSTIndex都合并到BLOCK:ab中来,多了一个[ab]的output为[-30,4],这是由fp=152, hasTerm=true, isFloor=false编码出来的。

接下来对于状态Sa,出度为1,并不做什么。对于初始状态S0,出度也为1,按说不做什么,但是在FindBlocks.freeze函数中,有这样的代码:

image

这里除了判断出度是否>min,还有idx==0,对于状态S0,还是需要调用writeBlocks,将BLOCK:ab写入tim中。

BlockTreeTermsWriter.TermsWriter.finish函数的blockBuilder.finish()就此结束。接下来从pending.get(0)得到根节点的FSTIndex,由于在compileIndex中,所有的子节点的FSTIndex都会加入到父节点中,最终根节点的FSTIndex是整个状态机的FSTIndex,然后将它写入在indexOut,也即tip文件中。

最终,tip和tim文件中Block和FSTIndex的格式和关系如图3-83所示。

image

 


FST性能基本跟HaspMap差距不大,但FST有个不可比拟的优势就是占用内存小,只有HashMap10分之一左右,这对大数据规模检索是至关重要的,毕竟速度再快放不进内存也是没用的。 
  因此一个合格的词典结构要求有: 
  1. 查询速度。 
  2. 内存占用。 
  3. 内存+磁盘结合。 

posted @ 2017-12-20 10:45  車輪の唄  阅读(147)  评论(0编辑  收藏  举报  来源