Lucene Search流程之二

一、前言

上一篇文章介紹如何通過TermsDict定位Term對應的Postings的所在位置並讀取,屬於Query跟索引(IndexReader)交互部分,我們將它定義爲搜索流程的後端。開始之前,先回顧搜索主流程,它包含如下幾個步驟,其中被標記爲刪除的部分表示已經介紹的後端部分內容。其它的則今天要探討的內容,也是將它們歸前端的部分。

  1. 解析查詢條件生成一棵邏輯語法樹
  2. 提取基於Term的原子查詢
  3. 通過字典信息定位Term的Postings位置
  4. 讀取Posting用於文檔匹配
  5. 構建評分器對文檔評分
  6. 按語法樹的定義執行邏輯運算
  7. 通過Collector收集目標文檔集合

二、Query

IndexSearch在接到用戶的搜索請求之後,首先解析用戶查詢條件生成語法樹。語法樹的所有葉子結點都是爲這次搜索請求的原子搜索條件,如TermQuery,即需要Query與IndexReader交互,通過IndexReader獲取Term對應的Postings信息。非葉子點,即非原子查詢,則無須與IndexReader交互,由它的子節點提供的Postings運算所得。

TermQuery屬於單Term查詢,所以查詢得到Posting List。但是近似查詢屬於多Term查詢,那它將得到Posting Lists。

但如果用戶提交的查詢,原本就是原子查詢的話,IndexSearch實際上就沒有執行解析成語法樹的過程。

2.1. TermQuery

原子查詢是最基礎、常用的查詢類型。TermQuery具有一般原子查詢必須經過的流程,同時它又足夠簡單,因而選擇從TermQuery開始剖析Lucene搜索流程以及處理機制。

TermQuery按下面流程得到Postings信息,並構建成可迭代的鏈表(PostingsEnum),也稱它爲迭代器。它依然保持按DocID從小到大有序,並且提供advance(target)操作實現將迭代器前進到不小於target的最小DocID的位置且返回。advance(traget)是非常重要的操作後續還會用到。這是上一篇博客長篇大論介紹的內容。

Lucene將Postings相關信息打包封裝在PostingsEnum對象裏,因此在全文中PostingsEnum與Postings可以認爲同意。

此步驟在《Lucene Search流程之一》已經詳細的介紹過了從Query到獲得PostingsEnum的過程,同時也會把Term的相關統計信息(如TTF,TDF等)記得在環境中以便後續評分過程中如必要時可以直接使用。

a. Weight

接着IndexSearch利用Query創建一個Weight用於計算原子查詢類型中的自身權重,也在複合查詢中承載着子查詢間的邏輯運算。由於Query在上一個步驟獲取的PostingsEnum也會交付給它,使得直接能夠訪問Postings,因此也有爲候選文檔計算相似度的條件。

實際上Weight並沒有真實的計算Query的權重,Weight只是擁有訪問Postings的能力,一方面創建一個與它對應的Scorer評分器,由它負責文檔的評分方面功能(這部分內容後續還會介紹到)。另一方面,通過matches接口向外部提供訪問Term的Postings信息功能,即指定的Term出現在哪些文檔上,在其中每個文檔出現次數,以及位置和每個位置附帶額外信息。

Weight還提供的訪問Terms在候選文檔上位置信息和Payload信息的matches接口,在Explain時會用到它。當然也可以用它來實現類似PharseQuery的功能,雖然PharseQuery沒有直接它,不過也是採用雷同的方式。

Weight在搜索流程中,主要負責依據Query的查詢條件創建對應Scorer,以及提供對評分結果進行解釋的explain的方法。

b. Scorer

Scorer是Lucene在搜索流程用於計算Query與文檔相似度計算的外圍組件,它實際上並不負責文檔得分的計算,這部分工作是委託給Similarity去做的。Similarity纔是真正的評分器,而Scorer只是負責評分外圍的工作。比如它爲文檔評分提供必須參數,決定文檔是否需要評分,哪部分文檔進行評分,哪些文檔不用評分。也就說它決定了讀取Postings信息的種類和模式,是否啓用跳錶優化等。

Query與文檔的相似度代表文檔在這次查詢的得分,得分越高,相似度越高。博文出現文檔得分與相似度屬於相同意思。

雖然Scorer不負責文檔得分的計算,但它卻是能夠給出文檔最終得分的組件。關於真正得分計算是Similarity插件完成,Lucene實現了兩種常用的計算模型,空間向量模型BM25概率模型。這兩種模型都依賴於TF-IDF,它是一種統計方法,可以粗略的將二者關係理解爲:TF-IDF用於計算權重,兩模型通過權重計算相似度。

