如何索引數以十億計的文本向量?

信息檢索中經常出現的一個問題是查找相似的文本片段。正如我們在之前的文章中所描述的(一個新的搜索引擎以及從頭構建一個搜索引擎),查詢是Cliqz的一個重要構建塊。在這種情況下,查詢可以是用戶生成的查詢(即用戶輸入到搜索引擎中的文本片段),也可以是我們生成的合成查詢。一個常見的用例是,我們希望將輸入查詢與索引中已有的其他查詢進行匹配。在這篇文章中,我們將看到我們如何能夠構建一個系統,在不投入(我們沒有)大量服務器基礎設施資金的情況下,使用數十億查詢解決這個任務。

正文

本文最初發佈於Clipz技術博客,由InfoQ中文站翻譯並分享。

信息檢索中經常出現的一個問題是查找相似的文本片段。正如我們在之前的文章中所描述的(一個新的搜索引擎以及從頭構建一個搜索引擎),查詢是Cliqz的一個重要構建塊。在這種情況下,查詢可以是用戶生成的查詢(即用戶輸入到搜索引擎中的文本片段),也可以是我們生成的合成查詢。一個常見的用例是,我們希望將輸入查詢與索引中已有的其他查詢進行匹配。在這篇文章中,我們將看到我們如何能夠構建一個系統,在不投入(我們沒有)大量服務器基礎設施資金的情況下,使用數十億查詢解決這個任務。

我們首先對這個問題下個形式化的定義:

對於特定的查詢集合Q、輸入查詢q、整數k,找出一個子集R={q0​,q1​,…,qk}⊂Q,其中,qi∈R與q的相似度比Q∖R中的任何查詢都高。

例如下面這個Q的子集:

{tesla cybertruck, beginner bicycle gear, eggplant dishes, tesla new car, how expensive is cybertruck, vegetarian food, shimano 105 vs ultegra, building a carbon bike, zucchini recipes}

k=3,我們可能會得到以下結果:

輸入查詢q 相似查詢R
tesla pickup {tesla cybertruck, tesla new car, how expensive is cybertruck}
best bike 2019 {shimano 105 vs ultegra, are carbon bikes better, bicycle gearing}
cooking with vegetables {eggplant dishes, zucchini recipes, vegetarian food}

請注意,我們還沒有定義相似。在這種情況下,它幾乎可以表示任何意思,但它通常歸結爲某種形式的關鍵字或基於向量的相似性。使用基於關鍵字的相似度,如果兩個查詢有足夠多的相同詞彙,我們可以認爲它們是相似的。例如,opening a restaurant in munich和best restaurant of munich這兩個查詢是相似的,因爲它們共享restaurant 和munich這兩個詞,而best restaurant of munich和where to eat in munich這兩個查詢則不太相似,因爲它們只共享一個詞。然而,對於在慕尼黑尋找餐館的人,如果認爲第二組查詢相似,他們可能會得到更好的服務。這就是基於向量的匹配發揮作用的地方。

將文本嵌入到向量空間

詞嵌入是自然語言處理中的一種機器學習技術,用於將文本或單詞映射成向量。通過將問題移到向量空間中,我們可以使用數學運算,例如對向量求和或計算距離。我們甚至可以使用傳統的向量聚類技術將相似的單詞鏈接在一起。這些操作的意思在原來的單詞空間中不一定那麼明顯,但好處是,我們現在有豐富的數學工具可以使用。要了解更多關於單詞向量及其應用的信息,感興趣的讀者可以看下word2vecGloVe

一旦我們有了從單詞生成向量的方法,下一步就是將它們組合成文本向量(也稱爲文檔或句子向量)。一種簡單而常見的方法是對文本中所有單詞的向量求和(或求平均值)。

圖1:查詢向量

我們可以通過將兩個文本片段(或查詢)映射到向量空間並計算向量之間的距離來確定它們有多相似。一個常見的選擇是使用角距離。

總而言之,詞嵌入允許我們做一種不同類型的文本匹配,以補充上面提到的基於關鍵字的匹配。我們能夠以一種以前不可能的方式探索查詢之間的語義相似性(例如,best restaurant of munich和where to eat in munich)。

近似最近鄰搜索

我們現在準備將我們的初始查詢匹配問題簡化爲以下問題:

對於特定的查詢向量集Q、向量q和整數k,找出一個向量子集R={q0​,q1​,…,qk}⊂Q,使得從q
到qi∈R的角距離小於到Q∖R中向量的角距離.

