1. 程式人生 > >Lucene倒排索引簡述 之倒排表

Lucene倒排索引簡述 之倒排表

一、前言

上一篇《Lucene倒排索引簡述 之索引表》,已經對整個倒索引的結構進行大體介紹,並且詳細介紹了索引表(TermsDictionary)的內容。同時還詳細介紹了Lucene關於索引表的實現,相關檔案結構詳解,以及對索引表採用的資料結構進行剖析解讀。

本篇部落格將繼續剖析Lucene關於倒排索引實現有另一個核心內容,倒排表(Postings)。我一直覺得Postings內容相對而言是比較簡單,雖然內容很多,但是Lucene的官方文件講得也非常詳細了。如果對Lucene文件上的描述檔案結構的表達方式不太熟悉的話,個人覺得可以參考前面的圖自行畫出檔案結構示意圖,或者直接在網路搜尋相關的圖,只要能整出索引檔案的結構示意圖,那麼理解起來就應該不會太困難。

二、Postings編碼

開始之前先介解Lucene在Postings採用了兩個關鍵的編碼格式,PackedBlock和VIntBlock。PackedBlock是在Lucene4.0引入,帶來向量化優化。

1. VIntBlock

VIntBlock是能夠儲存複合資料型別的資料結構,主要通過變長整型(Variable Integer)編碼達到壓縮的目的。此外VIntBlock還能夠儲存byte[],比如.pay用VIntBlock儲存了payloads資料等。

值得一提的是,VIntBlock可以儲存變長資料結構,如.doc用它儲存DocID和TermFreq時,由於在特定條件下(TermFreq=1),Lucene會省略TermFreq以提高空間佔用率。我知道Lucene用一個VInt來表示DocID,VInt則用每個Byte左邊第一個Bit來表示是否需要讀取順續到下個Byte。也就是說一個VInt有效位是28bit

,這就說明VInt頭部是有特殊含義的,因此Lucene只能在VInt最右邊的一個bit下功夫。讓VInt的右邊第一Bit來表示是否有下個數據。

具體用法會在介紹.doc檔案格式時介紹。

2. PackedBlock

PackedBlock只能儲存單一結構,整數陣列(Integer/Long)。這裡主要是介紹PackedInts,即是將一個int[]打包成一個Block。PackedBlock只能能夠儲存固定長度的陣列(Lucene規定其長度為128個元素),它壓縮方式是將每個元素截斷為預算的長度(length,單位是bit)壓縮的。所以當長度length不是8的倍數時,會出現一個byte被多個元素佔用。

PackedBlock需要把整個int[]的所有條目指定長度編碼,所以PackedBlock只能選擇int[]最大的數還來計算長度,否則會讓大數失真。反過來,PackedBlock都選擇64位,則會浪費空間,不能達到壓縮的目的。

Lucene預先編譯了64個PackedFormat編碼器和解碼器,即針對Long以內的每種長度都資料都有自己的解碼和編碼器,以提高編解碼的效能。

PackedPosDeltaBlock與PackedDocDeltaBlock和PackedFreqBlock一樣採用PackedInts結構,它能儲存的資訊實際上是很有限的,只能儲存Int的陣列。所以在PackedPosDeltaBlock的時候,只能儲存position資訊,在VIntBlock則會儲存更多必要的資訊,減少搜尋時的IO操作。

這也是為什麼需要將DocId和TermFreq拆分成PackedDocDeltaBlock和PackedFreqBlock兩個Block儲存的原因了。

定長是指PackedBlock限定了一個Block僅允許儲存長度128的整型陣列,而不是限定Block用多少個Bytes來儲存編碼後的結果。另外Block儲存佔用的大小,是按陣列中最大那個數的有效bits長度來計算整個Block需要佔用多大的Bytes陣列的。也就是Block的每個資料的長度都是一樣,都按最長bits的來算。

比如:(我們定義一個函式,bit(num)用來計算num佔用多少個bits)

  1. 陣列中最大的是1,那麼PackedBlock的長度僅是16Bytes。bit(1) * 128 / 8 = 16
  2. 陣列中最大的是128,PackedBlock長度則是144個Bytes。bit(128) * 128 / 8 = 144
  3. 陣列中最大的是520,PackedBlock則需要160Bytes。bit(520) * 128 / 8 = 160

小結,PackedBlock相當於是實現了向量化優化,Lucene通常會將整個PackedBlock載入到內在,既可以減少IO運算元,又能提高解碼的效能。相對而言VIntBlock則能夠更豐富資料型別,比較適合儲存少量資料。

三、Postings檔案結構說明

進入正題,我們知道整個Postings被拆成三個檔案分別儲存,實際上它們之間相對也是比較獨立的。基本所有的查詢都會用.doc,且一般的Query也僅需要用到.doc檔案就足夠了;近似查詢則需要用.pox;.pay則是用於Payloads搜尋(關於這個之前寫一篇部落格《Solr 遲到的Payloads》,介紹了Payloads用法和場景)。

1. Frequencies And Skip Data(.doc檔案)

