Lucene倒排索引簡述 之倒排表

前言

上一篇《Lucene倒排索引簡述 之索引表》,已經對整個倒索引的結構進行大體介紹,並且詳細介紹了索引表的內容,同時還詳細介紹了Lucene關於索引表的實現、相關文件結構和索引表採用的數據結構。

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

Postings編碼

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

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文件格式時介紹。

PackedBlock

PackedBlock只能存儲單一結構,整數(Integer/Long)。這裏主要是介紹PackedInts,即是讓多個Int打包成一個Block。
Lucene規定PackedBlock恆定是128個val(長度爲128的int[]),每個val都的長度都是length個Bits。所以最後一個Byte可能不滿的,有可能多個val共享一個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用法和場景)。

Frequencies And Skip Data(.doc文件)

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

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

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上)。

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集合進行析取操作,通過跳錶能夠有效有減少比較的次數。

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的是在哪個文檔上的那個位置的。

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之美,值得我們反覆研讀。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章