Lucene6.0之前的版本中,Lucene默認使用空間向量模型實現作爲評分器,之後改用BM25概率模型作爲默認實現。

TF-IDF用於計算查詢詞Term文檔或者Query的權重,那麼文檔與Query的相似度如何用這兩種權重來表示呢?以空間向量模型爲例,Query中的Term系列,可以計算得每個Term的文檔權重,得到文檔權重系列,它也是文檔權重的向量。同樣的方式也可以得到Query權重的向量,那麼Query與文檔的最終相似度便可以表示爲兩向量的距離

I. Score_Mode

至於如何決定文檔是否需要評分,Lucene定義了三種模式分別如下:

  1. TOP_SCORE,最常用的方式,即按文檔得分取查詢結果集的TopK
  2. COMPLETE,則需要爲所有候選文檔都進行評分
  3. NOT_COMPLETE,與COMPLETE相反,它表示完全不需要評分

關於評分模式,通常由collector決定的,如在大部分的facet查詢的collector便是完全無須評分的。但也有由排序的條件決定,如按字段排序。NOT_COMPLETE表示

在默認情況下,即沒有指定Collector和排序條件,此時IndexSearcher採用TOP_SCORE的評分模式,僅給用戶返回TopK文檔。顯然Lucene僅需要對頭部分文檔進行評分即可,即跳過部分候選文檔不用爲之計算就可以淘汰的。

Lucene爲什麼可以直接淘汰跳過部分文檔,而不需要爲之計算評分,且最終不影響召回的結果集呢?

首先從評分角度看,哪些因素會最終影響了評分呢?下面是對Lucene官方文檔給出的TFIDFSimlarity相似度評分公式展開之後得到的式子:

scorer(q,d)=t in q((1+logdoc_count+1doc_freq+1)2×frequency(t,d)×norm(t,d)) scorer(q,d) = \sum_{t\ in\ q}{((1 + \log{\frac{doc\_count+1}{doc\_freq+1}})^2 \times frequency(t,d) \times norm(t,d))}

doc_freq表示搜索關鍵詞Term出現在多少個文檔中
doc_count表示整個Segment的總文檔個數
frequency(t,d)表示詞條t在文檔d中出現頻次
norm(t,d)表示詞條t在文檔d歸一化因子
其中這兩個函數norm(t,d)和frequency(t,d)的值與本文中的上下文均用normfreq表示

結論是,通過freq和norm便能夠進行文檔得分的比較,而不需要先計算文檔得分。

由此可見,其中只有norm(t,d)frequency(t,d)是隨文檔變化的,其它參數都在segment內確定不變的固定值。

其次是與Postings的存儲結構相關,Postings是有序且分塊存儲的。爲了Postings能達到非順序查找,Lucene爲此構建了多層SkipList,且在構建的時候,爲每個節點記下當前以及之前所有Block的freqnorm信息。此時,Lucene便可以啓用SkipList的優化,直接跳過低的文檔。

已知Postings是有序的鏈表結構,且它是分塊(Block)存儲的。
Postings並不一次全部加載到內存,那樣會那樣非常佔用heap內存,可能加劇JVM的GC,甚至是OOM,導致qps受到限制。總之會影響搜索引擎的性能。

所以Postings在存儲時,也是分塊存儲的,使得它對很長的Postings也非常友好。分塊之後,爲了能夠快速的訪問某個塊,實際上也是快速試某個文檔是否存在的,Lucene對此需求做了優化。即在寫Postings的數據塊時,爲它們創建索引——多層跳錶。

關於倒排表的內容曾在《Lucene倒排索引簡述 之倒排表》中介紹過詳細倒排表的存儲結構,順帶簡單介紹了引入SkipList的提前和帶來的好處。這裏從使用的角度重新回顧SkipList的結構,然後進一步介紹它應用在搜索過程中哪些步驟和情景、以及如何能夠提升搜索性能。

II. SkipList

SkipList本質上是在有序的鏈表上實現實現二分查找,它能有效的提升鏈表的查找效率,其時間複雜度爲O(logn)(其中n爲鏈表長度)。簡單說SkipList優化了Postings的隨機查找的性能問題。

SkipList的節點存儲了三部分數據,分別是當前節點指向Block的信息,是關於Block本身的信息;指向下層的索引;最後是存儲freq和norm的信息,它被封裝在Impact裏面。

Impact結構僅是<freq, norm>的鍵值對,與文檔無關,在SkipList的索引節點中。Impacts表示一系列Impact結構,用有序的TreeSet存儲。這裏強調的是Impact並沒有與具體文檔關聯,其次按freq和norm作爲主鍵去重。也就是Impacts代表了該索引節點指向數據點以及之前所有數據節點所包含的文檔得分的分佈。
如果此索引節點中最大的Impact都小於Scorer的水位線,那麼此節點的範圍內的所有節點都不需要再進入Scorer評分程序,在TOP_SCORE模式下。