在Lucene倒排索引中,只有.doc是Postings必要檔案,即是它是不能被省略。除此之外的兩個檔案都是通過配置,然後將其省略的。那麼.doc到底是儲存哪些不可告人的祕密呢?直接上圖,開始剖析吧!

這裡畫得不夠清晰,每個Term都有成對的TermFreqs和SkipData的。換言之,SkipData是為TermFreqs構建的跳錶結構,所以它們是成對出現的。

1.1. TermFreqs – Frequencies

TermFreqs儲存了Postings最核心的內容,DocID和TermFreqs。分別表示文件號和對應的詞頻,它們是一一對應的,Term出現在文件上,就會有Term在文件中出現次數(TermFreqs)。

Lucene早期的版本還沒有PackedBlock結構,所以DocID與TermFreq是以一個二元組的方式儲存的。這個結構非常好,是因為它好理解,之所以好理解是因為它貼近我們的心中的預想。但實際上這個結構並不太準確,只不過我們先簡單這麼理解也無傷大雅。既然是想深入剖析,還是有必要還原真相的。

TermFreqs採用的是混合儲存,由Packed Blocks和VInt Blocks兩種結構組成。由於PackedBlock是定長的,當前Lucene預設是128個Integers。所以在不滿128的時候,Lucene則採用VIntBlocks結構還儲存。需要注意的是當用Packed Blocks結構時,DocID和TermFreq是分開儲存的,各自將128個數據寫入到一個Block。

當用VIntBlocks結構時,還是沿用舊版本的儲存方式,即上面描述的二元組的方式儲存。所以說,將DocID和TermFreq當成一條資料的說法是不完全正確的。

在Lucene4.0之前的版本,還沒有引入PackedBlock時,DocID和TermFreq確定完全是成對出現,當時只有VIntBlock一種結構。

Lucene儘可能優先採用PackedBlocks,剩餘部分(不足128部分)則用VIntBlocks儲存。引入PackedBlock之後,PackedDocDeltaBlock跟PackedFreqBlock是成對的,所以它的寫出來的示意圖應該是如下:

每個PackedBlock由一個PackedDocDeltaBlock和一個PackedFreqBlock構成,它們都採用PackedInts格式。

例如,在同一個Segment裡,某一個Term A在259個文件同一個欄位出現,那麼Term A就需要把這259個文件的文件編號和Term A在每個文件出現的頻率一同記下來儲存在.doc。此時,Lucene需要用到2個PackedBlocks和3個VIntBlocks來儲存它們。

VIntBlock結構相對而言就高階很多了,它能夠以一種巧妙的方式儲存複雜的多元組結構。在.doc,用VIntBlock儲存DocID和TermFreqs,是二元組。後面將介紹的Positions則用VIntBlock儲存了Postition、Payload和Offset多元組,
byte[]和VInt多種資料型別。

這裡每一個PackedBlock結構都包含了一個PackedDocDeltaBlock和一個PackedFreqBlock,如果沒有省略Frequencies(TermFreq)的話;如果使用者配置了不儲存詞頻(TermFreq)的話,此時一個PackedBlock僅含有一個PackedDocDeltaBlock。PackedFreqBlock(TermFreq)的儲存方式跟PackedDocDeltaBlock(DocID)完全一致,包括後面要講的pos/pay也都一樣的。也都是使用Packed Block這種編碼方式。

在VIntBlock上如何儲存DocDelta和TermFreq的呢,當設定為不儲存TermFreq時,Lucene將所有DocDelta以Variable Integer的編碼方式直接寫檔案上。

但當DocDelta和TermFreq兩者都儲存時,官方文件給出一個比較完整且複雜的計算說明。反正是我覺得有點複雜,所以沒有用直接官方的上說明,我們來點簡單的。

首先需要換算的原因是,Lucene做一個小優化,即是當TermFreq=1時,TermFreq將不被儲存。那麼原本DocDelta(DocID的增量)後面緊跟一個Frequencies的情況變得不再確定,我壓根就不知道我讀的DocDelta後面有沒有TermFreq的資訊。

那麼問題就變成怎麼標記儲存還是沒有儲存TermFreq,Lucene先把數值向左移動一位,然後用最右的一個Bit的標記是否儲存TermFreq。最後右邊的一個bit1表示沒有儲存,0作為有儲存TermFreq。實際上這已經是Lucene的慣用手段了。

左移一位,實際上等同於X2,當最後一個bit是0,此時是一定是偶數,表示後面還存 儲了TermFreq;
左移一位再+1,相當於偶數+1,那就是奇數,此時最後一個bit是1,表示TermFreq=1,所以後面沒有儲存TermFreq。

這基本上就是官方文件上的大體意思了。

DocFreq=1時,Lucene做一個叫Singletion(僅出現在一個文件)的優化,當時就沒有TermFreq和SkipData。因為TermFreq就等同於TotalTermFreq(上篇文章介紹過,儲存在.tim的FieldMetadata上)。

1.2. Multi-level SkipList – SkipData

SkipData是.doc檔案核心部件之一,Lucene採用的是多層次跳錶結構,首先我們先預熱一下了解SkipList的邏輯結構圖,最後剖析Lucene儲存SkipList的物理結構圖。