這被稱爲最近鄰問題,有很多算法可以快速解決低維空間的最近鄰問題。另一方面,使用詞嵌入,我們通常使用高維向量(100-1000維)。在這種情況下,精確的方法會崩潰。

在高維空間中,沒有快速獲取最近鄰的可行方法。爲了克服這個問題,我們將通過允許近似結果使問題變得更簡單,也就是說,不要求算法總是精確地返回最近的向量,我們接受只得到部分最近的鄰居或稍微接近的鄰居。我們稱之爲近似近鄰(ANN)問題,這是一個活躍的研究領域。

分層可導航小世界圖

分層可導航小世界圖(Hierarchical Navigable Small-World graph),簡稱HNSW,是一種快速的近似近鄰搜索算法。HNSW中的搜索索引是一個多層結構,每一層都是一個鄰近圖。圖中的每個節點都對應於一個查詢向量。

圖2:多層鄰近圖

在HNSW中,最近鄰搜索使用放大方法。它從最上層的一個入口節點開始,在每一層上遞歸執行貪婪圖遍歷,直到到達最下層的局部最小值。

關於算法的內部工作原理以及如何構造搜索索引的細節在論文中有詳細描述。重要的是,每個最近鄰搜索都包含一個圖遍歷,需要訪問圖中的節點並計算向量之間的距離。下面幾節將概述使用此方法在Cliqz構建大型索引所採取的步驟。

索引數十億查詢面臨的挑戰

我們假設目標是索引40億個200維的查詢向量,其中每一維由一個4字節的浮點數表示。一個粗略的計算告訴我們,向量的大小大約是3TB。由於許多現有的ANN庫都是基於內存的,這意味着我們需要一個非常大的服務器來將向量放入RAM中。注意,這個大小不包括大多數方法中需要的額外搜索索引。

縱觀我們的搜索引擎歷史,我們有幾種不同的方法來解決這個大小問題。讓我們回顧一下其中的幾個。

數據子集

第一個也是最簡單的方法,它並沒有真正地解決問題,就是限制索引中向量的數量。僅使用所有可用數據的十分之一,與包含所有數據的索引相比,我們可以構建只需要10%內存的索引,這並不奇怪。這種方法的缺點是搜索質量受到影響,因爲可供匹配的查詢更少。

量化

第二種方法是包含所有數據,但通過使其變小。通過允許一些舍入錯誤,我們可以將原始向量中每一個4字節的浮點數替換爲量化後的1字節版本。這可以將向量所需的RAM減少75%。儘管如此,我們仍然需要在RAM中容納750GB(仍然忽略索引結構本身的大小)的數據,這仍然需要使用非常大的服務器。

使用Granne解決內存問題

上述方法確實有其優點,但在成本效率和質量上存在不足。即使有ANN庫在不到1毫秒的時間內內產生合理的召回率,但對於我們的用例,我們可以接受犧牲一些速度來降低服務器成本。

Granne(基於圖的近似近鄰)是一個基於HNSW的庫,Cliqz開發並使用它來查找類似的查詢。它是開源的,仍在積極開發中。一個改進版本正在開發中,並將於2020年在crates.io上發佈。它是用Rust編寫的,提供了Python語言綁定。它針對數十億個向量進行設計,並充分考慮了併發。更有趣的是,在查詢向量上下文中,Granne有一個特殊的模式,它使用的內存比前面那些庫少得多。

查詢向量的壓縮表示形式

減少查詢向量本身的大小將帶來最大的好處。爲此,我們必須後退一步,首先考慮如何創建查詢向量。因爲我們的查詢由單詞組成,而我們的查詢向量是詞向量的和,所以我們可以避免直接存儲查詢向量,而是在需要時計算它們。

我們可以將查詢存儲爲一組單詞,並使用查詢表查找對應的詞向量。但是,爲了避免間接取值,我們將每個查詢存儲爲一個整數id列表,其中的每個id對應查詢中的一個詞向量。例如,我們將查詢best restaurant of munich存儲爲:

其中,i_{\mathrm{best}}是best的詞向量的id,以此類推。假設我們有不到1600萬個詞向量(再多的話就要付出每個詞1字節的代價),我們可以用3個字節表示每個單詞的id。因此,我們不用存儲800字節(或200字節的量化向量),我們只需要爲這個查詢存儲12個字節