.doc文件讀取出來的SkipList如下,爲了方便製圖,把步長縮小爲2。那麼在第0層,每兩個Block創建一個索引節點,第1層在第0層的基本上構建,依此類推。

常規多層跳錶結構,每個索引節點兩個指針,一個指向同層下一個節點,叫next指針;另一個指向下一層的down指針。在下圖中,第1層的節點4指向第0層的節點4的指針即是down指針,而從節點4直接指向節點8的叫next指針

實際上SkipList的性能提升是通過在鏈表上加上多級索引獲得的,所以說它屬於空間換時間的做法,在索引時犧牲小量空間換取在搜索時的性能提升。而層級越高,索引的步長越短,構建索引的空間代價也會越高。這也解釋了Lucene爲什麼要採用8個Block作爲步長,雖然它的查詢性能相比會差一些,但是需要的空間也縮減少n/8,是一種存儲空間和性能的折中方案

查找過程:以查找第7個Block爲例,與最上層第二層的第1個節點比較,7 < 8;通過down指針下沉到第一層,7 < 4,通過next指針找到下一個索引節點繼續比較,7 < 8。所以回溯到節點4,然後下沉到第0層,7 > 67 < 8。所以回到6節點並下沉,前進一個節點之後發現7 = 7,成功找到並返回。

Lucene的SkipList僅多花費n/8的存儲空間,便將Block的隨機查詢的性能提到O(logn)的時間複雜度。PostingsEnum的advance(target)是SkipList主要應用場景,它除了應用於TOP_SCORE,還能用在多個結果集間做析取和合取運算上。

2.2. BooleanQuery

在真實的運用情景下,並非全是單個查詢條件的,它更多的往往是多個條件的複合查詢。布爾查詢(BooleanQuery)是檢索模型中最簡單且使用廣泛的模型,通過布爾代數的連接詞(與或)將複雜的查詢集合串聯成布爾表達式,最終通過布爾代數計算查詢與文檔之間的相似度的。

其所有葉子節點都是原子查詢,它需要讀取Postings信息,但非葉子節點都通過對葉子節點的Postings進行謂詞運算獲得。

布爾查詢由與、或兩種連接詞串聯起來的表達式,在查詢場景下考慮的是如何將每個查詢條件查詢得到的Postings實現布爾表達式的運算呢?換言之,換成數學問題中如何實現DocID集合進行交集、並集運算。對於運算,是需要如何找出所有集合共同出現的子集——取交集運算;運算,需要考慮的則是如何去重——取並集運算。

Lucene爲Postings的遍歷設計了一個叫advance(traget)的方法,含義是前進到Postings中不小於target的最小的文檔編碼(DocID)。如果不存在滿足條件的文檔時,返回NO_MORE_DOCS。其隱含含義是Postings迭代器中沒更多的文檔,遍歷結束。

隨着搜索引擎索引索的文檔越來越多,一次查詢中某些Term的Postings的長度可能會很長,尤其是一個Term(常用詞)出現在非常普遍的文檔中。此時對整個Postings的所有文檔都進評分的代價也會隨之增高,因此根據集合的布爾運算的特點設計如下兩種算法。

a. Conjunction

布爾運算的運算,要求所有的查詢關鍵詞(查詢條件)共同命中候選文檔,即候選文檔同時出現了所有查詢條件的關鍵詞。也就是Postings中都出現的文檔號纔是最終結果集。

實際上就是在多個集合間取交集,易知最終結果集必然是任意集合的子集。因此,基於最小的集合開始遍歷,可以避免不必須嘗試。而Lucene通過二階驗證,可以進一步減小無效嘗試。基本思想是,合併後的結果集中每個文檔必須是每個Postings都存在。

Lucene實現比較巧妙,首先在Posting Lists中取出最短Postings命名爲Lead1,接着取出次短Postings的命名Lead2,除此之外稱爲Others。然後遍歷Lead1的每個文檔的過程中,每個文檔都在Lead2中做校驗。假如在Lead2中不存在,則直接退出,否則到others中校驗判斷是否存在。簡單說通過Lead1可以非常有效的減小嚐試次數,通過Lead2則能進一步減小嚐試的次數。總體思路就是避免到Others列表校驗文檔是否存在,流程如下。

在Others的校驗的式子如下,一旦max(...)返回NO_MORE_DOCS退出循環,合併完成。

