Lucene Search流程之一

一、 搜索印象

Lucene的搜索流程大概的步驟是,先將用戶搜索條件改寫一系列的原子查詢條件,將查詢條件向量化。進而通過字典(TermsDict)定位Term的posting信息所在的位置,讀取posting的信息是對文檔的評分的依據,最後collector收集所期望的目標結果集,最終召回文檔。

如果我們把查詢流程切分,前端是Query處理用戶搜索條件邏輯的關係,後端是主要通過TermsDict找到對應Postings信息返回給前端,前端再依據Query的查詢邏輯處理Postings得到最終希望得到查詢結果集。

也就說用戶編寫的查詢條件是屬於前端的部分,需要轉化成後端IndexReader能懂讀懂的查詢語言。爲了方便用戶編寫複雜的查詢,Lucene查詢前端提供豐富的Query類型,以幫助用戶實現多個查詢條件組合實現搜索需求。如何將查詢條件的組織關係影響結果集,即如何評分和如何合併Postings呢(每個查詢都會返回一個Postings,如果Term在Segment沒有文檔,則爲空)。

二、Query

後端主要涉及如何在TermsDict找到目標Term對應的Postings信息,而Postings含有那麼多種信息,這些信息並非所有的查詢都需要所有信息的。因此可以在這兩個維度上對Query分類,然後繼續剖析搜索流程中如何讀取和利用這些爲搜索加速。

搜索流程後端的第一步是,如何在字典找到目標Postings位置信息,第二步纔是繼續利用這些信息對文檔進行篩選過濾和評分。

A. 獲取Postings位置信息

開始之前先回顧一下TermsDict在存儲結構,分兩部分,TermsDict的索引部分FST和索引信息block列表。它們組成了Burst-Trie結構,FST將以TermsDict的block的共同前綴構建的,可以將其理解爲Trie。每個block將記錄每個Term以及其索引信息的位置。

TermsDict在構建Burst-Trie結構時,將所有Terms排序之後,在從小到大遍歷的過程中,一步一步將共同前綴合並。合併的意思是將共同前綴的每個字符變成一個節點,當一節點超過25個子節點時,TermsDict將這個節點的所有子節點變換成一個Block寫入Block列表中。
每當一個節點的子節點數超過25個時,就會改寫成Block並這些節點從Tree中刪除。
且當一個節點的子節點數據超過48個時,會拆分成多個Block,從而保證每個Block的Terms數目在25-48個範圍內。

最終就是會如上圖所示,一個RoodCode(根節點)和一系列Block。這裏以2個節點爲一個Block,虛線框表示非葉子節點。

在內存中,TermsDict最終由SegmentTermsEnum或者IntersectTermsEnum表示,它們分別代表兩種查詢TermsDict的方式,精確查詢和近似查詢。此外還有區間查詢,但它情況比較特殊,需要區間是否是points類型,若是則採用結構特性直接進行區間查詢;否則與近似查詢的流程基本雷同。

SegmentTermsEnum/IntersectTermsEnum都是IndexReader讀取TermsDict的介質,Frame自然就對應TermsDict中的Block部分。Frame加載過程分兩個步驟,第一步是初始化過程中讀取元數據,第二步是纔會讀取索引數據。在真正需要用到數據的時候,Frame纔會去發生第二個步驟去讀取索引信息。

已然知道,Search過程得先確認Terms在Segment的詞典(TermsDict)中存在,確認存在之後才能繼續下一步的操作。就Query如何確認搜索目標詞是否存在以及如何定位它的Postings相關信息的位置,將Query分爲三大類,分別是:精確查詢、近似查詢和區間查詢。當然,還有不需要訪問TermsDict的MatchAllQuery等特殊的查詢這裏暫時先不展開討論。

2. SegmentTermsEnum - 精確查詢