關於詞向量:我們仍然需要它們。然而,通過組合這些單詞可以創建的查詢要比這些詞多得多。由於它們與查詢相比非常少,所以它們的大小沒有那麼重要。通過將詞向量的4字節浮點版本存儲在一個簡單的數組v中,每百萬個詞向量的大小不足1GB,可以很容易地存儲在RAM中。由此可以得出,上例中的查詢向量爲:

查詢表示的最終大小自然取決於所有查詢中單詞組合的數量,但是對於我們的40億個查詢,總大小最終約爲80GB(包括詞向量)。換句話說,我們看到,與原始查詢向量相比大小減少了97%以上,或者與量化向量相比減少了90%左右。

還有一個問題需要解決。對於單個搜索,我們需要訪問圖中的大約200到300個節點。每個節點有20到30個鄰居。因此,我們需要計算從輸入查詢向量到索引中的4000到9000個向量的距離,而在此之前,我們需要生成這些向量。動態創建查詢向量的時間代價是否過高?

事實證明,使用一個相當新的CPU,它可以在幾毫秒內完成。對於之前花費1毫秒的查詢,我們現在需要大約5毫秒。但與此同時,我們正在將向量的RAM使用量減少90%——我們很樂意接受這種折衷。

內存映射向量和索引

到目前爲止,我們只關注向量的內存佔用。然而,在上述顯著的大小縮減之後,限制因素變成了索引結構本身,因此我們也需要考慮它的內存需求。

Granne中的圖結構被緊湊地存儲爲每個節點具有可變數量鄰居的鄰接表。因此,在元數據存儲上幾乎不會浪費空間。索引結構的大小在很大程度上取決於結構參數和圖的屬性。儘管如此,爲了瞭解索引大小,我們可以爲40億個向量構建一個可用的索引,其總大小大約爲240GB。這在大型服務器上的內存裏使用是可能的,但實際上我們可以做得更好。

圖3:兩種不同的RAM/SSD佈局配置

Granne的一個重要特性是它能夠將索引和查詢向量進行內存映射。這使我們能夠延遲加載索引並在進程之間共享內存。索引和查詢文件實際上被分割成單獨的內存映射文件,可以與不同的SSD/RAM佈局配置一起使用。如果延遲要求不那麼嚴格,那麼可以將索引文件放在SSD上,將查詢文件放在RAM中,這仍然可以獲得合理的查詢速度,而且不需要付出過多的RAM。在這篇文章的最後,我們將看到這種權衡的結果。

提高數據局部性

在我們當前的配置中,索引放置在SSD上,每次搜索需要對SSD進行200到300次讀取操作。我們可以嘗試對元素相近的向量進行排序,從而增加數據的局部性,進而使它們的HNSW節點在索引中也緊密地排列在一起。數據局部性提高了性能,因爲單個讀取操作(通常獲取4KB或更多)更可能包含圖遍歷所需的其他節點。這反過來減少了我們在一次搜索中需要獲取數據的次數。

圖4:數據局部性減少了取數次數

應該注意的是,重新排列元素並不會改變結果,而僅僅是加速搜索的一種方法。這意味着任何順序都可以,但並不是所有的順序都會加快速度。很可能很難找到最優的排序。然而,我們成功使用的一種啓發式方法是根據每個查詢中最重要的單詞對查詢進行排序。

小結

在Cliqz,我們使用Granne來構建和維護一個數十億的查詢向量索引,用相對較低的內存需求實現了相似查詢搜索。表1總結了不同方法的需求。應該對搜索延遲的絕對數值持保留態度,因爲它們高度依賴於可接受的召回率,但是它們至少可以讓你對這些方法之間的相對性能有個大概的瞭解。

Baseline Quantization Granne (RAM only) Granne (RAM+SSD)
Memory 3000 + 240 GB 750 + 240 GB 80 + 240 GB 80-150 GB[11]
SSD - - - 240 GB
Latency 1 ms 1 ms 5 ms 10-50 ms
表1:不同設置下的延遲需求對比

我們想指出的是,這篇文章中提到的一些優化,並不適用於具有不可分解向量的一般近鄰問題。但是,它可以適用於任何元素可以由更小數量的塊(例如查詢和單詞)生成的情況。如果不是這樣,那麼仍然可以對原始向量使用Granne;它只是需要更多的內存,就像其他庫一樣。

英文原文:

Indexing Billions of Text Vectors

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