日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

新一代搜索引擎项目 ZeroSearch 设计探索

發布時間:2024/2/28 编程问答 32 豆豆
生活随笔 收集整理的這篇文章主要介紹了 新一代搜索引擎项目 ZeroSearch 设计探索 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

本文作者:kaelhua,騰訊 WXG 后臺開發工程師

背景

寫這篇文章很大的原因在于不論是內網還是外網,分享內存檢索引擎設計的資料都非常稀少,且存量的資料大多側重于功能性的介紹。

另一方面,在磁盤檢索引擎方面,由于開源搜索引擎 ES 的盛行,對于其使用的索引庫 lucence 的分析資料反而較為豐富。

本文意在通過分享對于內存檢索引擎的認識,核心的解決方案,和一些優化方向的思考等等,略微填補一下關于內存檢索引擎設計的資料空缺。

需要說明的是本人進入搜索領域的時間并不長,盡管之前搭建過一些垂類搜索系統,但只是站在應用層面進行使用,真正從事引擎設計的工作也是通過今年 4 月份左右組內重新設計新一代搜索引擎的項目 ZeroSearch 開始,恰巧承擔了在線檢索的設計與開發。因此這并不是一份多么標準的答案,而是我們對于引擎設計的探索,其質量還需時間檢驗和調整。

本文屬于 ZeroSearch 系列分享中的在線檢索設計分享。在本文中假定讀者已經對搜索引擎有了基本的了解,至少對倒排求交,打分排序有基本的概念。

系統認知

對于系統的認知深度,會決定我們怎么去看待內存檢索這樣一個問題,以及由此而產生的的設計方案。盡管本文要講的是內存檢索引擎設計,然而我們還是得從對磁盤搜索引擎的認識開始。

由于 ES 的盛行,以及網頁搜索(搜索領域的大 boss)體驗的存在,大多數人對檢索引擎的認識可能都是基于磁盤檢索引擎來理解的,即系統的倒排,正排數據都位于磁盤中,只有在執行檢索時,才會將相關的數據 load 到內存中。

其整體的流程大概如圖所示:

磁盤搜索引擎在設計的過程中面臨的主要問題為:

  • 同時兼具計算密集型與IO密集型任務

  • 磁盤與內存及CPU存在數量級差距的性能GAP,磁盤資源屬于瓶頸,而計算量富余。

因此其在設計過程中考慮的核心要素為兩點:

  • 任務調度的設計,即管理 IO 任務與計算任務

  • IO 優化 如異步 IO 設計,IOCache 優化,索引壓縮等等

盡管 IO 優化也是非常重要的一環,但我們認為磁盤搜索引擎的核心,本質上是一個任務調度的問題。

現在回到內存搜索引擎的討論上來。很明顯,內存檢索引擎在去除磁盤 IO 后,其要解決的核心問題是計算量的分配問題,即如何合理的分配計算量,能盡可能的讓優質結果展現給用戶。

下面是我們給內存檢索引擎制定的核心流程:

可以看到我們對于計算量的分配,抽象出了求交,L1 打分,L2 打分等 3 個邏輯階段。

求交 即根據查詢串取出對應的倒排鏈進行求交,得到結果文檔L1打分 求交出來的文檔均會送入L1打分L2打分 L1得分Top的文檔才能進入L2打分

這里為何要將打分分為兩個階段呢?

1 滿足高求交數的需要

由于倒排數據處在內存中,因此單篇文檔的求交消耗較少,限制引擎召回量的瓶頸往往不在求交,而在打分。輕量級的打分配合高求交數,可以避免求交截斷導致的文檔無法召回問題的出現

2 滿足輕量級業務的打分需求

對于一些排序較簡單的業務,不需要單獨的精排服務,可以在引擎的 L2 打分過程中滿足它的需求。

需要注意的是對于一些高消耗的模型,我們會放在更高層次的排序中,并對其進行抽離,放在獨立的 tf 服務上執行,并不會放在引擎的 L1、L2 階段來執行。

L0打分在離線索引過程中我們會提供接口用于計算文檔的質量分,因此全量文檔計算都會進行質量分的計算,建立倒排索引過程中,質量分越高的文檔,排序越靠前,以保證被優先查找到

核心設計

設計背景

在講述核心設計之前,需要先了解以下幾點背景

1 索引分片分庫

索引會先進行分片,多個分片再合并為一個索引庫。分片數一旦指定后便不可更改,但是索引庫的庫數是可以靈活調整的,可以滿足業務數據增長,索引數據多集群劃分的需求。在檢索過程中,索引庫是檢索的基本單位。這一點與 ES 可做一個簡單的對比,ES 為庫->分片(多個庫)->實例(多個分片)的設計,而我們的設計為分片->庫(多個分片)->實例(多個庫),即我們將數據分片放到了更底層,打開了它的數量限制,同時對庫的數量進行了收斂,原因在于庫數越多,引擎性能將越差。關于索引分片分庫的詳細背景和設計后續組內會另有同學來進行介紹。

2 無 RPC 框架設計

引擎自身不攜帶 RPC 框架,我們以組件化的思想來進行設計。通俗來說,就是封裝成了一個庫,提供了初始化函數和唯一的檢索入口函數來給到外部進行使用。這種方式有優有劣,優勢為無須考慮上層的協議頭,可靈活適配于各種 RPC 框架中,并復用已有的運維體系。劣勢為對線程的控制能力較弱,理想情況下引擎自身的工作線程與 RPC 工作線程應當資源隔離,通過親緣性各自分配和獨占 CPU,這一點在組件化里難以實現。

事實上我們是面向 Controller-Proxy-Work 這一類 RPC 框架進行設計的,典型的如 SPP,Svrkit 等,并且在我們的實現過程中,將預處理和回包處理的邏輯均放到了 RPC-Work 線程中進行。

3 以易用性為第一優先級

組內上一代的內存搜索引擎由于基礎配置項過多,引擎細節暴露過多,且欠缺配套的 debug 工具/能力,導致它的學習和維護成本都非常高。在新引擎的設計過程中,我們將易用性列為了第一優先級,本質上也是以服務業務為第一優先級,即便是性能方面也需要為易用性讓步。易用性方面主要會體現在以下幾點:

3.1 引擎的學習成本應具備梯度,滿足快速入門使用的需求;

3.2 配置項盡可能少,盡可能避免暴露引擎細節,盡可能以通俗語言表達,如內存大小,線程數量等;

3.3 需要有全面的問題定位能力,根據經驗,維護垂搜業務時,最常做的事情就是查文檔為什么召不回,如果引擎具備問題一鍵定位的能力,那么可以有效的減少運維成本。

需要說明的是,盡管這里提到了易用性,但是下面的內容不會涉及到我們為了提升引擎易用性采取的具體做法。這里之所以單獨拎出來進行強調,在于根據我過往的業務開發經驗,部門內上一代內存搜索引擎的學習和維護成本過高,與業務的快速發展已經不匹配,我認為作為一個基礎平臺,性能 100 分,還是 80 分,甚至是 70 分,只要可以通過加機器來解決,對于增長型業務來說基本就不太 care 了,而易用性(含可維護性)才是最優先被考量的因素,其對團隊的整體效率有很大的影響。

