新一代搜索引擎项目 ZeroSearch 设计探索
本文作者:kaelhua,騰訊 WXG 后臺開發(fā)工程師
背景
寫這篇文章很大的原因在于不論是內(nèi)網(wǎng)還是外網(wǎng),分享內(nèi)存檢索引擎設(shè)計的資料都非常稀少,且存量的資料大多側(cè)重于功能性的介紹。
另一方面,在磁盤檢索引擎方面,由于開源搜索引擎 ES 的盛行,對于其使用的索引庫 lucence 的分析資料反而較為豐富。
本文意在通過分享對于內(nèi)存檢索引擎的認(rèn)識,核心的解決方案,和一些優(yōu)化方向的思考等等,略微填補一下關(guān)于內(nèi)存檢索引擎設(shè)計的資料空缺。
需要說明的是本人進(jìn)入搜索領(lǐng)域的時間并不長,盡管之前搭建過一些垂類搜索系統(tǒng),但只是站在應(yīng)用層面進(jìn)行使用,真正從事引擎設(shè)計的工作也是通過今年 4 月份左右組內(nèi)重新設(shè)計新一代搜索引擎的項目 ZeroSearch 開始,恰巧承擔(dān)了在線檢索的設(shè)計與開發(fā)。因此這并不是一份多么標(biāo)準(zhǔn)的答案,而是我們對于引擎設(shè)計的探索,其質(zhì)量還需時間檢驗和調(diào)整。
本文屬于 ZeroSearch 系列分享中的在線檢索設(shè)計分享。在本文中假定讀者已經(jīng)對搜索引擎有了基本的了解,至少對倒排求交,打分排序有基本的概念。
系統(tǒng)認(rèn)知
對于系統(tǒng)的認(rèn)知深度,會決定我們怎么去看待內(nèi)存檢索這樣一個問題,以及由此而產(chǎn)生的的設(shè)計方案。盡管本文要講的是內(nèi)存檢索引擎設(shè)計,然而我們還是得從對磁盤搜索引擎的認(rèn)識開始。
由于 ES 的盛行,以及網(wǎng)頁搜索(搜索領(lǐng)域的大 boss)體驗的存在,大多數(shù)人對檢索引擎的認(rèn)識可能都是基于磁盤檢索引擎來理解的,即系統(tǒng)的倒排,正排數(shù)據(jù)都位于磁盤中,只有在執(zhí)行檢索時,才會將相關(guān)的數(shù)據(jù) load 到內(nèi)存中。
其整體的流程大概如圖所示:
磁盤搜索引擎在設(shè)計的過程中面臨的主要問題為:
同時兼具計算密集型與IO密集型任務(wù)
磁盤與內(nèi)存及CPU存在數(shù)量級差距的性能GAP,磁盤資源屬于瓶頸,而計算量富余。
因此其在設(shè)計過程中考慮的核心要素為兩點:
任務(wù)調(diào)度的設(shè)計,即管理 IO 任務(wù)與計算任務(wù)
IO 優(yōu)化 如異步 IO 設(shè)計,IOCache 優(yōu)化,索引壓縮等等
盡管 IO 優(yōu)化也是非常重要的一環(huán),但我們認(rèn)為磁盤搜索引擎的核心,本質(zhì)上是一個任務(wù)調(diào)度的問題。
現(xiàn)在回到內(nèi)存搜索引擎的討論上來。很明顯,內(nèi)存檢索引擎在去除磁盤 IO 后,其要解決的核心問題是計算量的分配問題,即如何合理的分配計算量,能盡可能的讓優(yōu)質(zhì)結(jié)果展現(xiàn)給用戶。
下面是我們給內(nèi)存檢索引擎制定的核心流程:
可以看到我們對于計算量的分配,抽象出了求交,L1 打分,L2 打分等 3 個邏輯階段。
求交 即根據(jù)查詢串取出對應(yīng)的倒排鏈進(jìn)行求交,得到結(jié)果文檔L1打分 求交出來的文檔均會送入L1打分L2打分 L1得分Top的文檔才能進(jìn)入L2打分這里為何要將打分分為兩個階段呢?
1 滿足高求交數(shù)的需要
由于倒排數(shù)據(jù)處在內(nèi)存中,因此單篇文檔的求交消耗較少,限制引擎召回量的瓶頸往往不在求交,而在打分。輕量級的打分配合高求交數(shù),可以避免求交截斷導(dǎo)致的文檔無法召回問題的出現(xiàn)
2 滿足輕量級業(yè)務(wù)的打分需求
對于一些排序較簡單的業(yè)務(wù),不需要單獨的精排服務(wù),可以在引擎的 L2 打分過程中滿足它的需求。
需要注意的是對于一些高消耗的模型,我們會放在更高層次的排序中,并對其進(jìn)行抽離,放在獨立的 tf 服務(wù)上執(zhí)行,并不會放在引擎的 L1、L2 階段來執(zhí)行。
L0打分在離線索引過程中我們會提供接口用于計算文檔的質(zhì)量分,因此全量文檔計算都會進(jìn)行質(zhì)量分的計算,建立倒排索引過程中,質(zhì)量分越高的文檔,排序越靠前,以保證被優(yōu)先查找到
核心設(shè)計
設(shè)計背景
在講述核心設(shè)計之前,需要先了解以下幾點背景
1 索引分片分庫
索引會先進(jìn)行分片,多個分片再合并為一個索引庫。分片數(shù)一旦指定后便不可更改,但是索引庫的庫數(shù)是可以靈活調(diào)整的,可以滿足業(yè)務(wù)數(shù)據(jù)增長,索引數(shù)據(jù)多集群劃分的需求。在檢索過程中,索引庫是檢索的基本單位。這一點與 ES 可做一個簡單的對比,ES 為庫->分片(多個庫)->實例(多個分片)的設(shè)計,而我們的設(shè)計為分片->庫(多個分片)->實例(多個庫),即我們將數(shù)據(jù)分片放到了更底層,打開了它的數(shù)量限制,同時對庫的數(shù)量進(jìn)行了收斂,原因在于庫數(shù)越多,引擎性能將越差。關(guān)于索引分片分庫的詳細(xì)背景和設(shè)計后續(xù)組內(nèi)會另有同學(xué)來進(jìn)行介紹。
2 無 RPC 框架設(shè)計
引擎自身不攜帶 RPC 框架,我們以組件化的思想來進(jìn)行設(shè)計。通俗來說,就是封裝成了一個庫,提供了初始化函數(shù)和唯一的檢索入口函數(shù)來給到外部進(jìn)行使用。這種方式有優(yōu)有劣,優(yōu)勢為無須考慮上層的協(xié)議頭,可靈活適配于各種 RPC 框架中,并復(fù)用已有的運維體系。劣勢為對線程的控制能力較弱,理想情況下引擎自身的工作線程與 RPC 工作線程應(yīng)當(dāng)資源隔離,通過親緣性各自分配和獨占 CPU,這一點在組件化里難以實現(xiàn)。
事實上我們是面向 Controller-Proxy-Work 這一類 RPC 框架進(jìn)行設(shè)計的,典型的如 SPP,Svrkit 等,并且在我們的實現(xiàn)過程中,將預(yù)處理和回包處理的邏輯均放到了 RPC-Work 線程中進(jìn)行。
3 以易用性為第一優(yōu)先級
組內(nèi)上一代的內(nèi)存搜索引擎由于基礎(chǔ)配置項過多,引擎細(xì)節(jié)暴露過多,且欠缺配套的 debug 工具/能力,導(dǎo)致它的學(xué)習(xí)和維護(hù)成本都非常高。在新引擎的設(shè)計過程中,我們將易用性列為了第一優(yōu)先級,本質(zhì)上也是以服務(wù)業(yè)務(wù)為第一優(yōu)先級,即便是性能方面也需要為易用性讓步。易用性方面主要會體現(xiàn)在以下幾點:
3.1 引擎的學(xué)習(xí)成本應(yīng)具備梯度,滿足快速入門使用的需求;
3.2 配置項盡可能少,盡可能避免暴露引擎細(xì)節(jié),盡可能以通俗語言表達(dá),如內(nèi)存大小,線程數(shù)量等;
3.3 需要有全面的問題定位能力,根據(jù)經(jīng)驗,維護(hù)垂搜業(yè)務(wù)時,最常做的事情就是查文檔為什么召不回,如果引擎具備問題一鍵定位的能力,那么可以有效的減少運維成本。
需要說明的是,盡管這里提到了易用性,但是下面的內(nèi)容不會涉及到我們?yōu)榱颂嵘嬉子眯圆扇〉木唧w做法。這里之所以單獨拎出來進(jìn)行強調(diào),在于根據(jù)我過往的業(yè)務(wù)開發(fā)經(jīng)驗,部門內(nèi)上一代內(nèi)存搜索引擎的學(xué)習(xí)和維護(hù)成本過高,與業(yè)務(wù)的快速發(fā)展已經(jīng)不匹配,我認(rèn)為作為一個基礎(chǔ)平臺,性能 100 分,還是 80 分,甚至是 70 分,只要可以通過加機(jī)器來解決,對于增長型業(yè)務(wù)來說基本就不太 care 了,而易用性(含可維護(hù)性)才是最優(yōu)先被考量的因素,其對團(tuán)隊的整體效率有很大的影響。
在清楚了大概的設(shè)計背景之后,可以開始真正考慮該如何設(shè)計我們的檢索引擎了。
線程模型設(shè)計
下圖是我們的檢索組件目前使用的線程模型:
每個檢索請求到達(dá)時,會生成一系列的求交與打分任務(wù),在召回完成之后,會生成一個資源清理任務(wù)進(jìn)行提交,請求完成。
下面對圖中的主要元素做下簡單的介紹
1?主線程 即RPC框架的Work線程,在Work線程中,會完成請求的預(yù)處理和回包處理的邏輯,并且處理求交或者打分任務(wù)完成后的回調(diào)邏輯。2?JoinThreadPool 負(fù)責(zé)處理求交任務(wù)的線程池,在上面已經(jīng)提到過,索引會分片分庫,索引庫是檢索的基本單位,而一個求交任務(wù)至少會處理一個索引庫(由于數(shù)據(jù)實時更新,系統(tǒng)中會存在一些小庫,多個小庫可能會被放到一個求交任務(wù)里進(jìn)行處理),每個求交任務(wù)一旦分配到線程,就會將任務(wù)完整的執(zhí)行完(或者超時)。3?ScoreThreadPool 負(fù)責(zé)處理打分任務(wù)的線程池,打分任務(wù)分為L1打分任務(wù)和L2打分任務(wù),但是線程池是共用的一個。對于L1打分任務(wù),當(dāng)一個求交任務(wù)完成的求交文檔數(shù)量達(dá)到一定程度時,便會生成一個L1打分任務(wù)Push到打分隊列中。L2打分任務(wù)同理,也是等到L1打分文檔達(dá)到一定數(shù)量才會生產(chǎn)。4?CleanThreadPool 負(fù)責(zé)處理資源清理任務(wù)的線程池,即資源的清理是異步進(jìn)行的5?求交資源池 負(fù)責(zé)管理求交時需要的一些數(shù)據(jù)結(jié)構(gòu),以資源池的形式來完成復(fù)用可以看到整個線程模型是以 Task 為調(diào)度粒度的,這種模型有個比較大的缺陷,每個 Task 的消耗其實是不一致的。對于求交任務(wù)而言,每個任務(wù)會將一個索引庫給求交完(達(dá)到限制或者超時),而隨之產(chǎn)生的 L1 打分任務(wù)和 L2 打分任務(wù),每個任務(wù)其實都只是求交出來的部分文檔,因此求交任務(wù)的消耗是非常高的,并且求交任務(wù)在入隊時是在一個 for 循環(huán)里集中式的入隊(直到所有的索引庫都分配完),為了防止打分任務(wù)餓死,這里劃分了 3 個線程池以避免這個問題。
然而劃分多個線程池本身就是問題所在,至少存在以下 3 個方面的問題:1 增加了配置項,降低了易用性。
2 其實業(yè)務(wù)并不知道該如何去對各個線程池的線程數(shù)量進(jìn)行配置(盡管引擎會簡單的根據(jù) CPU 邏輯核數(shù)量進(jìn)行默認(rèn)設(shè)置),只能不斷去調(diào)整測試來達(dá)到一個合適值。
3 多個線程池的方式不論怎么去配置數(shù)量,都不太可能把所有線程都高效利用起來,必然會有計算資源不能充分利用的線程池存在。
盡管存在這么多很容易預(yù)見的問題,我們還是先這樣做了,一方面是目前開發(fā)人力非常少,在線檢索這塊的開發(fā)只有我一個人在兼職,需要彌補完善的東西還有很多,整體確實還比較粗糙,另一方面主要也是目前我們還沒有建立一個用于質(zhì)量標(biāo)準(zhǔn)評測的系統(tǒng),因此一些優(yōu)化類的工作優(yōu)先級都排的比較低。
關(guān)于有沒有餓死情況的出現(xiàn),我們的評判標(biāo)準(zhǔn)并不是針對個例的發(fā)現(xiàn),而是通過統(tǒng)計p99,p995,p999等指標(biāo)來進(jìn)行評判。因此嚴(yán)格意義上來說,也并不是真正的餓死,畢竟FIFO隊列只要入隊了遲早會被執(zhí)行,只是等待時間長和短的問題。正如標(biāo)題是對于引擎設(shè)計的探索,這里簡單分享一下后續(xù)計劃要嘗試的幾個線程模型的方向,當(dāng)然下面所有的方向都是只使用一個線程池。1 繼續(xù)維持 FIFO 的模式這個很好理解,也就是所有的 Task 都入同一個先進(jìn)先出的隊列,其實這個改動起來非常簡單,只是質(zhì)量標(biāo)準(zhǔn)評測系統(tǒng)還沒搭起來,就暫時沒去做對比測試了。
2 邏輯越輕量,優(yōu)先級越高同樣很好理解,即創(chuàng)建一個特殊的優(yōu)先級隊列,對各類 Task 根據(jù)邏輯的繁重設(shè)定一個優(yōu)先級,設(shè)想的情況是這樣的,優(yōu)先級從高到低為:
清理Task > L1Task > L2Task > 求交Task在 Push 任務(wù)時,優(yōu)先級越高的直接插入到隊首,但是同類 Task 之間依然保持先進(jìn)先出的關(guān)系。最終是一個這樣的隊列:
為什么要這樣做呢?
可以有效解決由于求交任務(wù)的高消耗和集中入隊導(dǎo)致其它任務(wù)餓死的問題。通俗一點理解,那就是只有當(dāng)所有的打分任務(wù)都完成了才會去執(zhí)行求交任務(wù)。
從過程上看,似乎會有一個新的問題,即便系統(tǒng)有多個邏輯核,索引庫之間的求交打分變成了線性的模式(串行),而非并發(fā)的模式?
但是從結(jié)果上進(jìn)行分析,這種調(diào)度模式是否改變了處理請求時所需要的計算量?很明顯,并沒有。同樣的,單位時間內(nèi)機(jī)器的算力也并沒有浪費,因為除非請求已經(jīng)完成,否則一旦有空閑線程出現(xiàn),那么必定會被分配給求交任務(wù)。那么至少從結(jié)果上來分析,這種模式應(yīng)該是有效的,當(dāng)然具體效果優(yōu)劣,數(shù)據(jù)表現(xiàn)如何,還需實驗驗證。
以邏輯越輕量,優(yōu)先級越高的優(yōu)先級隊列管理任務(wù)似乎會引入一個新的問題,即求交任務(wù)可能被'餓死'???這一點很難評判,原因在于兩點1 打分任務(wù)其實是由求交任務(wù)產(chǎn)生,如果求交任務(wù)得不到執(zhí)行,那么也就不會有打分任務(wù)了。2 單個打分任務(wù)的文檔數(shù)較少,邏輯相對較輕,影響較小。具體還是需要實驗驗證后才能得出明確結(jié)論。3 以時間片為調(diào)度粒度徹底改變 Task 為調(diào)度粒度的模式,換為時間片的模式,同時繼續(xù)保持 FIFO 的模式,每個 Task 消耗完時間片后就被丟入隊尾,直至超時或完成。
以時間片為調(diào)度粒度時,此時眾生平等,也就不需要關(guān)注 Task 之間的消耗程度孰輕孰重帶來的餓死情況輕重的問題了。 時間片調(diào)度這種模式其實還有一個好處,對于一些召回數(shù)過低的請求,大概率在一個時間片內(nèi)就能被執(zhí)行完,那么它總體的等待時間就會少很多,從它要入隊時開始分析,等待時間由:
sum(隊列Task-處理完成所需耗時之和)降低到 sum(隊列 Task-時間片之和),但這種模式有沒有被引入的問題?
同樣有的,對于高召回的請求需要多個時間片才能執(zhí)行完,由于每次時間片執(zhí)行完需要重新入隊,那么它的等待時間相比 Task 的模式大概率是會增加的。不過這個問題相對來說還比較好解決,至少我們可以從以下兩點來緩解和解決。
1 增大時間片的粒度即將時間片粒度變大,如由原先的 500us,增大為 1ms。從而可變相減少高召回請求的入隊次數(shù)。當(dāng)然這里也需要控制力度,極端情況下會退化為 Task 模式。
2 增大高召回請求的時間片粒度即給高召回請求的分配的時間片為基礎(chǔ)的時間片*2,或者*3 等等,這是一種有效解決高召回請求入隊次數(shù)過多的方法。但是難點在于我們?nèi)绾巫R別出高召回請求?這是一件很有挑戰(zhàn)性的事情,不過這里先不介紹我們在籌劃的做法,下文中會提到。事實上,它不止對于線程模型調(diào)度的設(shè)計有直接影響,對于稍后介紹的任務(wù)模型同樣有影響。
細(xì)心的讀者可能已經(jīng)想到了,高召回請求是從結(jié)果上來看的,當(dāng)我們從過程上來看時,問題就簡單很多了,即回到了問題本身,它是入隊次數(shù)過多的請求。那么我們只需要增加每次重新入隊時被分配的時間片即可,一種最簡單的方式是參考 vector 的內(nèi)存增長的方式,更高級的方式這里就不展開了,索引數(shù)據(jù)和求交進(jìn)度也是分配的參考項。從而有效解決高召回請求入隊次數(shù)過多的問題。不過同樣,這里的增長也需要控制力度,極端情況下會退化為 Task 模式。
線程模型的介紹暫時就到這里了,下面我們看一下任務(wù)模型的設(shè)計。
任務(wù)模型設(shè)計
任務(wù)模型與線程模型有什么區(qū)別呢?
線程模型更專注于計算量(任務(wù))的執(zhí)行,而任務(wù)模型更專注于計算量(任務(wù))的分配。對于執(zhí)行者來說,它是任務(wù)無關(guān)的,而對于分配者來說,它本身就是任務(wù)的創(chuàng)建者,與任務(wù)是強相關(guān)的。在介紹線程模型時,其實我們已經(jīng)大概清楚了,引擎中有以下 4 類任務(wù),分別為清理任務(wù),求交任務(wù),L1 打分任務(wù),L2 打分任務(wù)。其中清理任務(wù)較為獨立,就不多花筆墨介紹了。
下面直接看我們的求交打分任務(wù)模型:
任務(wù)模型的核心要素為以下 3 點:
1?求交依然維持單庫單線程求交(小庫例外,多個小庫合并一個求交任務(wù))2?求交文檔達(dá)到閾值時生成一個L1打分任務(wù)3?L1打分文檔達(dá)到一定閾值時生成一個L2打分任務(wù)從而可以實現(xiàn)求交、L1 打分、L2 打分并行執(zhí)行的效果,整體達(dá)到一個流水線的設(shè)計,就如上圖所示一樣。組內(nèi)的上一代內(nèi)存搜索引擎對于求交打分是一個階段一個階段的執(zhí)行,整體是一個串行的模式
求交階段 ---> L1打分階段 ---> L2打分階段在實際的實現(xiàn)里,上一代引擎對于每一個索引庫其實是單線程邊求交邊 L1 打分的(因此本質(zhì)上屬于串行),等全部求交文檔 L1 打分執(zhí)行完畢后,再進(jìn)行一次快速選擇排序選出 TopK 得分的文檔,然后把這部分文檔送入 L2 打分,L2 打分結(jié)束后,進(jìn)行最終的 TopK 排序,然后進(jìn)入回包處理階段。
在新引擎中,我們將求交與 L1 打分進(jìn)行了拆分,并對打分任務(wù)以 Task 為粒度進(jìn)行調(diào)度。為什么要這樣做呢?當(dāng)然是因為這是一種 CPU 利用率更高的做法。下面我們進(jìn)行一個討論,在這個討論里我們先假定引擎的工作線程池只有一個,這樣的話更利于分析。
假設(shè)索引庫數(shù)?=?CPU邏輯核數(shù)1?在一個線程處理一個庫內(nèi)文檔的求交與L1打分的形式下,各個線程耗時計算方式是固定的 |?------------------|-------------------|求交耗時?+??l1打分耗時 最終耗時為:?max(各個庫的求交耗時+l1打分耗時)2?如果我們將求交與打分拆開,每次求交部分后,再將這部分送出去進(jìn)行打分,讓打分 獨立出來,從而達(dá)到流水線化: |?------------------|求交耗時|-------------------|打分耗時 |-------------------------|總耗時 理想情況下,最終耗時為: sum(各個庫的求交耗時+l1打分耗時)?/?引擎工作線程數(shù)?=?avg(各個庫的求交耗時+l1打分耗時) 原因是計算總量雖然并未減少,但是被打散得更均勻了。很明顯這種模式能更好的利用CPU資源假設(shè)索引庫數(shù)?>?CPU邏輯核數(shù)3 將會出現(xiàn)一個線程處理多個索引庫,我們可以理解為這多個索引庫只是一個更大的索引庫,從而問題回歸到討論1與討論2中。假設(shè)索引庫數(shù)?<?CPU邏輯核數(shù)4?老模式下其最終耗時依然為:max(各個庫的求交耗時+l1打分耗時)新模式下其最終耗時為: (sum(各個庫的求交耗時+l1打分耗時)?-?非求交線程承擔(dān)的L1打分耗時?)?/?求交線程數(shù) 該值比avg(各個庫的求交耗時+l1打分耗時)會更小。盡管拆分后的方式 CPU 利用率更高,但是很明顯,新的方式在總吞吐方面并不會提高。
計算基礎(chǔ) 1?單位時間內(nèi)機(jī)器的算力是固定的2?每個請求需要消耗的算力并沒用變因此在極限情況下,吞吐方面確實沒有提升。不過實際上,在正常情況下,我們都會保證機(jī)器的負(fù)載在一個較低的水平,以此來保證服務(wù)的安全,而當(dāng)機(jī)器負(fù)載未滿時,新模式下長尾求交任務(wù)通過把l1打分邏輯分發(fā)出去可以更充分利用總的CPU資源,從而減少請求的耗時。我們可以得出以下兩個結(jié)論:
新模式在極限情況下的總吞吐沒有提升
相同吞吐情況下,新模式 CPU 利用率更高,因此請求處理平均耗時會更少
另外一個問題,為什么我們要維持單庫單線程求交?簡單來說,求交不是召回瓶頸,當(dāng)然如果真的發(fā)生了這種事情,求交成為了召回瓶頸時,我們的建議是減少每個索引庫包含的分片數(shù)。
最后,細(xì)心的讀者可能早早就發(fā)現(xiàn)了,求交出來的文檔是需要都送入 L1 打分的,但是只有 L1 得分 Top 的文檔才能進(jìn)入 L2 打分,整個任務(wù)模型里的求交-L1 打分-L2 打分的流水線處理應(yīng)該無法實現(xiàn)才對。的確是的,求交結(jié)果進(jìn)入 L1 打分是一個確定的行為,而 L1 打分結(jié)果是否進(jìn)入 L2 打分是一個待定的行為。為了滿足流水線的計算,我們需要將待定行為轉(zhuǎn)為確定行為。
1 文檔預(yù)估
如果我們能夠知道一個請求能求交得到多少篇文檔,那么當(dāng)求交文檔數(shù) < L1 結(jié)果限制數(shù)(TopK 里的 k 值),那么很明顯,所有完成了 L1 打分的文檔都可以直接進(jìn)入 L2 打分。
1.1 根據(jù)文檔頻率預(yù)估這是一種簡單粗暴的方式,例如用戶搜索[蘋果手機(jī)],它的分詞結(jié)果得[蘋果 | 手機(jī)],兩者的關(guān)系為求交,那么這個 query 的預(yù)估召回文檔數(shù)就為:
min(Term(蘋果)倒排鏈長度,Term(手機(jī))倒排鏈長度)如果考慮到蘋果手機(jī)整體與 iphone 同義,那么其預(yù)估召回文檔數(shù)就為:
max(Term(iphone)倒排鏈長度,min(Term(蘋果)倒排鏈長度,Term(手機(jī))倒排鏈長度))即根據(jù)各個 Term 的文檔頻率和其邏輯關(guān)系來進(jìn)行簡單的推導(dǎo)。很明顯,實際求交文檔數(shù),一定會小于等于該推導(dǎo)值。
1.2 緩存查表由于一定時間內(nèi)的索引數(shù)據(jù)是相對穩(wěn)定的,我們可以通過緩存檢索 query 和求交數(shù)的映射關(guān)系,每個請求到達(dá)時進(jìn)行一次查表來完成預(yù)估。可能有讀者會質(zhì)疑,那為什么不直接緩存求交結(jié)果呢?
其實這是兩個維度的東西,它們本身也并不沖突,如果在引擎內(nèi)對結(jié)果緩存會占用較多的內(nèi)存,我們期望的做法是在更上層對分頁后的結(jié)果進(jìn)行緩存,因為可以明確的一點是首頁的緩存命中率一定會顯著高于后續(xù)的結(jié)果頁。另外引擎內(nèi)進(jìn)行緩存的話還會影響系統(tǒng)的時效性,這一點并不合適。
1.3 模型預(yù)估通過模型來對一個 query 的召回文檔數(shù)進(jìn)行預(yù)估。由于每個業(yè)務(wù)的數(shù)據(jù)量是相對穩(wěn)定的,可以通過在線收集 query 和查 詢語法樹的特征以及倒排鏈相關(guān)的特征,離線訓(xùn)練,在線接入,來完成預(yù)估。
2 預(yù)計算即選出一部分 L1 打分完成的文檔,先進(jìn)行 L2 打分計算。目前我們實現(xiàn)的方式有以下幾種:
2.1 固定篇數(shù)模式取固定的 l1 結(jié)果數(shù)進(jìn)行預(yù)計算,原則為先完成 l1 打分的文檔將會被送入,這是因為由于 l0 得分的存在,通常我們認(rèn)為越先被求交出來的文檔,其質(zhì)量越高
2.2 得分閾值模式l1 得分大于得分閾值的進(jìn)行預(yù)計算
2.3 得分比例模式l1 得分大于 (已完成 l1 打分的文檔的平均分 * rate) 的文檔進(jìn)行預(yù)計算,因此其實這是一種特殊的得分閾值模式,只是它的閾值在不斷調(diào)整。
由于我們目前只有一些較簡單的離線業(yè)務(wù)接入了新引擎,上面幾種方案的具體效果如何還沒有得到一個可信的數(shù)據(jù)。另外后續(xù)也會考慮不斷加入新的預(yù)計算方式,例如將固定篇數(shù)模式與得分閾值模式組合起來使用。
事實上,預(yù)計算幾乎肯定會有浪費計算量的情況出現(xiàn),即本不能進(jìn)入 L2 打分的文檔卻被執(zhí)行了 L2 打分。其浪費率以及耗時降低的收益需要根據(jù)各個業(yè)務(wù)自己的需求而定。
需要特別說明的是,新引擎在 L1 打分階段完成之后(求交階段已完成,且 L1 打分任務(wù)全部完成),依然會整體進(jìn)入 L2 打分階段,對 L1 結(jié)果集取 TopK,然后分配 L2 打分任務(wù),只是每個 L2 打分任務(wù)對分配到的文檔進(jìn)行打分時會先判斷是否已經(jīng)被預(yù)計算過了,如果是的話則直接跳過。因此預(yù)計算的存在并不會導(dǎo)致結(jié)果不穩(wěn)定的問題出現(xiàn)。
求交設(shè)計
求交設(shè)計分為兩塊,一塊是語法樹求交設(shè)計,主要是查詢語法樹的設(shè)計和求交算法。另一塊是查找算法設(shè)計,主要介紹倒排查找的做法。
語法樹求交設(shè)計
對于求交而言,基本的理解其實就是取出幾條倒排鏈,然后計算出倒排鏈中公共的文檔。不過實際情況比這個要復(fù)雜很多。對于求交設(shè)計而言,第一步要考慮的是查詢語法樹的設(shè)計,我們從同義詞開始,在新引擎的設(shè)計里,我們采用的 3 層結(jié)構(gòu)語法樹。假設(shè) [蘋果手機(jī)] 存在同義詞 [iphone],那么對于 query [蘋果手機(jī)回收] 的最終的檢索語法樹為下圖所示:
這里以寬度優(yōu)先的方式給每個節(jié)點進(jìn)行了編號。可以看到這是一顆 and-or-and 的語法樹,可以支持多對多的同義詞表達(dá)形式,例如這里的節(jié)點 2 下面的兩個同義詞詞組,就是一個 2 對 1 的同義詞組。
現(xiàn)在我們要需要考慮一下這樣的一顆語法樹如何做召回。一種很直觀的做法是這樣的:
1?節(jié)點6與節(jié)點7的倒排鏈進(jìn)行求交,得到的pageid作為節(jié)點4的pageid2?節(jié)點2的pageid?=?min(節(jié)點4?pageid,節(jié)點5?pageid)3?比較節(jié)點2的pageid與節(jié)點3的pageid3.1?節(jié)點2?pageid?=?節(jié)點3?pageid 則彈出該節(jié)點作為求交結(jié)果,所有節(jié)點對應(yīng)的倒排鏈后移一位3.2?節(jié)點2?pageid?<?節(jié)點3?pageid 節(jié)點2先內(nèi)部求交得到一個大于等于節(jié)點3?pageid的文檔3.3?節(jié)點2?pageid?>?節(jié)點3?pageid 節(jié)點3對應(yīng)的倒排鏈查找第一個大于等于節(jié)點2?pageid的文檔上述過程一直持續(xù)有節(jié)點2或者節(jié)點3有節(jié)點到達(dá)了末尾為止。其中節(jié)點2由于是一顆子樹,它是否到達(dá)末尾,由其子節(jié)點節(jié)點4與節(jié)點5到達(dá)了末尾為止,節(jié)點4同理。關(guān)于pageid 在建索引庫時我們會對進(jìn)入到該索引庫的文檔按L0得分排序,從0開始重新編號,當(dāng)然庫內(nèi)會有一片區(qū)域保存庫內(nèi)pageid到原docid的映射關(guān)系。這一點的主要目的是為了保證倒排鏈中的文檔按L0得分排列后依然有序,次要目的是為了對倒排鏈進(jìn)行壓縮。但是對于內(nèi)存搜索引擎而言,我們暫時還沒有嘗試對倒排鏈進(jìn)行壓縮,一方面是因為CPU同樣是緊張資源,另一方面團(tuán)隊也還沒有精力投入到這一塊。因此目前其最大的作用只是保證了倒排鏈中的文檔id有序,以及庫內(nèi)文檔id的連續(xù)(從而可以根據(jù)庫內(nèi)文檔id直接下標(biāo)訪問文檔數(shù)據(jù)),另外把8字節(jié)的文檔id,轉(zhuǎn)成了4字節(jié)的庫內(nèi)pageid,省了一半內(nèi)存。本人之前聽到過多次這樣的說法:語法樹的層級越高,求交的性能就會越差。如果是按照我上面所述的求交方式的話,那么的確是的,層級越高,求交性能就會越差。原因是什么呢?
原因在于高層級的語法樹進(jìn)行求交時可能會存在一些不必要的求交行為。以上面的那顆 3 層語法樹為例,假如節(jié)點 6[蘋果]和節(jié)點 7[手機(jī)]這兩條倒排鏈中的 pageid 都非常小,而節(jié)點 3 的 pageid 比較大時,那么有可能節(jié)點 2 所有的求交結(jié)果都來自節(jié)點 5。
那為什么會存在一些不必要的求交行為呢?其本質(zhì)在于上面所述的求交方式是一種邏輯先驗的求交算法。下面介紹一種邏輯后驗的算法。
定義求交基準(zhǔn)為一個可能的求交結(jié)果1?計算求交基準(zhǔn)N?=?max(?min(?max(節(jié)點6?pageid,?節(jié)點7?pageid),?節(jié)點5?pageid),?節(jié)點3?pageid)2?所以倒排鏈全部往N靠攏,找到第一個>=N的位置3 判斷所有倒排鏈當(dāng)前的結(jié)果是否符合求交邏輯關(guān)系,若符合則彈出結(jié)果且相關(guān)節(jié)點后移一位。4?判斷是否求交結(jié)束,如果未結(jié)束則流程回到1,否則退出求交損耗的本質(zhì)為各條倒排鏈的跳躍查找次數(shù),and/or 等語法只是建立在倒排鏈的跳躍查找之上的邏輯關(guān)系,跳躍查找次數(shù)越少的,性能也就越好。在邏輯后驗求交算法里,每次選出的求交基準(zhǔn) N 都是一個可能的求交結(jié)果,也就是說除非我們能找到新的算法可以再次排除一些可能的求交結(jié)果位置,否則不會有比它性能更好的語法樹求交算法。
現(xiàn)在嘗試一下將語法樹打平,看一下打平后的語法樹具備哪些方面的優(yōu)勢。這里要介紹的是我們上一代內(nèi)存搜索引擎中將 3 層結(jié)構(gòu)語法樹轉(zhuǎn)化為 2 層結(jié)構(gòu)語法樹進(jìn)行求交的做法。
笛卡爾積語法樹
通過將 3 層語法樹結(jié)構(gòu)里的同義詞節(jié)點做笛卡爾積,可得到與其等效的 2 層結(jié)構(gòu)語法樹,還是以 query [蘋果手機(jī)回收] 為例,其中 [蘋果手機(jī)]和[iphone] 互為同義詞,將其轉(zhuǎn)換為笛卡爾積語法樹后,其結(jié)構(gòu)如下圖所示
其原理為 (A?&&?B)?||?(C?&&?D) =?(A?||?(C?&&?D))?&&?(B?||?(C?&&?D)) =?(A?||?C)?&&?(A?||?D)?&&?(B?||?C)?&&?(B?||?D)當(dāng)然這里的同義詞組更簡單,為(A && B) || C 的模式。下面我們分析一下笛卡爾積語法樹與原語法樹的差別。
求交性能分析
為了能夠更具體一點的了解不同語法樹之間的性能差異。我們需要對求交性能做一個定量的分析。下面對 3 層結(jié)構(gòu)的原語法樹和其對應(yīng)的笛卡爾積語法樹各自的求交過程來進(jìn)行性能分析,依然以蘋果手機(jī)回收這個 case 為例。
1?原語法樹求交基準(zhǔn)的計算公式:(此處直接以Term值來表示對應(yīng)Term節(jié)點) max?(?min(蘋果?&&?手機(jī)??,?iphone)??,?回收)笛卡爾積語法樹求交基準(zhǔn)的計算公式: max(?min(蘋果,iphone)?,?min(手機(jī),iphone)?,??回收)2?給定一個基準(zhǔn)的前提下:源語法樹需要操作的語法節(jié)點為4個,對應(yīng)為4條倒排鏈笛卡爾積語法樹需要操作的語法樹節(jié)點為5個,對應(yīng)為5條倒排鏈3?兩種類型的語法樹結(jié)束條件較為相似,都是某一顆子樹到達(dá)末尾 源語法樹結(jié)束條件: (End(蘋果?||?手機(jī))??&&??End(iphone))??||??End(回收) 笛卡爾積語法樹結(jié)束條件: (End(蘋果?||?iphone)?||?End(手機(jī)?||?iphone))?||?End(回收) 由于 End(蘋果?||?手機(jī))??&&??End(iphone)?=?End(蘋果?||?iphone)?||?End(手機(jī)?||?iphone) 因此結(jié)束條件的位置其實是一致的。為了簡化性能評估,我們假定每次語法節(jié)點的操作損耗相同,則性能評估的大致公式為:
從公式上來看,決定性能的因素主要有以下 4 點:
求交基準(zhǔn)總數(shù)
語法節(jié)點個數(shù)
求交基準(zhǔn)選取損耗
語法樹節(jié)點操作個數(shù)
現(xiàn)在我們來對比下打平后的笛卡爾積語法樹和原語法樹之間的差異。
1 求交基準(zhǔn)總數(shù)由于(A && B) || C = (A || C) && ( B || C),因此兩顆語法樹的最終邏輯肯定是一致的,只是表現(xiàn)形式不一樣而已,那么僅從公式上,可以知道這兩顆語法樹的求交基準(zhǔn)個數(shù)肯定是一樣多的(這句話其實是有一些問題的,不過可以先這么理解)。
2 語法樹節(jié)點個數(shù)很明顯,笛卡爾積語法樹的語法節(jié)點數(shù)會大于原語法樹的節(jié)點個數(shù),(A && B) || C ==> (A || C) && ( B || C)的轉(zhuǎn)換,其實是析取范式到合取范式的一個轉(zhuǎn)換,并且恰好屬于轉(zhuǎn)換后會導(dǎo)致子句指數(shù)型暴漲的情況,即同義詞組的個數(shù)越多,每個同義詞的葉子節(jié)點越多,那么轉(zhuǎn)換后的語法樹節(jié)點就越多,并呈指數(shù)型增長。語法樹節(jié)點越多,求交時的邏輯也就越重。
3 求交基準(zhǔn)選取損耗對于求交基準(zhǔn)的選取損耗很明顯是跟語法樹節(jié)點個數(shù)強相關(guān)的,由于笛卡爾積語法樹的語法樹節(jié)點遠(yuǎn)超原語法樹,因此笛卡爾積語法樹每次的求交基準(zhǔn)選取損耗都會大于原語法樹。
4 語法樹節(jié)點操作個數(shù)笛卡爾積語法樹層數(shù)降低為了 2 層,并且消除了第 3 層的 and 邏輯,整顆語法樹只剩下最頂層的 and 邏輯。這一點有什么優(yōu)勢呢?我們在對 and 節(jié)點下的子節(jié)點進(jìn)行求交的時候,往往都是一個節(jié)點一個節(jié)點的操作,因此如果只剩下最頂層的 and 節(jié)點的時候,一旦發(fā)現(xiàn)有節(jié)點經(jīng)過跳躍查找后,跟求交基準(zhǔn)的值不一致,可以很方便的提前就結(jié)束掉對于該求交基準(zhǔn) N 的查找,即可以很方便的提前排除掉求交基準(zhǔn) N。
這一點對于笛卡爾積語法樹來說是一個優(yōu)勢,可以減少排除一個基準(zhǔn)的需要操作的節(jié)點個數(shù)。但是其只是降低了提前結(jié)束求交基準(zhǔn) N 的查找的代碼實現(xiàn)的復(fù)雜度,對邏輯后驗求交算法進(jìn)行改進(jìn)后同樣可以實現(xiàn)。
改進(jìn)的邏輯后驗求交算法當(dāng)我們得出一個求交基準(zhǔn)時,各條倒排鏈都需要進(jìn)行跳躍查找第一個大于等于求交基準(zhǔn) N 的值,我們可以通過在查找過程中就更新求交基準(zhǔn) N 的值,從而減少后續(xù)每條倒排鏈的查找次數(shù)
經(jīng)過對比后可以發(fā)現(xiàn),語法樹打平之后其實并沒有什么優(yōu)勢,并且會導(dǎo)致語法節(jié)點數(shù)指數(shù)型增長,因此我們目前認(rèn)為使用原語法樹配合邏輯后驗求交算法就是內(nèi)存檢索引擎最佳的求交方式。這種想法當(dāng)然有點坐井觀天了,如果有讀者有更好的方式,歡迎指點一二。
在了解完同義詞,以及 3 層結(jié)構(gòu)語法樹的邏輯后驗求交算法后,可以再簡單了解一下其余的查詢語法。
1 丟棄詞可設(shè)置 and 節(jié)點下的 term 節(jié)點為丟棄詞,這樣的話,它不會參與求交。為什么不在 query 處理環(huán)節(jié)就把它丟棄掉呢?這里存在一些差別,一個 term 節(jié)點即便被設(shè)置丟棄詞,我們依然會為它設(shè)置文檔的命中信息,這對于相關(guān)性庫(文檔打分庫)來說是有必要的。其實現(xiàn)方式為不參與邏輯后驗以及求交基準(zhǔn)的計算,但是會參與對于求交基準(zhǔn)的倒排鏈跳躍查找。
2 動態(tài)非必留與必留詞動態(tài)非必留是一種動態(tài)求交方式,例如一個 and 節(jié)點下掛了 3 個 term 節(jié)點 A,B,C(均不是丟棄詞),動態(tài)非必留設(shè)置為 2 個 term 命中即可召回,那么一個文檔只要 A,B 或者 A,C,或者 B,C 命中即可。
必留詞是指在進(jìn)行動態(tài)非必留求交時,該詞必須是求交元素,一般我們會設(shè)置在一些核心詞上面。例如 A 設(shè)置為了必留詞的話,那么一個文檔只要 A,B 或者 A,C 命中即可召回。動態(tài)非必留適用于一些召回不足的場景。其實現(xiàn)方式為對 and 節(jié)點下的 term 進(jìn)行快速選擇排序,在選取求交基準(zhǔn)時,不再對所有 term 節(jié)點取 max,而是取倒數(shù)第[必留個數(shù)]大的 term 的 pageid 彈出去。
3 位置約束可對一個 and 節(jié)點下的 term 設(shè)置位置約束。例如一個 and 節(jié)點下掛了 3 個 term 節(jié)點 A,B,C(均不是丟棄詞),我們可以設(shè)置 B 存在 Pos=OneOfPos(A)+1,設(shè)置 C 存在 Pos=OneOfPos(B) + 2。在我們的引擎里,如果是相鄰 term 也設(shè)置了位置約束,那么它們會作為一個整體來進(jìn)行位置約束判斷,有點類似于多槽位的模板匹配。其實現(xiàn)方式為取出相關(guān) term 的 pos 列表做二分查找。
and-or語法,配合丟棄詞的求交方式,特別依賴于Query處理能力,丟棄詞設(shè)置的好與壞會決定求交結(jié)果準(zhǔn)確與否。例如對于Query:[深圳有哪些景點],如果深圳或者景點被設(shè)置了丟棄詞,那么召回結(jié)果可能會完全偏移,這種屬于在召回側(cè)結(jié)果集就已經(jīng)偏移,在相關(guān)性上面進(jìn)行排序調(diào)整也十分吃力。 WeakAnd求交方式是我們目前處于計劃中,但還未實現(xiàn)的一個功能,主要原因在于其標(biāo)準(zhǔn)實現(xiàn)方式與新引擎當(dāng)前的任務(wù)模型有沖突,我們還未能找到方法將其良好的融合進(jìn)去。對于WeakAnd的實現(xiàn)方式網(wǎng)上的資料很多,這里不想贅述。我們對它的認(rèn)識在于這種求交方式可以緩解對于Query處理能力的依賴。查找算法設(shè)計
正如上面提過的一樣,求交損耗的本質(zhì)為各條倒排鏈的跳躍查找次數(shù),跳躍查找次數(shù)越少的,性能也就越好。語法樹求交設(shè)計解決的問題是盡可能減少跳躍查找次數(shù),而查找算法設(shè)計解決的問題是盡可能減少每次跳躍查找的消耗。
由于新引擎的倒排索引結(jié)構(gòu)細(xì)節(jié)較多,為了方便闡述這塊的內(nèi)容,這里看一下我們組內(nèi)上一代內(nèi)存檢索引擎的倒排索引結(jié)構(gòu),由于其相對簡單,適合拿來介紹查找算法。
倒排結(jié)構(gòu)整體是先分塊(Block,BLK),每個塊內(nèi)再保存具體的 page 信息,page 信息主要分為兩部分,一部分自然是 pageid 列表,另一塊是 page info 結(jié)構(gòu),保存的各個文檔的 term 級別的信息,這里就不對其進(jìn)行介紹了,直接忽略它即可。
關(guān)于分塊設(shè)計的背景 1?繼承自磁盤檢索系統(tǒng),磁盤分塊讀取2 內(nèi)存分塊,有助于實時索引構(gòu)建。相當(dāng)于是說對于實時索引數(shù)據(jù)是以塊為單位進(jìn)行加載的,不過我們的系統(tǒng)并不是這樣實現(xiàn)的,我們的實時索引數(shù)據(jù)依然是以庫為粒度進(jìn)行加載的,因此在我們的系統(tǒng)中索引數(shù)據(jù)都是分布在連續(xù)內(nèi)存中。以庫粒度進(jìn)行加載,其索引時效性如何保障?這個問題暫且擱置,在后續(xù)的ZeroSearch系列文章中會有解答。上一代引擎在查找某個 pageid 時(連續(xù)內(nèi)存中),采取的做法是先二分查找到對應(yīng)塊,然后再在塊內(nèi)進(jìn)行二分查找。
有問題么?表面上看,似乎并沒有什么問題。我們對它簡單分析一下,假設(shè)某個 term 的倒排鏈長度為 L,塊長為 T(即每個 BLK 內(nèi)至多保存 T 個文檔信息),則塊數(shù)為 N=L/T,則查找次數(shù)為:logN + logT = logN*T = logL 而不分塊直接對整條倒排鏈二分查找的查找次數(shù)顯然也是 logL。
因此上一代引擎的索引設(shè)計以及查找算法其實并沒有帶來查找效率的提升。從一個有序列表中,找到第一個大于等于 N 值的位置,二分查找就是最快速的查找方式了,似乎并沒有優(yōu)化空間了?如果從結(jié)果出發(fā),站在宏觀的角度來思考優(yōu)化,那幾乎不可能能得出答案,我們需要以微觀的角度,深入到過程來尋找優(yōu)化空間。
對于倒排查找過程的思考
1 過程的連續(xù)性事實上倒排查找并不是只查找一個 N 值,而是隨著求交過程,需要不斷的去查找新的 N 值,且 N 值之間滿足嚴(yán)格遞增關(guān)系。即整個過程是具備連續(xù)性的。
2 數(shù)據(jù)分布特征索引分片分庫時文檔已經(jīng)被打散過一次(稀疏),這對倒排鏈(聚集)中的 pageid 分布是否會有影響,它們的值分布稠密或者稀疏對于求交是否又有影響。即倒排鏈 pageid 是否可能具備數(shù)據(jù)分布特征。
3 長鏈與短鏈長鏈與短鏈對于求交的影響如何,是否應(yīng)該區(qū)別處理,長鏈與短鏈該如何去定義。通過對求交過程進(jìn)行分析和思考,得出了這 3 個點。下面我們以一個特殊的實例來看一下求交過程。
假設(shè)存在這樣的一條短鏈 L1 和一條長鏈 L2,它們的 pageid 范圍相近,且前后 pageid 的間距都是固定的(數(shù)據(jù)分布均勻),其中短鏈的前后 pageid 固定為 d1,長鏈的前后 pageid 固定為 d2:
現(xiàn)在分析一下存在的求交組合情況,主要從查找消耗和求交基準(zhǔn)兩點進(jìn)行分析。
1?短鏈與短鏈求交 查找消耗:由于本身鏈路短,因此二分查找時,總的查找范圍較小,查找消耗較低。 求交基準(zhǔn):求交基準(zhǔn)的數(shù)目上限為短鏈長度L1。 特殊:由于短鏈的間距d1過大,單個Block內(nèi)的pageid跨度(范圍)會更大。下一個求交基準(zhǔn)落在本block內(nèi)的可能性較高。2?短鏈與長鏈求交 查找消耗:由于短鏈的間距d1過大,因此長鏈在查找過程中依然適用于二分查找,但是長鏈查找范圍會偏大,查找消耗一般 求交基準(zhǔn):求交基準(zhǔn)的數(shù)目上限為短鏈長度L13?長鏈與長鏈求交 查找消耗:長鏈與長鏈求交時,由于長鏈的間距d2較小,下一個求交基準(zhǔn)N大概率出現(xiàn)在上一個基準(zhǔn)附近,因此長鏈在二分查找時,查找范圍過大,資源消耗較高 求交基準(zhǔn):求交基準(zhǔn)數(shù)目上限由長鏈長度L2決定現(xiàn)在泛化到多條鏈之間的求交情況分析,即短鏈變?yōu)槎鄺l,或者長鏈變?yōu)槎鄺l,或者兩者都變?yōu)槎鄺l。由于我們采用的是多哨兵位的求交算法,是從整體進(jìn)行求交,那么在多條倒排鏈(大于 2 條)求交時,只有以下 3 種情況。
1?全部都為短鏈 問題回歸到短鏈與短鏈求交的討論2?同時存在短鏈與長鏈 問題回歸到短鏈與長鏈求交的討論3?全部都為長鏈 問題回歸到長鏈與長鏈求交的討論現(xiàn)在考慮數(shù)據(jù)分布不均勻情況下的求交特點。數(shù)據(jù)分布不均勻時,將存在稠密區(qū)域與非稠密區(qū)域,稠密區(qū)域內(nèi) pageid 的值分布集中,間距較小,而非稠密區(qū)域 pageid 的值分布較為分散,間距較大。同樣存在以下 3 種組合情況
1?非稠密區(qū)域與非稠密區(qū)域的查找,與短鏈與短鏈的查找特點相似2?非稠密區(qū)域與稠密區(qū)域的查找,與短鏈與長鏈的查找特點相似3?稠密區(qū)域與稠密區(qū)域的查找,與長鏈與長鏈的查找特點相似盡管問題又得到了回歸,但是數(shù)據(jù)分布均勻與否依然有著顯著的差異,即數(shù)據(jù)分布均勻的情況下,它的求交特點是穩(wěn)定的,而數(shù)據(jù)分布不均勻時,求交特點是變化的,可能上一次查找屬于非稠密區(qū)域與非稠密區(qū)域的查找,下一次查找時就落入了稠密區(qū)域與稠密區(qū)域的查找了,甚至隨著求交過程,長鏈與短鏈的相對關(guān)系也在變化。
關(guān)于如何去評判一條倒排鏈的數(shù)據(jù)分布情況,計劃采用的方式是通過計算間距的平均值和方差來進(jìn)行評判,因此實際上當(dāng)前我們對于這一點也還屬于還未開工的探索階段,暫且無法得到數(shù)據(jù)分布特征的一些數(shù)據(jù)。
不管怎樣,問題總算是得到了回歸,至少我們可以得到以下兩點結(jié)論和一個猜想:1 多條倒排鏈求交時,其耗時主要由短鏈的長度決定,原因是求交基準(zhǔn)的數(shù)目上限由短鏈決定。
2 多條長鏈求交時,長鏈每次查找范圍過大,因此查找消耗較大。
3 猜想:不論是對于長鏈還是短鏈,下一個求交基準(zhǔn)大概率落在近鄰 Block 內(nèi)
以這 3 點為基礎(chǔ),可以給我們的查找算法帶來一些新的思路。下面介紹求交優(yōu)化的做法。
1 倒排鏈查找優(yōu)化根據(jù)猜想:下一個求交基準(zhǔn)大概率落在近鄰 Block 內(nèi)。我們將先使用步長增長查找的方式對近鄰 Block 進(jìn)行查找,未找到再二分查找剩余 Block,Block 內(nèi)依然使用二分查找。
步長增長查找:每次向后查找的 Block 數(shù)量為 2^(n-1),確定目標(biāo)位置后,再在該 2^(n-1)個 Block 內(nèi)進(jìn)行二分查找。
了解到這種查找方式其實有個專有名詞叫:Galloping Search,其實是很輕松就能想到的方式。
需要注意的是,在這里我們需要對長鏈和短鏈區(qū)分處理,簡單來說就是短鏈查找的近鄰 Block 較少,長鏈查找的近鄰 Block 較多。具體下文會分析。
2 bitmap對于超長鏈,其倒排結(jié)構(gòu)使用 bitmap 進(jìn)行存儲,bitmap 具備快速求交、快速求并、快速查找等特性,然而 bitmap 在 bit 位稀疏時的順序迭代訪問性能較差,而求交基準(zhǔn)在選取時是需要獲取每個節(jié)點當(dāng)前指向的 pageid 的,對于 bitmap 來說,需要通過順序迭代訪問來找到第一個非零 bit 位。因此超長鏈的定義將主要由 bitmap 帶來的收益和迭代性能來決定,僅從空間使用率上來看,未壓縮的情況下其實一條倒排鏈只需要滿足長度大于等于索引庫文檔總數(shù)/32(pageid 采用 4 字節(jié)存儲)即可,當(dāng)然這個標(biāo)準(zhǔn)在性能上肯定是不行的。
3 語法樹查詢優(yōu)化
3.1 短鏈優(yōu)先查找由于短鏈節(jié)點查找消耗更低,單次查找更快,因此短鏈節(jié)點優(yōu)先查找,用于快速更新求交基準(zhǔn),減少后續(xù)倒排鏈的查找次數(shù),同時 pageid 值的間距更大的可能性較高,利于快速增長求交基準(zhǔn) N。
需要注意的是,隨著求交過程的不斷執(zhí)行,長鏈,短鏈的相對關(guān)系可能會發(fā)生變化,這里的短鏈優(yōu)先查找是在求交開始之前就對查詢節(jié)點的查詢順序進(jìn)行調(diào)整,后續(xù)不會再進(jìn)行調(diào)整。
3.2 同義詞子樹置后查找與短鏈節(jié)點優(yōu)先查找對應(yīng),由于同義詞子樹一次查找需要對多個 Term 節(jié)點進(jìn)行倒排查找,因此在評估單次查找消耗時,需要以整顆子樹進(jìn)行考慮,其查找消耗是該子樹下所有 Term 節(jié)點之和。最終的效果會導(dǎo)致同義詞子樹被置后查找。同樣的,這里的調(diào)整是在求交開始之前就完成,后續(xù)不會再進(jìn)行調(diào)整。
3.3 bitmap 合并bitmap 語法節(jié)點合并,對于有多個 bitmap 子節(jié)點的父節(jié)點,可對其新增一個虛擬語法節(jié)點,對 or 節(jié)點下的 bitmap 節(jié)點進(jìn)行求并,對 and 節(jié)點下的 bitmap 節(jié)點進(jìn)行求交。
3.4 重復(fù)詞節(jié)點合并對于語法樹中的每個 Term 節(jié)點,我們只會創(chuàng)建一個倒排訪問對象,簡稱游標(biāo)(Cursor)。在邏輯后驗算法里,所有倒排鏈都在往求交基準(zhǔn) N 查找,因此對于相同的 Term 節(jié)點,它們可以共享同一個游標(biāo)。
4 指令集優(yōu)化經(jīng)組內(nèi)同事 sen 指點,在求交過程中可以利用 SSE 指令集(需要硬件支持)中的 XMM(128bit)、YMM(256bit)等大長度寄存器,使用單指令多數(shù)據(jù)流的方法一次比較多個整形元素,來達(dá)到求交加速的效果。這類寄存器的使用跟步長增長查找的方式恰好十分匹配,這一點屬于我們后續(xù)打算嘗試的一個方向。
那么遺留下來的問題就是如何定義短鏈,長鏈,以及超長鏈了。
短鏈長鏈與超長鏈
其中超長鏈的定義相對簡單一些,設(shè)定一個閾值 X,當(dāng)且僅當(dāng):倒排鏈長度 / max(倒排鏈的 pageid) >= X 則倒排鏈?zhǔn)褂?bitmap 進(jìn)行存儲。X 的考慮主要是 bitmap 收益與順序迭代訪問損耗的一個折中,這一點需要通過業(yè)務(wù)數(shù)據(jù)來實際驗證后才能明確。
那么短鏈和長鏈又該如何定義。短鏈與長鏈的區(qū)別對待體現(xiàn)在近鄰查找時的 Block 數(shù)量上,在這里我們需要先簡單分析一下步長增長查找方式與二分查找在性能上的差異。
條件:假設(shè)倒排鏈總長度為 n,塊長為 T,塊數(shù)為 N。二分查找的時間復(fù)雜度為:
logN + logT = logn需要注意的是由于倒排查找過程中要找的是第一個大于等于求教基準(zhǔn) N 值的位置,因此每一次二分查找,其查找次數(shù)不多不少,都是 logn(n 為還未查找過的文檔數(shù)量)。如果以倒排鏈長度為 X 軸,時間復(fù)雜度為 Y 軸,那么二分查找的時間復(fù)雜度就是一條直線。
當(dāng)使用步長增長查找方式時,假設(shè)第 X 次確定了目標(biāo)位置,那么其時間復(fù)雜度為
X + log2^(X-1) * T = 2X + logT - 1,其中1 <= X <= log(N+1)其最小值約等于 logT,最大值約等于 logN + logn,如果以倒排鏈長度為 X 軸,時間復(fù)雜度為 Y 軸,那么很明顯,步長增長查找方式的時間復(fù)雜度為一條曲線。
從步長增長查找的復(fù)雜度公式里的最小值和最大值可以知道,越在前面找到下一個求交基準(zhǔn)的位置,那么步長增長查找?guī)淼奶嵘驮酱?#xff0c;再往后,其性能就開始落后于二分查找了。現(xiàn)在可以開始考慮短鏈與長鏈了。假設(shè)存在閾值 K,Block 數(shù)目大于 K 的就是長鏈,小于 K 的就是短鏈,我們希望的最理想的效果是使用步長增長查找時的平均復(fù)雜度小于等于使用二分查找的平均時間復(fù)雜度。
由于二分查找的時間復(fù)雜度固定為 logn,因此其平均復(fù)雜度也就是 logn 了。而步長增長查找的平均時間復(fù)雜度的計算要麻煩許多,假定求交基準(zhǔn)落在每一個 Block 的概率是相等的話,那么其平均時間復(fù)雜度為:
乍一看好像挺復(fù)雜的,完全誤會了,本人數(shù)學(xué)底子非常渣。這里其實就是對于每一個 X,都乘了一下[當(dāng)前 X 所覆蓋的 Block 數(shù)量 / 總 Block 數(shù)量]的比例值,得到的平均時間復(fù)雜度的公式。
總之,由于本人的數(shù)學(xué)底子非常渣的原因,我這里就直接給出我們這邊計劃嘗試的 K 值了,K=15。怎么得出來的呢?通過計算 k=3,7,15,31,63 等等情況下的兩者的平均時間復(fù)雜度,發(fā)現(xiàn) k 值越大,步長增長查找的平均時間復(fù)雜度就越高(但其實算法實現(xiàn)的時候會發(fā)現(xiàn),當(dāng)我們限定了只查找多少個 Block 時,步長增長查找方式的邏輯會輕很多,盡管查找次數(shù)上并不占優(yōu))。最后因為如下 2 點原因選擇了 15:
1 越靠近的Block,命中下一個求交基準(zhǔn)的概率就越高(僅僅是猜想,還未有實際業(yè)務(wù)驗證),雖然k=15時平均復(fù)雜度已經(jīng)比二分高了,但如果越靠前的Block命中概率越高的話,靠前的Block區(qū)域加權(quán)因子變大,靠后的Block區(qū)域加權(quán)因子變小,那么其平均復(fù)雜度可能并不會比二分高。2 XMM寄存器一次可比較4個32bit的整數(shù),與步長增長查找15個Block剛好對應(yīng)。如果XMM的使用確實帶來了優(yōu)化,那么我們后續(xù)也會對YMM進(jìn)行測試。這一點才是主要考慮的原因,為后續(xù)指令集優(yōu)化埋下伏筆。即在我們的引擎里,Block 數(shù)量大于等于 15 的則為長鏈,小于 15 的則為短鏈。對于長鏈,在近鄰搜索時最多查找 15 個 Blcok(含當(dāng)前 Block),對于短鏈,我們只與當(dāng)前 Block 的最大值進(jìn)行比較一次。
需要再次說明的是以上的討論都是建立在猜想:越靠近的 Block,命中下一個求交基準(zhǔn)的概率就越高。原因在于文檔在索引分片分庫時已經(jīng)被打散(稀疏)過一次,同時一個 Block 內(nèi)保存的是多個 pageid,被稀疏過后的文檔又緊密存儲(聚集)在一起。
如果猜想不成立呢?
計劃用質(zhì)量標(biāo)準(zhǔn)系統(tǒng)統(tǒng)計一下長鏈在求交過程中命中下一個求交基準(zhǔn)的 Block 與上一個位置所處的 Block 的距離。例如如果有 90%以上是落在 X 個 Block 內(nèi)的話,那么依然還是有價值先對這 X 個 Block 進(jìn)行查找的。
另外當(dāng)我們確定要對 X 個 Block 進(jìn)行查找時,是有多種查找方式可以使用的,例如從前往后查,或者從后往前查,恒定步長的查找方式,步長增長的查找方式等等。具體如何使用還是需要視數(shù)據(jù)分布特征而定。
關(guān)于近鄰查找的篇幅顯得有點啰嗦了,最后再簡單總結(jié)一下:由于文檔在索引分片分庫的過程中被稀疏和聚集過一次,求交過程的連續(xù)性,以及索引數(shù)據(jù)相對穩(wěn)定的特點,我們嘗試去尋找一些特征來幫助我們加速整個倒排查找過程。
如果把兩種查找方式的時間復(fù)雜度相減,即2X?+?logT?-?1?-?logn,由于T和n都是常數(shù),因此它們的差值為 f(x)?=?2x?+?logT?-?1?-?logn 當(dāng)f(x)大于0時,表示對近鄰Block進(jìn)行搜索時,步長增長方式的查找消耗更高 當(dāng)f(x)小于0時,表示對近鄰Block進(jìn)行搜索時,步長增長方式的查找消耗更低 很明顯,在二元坐標(biāo)軸里,f(x)是一根斜向上的直線,當(dāng)f(x)?=?0時 x?=?(1?+?logn?-?logT)?/?2 x?=?(1?+?log(n/T))?/?2 x?=?(1?+?logN)?/?2 即只有當(dāng)x <?(1 + logN)?/ 2 時,步長增長查找方式性能才會比二分查找性能會好。需要注意的是這里的f(x)中x的定義,其定義為步長增長式查找時確認(rèn)到了目標(biāo)位置時的查找次數(shù)。引擎組件化
在文章的開頭提到過,我們以組件化的思想來進(jìn)行設(shè)計,在線檢索能力被封裝成了一個庫,相比于攜帶 RPC 框架的引擎,檢索庫的形式可較好的融入已有的開發(fā)體系和運維體系。既然是以庫的形式存在,就需要有合適的接口暴露出來,讓使用者能嵌入業(yè)務(wù)邏輯和業(yè)務(wù)數(shù)據(jù)。對于組件化設(shè)計,核心的設(shè)計點如下
1 在線檢索過程中檢索邏輯與數(shù)據(jù)需要進(jìn)行分離,一個請求相關(guān)的所有數(shù)據(jù)都是通過檢索 Session 來進(jìn)行管理
2 業(yè)務(wù)數(shù)據(jù)的嵌入通過檢索入口傳入,之后交由檢索 Session 管理,在這里可以簡單看下我們提供的唯一檢索入口:
int32_t?Retrieve(const?RetrieveOptions*?retrieve_options,void*?business_session)@arg1?retrieve_options?:?檢索協(xié)議(pb格式) @arg2?business_session?:?業(yè)務(wù)session數(shù)據(jù)3 對整個檢索流程中的各個環(huán)節(jié)暴露出接口封裝成類進(jìn)行管理,業(yè)務(wù)邏輯的嵌入通過反射的形式來實現(xiàn)注入
4 相關(guān)性接口同樣封裝成類,業(yè)務(wù)通過反射的形式來實現(xiàn)注入
整體如下圖所示:
在進(jìn)行組件化設(shè)計之后,檢索的細(xì)節(jié)都被封裝在庫里。這里對 SearcherStage 設(shè)計和相關(guān)性接口設(shè)計再簡單介紹一下。
Searcher 是在線檢索組件的名稱,Stage 是我以我拙劣的英文水平選的一個詞,意為階段。在大環(huán)節(jié)上面,檢索流程分為預(yù)處理,核心處理,回包 3 個環(huán)節(jié),我們在每個大環(huán)節(jié)的開始和結(jié)束階段都暴露了接口,并把所有接口放到了 SearcherStage 類中進(jìn)行管理。對于任何想使用 Searcher 來作為部門內(nèi)通用搜索引擎的用戶來說,它必須通過繼承并實現(xiàn) SearcherStage 類的相關(guān)接口來實現(xiàn)自己的通用搜索引擎,一般來說,至少需要通過 SearcherStage 類完成以下 2 件事情。
1?在AfterHandleResponse中將索引數(shù)據(jù)轉(zhuǎn)化為業(yè)務(wù)數(shù)據(jù) 2 在RetrieveKPIReport中對本次請求的檢索情況進(jìn)行上報,如檢索狀態(tài),各個階段的文檔數(shù)量,耗時等等。需要再次說明的是,每一個 SearcherStage 對象都是一個獨立的通用搜索引擎,例如在搜一搜這邊,也只是存在一個 S1SSearcherStage 類,并以它為基礎(chǔ)封裝為了搜一搜的檢索庫,其余的所有垂搜業(yè)務(wù)都是鏈接該檢索庫,而非 Searcher 組件。
下面再簡單介紹一下相關(guān)性接口的設(shè)計。相關(guān)性接口設(shè)計的總體原則:控制復(fù)雜度。其體現(xiàn)為以下兩點
人性化的相關(guān)性輸入信息
合理的邏輯拆分
關(guān)于人性化的相關(guān)性輸入信息,本文暫且不提,這里簡單介紹下邏輯拆分的背景。相關(guān)性接口的執(zhí)行場景分為全局初始化、請求級別初始化及各打分環(huán)節(jié)初始化、文檔打分 3 個,在我們的上一代內(nèi)存檢索引擎中,所有的相關(guān)性接口都集中在了一個類中,該設(shè)計客觀上導(dǎo)致了當(dāng)前所有業(yè)務(wù)的打分主邏輯都集中了一個類的實現(xiàn)里,臃腫,多個場景/環(huán)節(jié)的變量和邏輯交織在一起,容易出錯,另一方面所有的代碼都集中了一個.h 和.cc 文件中,可讀性差,難以管理和協(xié)作開發(fā)。也因此,在新引擎中,我們對各個過程進(jìn)行了拆分,抽象為了獨立的類,類之間以組合的形式進(jìn)行訪問。拆分之后還有一個好處在于,由于每個文檔都有獨立的打分對象,文檔的打分從無狀態(tài)變?yōu)榱擞袪顟B(tài)。
末尾
大概的內(nèi)容就是這樣了,在引擎的整個設(shè)計過程中,很多關(guān)鍵的設(shè)計點都是跟組內(nèi)同事 sen 進(jìn)行探討后得到,sen 給了我很多指導(dǎo)和把控。我們整體的設(shè)計原則其實非常簡單:
1 充分考慮易用性以服務(wù)業(yè)務(wù)為第一優(yōu)先級,易用性將決定業(yè)務(wù)的服務(wù)舒適度
2 還是要像一個內(nèi)存搜索引擎設(shè)計方案上還是不能太糟糕,不能存在明顯的設(shè)計問題
本文取名為關(guān)于內(nèi)存檢索引擎設(shè)計的探索,實則也是我之前想寫的檢索初階系列中的第二篇:內(nèi)存檢索引擎設(shè)計。之前檢索初階(一)和(三)都早早就發(fā)出來了,但其實那會對檢索引擎的理解還比較淺,這篇雖然是(二),不過是作為收官之作來寫的。
組內(nèi)這次開發(fā)的新引擎(ZeroSearch)后續(xù)也會準(zhǔn)備對公司內(nèi)部開源,時間點應(yīng)該要到明年了,其實目前已經(jīng)完成了一個比較粗糙的版本,目前正處于推動業(yè)務(wù)升級的階段,但是還有大量的 TODO 和方向還沒去嘗試。不過在那之前,組內(nèi)后續(xù)還會有同學(xué)分別介紹 ZeroSearch 的分布式索引系統(tǒng)設(shè)計,索引庫構(gòu)建流程設(shè)計,索引結(jié)構(gòu)設(shè)計等等一系列的文章 ,讓我們一起為內(nèi)存檢索引擎設(shè)計的資料空缺出把力吧。
最后打一個小廣告,微信搜索誠招 C++后臺開發(fā),如有搜索開發(fā)經(jīng)驗或大廠工作經(jīng)驗者更佳,誠邀有志之士,共襄大業(yè)。有興趣的同學(xué)可與本人郵件聯(lián)系:scut_huajian@qq.com
歡迎關(guān)注我們的視頻號:騰訊程序員
最新視頻:如果程序員媽是產(chǎn)品經(jīng)理
騰訊技術(shù)官方交流微信群已經(jīng)開放
進(jìn)群添加微信:journeylife1900
(備注:騰訊技術(shù))
總結(jié)
以上是生活随笔為你收集整理的新一代搜索引擎项目 ZeroSearch 设计探索的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 从无盘启动看 Linux 启动原理
- 下一篇: 产品经理日常数据分析工作