SegmentTermsEnum查詢時,支持利用TermsDict的索引FST加速TermsDict的查詢。多次提到FST是由每個Block的共同前綴構建的,因此通過FST只能定位Term可能存在的Block。當某個節點的子節點數量比較多時,還可能出現拆分多個Block存儲的情況,此時SegmentTermsEnum需要繼續讀取多個Block才能確定Terms是否真正存儲,以及存在的時候Postings所在的位置信息。

SegmentTermsEnum只能爲精確查詢提供服務,其實查詢的代價很低、性能也很好。

3. IntersectTermsEnum - 近似查詢

IntersectTermsEnum除了支持通過FST定位Term的Postings所在位置之外,它還支持有限決定狀態機的查詢獲取所有滿足表達式的Terms以及其Postings信息的位置。這也就說,IntersectTermsEnum支持搜索條件爲正則表達式(區間查詢也是將查詢條件改寫成正則表達式),通過正則表達式篩選TermsDict,最終返回所以符合正則表達式的Terms集合,Query依據Query潛在邏輯關係將多個Terms的查詢改成複合條件查詢,如BooleanQuery等。

IntersectTermsEnum是支持在近似查詢時,支持IntersectTermsEnum從在哪裏和在哪裏的終止,從而減少SegmentTermsEnum遍歷TermsDict的Block列表的區間提升搜索的性能。也說是IntersectTermsEnum也是支持FST快速Block的定位的,通過開始條件找到IntersectTermsEnum滿足條件的的Block之後開始遍歷。

當前版本Lucene7.6,在做前綴查詢和區間查詢時,並沒有啓用startTerm跳過不必要的Block,減少遍歷Block列表的範圍。

  1. 近似查詢
    近似查詢的查詢關鍵詞可能是通配符查詢、前綴查詢、甚至是正則表達式,總之查詢涉及的Term不是唯一確定的。

  2. 區間查詢
    區間查詢的Terms也是不確定的,它只指定一個範圍,需要先通過TermsDict找到所有在Query指定區間內的所有Terms,再轉成BooleanQuery進行查詢。

近似查詢和區間查詢首先在TermsDict中找到所有符合條件的Terms,最終會先得到一個Terms的集合,根據它的潛伏關係改成一系列精確查詢的組合,如BooleanQuery/DisjunctionMaxQuery等,回到精確查詢的流程。

近似查詢的類型很多,實際上是用正規表達式來表示的,Lucene的底層也是如此實現的。通過有限決定自動機,它就是正規表達式的實現。在驗證Term是否存在和查找Term的索引信息的位置時,排除points查詢之外,區間查詢與精確查詢是差不多的,但近似查詢需要構建DFA,然後查找滿足近似查詢表達式具體的Term退化成精確查詢。

B. 需要用posting哪些信息

先重新認識Lucene的Postings的邏輯結構,由於Lucene真正的Postings存儲結構理解起來確實比較晦澀,這裏換種簡單的、更直觀的方式來理解它,有利於理解搜索流程背後的邏輯。

圖中對述Lucene的索引結構描並不準確,但是它完全展現了整索引表包含的信息,以及各種信息之間的關係。 只是Lucene存儲時,出於讀寫性能的考慮改變了存儲結構。它說明了Postings包含的信息有DocIDTermFreqPositions,表示一個Term在文檔DocId出現了TermFreq次,分別出現在Postition,這些位置以及在位置的附加信息。位置信息是指第幾個詞(Posititon),從第幾個字段符到第幾個字符(StartOffsetEndOffset),和附加信息Payload每個Position含有三部分信息,它們是一個三元組結構。

關於Lucene的倒排表存儲結構內容,在《Lucene倒排索引簡述 之倒排表》中有詳細的介紹。

我們知道posting在Lucene分成三個文檔存儲,拆分是爲了讓不需要用這些信息的查詢,不必浪費資源讀取不用的信息。這裏查詢涉及索引信息的種類將Query重新劃分,需要讀取Postings的多少種信息才能支撐這次查詢,從Search流程中Query需要讀取Postings的信息情況又可以將Query分成三類,TermQueryPharseQueryPayloadQuery