在清楚了大概的設計背景之后,可以開始真正考慮該如何設計我們的檢索引擎了。

線程模型設計

下圖是我們的檢索組件目前使用的線程模型:

每個檢索請求到達時,會生成一系列的求交與打分任務,在召回完成之后,會生成一個資源清理任務進行提交,請求完成。

下面對圖中的主要元素做下簡單的介紹

1?主線程 即RPC框架的Work線程,在Work線程中,會完成請求的預處理和回包處理的邏輯,并且處理求交或者打分任務完成后的回調邏輯。2?JoinThreadPool 負責處理求交任務的線程池,在上面已經提到過,索引會分片分庫,索引庫是檢索的基本單位,而一個求交任務至少會處理一個索引庫(由于數據實時更新,系統中會存在一些小庫,多個小庫可能會被放到一個求交任務里進行處理),每個求交任務一旦分配到線程,就會將任務完整的執行完(或者超時)。3?ScoreThreadPool 負責處理打分任務的線程池,打分任務分為L1打分任務和L2打分任務,但是線程池是共用的一個。對于L1打分任務,當一個求交任務完成的求交文檔數量達到一定程度時,便會生成一個L1打分任務Push到打分隊列中。L2打分任務同理,也是等到L1打分文檔達到一定數量才會生產。4?CleanThreadPool 負責處理資源清理任務的線程池,即資源的清理是異步進行的5?求交資源池 負責管理求交時需要的一些數據結構,以資源池的形式來完成復用

可以看到整個線程模型是以 Task 為調度粒度的,這種模型有個比較大的缺陷,每個 Task 的消耗其實是不一致的。對于求交任務而言,每個任務會將一個索引庫給求交完(達到限制或者超時),而隨之產生的 L1 打分任務和 L2 打分任務,每個任務其實都只是求交出來的部分文檔,因此求交任務的消耗是非常高的,并且求交任務在入隊時是在一個 for 循環里集中式的入隊(直到所有的索引庫都分配完),為了防止打分任務餓死,這里劃分了 3 個線程池以避免這個問題。

然而劃分多個線程池本身就是問題所在,至少存在以下 3 個方面的問題:1 增加了配置項,降低了易用性。

2 其實業務并不知道該如何去對各個線程池的線程數量進行配置(盡管引擎會簡單的根據 CPU 邏輯核數量進行默認設置),只能不斷去調整測試來達到一個合適值。

3 多個線程池的方式不論怎么去配置數量,都不太可能把所有線程都高效利用起來,必然會有計算資源不能充分利用的線程池存在。

盡管存在這么多很容易預見的問題,我們還是先這樣做了,一方面是目前開發人力非常少,在線檢索這塊的開發只有我一個人在兼職,需要彌補完善的東西還有很多,整體確實還比較粗糙,另一方面主要也是目前我們還沒有建立一個用于質量標準評測的系統,因此一些優化類的工作優先級都排的比較低。

關于有沒有餓死情況的出現,我們的評判標準并不是針對個例的發現,而是通過統計p99,p995,p999等指標來進行評判。因此嚴格意義上來說,也并不是真正的餓死,畢竟FIFO隊列只要入隊了遲早會被執行,只是等待時間長和短的問題。正如標題是對于引擎設計的探索,這里簡單分享一下后續計劃要嘗試的幾個線程模型的方向,當然下面所有的方向都是只使用一個線程池。

1 繼續維持 FIFO 的模式這個很好理解,也就是所有的 Task 都入同一個先進先出的隊列,其實這個改動起來非常簡單,只是質量標準評測系統還沒搭起來,就暫時沒去做對比測試了。

2 邏輯越輕量,優先級越高同樣很好理解,即創建一個特殊的優先級隊列,對各類 Task 根據邏輯的繁重設定一個優先級,設想的情況是這樣的,優先級從高到低為:

清理Task > L1Task > L2Task > 求交Task

在 Push 任務時,優先級越高的直接插入到隊首,但是同類 Task 之間依然保持先進先出的關系。最終是一個這樣的隊列:

為什么要這樣做呢?

可以有效解決由于求交任務的高消耗和集中入隊導致其它任務餓死的問題。通俗一點理解,那就是只有當所有的打分任務都完成了才會去執行求交任務。

從過程上看,似乎會有一個新的問題,即便系統有多個邏輯核,索引庫之間的求交打分變成了線性的模式(串行),而非并發的模式?

但是從結果上進行分析,這種調度模式是否改變了處理請求時所需要的計算量?很明顯,并沒有。同樣的,單位時間內機器的算力也并沒有浪費,因為除非請求已經完成,否則一旦有空閑線程出現,那么必定會被分配給求交任務。那么至少從結果上來分析,這種模式應該是有效的,當然具體效果優劣,數據表現如何,還需實驗驗證。

以邏輯越輕量,優先級越高的優先級隊列管理任務似乎會引入一個新的問題,即求交任務可能被'餓死'???這一點很難評判,原因在于兩點1 打分任務其實是由求交任務產生,如果求交任務得不到執行,那么也就不會有打分任務了。2 單個打分任務的文檔數較少,邏輯相對較輕,影響較小。具體還是需要實驗驗證后才能得出明確結論。

3 以時間片為調度粒度徹底改變 Task 為調度粒度的模式,換為時間片的模式,同時繼續保持 FIFO 的模式,每個 Task 消耗完時間片后就被丟入隊尾,直至超時或完成。

以時間片為調度粒度時,此時眾生平等,也就不需要關注 Task 之間的消耗程度孰輕孰重帶來的餓死情況輕重的問題了。  時間片調度這種模式其實還有一個好處,對于一些召回數過低的請求,大概率在一個時間片內就能被執行完,那么它總體的等待時間就會少很多,從它要入隊時開始分析,等待時間由:

sum(隊列Task-處理完成所需耗時之和)

降低到 sum(隊列 Task-時間片之和),但這種模式有沒有被引入的問題?

同樣有的,對于高召回的請求需要多個時間片才能執行完,由于每次時間片執行完需要重新入隊,那么它的等待時間相比 Task 的模式大概率是會增加的。不過這個問題相對來說還比較好解決,至少我們可以從以下兩點來緩解和解決。

1 增大時間片的粒度即將時間片粒度變大,如由原先的 500us,增大為 1ms。從而可變相減少高召回請求的入隊次數。當然這里也需要控制力度,極端情況下會退化為 Task 模式。

2 增大高召回請求的時間片粒度即給高召回請求的分配的時間片為基礎的時間片*2,或者*3 等等,這是一種有效解決高召回請求入隊次數過多的方法。但是難點在于我們如何識別出高召回請求?這是一件很有挑戰性的事情,不過這里先不介紹我們在籌劃的做法,下文中會提到。事實上,它不止對于線程模型調度的設計有直接影響,對于稍后介紹的任務模型同樣有影響。

