Lucene 4.X 倒排索引原理與實現: (3) Term Dictionary和Index檔案 (FST詳細解析)

劉超覺先發表於2014-08-29

我們來看最複雜的部分,就是Term Dictionary和Term Index檔案,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

 

最後我們再看一下FSTIndex的二進位制內容,如下圖3-84所示。

image

相關文章