boolean matches = (DocID == max(pe1.advance(DocID), pe2.advance(DocID), pe3.advance(DocID), ...);

通過如上流程中,都是通過PostingsEnum#advance(target)方法尋找離target最近且不小於target的DocID。而advance(target)在有SkipList的情況下,可能會啓用SkipList優化。

b. Disjunction

布爾運算的運算,要求將每個查詢條件的結果集進行並集運算。每個查詢的結果集Postings,在Luecne中都被會表示爲PostingsEnum。前面介紹PostingsEnum的最重要操作advance(target),這個方法是取Postings的文檔號不小於target的最小文檔號。所以用編程語言描述爲:

DocID = min(pe1.advance(DocID), pe2.advance(DocID), pe3.advance(DocID), ...);

此過程用圖示如下,這裏簡化的Lucene的運算流程。實際上就是將DocID++之後進行上面式子的運算,直至每個元素都至少會被觸達一次,也是DocID恰爲NO_MORE_DOCS時表示計算完成。

每一輪都會得到一條存在當前DocID的Postings數組,然後計算查詢條件命中率,即擁有當前DocID的Postings佔所有原子查詢條件的比例。當它小於某個閥值時,該DocID被以爲不匹配直接丟棄。而對於滿足條件的DocID的Postings鏈表則會用於計算文檔最終評分的計算,它有兩種常用的計算策略,有SumScore和MaxScore,即是對每個子詢的文檔得分彙總和取所有原子查詢中的最高得分。

Disjunction在計算文檔得分時,針對TOP_SCORE模式採用一種剪枝算法,Weak-AND算法

三、Collector

收集目標文檔集可以算是Search流程的最後一個步驟了,它是對候選文檔集進行過濾。這裏有多種策略,比如通過該查詢的候選文檔的評分取TopK,或者按文檔的某些字段值取TopK等。此還有一些高級用法,如Group、Facet和Stats以統計分主的聚合運算。

這裏主要討論按文檔評分取TopK爲例,介紹Collector搜索過程的主路徑中充當角色負責的任務,順帶介紹取TopK的常見方案。

按確切流程講,實際上Search工作過程中,首先是Collector會爲每個Segment分配一個屬於的LeafCollector,針對它的Segment執行收集任務。然後它觸發Scorer對文檔進行評分,再收集文檔的得分和文檔編號。在LeafCollector在收集過程會不斷更新它的最小得分,有利用於scorer更好過濾無評分的文檔。

在Lucene中採用名爲ScoreDoc的結構表,它由於DocID,score以及分片號三部分構成。

在收集過程中,由於最終只按文檔得分的取TopK文檔,所以Lucene並不需要保留過程中所有的文檔。因此問題轉爲化如何取TopK方案,Lucene採用經典的做法,依賴於優先隊列,它的插入的取出的複雜度均爲O(logK),而需要的內存僅爲O(K)

a. PriorityQueue

PriorityQueue的創建時,需要先指定期望獲得結果集的長度,然後PriorityQueue創建一個指定長度的數組。PriorityQueue在數組上構建一棵完全二叉樹,其中第0個元素留空,第1個元素作爲樹的根節點。

如上圖可能看起來還不夠直觀,爲此把它位置重新調整一下變成如下所示。注意,圖中的數字表示數組下標,而非是數據的值。

比較特殊的是PriorityQueue並不保證任意一個父節點的左右兩個子節點之間有序,它只保證父節點小於任意子節點。

它的根節點稱爲隊首,也是整棵樹中最小的節點。PriorityQueue如下幾個操作,

  1. 定義節點比較器
  2. 取隊首
  3. 出隊
  4. 入隊

PriorityQueue的出入隊的時間複雜度爲O(logK),其中K爲隊列的長度,而取隊首是O(1)的時間複雜度。

入隊可以區別爲兩種方式,第一種是列隊仍不滿時,將數據放入隊列中,然後將它與父節點比較,如果小於父節點,則對換位置之後,繼續比較直至不小於父節點,入隊操作完成。
第二種是列隊已經滿時,如果入隊的數據小於父節點,入隊操作完成。否則,將隊首置換爲入隊的節點,然後它不斷與它的子節點比較,直到它不大於子節點。

相比之下,出隊則比較簡單,即將隊尾置換隊首,然後執行一下第二種入隊操作。

不管是出隊還是入隊,都是被更新的節點是上浮或下沉兩種操作。在下沉的過程,它還需要與兩個節點都做一次操作,選擇繼續下沉的路徑。

四、總結

接着上一篇文章介紹Lucene搜索流程中的評分模式和布爾查詢中的合取操作的析取操作,順帶着介紹兩個結構SkipList和PriorityQueue的原理和應用場景。從索引應用的角度進一步鞏固Lucene索引構建流程,知其然也知其所以然。

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