細心的讀者可能已經想到了,高召回請求是從結果上來看的,當我們從過程上來看時,問題就簡單很多了,即回到了問題本身,它是入隊次數過多的請求。那么我們只需要增加每次重新入隊時被分配的時間片即可,一種最簡單的方式是參考 vector 的內存增長的方式,更高級的方式這里就不展開了,索引數據和求交進度也是分配的參考項。從而有效解決高召回請求入隊次數過多的問題。不過同樣,這里的增長也需要控制力度,極端情況下會退化為 Task 模式。

線程模型的介紹暫時就到這里了,下面我們看一下任務模型的設計。

任務模型設計

任務模型與線程模型有什么區別呢?

線程模型更專注于計算量(任務)的執行,而任務模型更專注于計算量(任務)的分配。對于執行者來說,它是任務無關的,而對于分配者來說,它本身就是任務的創建者,與任務是強相關的。在介紹線程模型時,其實我們已經大概清楚了,引擎中有以下 4 類任務,分別為清理任務,求交任務,L1 打分任務,L2 打分任務。其中清理任務較為獨立,就不多花筆墨介紹了。

下面直接看我們的求交打分任務模型:

任務模型的核心要素為以下 3 點:

1?求交依然維持單庫單線程求交(小庫例外,多個小庫合并一個求交任務)2?求交文檔達到閾值時生成一個L1打分任務3?L1打分文檔達到一定閾值時生成一個L2打分任務

從而可以實現求交、L1 打分、L2 打分并行執行的效果,整體達到一個流水線的設計,就如上圖所示一樣。組內的上一代內存搜索引擎對于求交打分是一個階段一個階段的執行,整體是一個串行的模式

求交階段 ---> L1打分階段 ---> L2打分階段

在實際的實現里,上一代引擎對于每一個索引庫其實是單線程邊求交邊 L1 打分的(因此本質上屬于串行),等全部求交文檔 L1 打分執行完畢后,再進行一次快速選擇排序選出 TopK 得分的文檔,然后把這部分文檔送入 L2 打分,L2 打分結束后,進行最終的 TopK 排序,然后進入回包處理階段。

在新引擎中,我們將求交與 L1 打分進行了拆分,并對打分任務以 Task 為粒度進行調度。為什么要這樣做呢?當然是因為這是一種 CPU 利用率更高的做法。下面我們進行一個討論,在這個討論里我們先假定引擎的工作線程池只有一個,這樣的話更利于分析。

假設索引庫數?=?CPU邏輯核數1?在一個線程處理一個庫內文檔的求交與L1打分的形式下,各個線程耗時計算方式是固定的 |?------------------|-------------------|求交耗時?+??l1打分耗時 最終耗時為:?max(各個庫的求交耗時+l1打分耗時)2?如果我們將求交與打分拆開,每次求交部分后,再將這部分送出去進行打分,讓打分 獨立出來,從而達到流水線化: |?------------------|求交耗時|-------------------|打分耗時 |-------------------------|總耗時 理想情況下,最終耗時為: sum(各個庫的求交耗時+l1打分耗時)?/?引擎工作線程數?=?avg(各個庫的求交耗時+l1打分耗時) 原因是計算總量雖然并未減少,但是被打散得更均勻了。很明顯這種模式能更好的利用CPU資源假設索引庫數?>?CPU邏輯核數3 將會出現一個線程處理多個索引庫,我們可以理解為這多個索引庫只是一個更大的索引庫,從而問題回歸到討論1與討論2中。假設索引庫數?<?CPU邏輯核數4?老模式下其最終耗時依然為:max(各個庫的求交耗時+l1打分耗時)新模式下其最終耗時為: (sum(各個庫的求交耗時+l1打分耗時)?-?非求交線程承擔的L1打分耗時?)?/?求交線程數 該值比avg(各個庫的求交耗時+l1打分耗時)會更小。

盡管拆分后的方式 CPU 利用率更高,但是很明顯,新的方式在總吞吐方面并不會提高。

計算基礎 1?單位時間內機器的算力是固定的2?每個請求需要消耗的算力并沒用變因此在極限情況下,吞吐方面確實沒有提升。不過實際上,在正常情況下,我們都會保證機器的負載在一個較低的水平,以此來保證服務的安全,而當機器負載未滿時,新模式下長尾求交任務通過把l1打分邏輯分發出去可以更充分利用總的CPU資源,從而減少請求的耗時。

我們可以得出以下兩個結論:

  • 新模式在極限情況下的總吞吐沒有提升

  • 相同吞吐情況下,新模式 CPU 利用率更高,因此請求處理平均耗時會更少

另外一個問題,為什么我們要維持單庫單線程求交?簡單來說,求交不是召回瓶頸,當然如果真的發生了這種事情,求交成為了召回瓶頸時,我們的建議是減少每個索引庫包含的分片數。

最后,細心的讀者可能早早就發現了,求交出來的文檔是需要都送入 L1 打分的,但是只有 L1 得分 Top 的文檔才能進入 L2 打分,整個任務模型里的求交-L1 打分-L2 打分的流水線處理應該無法實現才對。的確是的,求交結果進入 L1 打分是一個確定的行為,而 L1 打分結果是否進入 L2 打分是一個待定的行為。為了滿足流水線的計算,我們需要將待定行為轉為確定行為。

1 文檔預估

如果我們能夠知道一個請求能求交得到多少篇文檔,那么當求交文檔數 < L1 結果限制數(TopK 里的 k 值),那么很明顯,所有完成了 L1 打分的文檔都可以直接進入 L2 打分。

1.1 根據文檔頻率預估這是一種簡單粗暴的方式,例如用戶搜索[蘋果手機],它的分詞結果得[蘋果 | 手機],兩者的關系為求交,那么這個 query 的預估召回文檔數就為:

min(Term(蘋果)倒排鏈長度,Term(手機)倒排鏈長度)

如果考慮到蘋果手機整體與 iphone 同義,那么其預估召回文檔數就為:

max(Term(iphone)倒排鏈長度,min(Term(蘋果)倒排鏈長度,Term(手機)倒排鏈長度))

即根據各個 Term 的文檔頻率和其邏輯關系來進行簡單的推導。很明顯,實際求交文檔數,一定會小于等于該推導值。

1.2 緩存查表由于一定時間內的索引數據是相對穩定的,我們可以通過緩存檢索 query 和求交數的映射關系,每個請求到達時進行一次查表來完成預估。可能有讀者會質疑,那為什么不直接緩存求交結果呢?

其實這是兩個維度的東西,它們本身也并不沖突,如果在引擎內對結果緩存會占用較多的內存,我們期望的做法是在更上層對分頁后的結果進行緩存,因為可以明確的一點是首頁的緩存命中率一定會顯著高于后續的結果頁。另外引擎內進行緩存的話還會影響系統的時效性,這一點并不合適。

