1. 程式人生 > >Lucene倒排索引簡述 番外篇

Lucene倒排索引簡述 番外篇

一、前言

Lucene構建索引是一個非常複雜的過程,需要經過多道工序才能完成。那你知道Lucene在索引構建過程有哪些工序嗎?又是整體流程是怎麼樣的呢?這裡儘量從巨集觀的角度來介紹索引全過程,給大家一個全景的印象,且不失關鍵步驟細節的介紹。

在Lucene接使用者提交的第一個文件開始,Lucene為使用者建立一個Segment,從此開始索引之旅。

文件到Lucene之後,大概會如下6大步驟,當然並不是所有欄位都會經歷這些步驟。如步驟2與3、3跟4、4跟5都是互斥,同時每個步驟都通過配置被跳過。

  1. 先將文件中需要儲存的欄位編碼然後儲存。
  2. 將需要正向索引的欄位,構建正向索引。
  3. 需要分詞的欄位進行分詞,倒排索引和TermVectors。
  4. Lucene6.0之後的版本,如果是Points欄位的話,則需要儲存PointValues。
  5. 儲存分詞欄位的Norms資訊,如果沒有配置省略的話。
  6. 最終一次沖刷之後,完成所以資料的落地。

在Lucene的官方檔中,實際上是對Segment、Document、Field和Term等術語都給出了完整的定義了。這裡也重新認識一下:

  1. Segment是最小的獨立的索引單位。
  2. Document是Field的集合。
  3. Field是Term的集合。

Document進到Segment的管轄內會被分配一個唯一的ID叫DocID,同時根據每個Field的名字定一個唯一的稱呼FieldNaming,對於索引欄位進到Postings之前也會被分配一個唯一的TermID。Field除了FieldNaming之外也有一個FieldNumber的,跟DocID和TermID都是一個自增長的數值。

二、索引生產流程

在程式中,我們是這樣組織文件的。然後將文件逐一提交給Lucene,在接到文件之後,先是給每個文件打上一個標記,叫DocID。從此開始文件的索引之旅。

這裡用不同的背景色來表示不同的DocID和FieldNaming。

1. 欄位儲存

文件是由於一系列的欄位組成的,只要將所有需要的欄位都儲存了,便是完成了文件的儲存。這過程是比較容易理解的,無非是將文件按自己的組織方式儲存。Lucene的文件儲存格式跟MySQL的frm檔案的格式基本類似,將整個文件編碼之後寫到檔案上。即就是說Lucene接收到來自使用者提交的文件之後,就能夠編碼並且儲存的。

這個過程中,主要是由StoredFieldsConsumer

完成。

Lucene為了保證寫入的效能,文件一旦提交實際上不可變的,這點跟MySQL的frm檔案是不一樣的。欄位儲存是唯一一個按文件儲存的檔案,此外都是打破文件的邊界按欄位儲存的。

另外,因為Lucene作為搜尋引擎,它讀取文件的操作往往是以隨機訪問的方式。為此Lucene將文件資料寫到.fdt檔案的同時,還需要為DocID對應的文件所在.tdt檔案上位置構建索引,並將索引儲存到.fdx檔案。以提供快速隨機訪問的能力。

DocID是Lucene內部ID,使用者並不能直接獲取。實際上是.fdx通過倒排索引得到了DocID之後,能夠快速取回文件的作用。需要區分DocID與使用者定義的有業務意義的文件ID,它們不是一回事。

2. 索引構建及儲存

索引是搜尋引擎的根礎,關乎搜尋引擎的效能。在Lucene中,索引可以分成兩類,正向索引和反向索引的倒排索引。在倒排索引表過程中,通常需要指定在哪個Field進行檢索的,也就是TermID在所有具有相同FieldName的Field中唯一的。

方便理解,將文件進行加工變形。實現上,Lucene通常為所有相同FieldName的欄位分配一個PerField物件,由PerField實現所有欄位級別所有操作。

相同FieldName的所有Field在處理邏輯是可以認為是連續,DocID被在這裡僅是Term的屬性。Field是Terms的集合,因此可以以為索引階段Field就是所有同名Field的所有Terms的集合。