跳錶的原理非常簡單,跳錶其實就是一種可以進行二分查詢的有序連結串列。跳錶在原有的有序連結串列上面增加了多級索引,通過索引來實現快速查詢。首先在最高階索引上查詢最後一個小於當前查詢元素的位置,然後再跳到次高階索引繼續查詢,直到跳到最底層為止,這時候以及十分接近要查詢的元素的位置了(如果查詢元素存在的話)。由於根據索引可以一次跳過多個元素,所以跳查詢的查詢速度也就變快了。 ——— 來自百度百科

將搜尋時耗時轉嫁給索引時,空間換時間是索引的基本思想。為此Lucene為Postings構建SkipList
,並把按層級將它系列化儲存。第一個SkipLevel是最高,擁有最少的索引數。

易知Lucene是在索引時構建了SkipList,在Segment中 每個Term都有自己唯一的Postings,每個Postings都有需要構建一個SkipList。這三者是一一對應的。所以畫出來結構圖如下:

除了第0層之外所有SkipLevel的每個跳錶資料塊(SkipDatum)會儲存了指向下一個SkipLevel的指標。圖中SkipChildLevelFPg帶?的原因是在Level 0時,SkipDatum沒有下一級可以記錄。如果Postings有儲存positions、payloads和offsets的話,在跳錶資料塊中也會記錄它們的Block所有檔案指標。

也就是說,通過SkipList可以找到DocID和TermFreq之外,還能找到Positions、Payloads和Offsets這三部分資訊。所以在搜尋時,通過SkipList的可以快速定位Postings的所有相關資訊。

關於Lucene如何構建SkipList的諸多細節,Lucene規定SkipList的層級不超過10層。

  1. 第0層,SkipList為每個Block增加索引,所以VIntBlock不在SkipList上。
  2. 第9層,SkipList的第一個節點是在第89 (227)Block。(這個數確實有點大)
  3. 第n層,SkipList的第m個節點的位置是第8nm8^n * m個Block。

跳錶的第一層是最密的,越高層越稀疏。按層級從低到高依次系列化為寫入.doc的SkipData部分。換言之,SkipDatum的個數越來越多,SkipLevelLength會越來越大。

SkipLevelLength說明當前層次Skip系列化之後的長度,SkipLevel是包含該層的所有節點的資料SkipDatum。SkipDatum包含四部分資訊,doc_id和term_freq、positions、payloads、以及下一層開始的位置(是第N層指向第N-1層的前一個索引)。

SkipList主要是搜尋時的優化,主要是減少集合間取交集時需要比較的次數,比如在Query被分詞器分成多個關鍵詞時,搜尋結果需要同時滿足這些關鍵詞的。即是需要將每個Term對應的DocId集合進行析取操作,通過跳錶能夠有效有減少比較的次數。

2. Postitions(.pos檔案)

.pos檔案儲存所有Terms出現文件中的位置資訊。為更好的搜尋效能,Lucene還在VIntBlock上儲存了部分payloads和offsets的資訊。實際上因為只有VIntBlock才有能力來儲存複雜的資料結構,而PackedBlock是不具備這樣的能力的。具體請參考下面的示意圖:

Lucene把同一個Term的所有position資訊儲存在同一個TermPositions上,並沒有邏輯或者物理上的劃分的。將在一個文件裡出現的所有位置資訊,按出現的先後順序依次寫入。
關鍵在於,position與TermFreq並不是在一維度上,TermFreq的數值就是position的個數。也就是通過.pos檔案,無法知道每個position的具體含義的,PostingsReader通過.doc檔案的DocID和TermFreq資訊才能算出Postition的是在哪個文件上的那個位置的。

3. Payloads and Offsets(.pay檔案)

Payloads,可以理解為Term的附加資訊,它實際上是跟Term成對出現的,類似於Map。在用法上也是如此,Payloads的資訊需要用byte陣列儲存,所以在TermPayloads並不能用PackedBlock結構來儲存。但是TermOffsets是由2個int來表示Offet的開始位置和長度的,即是能將它們拆成兩個等size的int[],故可以用PackedBlock儲存。故有如下圖:

四、總結

開篇先學習了Lucene用於儲存Postings的兩種結構,或者說編碼方式,PackedBlock和VIntBlock。PackedBlock是Lucene4.0引用的,它就是int[],給Postings向量化優化。除之外,還有一原著民VIntBlock,也是一種很巧妙且優雅的結構,能儲存複雜的型別。

而後,在介紹.doc檔案格式的同時,又對上面的兩大結構反覆剖析。個人認為了解這兩個結構之後,整個postings的理解應該不成問題。並且剖析了.doc檔案上採用的SkipList資料結構,主要是搜尋時集合間AND操作上的一個優化。所以在postings其它兩個檔案格式,僅用非常短的篇幅介紹。

Lucene倒排索引部分內容到這裡全部結束,其它很多優雅的設計和巧妙的結構,其中蘊含的Lucene之美,值得我們反覆研讀。