1.3 模型預估通過模型來對一個 query 的召回文檔數進行預估。由于每個業務的數據量是相對穩定的,可以通過在線收集 query 和查 詢語法樹的特征以及倒排鏈相關的特征,離線訓練,在線接入,來完成預估。

2 預計算即選出一部分 L1 打分完成的文檔,先進行 L2 打分計算。目前我們實現的方式有以下幾種:

2.1 固定篇數模式取固定的 l1 結果數進行預計算,原則為先完成 l1 打分的文檔將會被送入,這是因為由于 l0 得分的存在,通常我們認為越先被求交出來的文檔,其質量越高

2.2 得分閾值模式l1 得分大于得分閾值的進行預計算

2.3 得分比例模式l1 得分大于 (已完成 l1 打分的文檔的平均分 * rate) 的文檔進行預計算,因此其實這是一種特殊的得分閾值模式,只是它的閾值在不斷調整。

由于我們目前只有一些較簡單的離線業務接入了新引擎,上面幾種方案的具體效果如何還沒有得到一個可信的數據。另外后續也會考慮不斷加入新的預計算方式,例如將固定篇數模式與得分閾值模式組合起來使用。

事實上,預計算幾乎肯定會有浪費計算量的情況出現,即本不能進入 L2 打分的文檔卻被執行了 L2 打分。其浪費率以及耗時降低的收益需要根據各個業務自己的需求而定。

需要特別說明的是,新引擎在 L1 打分階段完成之后(求交階段已完成,且 L1 打分任務全部完成),依然會整體進入 L2 打分階段,對 L1 結果集取 TopK,然后分配 L2 打分任務,只是每個 L2 打分任務對分配到的文檔進行打分時會先判斷是否已經被預計算過了,如果是的話則直接跳過。因此預計算的存在并不會導致結果不穩定的問題出現。

求交設計

求交設計分為兩塊,一塊是語法樹求交設計,主要是查詢語法樹的設計和求交算法。另一塊是查找算法設計,主要介紹倒排查找的做法。

語法樹求交設計

對于求交而言,基本的理解其實就是取出幾條倒排鏈,然后計算出倒排鏈中公共的文檔。不過實際情況比這個要復雜很多。對于求交設計而言,第一步要考慮的是查詢語法樹的設計,我們從同義詞開始,在新引擎的設計里,我們采用的 3 層結構語法樹。假設 [蘋果手機] 存在同義詞 [iphone],那么對于 query [蘋果手機回收] 的最終的檢索語法樹為下圖所示:

這里以寬度優先的方式給每個節點進行了編號。可以看到這是一顆 and-or-and 的語法樹,可以支持多對多的同義詞表達形式,例如這里的節點 2 下面的兩個同義詞詞組,就是一個 2 對 1 的同義詞組。

現在我們要需要考慮一下這樣的一顆語法樹如何做召回。一種很直觀的做法是這樣的:

1?節點6與節點7的倒排鏈進行求交,得到的pageid作為節點4的pageid2?節點2的pageid?=?min(節點4?pageid,節點5?pageid)3?比較節點2的pageid與節點3的pageid3.1?節點2?pageid?=?節點3?pageid 則彈出該節點作為求交結果,所有節點對應的倒排鏈后移一位3.2?節點2?pageid?<?節點3?pageid 節點2先內部求交得到一個大于等于節點3?pageid的文檔3.3?節點2?pageid?>?節點3?pageid 節點3對應的倒排鏈查找第一個大于等于節點2?pageid的文檔上述過程一直持續有節點2或者節點3有節點到達了末尾為止。其中節點2由于是一顆子樹,它是否到達末尾,由其子節點節點4與節點5到達了末尾為止,節點4同理。關于pageid 在建索引庫時我們會對進入到該索引庫的文檔按L0得分排序,從0開始重新編號,當然庫內會有一片區域保存庫內pageid到原docid的映射關系。這一點的主要目的是為了保證倒排鏈中的文檔按L0得分排列后依然有序,次要目的是為了對倒排鏈進行壓縮。但是對于內存搜索引擎而言,我們暫時還沒有嘗試對倒排鏈進行壓縮,一方面是因為CPU同樣是緊張資源,另一方面團隊也還沒有精力投入到這一塊。因此目前其最大的作用只是保證了倒排鏈中的文檔id有序,以及庫內文檔id的連續(從而可以根據庫內文檔id直接下標訪問文檔數據),另外把8字節的文檔id,轉成了4字節的庫內pageid,省了一半內存。

本人之前聽到過多次這樣的說法:語法樹的層級越高,求交的性能就會越差。如果是按照我上面所述的求交方式的話,那么的確是的,層級越高,求交性能就會越差。原因是什么呢?

原因在于高層級的語法樹進行求交時可能會存在一些不必要的求交行為。以上面的那顆 3 層語法樹為例,假如節點 6[蘋果]和節點 7[手機]這兩條倒排鏈中的 pageid 都非常小,而節點 3 的 pageid 比較大時,那么有可能節點 2 所有的求交結果都來自節點 5。

那為什么會存在一些不必要的求交行為呢?其本質在于上面所述的求交方式是一種邏輯先驗的求交算法。下面介紹一種邏輯后驗的算法。

定義求交基準為一個可能的求交結果1?計算求交基準N?=?max(?min(?max(節點6?pageid,?節點7?pageid),?節點5?pageid),?節點3?pageid)2?所以倒排鏈全部往N靠攏,找到第一個>=N的位置3 判斷所有倒排鏈當前的結果是否符合求交邏輯關系,若符合則彈出結果且相關節點后移一位。4?判斷是否求交結束,如果未結束則流程回到1,否則退出

求交損耗的本質為各條倒排鏈的跳躍查找次數,and/or 等語法只是建立在倒排鏈的跳躍查找之上的邏輯關系,跳躍查找次數越少的,性能也就越好。在邏輯后驗求交算法里,每次選出的求交基準 N 都是一個可能的求交結果,也就是說除非我們能找到新的算法可以再次排除一些可能的求交結果位置,否則不會有比它性能更好的語法樹求交算法。

現在嘗試一下將語法樹打平,看一下打平后的語法樹具備哪些方面的優勢。這里要介紹的是我們上一代內存搜索引擎中將 3 層結構語法樹轉化為 2 層結構語法樹進行求交的做法。

笛卡爾積語法樹

通過將 3 層語法樹結構里的同義詞節點做笛卡爾積,可得到與其等效的 2 層結構語法樹,還是以 query [蘋果手機回收] 為例,其中 [蘋果手機][iphone] 互為同義詞,將其轉換為笛卡爾積語法樹后,其結構如下圖所示

其原理為 (A?&&?B)?||?(C?&&?D) =?(A?||?(C?&&?D))?&&?(B?||?(C?&&?D)) =?(A?||?C)?&&?(A?||?D)?&&?(B?||?C)?&&?(B?||?D)

當然這里的同義詞組更簡單,為(A && B) || C 的模式。下面我們分析一下笛卡爾積語法樹與原語法樹的差別。

求交性能分析