2.1. 正向索引

正向索引記錄了DocId到FieldValue的對映關係,提供了通過DocID就能直接獲取欄位值的能力。DocValuesWriter將DocIdSet與FieldValue分別儲存在類似陣列的結構中,他們的儲存順序的是一致的。然後,DocValuesConsumer將FieldValues和DocIdSet一併寫到.dvd檔案中。每個需要儲存DocValues的Field都有一對這樣的結構,且DocValues是按欄位連續儲存在.dvd檔案中。每個Field的DocIdSet和FieldValues在dvd檔案中的索引資訊(起始位置),被儲存在.dvm檔案中。

這種儲存結構實現上是列式儲存的結構,當然Lucene也是一種列儲存資料庫了。這種列式儲存結構,給Lucene帶來很多二次計算的可能,比如Hive On Solr/ElasticSearch,Solr的高階特性Streaming Expression等。Streaming Expression是Solr提供基於Lucene索引實現的計算框架,以及在Streaming Expression上實現SQL的能力。

儲存DocID的記憶體是用DocsWithFieldSet(底層實際上是BitSet),在磁碟上的則是要複雜一些。在Lucene7.0之後,Lucene針對BitSet的稠稀性,用不儲存的方式。當BitSet比較稀疏時,直接儲存DocID;當BitSet稠密時,則將BitSet的Bits資料儲存。根據資料的分佈情況不同,採用適當的結構不僅可以提高空間的利用率,還能提高遍歷的效率。唯一的缺點估計就是實現起來比較複雜。

當前版本DocValues還不支援分詞。

Lucene定義多種DocValues型別,每種型別的儲存方式還不太一樣,但有一點是一樣的。DocValues儲存DocIDSet和DocValues是分開儲存的,總之DocValues是一個大話題,這裡先不展開討論。

2.2. 倒排索引

欄位的儲存,通常都會比較簡單,是因為他不需要知道全域性的狀態是怎麼樣的。但是,反向的倒排索引則要複雜一些,因為他都是需要整個Segment的資訊的。比如Term在哪些文件出現了,在每個文件分別出現幾次,每次出現在什麼位置等等。這些資訊都是需要站到Segment這個視角上才能夠收集的。

在Lucene中倒排索引實現又能成兩類,一種是傳統的,按Segment構建的Postings,這是我們所說的倒排索引;另一種方式是在每個上文件構建的TermVectors,又叫詞向量或者文件向量。

a. Postings

倒排索引的資料結構大家所熟悉的,左邊是Terms列表,記錄Field中出現的所有的Terms,也是叫TermsDictionary;右邊是Postings,記錄Term所對應的所出現哪些文件的文件號,出現次數,位置資訊等。畫出來的示意圖如下:

上圖看似簡單的結構,在實現過程卻是非常不簡單。在構建Posting過程中需要考慮如何收集Terms的位置資訊和統計資訊,還要考慮在大規模的資料量級下如何去重和排序。這些都是實現倒排索引需要考慮的關鍵問題,一些不合理的細節所導致的額外效能開銷,就會直接影響全域性索引效能。

那麼Lucene又是怎麼做的呢? 在構建索引時,Postings是記憶體上臨時構建的,在整個過程Postings完全是在記憶體上的。回到之前工序繼續工作,此時Field會被分詞,變成一系列Terms的集合。遍歷這個Terms的集合,為每個Term分配一個ID,叫TermID。當然,相同的Terms的ID必須得是一樣,所以Lucene用一個類HashMap的資料結構來儲存Term與TermID的對映關係,同時實現去重的目的。分配完TermID之後,基本上就都用TermID來表示Term的身份了。

在Postings構建過程中,會在PostingsArrays儲存上個文件的DocID和TermFreq,還有Term上次出現的位置和位移的情況。即是PostingsArrays由幾個int[]組成,其下標都是TermID(TermID是連續分配的整型數,所以PostingsArrays是能被緊湊的儲存的),對應的值便是記錄TermID上一次出現的各種資訊了。就是說Lucene用多個int[]儲存Term的各種資訊,一個int[]僅存TermID的一種資訊中的一個數據。