關於讀取postings,不管讀多少種索引信息,實際影響可能並不明顯的,當然讀取的流程也並不複雜。因爲在block中記錄所有需要記錄元數據了,同時Term被查找出來之後,其postings的所有元數據信息也已經被完全解析存在內存中。所以讀取一種或者兩種,三種實則性能影響並不大。

1. Basic : doc

首先,讀取DocIDSet是所有查詢都不可避免過程,包含Facet/Stats統計查詢,但不包括MatchAllQuery。而涉及TF-IDF評分的查詢,讀取索引文件中的TermFreq信息也必不可少。Lucene也正是將TermFreqDocID存儲在同一個索引文件(.doc)中,它也是Postings不可或缺的部分。

TermQuery是Lucene最基礎的查詢類型,查詢過程中僅需要.doc文件的信息。在TermsDict找到Postings的位置之後,將元數據信息裝載到BlockDocsEnum交給Scorer遍歷所有命中的文檔進行評分。Scorer是Query根據自己類型創建的,Query除了創建Score之外,還有用於計算Query權重的Weight。Scorer和Weight的計算公式由Simarity提供。最後由Collector收集所有命中的文檔以及最終相似度的評分,收集過程可以加入額外的邏輯取出需要的部分結果集。

2. Postitions : doc+pos

短語查詢和坡度查詢,它要求關鍵詞有連續出現,或者編輯距離小於指定長度纔算命中,因此PhraseQuery/SpanQuery必須用到Position信息才能確定關鍵詞在文檔的位置是否滿足查詢條件的要求。它依賴Position過濾查詢預設的位置要求的文檔,然後將結果再由Query交給scorer進行對文檔的評分。

在TokenStream中Position表示相對位置,相對於前一個Token而言。它是如下定義Position的:

  • 0表示是兩詞爲同源詞,或者採用了同義詞表;
  • 1表示連續,有停頓詞;
  • >1表示中間有停頓詞,其數值表示停頓詞的個數。

但在索引(.pos文件)中,它存儲是絕對位置,即需要累加前所有Token的Position,相同數值表示同源詞,或者同義詞;兩個Position差值爲1,表示連續;在有停頓詞的情況下,也會記錄停頓詞的個數。

Offset信息也是由TokenStream產出的,Offset分爲StartOffsetEndOffset兩種情況,分別代表Term的第一個字符的位置,以及最後一個字符的位置,它們表示Term在文檔中絕對位置。也就是說StartOffsetEndOffset兩個位置可以共同決定唯一個Term,與Postition不同,相同的Position可以有多個Term。

例如,索引時採用IK分詞器的索引時分詞策略時,IK可能會切出多個同源詞,比如將“中國人民共和國”分成“中國”、“中國人”等多個同源詞。此時,如果使用Postition來計算兩個詞之間的相對位置會很方便,比起使用StartOffsetEndOffset

從Position和Offset的區別上可知,正常來說並不需要PhraseQuery和SpanQuery兩種類型的查詢並不涉及Offset。另外.pos主要是存儲Postition和一小部分Offset信息和Payload信息,Offset和Payload主要是存儲於.pay文件中。

3. Anything : doc+pos+pay

PayloadScorerQuery允許用戶在查詢時,可以利用索引寫入的額外信息用於影響查詢評分,它是索引時的Boost升級版本,它支持更豐富信息和手段來干預文檔的最終評分。關於PayloadQuery查詢更詳細的介紹、使用場景和用法,請參考《Solr Payloads》

該查詢類型並不在Lucene核心發行包core中,而是在額外的發行包Queries包中。

三、總結

這裏主要介紹了搜索後端流程主要兩大步驟,如何在字典找到目標Postings所在位置信息,以及爲需要讀取哪些信息爲查詢提供篩選和評分的依據。以及每個步驟都以不同的角度將查詢類型進行分類,且對它們做了簡單的介紹。

下一篇將繼續介紹Query前端部分內容,瞭解Query在拿到Posting信息之後,後續如何加工成最終的用戶期望的結果集的呢?下回分析。

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