為了能夠更具體一點的了解不同語法樹之間的性能差異。我們需要對求交性能做一個定量的分析。下面對 3 層結構的原語法樹和其對應的笛卡爾積語法樹各自的求交過程來進行性能分析,依然以蘋果手機回收這個 case 為例。

1?原語法樹求交基準的計算公式:(此處直接以Term值來表示對應Term節點) max?(?min(蘋果?&&?手機??,?iphone)??,?回收)笛卡爾積語法樹求交基準的計算公式: max(?min(蘋果,iphone)?,?min(手機,iphone)?,??回收)2?給定一個基準的前提下:源語法樹需要操作的語法節點為4個,對應為4條倒排鏈笛卡爾積語法樹需要操作的語法樹節點為5個,對應為5條倒排鏈3?兩種類型的語法樹結束條件較為相似,都是某一顆子樹到達末尾 源語法樹結束條件: (End(蘋果?||?手機)??&&??End(iphone))??||??End(回收) 笛卡爾積語法樹結束條件: (End(蘋果?||?iphone)?||?End(手機?||?iphone))?||?End(回收) 由于 End(蘋果?||?手機)??&&??End(iphone)?=?End(蘋果?||?iphone)?||?End(手機?||?iphone) 因此結束條件的位置其實是一致的。

為了簡化性能評估,我們假定每次語法節點的操作損耗相同,則性能評估的大致公式為:

從公式上來看,決定性能的因素主要有以下 4 點:

  • 求交基準總數

  • 語法節點個數

  • 求交基準選取損耗

  • 語法樹節點操作個數

現在我們來對比下打平后的笛卡爾積語法樹和原語法樹之間的差異。

1 求交基準總數由于(A && B) || C = (A || C) && ( B || C),因此兩顆語法樹的最終邏輯肯定是一致的,只是表現形式不一樣而已,那么僅從公式上,可以知道這兩顆語法樹的求交基準個數肯定是一樣多的(這句話其實是有一些問題的,不過可以先這么理解)。

2 語法樹節點個數很明顯,笛卡爾積語法樹的語法節點數會大于原語法樹的節點個數,(A && B) || C ==> (A || C) && ( B || C)的轉換,其實是析取范式到合取范式的一個轉換,并且恰好屬于轉換后會導致子句指數型暴漲的情況,即同義詞組的個數越多,每個同義詞的葉子節點越多,那么轉換后的語法樹節點就越多,并呈指數型增長。語法樹節點越多,求交時的邏輯也就越重。

3 求交基準選取損耗對于求交基準的選取損耗很明顯是跟語法樹節點個數強相關的,由于笛卡爾積語法樹的語法樹節點遠超原語法樹,因此笛卡爾積語法樹每次的求交基準選取損耗都會大于原語法樹。

4 語法樹節點操作個數笛卡爾積語法樹層數降低為了 2 層,并且消除了第 3 層的 and 邏輯,整顆語法樹只剩下最頂層的 and 邏輯。這一點有什么優勢呢?我們在對 and 節點下的子節點進行求交的時候,往往都是一個節點一個節點的操作,因此如果只剩下最頂層的 and 節點的時候,一旦發現有節點經過跳躍查找后,跟求交基準的值不一致,可以很方便的提前就結束掉對于該求交基準 N 的查找,即可以很方便的提前排除掉求交基準 N。

這一點對于笛卡爾積語法樹來說是一個優勢,可以減少排除一個基準的需要操作的節點個數。但是其只是降低了提前結束求交基準 N 的查找的代碼實現的復雜度,對邏輯后驗求交算法進行改進后同樣可以實現。

改進的邏輯后驗求交算法當我們得出一個求交基準時,各條倒排鏈都需要進行跳躍查找第一個大于等于求交基準 N 的值,我們可以通過在查找過程中就更新求交基準 N 的值,從而減少后續每條倒排鏈的查找次數

經過對比后可以發現,語法樹打平之后其實并沒有什么優勢,并且會導致語法節點數指數型增長,因此我們目前認為使用原語法樹配合邏輯后驗求交算法就是內存檢索引擎最佳的求交方式。這種想法當然有點坐井觀天了,如果有讀者有更好的方式,歡迎指點一二。

在了解完同義詞,以及 3 層結構語法樹的邏輯后驗求交算法后,可以再簡單了解一下其余的查詢語法。

1 丟棄詞可設置 and 節點下的 term 節點為丟棄詞,這樣的話,它不會參與求交。為什么不在 query 處理環節就把它丟棄掉呢?這里存在一些差別,一個 term 節點即便被設置丟棄詞,我們依然會為它設置文檔的命中信息,這對于相關性庫(文檔打分庫)來說是有必要的。其實現方式為不參與邏輯后驗以及求交基準的計算,但是會參與對于求交基準的倒排鏈跳躍查找。

2 動態非必留與必留詞動態非必留是一種動態求交方式,例如一個 and 節點下掛了 3 個 term 節點 A,B,C(均不是丟棄詞),動態非必留設置為 2 個 term 命中即可召回,那么一個文檔只要 A,B 或者 A,C,或者 B,C 命中即可。

必留詞是指在進行動態非必留求交時,該詞必須是求交元素,一般我們會設置在一些核心詞上面。例如 A 設置為了必留詞的話,那么一個文檔只要 A,B 或者 A,C 命中即可召回。動態非必留適用于一些召回不足的場景。其實現方式為對 and 節點下的 term 進行快速選擇排序,在選取求交基準時,不再對所有 term 節點取 max,而是取倒數第[必留個數]大的 term 的 pageid 彈出去。

3 位置約束可對一個 and 節點下的 term 設置位置約束。例如一個 and 節點下掛了 3 個 term 節點 A,B,C(均不是丟棄詞),我們可以設置 B 存在 Pos=OneOfPos(A)+1,設置 C 存在 Pos=OneOfPos(B) + 2。在我們的引擎里,如果是相鄰 term 也設置了位置約束,那么它們會作為一個整體來進行位置約束判斷,有點類似于多槽位的模板匹配。其實現方式為取出相關 term 的 pos 列表做二分查找。

and-or語法,配合丟棄詞的求交方式,特別依賴于Query處理能力,丟棄詞設置的好與壞會決定求交結果準確與否。例如對于Query:[深圳有哪些景點],如果深圳或者景點被設置了丟棄詞,那么召回結果可能會完全偏移,這種屬于在召回側結果集就已經偏移,在相關性上面進行排序調整也十分吃力。 WeakAnd求交方式是我們目前處于計劃中,但還未實現的一個功能,主要原因在于其標準實現方式與新引擎當前的任務模型有沖突,我們還未能找到方法將其良好的融合進去。對于WeakAnd的實現方式網上的資料很多,這里不想贅述。我們對它的認識在于這種求交方式可以緩解對于Query處理能力的依賴。

查找算法設計

正如上面提過的一樣,求交損耗的本質為各條倒排鏈的跳躍查找次數,跳躍查找次數越少的,性能也就越好。語法樹求交設計解決的問題是盡可能減少跳躍查找次數,而查找算法設計解決的問題是盡可能減少每次跳躍查找的消耗。