Lucene為了能夠直接使用基本型別資料(因為基本型別有兩大好處,減少記憶體開銷和更高效能),所以才有了PostingsArrays結構。方便理解你可以理解成是Postings[],每個Postings物件含有docFreq,intStart,lastPos等屬性。

PostingsArrays這個結構只保留每個TermID最後出現的情況,對於TermID每次出現的具體資訊則是需要存在其它的結構之中。它們就是IntBlockPool&ByteBlockPool它能有效的避免Java堆中由於分配小物件而引發記憶體碎片化從而導致Full GC的問題,同時還解決陣列長度增長所需要資料拷貝問題,最後是不再需要申請超大且連續的記憶體。

這兩結構有點高階,關於它們的故事非常有趣,但由於篇幅的關係留到下一篇分享。這裡我們只需要把他看成是兩個連續的int[]和byte[]即可,跟一般的陣列有一樣的功能。Postings的資料實際只儲存在BytesBlockPool(byte[])一個地方,IntBlockPool(int[]),它儲存的是索引。

需要注意的是,Postings是在byte[]儲存的結構是一個表尾增加的連結串列結構,在構建索引的時候用IntBlockPool來記錄Term下一次要寫的位置。也就是說,PostingsArrays的intStarts[]是Term的byte[]的表尾,而表頭是記錄在PostingsArrays的byteStart上,這也是一個int陣列,記錄每個Term的在BytesBlockPool的起始位置。有了表頭和表尾之後,我們就可以ByteBlockPool裡拿到整條連結串列了。

為什麼不能直接寫磁碟? 之所以不能直接寫盤,是因為在構建過程中不能知道Postings有多長,不能確定要預留多少空間;另外構建過程中Term出現並非有序,所以還需要隨機寫;最後是Term難以再排序,只能按TermID的順序處理。

為什麼需要postingsArrays呢? 因為寫到byte[]的只是增量,那麼就需要找到上次的Term出現情況才能計算。如果總是在byte[]上找顯得過得重,因為Postings儲存在byte[]時,它的結構是單向連結串列結構。所以就有了PostingsArrays記錄上次的資訊,方便計算增量。

這裡有多提一句,Lucene在Segment提交之前,實現上不是在寫Buffer,而是先在記憶體上構建了。當Segment提交之後,將記憶體上的索引重新編碼之後再刷磁碟。
也就是說,索引在構建時寫在記憶體的資料結構和編碼與最終寫磁碟的完全不一樣的。
基於以上,且不僅限以上原因,需要先收集posting的資訊。知道這點之後,至於它叫什麼,是緩衝,預構建,還是收集都是一樣的。

收集完,也就是已經把整個Segment的文件全部遍歷了,此時觸發沖刷的操作。然後,將Term排序之後編排成TermEnum格式,此處進入索引寫磁碟的步驟了。

b. TermVectors

上面已經用了比較長的篇幅來介紹第一種,就是大家很熟悉的倒排索引結構了。接下來簡單介紹一下第二種,儲存的資料跟第一種實際上一樣的,都是Term和Term的統計資訊、位置資訊。只不過,TermVectors在Postings的基礎又將Terms按文件的重新排序。按文件的結構,兩個文件的同名Field的出現兩個同相的Term,會被分開記錄兩次。

回顧Postings記錄的幾種資訊的術語含義,這些資訊也TermVectors也會記錄的。

  1. TermFreq:在一個文件中出現的次數,通過與DocID成對出現。
  2. Position:Term出現的位置,相當於DocID。
  3. Offset:在文件內的位移,與Position一起才能確定一個位置。
  4. Payload:附加資訊,如詞性等,可用於自定義評分等。

顯然這些資訊實際上與是否在文件內並無影響,所以TermVectors記錄資訊實際上Postings並無太大差別。只不過對於TermVectors是已經知DocID的,所以並不會在所有Term上記錄DocID。當然,Freq、Position和Offset也不會記錄在其它Documnet出現的情況了。
此外還有一點需要注意的,TermVectors把Term所有資訊都記錄在同一個檔案上(.tvd),這與Postings的記錄方式是一樣的。Postings將它們拆分成三個檔案分別儲存DocID和TermFreq、Position和Offset、Payload。