由于新引擎的倒排索引結構細節較多,為了方便闡述這塊的內容,這里看一下我們組內上一代內存檢索引擎的倒排索引結構,由于其相對簡單,適合拿來介紹查找算法。

倒排結構整體是先分塊(Block,BLK),每個塊內再保存具體的 page 信息,page 信息主要分為兩部分,一部分自然是 pageid 列表,另一塊是 page info 結構,保存的各個文檔的 term 級別的信息,這里就不對其進行介紹了,直接忽略它即可。

關于分塊設計的背景 1?繼承自磁盤檢索系統,磁盤分塊讀取2 內存分塊,有助于實時索引構建。相當于是說對于實時索引數據是以塊為單位進行加載的,不過我們的系統并不是這樣實現的,我們的實時索引數據依然是以庫為粒度進行加載的,因此在我們的系統中索引數據都是分布在連續內存中。以庫粒度進行加載,其索引時效性如何保障?這個問題暫且擱置,在后續的ZeroSearch系列文章中會有解答。

上一代引擎在查找某個 pageid 時(連續內存中),采取的做法是先二分查找到對應塊,然后再在塊內進行二分查找。

有問題么?表面上看,似乎并沒有什么問題。我們對它簡單分析一下,假設某個 term 的倒排鏈長度為 L,塊長為 T(即每個 BLK 內至多保存 T 個文檔信息),則塊數為 N=L/T,則查找次數為:logN + logT = logN*T = logL 而不分塊直接對整條倒排鏈二分查找的查找次數顯然也是 logL。

因此上一代引擎的索引設計以及查找算法其實并沒有帶來查找效率的提升。從一個有序列表中,找到第一個大于等于 N 值的位置,二分查找就是最快速的查找方式了,似乎并沒有優化空間了?如果從結果出發,站在宏觀的角度來思考優化,那幾乎不可能能得出答案,我們需要以微觀的角度,深入到過程來尋找優化空間。

對于倒排查找過程的思考

1 過程的連續性事實上倒排查找并不是只查找一個 N 值,而是隨著求交過程,需要不斷的去查找新的 N 值,且 N 值之間滿足嚴格遞增關系。即整個過程是具備連續性的。

2 數據分布特征索引分片分庫時文檔已經被打散過一次(稀疏),這對倒排鏈(聚集)中的 pageid 分布是否會有影響,它們的值分布稠密或者稀疏對于求交是否又有影響。即倒排鏈 pageid 是否可能具備數據分布特征。

3 長鏈與短鏈長鏈與短鏈對于求交的影響如何,是否應該區別處理,長鏈與短鏈該如何去定義。通過對求交過程進行分析和思考,得出了這 3 個點。下面我們以一個特殊的實例來看一下求交過程。

假設存在這樣的一條短鏈 L1 和一條長鏈 L2,它們的 pageid 范圍相近,且前后 pageid 的間距都是固定的(數據分布均勻),其中短鏈的前后 pageid 固定為 d1,長鏈的前后 pageid 固定為 d2:

現在分析一下存在的求交組合情況,主要從查找消耗和求交基準兩點進行分析。

1?短鏈與短鏈求交 查找消耗:由于本身鏈路短,因此二分查找時,總的查找范圍較小,查找消耗較低。 求交基準:求交基準的數目上限為短鏈長度L1。 特殊:由于短鏈的間距d1過大,單個Block內的pageid跨度(范圍)會更大。下一個求交基準落在本block內的可能性較高。2?短鏈與長鏈求交 查找消耗:由于短鏈的間距d1過大,因此長鏈在查找過程中依然適用于二分查找,但是長鏈查找范圍會偏大,查找消耗一般 求交基準:求交基準的數目上限為短鏈長度L13?長鏈與長鏈求交 查找消耗:長鏈與長鏈求交時,由于長鏈的間距d2較小,下一個求交基準N大概率出現在上一個基準附近,因此長鏈在二分查找時,查找范圍過大,資源消耗較高 求交基準:求交基準數目上限由長鏈長度L2決定

現在泛化到多條鏈之間的求交情況分析,即短鏈變為多條,或者長鏈變為多條,或者兩者都變為多條。由于我們采用的是多哨兵位的求交算法,是從整體進行求交,那么在多條倒排鏈(大于 2 條)求交時,只有以下 3 種情況。

1?全部都為短鏈 問題回歸到短鏈與短鏈求交的討論2?同時存在短鏈與長鏈 問題回歸到短鏈與長鏈求交的討論3?全部都為長鏈 問題回歸到長鏈與長鏈求交的討論

現在考慮數據分布不均勻情況下的求交特點。數據分布不均勻時,將存在稠密區域與非稠密區域,稠密區域內 pageid 的值分布集中,間距較小,而非稠密區域 pageid 的值分布較為分散,間距較大。同樣存在以下 3 種組合情況

1?非稠密區域與非稠密區域的查找,與短鏈與短鏈的查找特點相似2?非稠密區域與稠密區域的查找,與短鏈與長鏈的查找特點相似3?稠密區域與稠密區域的查找,與長鏈與長鏈的查找特點相似

盡管問題又得到了回歸,但是數據分布均勻與否依然有著顯著的差異,即數據分布均勻的情況下,它的求交特點是穩定的,而數據分布不均勻時,求交特點是變化的,可能上一次查找屬于非稠密區域與非稠密區域的查找,下一次查找時就落入了稠密區域與稠密區域的查找了,甚至隨著求交過程,長鏈與短鏈的相對關系也在變化。

關于如何去評判一條倒排鏈的數據分布情況,計劃采用的方式是通過計算間距的平均值和方差來進行評判,因此實際上當前我們對于這一點也還屬于還未開工的探索階段,暫且無法得到數據分布特征的一些數據。

不管怎樣,問題總算是得到了回歸,至少我們可以得到以下兩點結論和一個猜想:1 多條倒排鏈求交時,其耗時主要由短鏈的長度決定,原因是求交基準的數目上限由短鏈決定。

2 多條長鏈求交時,長鏈每次查找范圍過大,因此查找消耗較大。

3 猜想:不論是對于長鏈還是短鏈,下一個求交基準大概率落在近鄰 Block 內

以這 3 點為基礎,可以給我們的查找算法帶來一些新的思路。下面介紹求交優化的做法。

1 倒排鏈查找優化根據猜想:下一個求交基準大概率落在近鄰 Block 內。我們將先使用步長增長查找的方式對近鄰 Block 進行查找,未找到再二分查找剩余 Block,Block 內依然使用二分查找。

步長增長查找:每次向后查找的 Block 數量為 2^(n-1),確定目標位置后,再在該 2^(n-1)個 Block 內進行二分查找。

了解到這種查找方式其實有個專有名詞叫:Galloping Search,其實是很輕松就能想到的方式。

需要注意的是,在這里我們需要對長鏈和短鏈區分處理,簡單來說就是短鏈查找的近鄰 Block 較少,長鏈查找的近鄰 Block 較多。具體下文會分析。

2 bitmap對于超長鏈,其倒排結構使用 bitmap 進行存儲,bitmap 具備快速求交、快速求并、快速查找等特性,然而 bitmap 在 bit 位稀疏時的順序迭代訪問性能較差,而求交基準在選取時是需要獲取每個節點當前指向的 pageid 的,對于 bitmap 來說,需要通過順序迭代訪問來找到第一個非零 bit 位。因此超長鏈的定義將主要由 bitmap 帶來的收益和迭代性能來決定,僅從空間使用率上來看,未壓縮的情況下其實一條倒排鏈只需要滿足長度大于等于索引庫文檔總數/32(pageid 采用 4 字節存儲)即可,當然這個標準在性能上肯定是不行的。

3 語法樹查詢優化

3.1 短鏈優先查找由于短鏈節點查找消耗更低,單次查找更快,因此短鏈節點優先查找,用于快速更新求交基準,減少后續倒排鏈的查找次數,同時 pageid 值的間距更大的可能性較高,利于快速增長求交基準 N。

需要注意的是,隨著求交過程的不斷執行,長鏈,短鏈的相對關系可能會發生變化,這里的短鏈優先查找是在求交開始之前就對查詢節點的查詢順序進行調整,后續不會再進行調整。

3.2 同義詞子樹置后查找與短鏈節點優先查找對應,由于同義詞子樹一次查找需要對多個 Term 節點進行倒排查找,因此在評估單次查找消耗時,需要以整顆子樹進行考慮,其查找消耗是該子樹下所有 Term 節點之和。最終的效果會導致同義詞子樹被置后查找。同樣的,這里的調整是在求交開始之前就完成,后續不會再進行調整。

3.3 bitmap 合并bitmap 語法節點合并,對于有多個 bitmap 子節點的父節點,可對其新增一個虛擬語法節點,對 or 節點下的 bitmap 節點進行求并,對 and 節點下的 bitmap 節點進行求交。

3.4 重復詞節點合并對于語法樹中的每個 Term 節點,我們只會創建一個倒排訪問對象,簡稱游標(Cursor)。在邏輯后驗算法里,所有倒排鏈都在往求交基準 N 查找,因此對于相同的 Term 節點,它們可以共享同一個游標。

4 指令集優化經組內同事 sen 指點,在求交過程中可以利用 SSE 指令集(需要硬件支持)中的 XMM(128bit)、YMM(256bit)等大長度寄存器,使用單指令多數據流的方法一次比較多個整形元素,來達到求交加速的效果。這類寄存器的使用跟步長增長查找的方式恰好十分匹配,這一點屬于我們后續打算嘗試的一個方向。

那么遺留下來的問題就是如何定義短鏈,長鏈,以及超長鏈了。

短鏈長鏈與超長鏈

其中超長鏈的定義相對簡單一些,設定一個閾值 X,當且僅當:倒排鏈長度 / max(倒排鏈的 pageid) >= X 則倒排鏈使用 bitmap 進行存儲。X 的考慮主要是 bitmap 收益與順序迭代訪問損耗的一個折中,這一點需要通過業務數據來實際驗證后才能明確。

那么短鏈和長鏈又該如何定義。短鏈與長鏈的區別對待體現在近鄰查找時的 Block 數量上,在這里我們需要先簡單分析一下步長增長查找方式與二分查找在性能上的差異。

條件:假設倒排鏈總長度為 n,塊長為 T,塊數為 N。二分查找的時間復雜度為:

logN + logT = logn

需要注意的是由于倒排查找過程中要找的是第一個大于等于求教基準 N 值的位置,因此每一次二分查找,其查找次數不多不少,都是 logn(n 為還未查找過的文檔數量)。如果以倒排鏈長度為 X 軸,時間復雜度為 Y 軸,那么二分查找的時間復雜度就是一條直線。

當使用步長增長查找方式時,假設第 X 次確定了目標位置,那么其時間復雜度為

X + log2^(X-1) * T = 2X + logT - 1,其中1 <= X <= log(N+1)

其最小值約等于 logT,最大值約等于 logN + logn,如果以倒排鏈長度為 X 軸,時間復雜度為 Y 軸,那么很明顯,步長增長查找方式的時間復雜度為一條曲線。

從步長增長查找的復雜度公式里的最小值和最大值可以知道,越在前面找到下一個求交基準的位置,那么步長增長查找帶來的提升就越大,再往后,其性能就開始落后于二分查找了。現在可以開始考慮短鏈與長鏈了。假設存在閾值 K,Block 數目大于 K 的就是長鏈,小于 K 的就是短鏈,我們希望的最理想的效果是使用步長增長查找時的平均復雜度小于等于使用二分查找的平均時間復雜度。

由于二分查找的時間復雜度固定為 logn,因此其平均復雜度也就是 logn 了。而步長增長查找的平均時間復雜度的計算要麻煩許多,假定求交基準落在每一個 Block 的概率是相等的話,那么其平均時間復雜度為:

乍一看好像挺復雜的,完全誤會了,本人數學底子非常渣。這里其實就是對于每一個 X,都乘了一下[當前 X 所覆蓋的 Block 數量 / 總 Block 數量]的比例值,得到的平均時間復雜度的公式。

總之,由于本人的數學底子非常渣的原因,我這里就直接給出我們這邊計劃嘗試的 K 值了,K=15。怎么得出來的呢?通過計算 k=3,7,15,31,63 等等情況下的兩者的平均時間復雜度,發現 k 值越大,步長增長查找的平均時間復雜度就越高(但其實算法實現的時候會發現,當我們限定了只查找多少個 Block 時,步長增長查找方式的邏輯會輕很多,盡管查找次數上并不占優)。最后因為如下 2 點原因選擇了 15:

1 越靠近的Block,命中下一個求交基準的概率就越高(僅僅是猜想,還未有實際業務驗證),雖然k=15時平均復雜度已經比二分高了,但如果越靠前的Block命中概率越高的話,靠前的Block區域加權因子變大,靠后的Block區域加權因子變小,那么其平均復雜度可能并不會比二分高。2 XMM寄存器一次可比較4個32bit的整數,與步長增長查找15個Block剛好對應。如果XMM的使用確實帶來了優化,那么我們后續也會對YMM進行測試。這一點才是主要考慮的原因,為后續指令集優化埋下伏筆。

即在我們的引擎里,Block 數量大于等于 15 的則為長鏈,小于 15 的則為短鏈。對于長鏈,在近鄰搜索時最多查找 15 個 Blcok(含當前 Block),對于短鏈,我們只與當前 Block 的最大值進行比較一次。

需要再次說明的是以上的討論都是建立在猜想:越靠近的 Block,命中下一個求交基準的概率就越高。原因在于文檔在索引分片分庫時已經被打散(稀疏)過一次,同時一個 Block 內保存的是多個 pageid,被稀疏過后的文檔又緊密存儲(聚集)在一起。

如果猜想不成立呢?