Lucene在儲存TermVectors的時候,預設將4096個文件打包成一個chunk來儲存。在一個chuck的結構如上圖,這裡想強調的是,TermFreq/Position/Offset/Payload的儲存格式基本一樣,這裡以TermFreq為例。

假設有個Term="Solr"出現這三個文件的FieldA、FieldB和FieldC三個欄位中,它們TermID是不一定相同,只要這三文件不是一樣,它們極可能是不相同的。因為每個文件的每個欄位都有自己的Term和TermID的對映表,這就是跟Postings最大差異。

為什麼沒有DocFreq、TotalTermFreq呢
如果已經讀過《Lucene倒排索引簡述 之索引表》的應該知道這些欄位級別的統計資訊,它們會在TermDictionary的FieldMetaData上的。也就是說,Postings和TermVectors都不會記錄部分資訊的。

TermVectors在Solr有挺多應該場景的,比如Highlight,tvch(TermVectorsComponentHandler),MoreLikeThis,等。TermVectors更多的應用可能還是在像MoreLikeThis,分類聚類等NLP任務上。

預設TermVectors是開啟的,雖然在搜尋時,只要不用它就不會去讀這部分資訊。但是在索引時,還是會一樣效能開銷的。不僅如此Segment沖刷之後還可能會出現多次Merge,也都會一定的開銷。如果在搜尋時,不需要用TermVectors的情況下是可以省略不寫TermVectors的。

4. PointValues

PointValues原本是用於地理資訊的索引和查詢,它在地理資訊、多維數值、或者多維數值區間索引和搜尋上表現都非常出色。因此,PointValues成為數值欄位的預設實現。原先的數值欄位(IntField/FloatField/LongField/DoubleField)全被標記為@Deprecated,且在加Legacy字首。

Solr在Solr7.0開始支援PointValues,併成為數值欄位的推薦使用型別。同時BackPort到Solr6.5版本。

PointValues採用新儲存結構,BKD-Tree(KD-Tree的變種)。KD-Tree主要應用於多維空間,範圍搜尋和最近鄰搜尋。BKD-Tree是基於KD-Tree實現的資料結構,它有高效的IO效能、更高磁碟利用率。基於BKD-Tree實現的IntPoint(含LongPoint,FloatPoint,DoublePoint)不管是索引的效能,還是在搜尋的效能都對原先的TrieField的效能更加高,索引檔案也更小,尤其是搜尋時佔用Heap記憶體要小很多。

PointValues是優秀特性,它並不只是適用於多維的空間搜尋,在一維的各個場景的效能指標都非常不錯。強烈推薦大家關注並且使用的新特性。

5. Norms

Norms,Normalization Factors,儲存的是每個文件中每個欄位的歸一化因子和Boost(索引時的Boost已經被棄用了,交由Payload接管)。這兩個數值都會直接影響搜尋時最終文件評分。

在TFIDFSimailary模型下,歸一化因子的計算可以簡單理解為log21numTermslog_2\frac{1}{numTerms}

Norms是所有索引檔案中最簡單的。它在我腦海中的存在感極弱,可能你在印象中也是如此。Norms可以通過配置FieldType.setOmitNorms(true),表示不儲存norms,但預設是會儲存的。這個配置是欄位級別的,也就是在一個多欄位的文件中,可以選擇部分欄位儲存,部分不儲存。由上公式就可以知道,Norms的計算需要知道欄位中去重後有多少個Terms,所以它跟Postings也算是有一點關係的,也是放在Postings之後處理的。

三、總結

這裡著重介紹了Postings是如何構建的,在記憶體中的儲存結構,順帶介紹了Norms等。

文章篇幅儘管是在多次刪減之後依然很長,可見Lucene倒排索引的多麼的巨集大與精彩。所以對我來說研讀Lucene是一種興趣愛好,也是一件非常美好的事情。