計劃用質量標準系統統計一下長鏈在求交過程中命中下一個求交基準的 Block 與上一個位置所處的 Block 的距離。例如如果有 90%以上是落在 X 個 Block 內的話,那么依然還是有價值先對這 X 個 Block 進行查找的。

另外當我們確定要對 X 個 Block 進行查找時,是有多種查找方式可以使用的,例如從前往后查,或者從后往前查,恒定步長的查找方式,步長增長的查找方式等等。具體如何使用還是需要視數據分布特征而定。

關于近鄰查找的篇幅顯得有點啰嗦了,最后再簡單總結一下:由于文檔在索引分片分庫的過程中被稀疏和聚集過一次,求交過程的連續性,以及索引數據相對穩定的特點,我們嘗試去尋找一些特征來幫助我們加速整個倒排查找過程。

如果把兩種查找方式的時間復雜度相減,即2X?+?logT?-?1?-?logn,由于T和n都是常數,因此它們的差值為 f(x)?=?2x?+?logT?-?1?-?logn 當f(x)大于0時,表示對近鄰Block進行搜索時,步長增長方式的查找消耗更高 當f(x)小于0時,表示對近鄰Block進行搜索時,步長增長方式的查找消耗更低 很明顯,在二元坐標軸里,f(x)是一根斜向上的直線,當f(x)?=?0時 x?=?(1?+?logn?-?logT)?/?2 x?=?(1?+?log(n/T))?/?2 x?=?(1?+?logN)?/?2 即只有當x <?(1 + logN)?/ 2 時,步長增長查找方式性能才會比二分查找性能會好。需要注意的是這里的f(x)中x的定義,其定義為步長增長式查找時確認到了目標位置時的查找次數。

引擎組件化

在文章的開頭提到過,我們以組件化的思想來進行設計,在線檢索能力被封裝成了一個庫,相比于攜帶 RPC 框架的引擎,檢索庫的形式可較好的融入已有的開發體系和運維體系。既然是以庫的形式存在,就需要有合適的接口暴露出來,讓使用者能嵌入業務邏輯和業務數據。對于組件化設計,核心的設計點如下

1 在線檢索過程中檢索邏輯與數據需要進行分離,一個請求相關的所有數據都是通過檢索 Session 來進行管理

2 業務數據的嵌入通過檢索入口傳入,之后交由檢索 Session 管理,在這里可以簡單看下我們提供的唯一檢索入口:

int32_t?Retrieve(const?RetrieveOptions*?retrieve_options,void*?business_session)@arg1?retrieve_options?:?檢索協議(pb格式) @arg2?business_session?:?業務session數據

3 對整個檢索流程中的各個環節暴露出接口封裝成類進行管理,業務邏輯的嵌入通過反射的形式來實現注入

4 相關性接口同樣封裝成類,業務通過反射的形式來實現注入

整體如下圖所示:

在進行組件化設計之后,檢索的細節都被封裝在庫里。這里對 SearcherStage 設計和相關性接口設計再簡單介紹一下。

Searcher 是在線檢索組件的名稱,Stage 是我以我拙劣的英文水平選的一個詞,意為階段。在大環節上面,檢索流程分為預處理,核心處理,回包 3 個環節,我們在每個大環節的開始和結束階段都暴露了接口,并把所有接口放到了 SearcherStage 類中進行管理。對于任何想使用 Searcher 來作為部門內通用搜索引擎的用戶來說,它必須通過繼承并實現 SearcherStage 類的相關接口來實現自己的通用搜索引擎,一般來說,至少需要通過 SearcherStage 類完成以下 2 件事情。

1?在AfterHandleResponse中將索引數據轉化為業務數據 2 在RetrieveKPIReport中對本次請求的檢索情況進行上報,如檢索狀態,各個階段的文檔數量,耗時等等。

需要再次說明的是,每一個 SearcherStage 對象都是一個獨立的通用搜索引擎,例如在搜一搜這邊,也只是存在一個 S1SSearcherStage 類,并以它為基礎封裝為了搜一搜的檢索庫,其余的所有垂搜業務都是鏈接該檢索庫,而非 Searcher 組件。

下面再簡單介紹一下相關性接口的設計。相關性接口設計的總體原則:控制復雜度。其體現為以下兩點

  • 人性化的相關性輸入信息

  • 合理的邏輯拆分

關于人性化的相關性輸入信息,本文暫且不提,這里簡單介紹下邏輯拆分的背景。相關性接口的執行場景分為全局初始化、請求級別初始化及各打分環節初始化、文檔打分 3 個,在我們的上一代內存檢索引擎中,所有的相關性接口都集中在了一個類中,該設計客觀上導致了當前所有業務的打分主邏輯都集中了一個類的實現里,臃腫,多個場景/環節的變量和邏輯交織在一起,容易出錯,另一方面所有的代碼都集中了一個.h 和.cc 文件中,可讀性差,難以管理和協作開發。也因此,在新引擎中,我們對各個過程進行了拆分,抽象為了獨立的類,類之間以組合的形式進行訪問。拆分之后還有一個好處在于,由于每個文檔都有獨立的打分對象,文檔的打分從無狀態變為了有狀態。

末尾

大概的內容就是這樣了,在引擎的整個設計過程中,很多關鍵的設計點都是跟組內同事 sen 進行探討后得到,sen 給了我很多指導和把控。我們整體的設計原則其實非常簡單:

1 充分考慮易用性以服務業務為第一優先級,易用性將決定業務的服務舒適度

2 還是要像一個內存搜索引擎設計方案上還是不能太糟糕,不能存在明顯的設計問題

本文取名為關于內存檢索引擎設計的探索,實則也是我之前想寫的檢索初階系列中的第二篇:內存檢索引擎設計。之前檢索初階(一)和(三)都早早就發出來了,但其實那會對檢索引擎的理解還比較淺,這篇雖然是(二),不過是作為收官之作來寫的。

組內這次開發的新引擎(ZeroSearch)后續也會準備對公司內部開源,時間點應該要到明年了,其實目前已經完成了一個比較粗糙的版本,目前正處于推動業務升級的階段,但是還有大量的 TODO 和方向還沒去嘗試。不過在那之前,組內后續還會有同學分別介紹 ZeroSearch 的分布式索引系統設計,索引庫構建流程設計,索引結構設計等等一系列的文章 ,讓我們一起為內存檢索引擎設計的資料空缺出把力吧。

最后打一個小廣告,微信搜索誠招 C++后臺開發,如有搜索開發經驗或大廠工作經驗者更佳,誠邀有志之士,共襄大業。有興趣的同學可與本人郵件聯系:scut_huajian@qq.com

歡迎關注我們的視頻號:騰訊程序員

最新視頻:如果程序員媽是產品經理

騰訊技術官方交流微信群已經開放

進群添加微信:journeylife1900

(備注:騰訊技術)

超強干貨來襲 云風專訪:近40年碼齡,通宵達旦的技術人生

總結

以上是生活随笔為你收集整理的新一代搜索引擎项目 ZeroSearch 设计探索的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。