如何在不到一分鐘內,讓 Meilisearch 更新具有數百萬向量嵌入的資料庫
我們如何在向量儲存中實作增量索引。

這是系列部落格文章的第 4 部分 最初發表於 Clément Renault 的部落格。從 第 1 部分、 第 2 部分和 第 3 部分開始旅程。這篇部落格文章假設您已經閱讀過第 1 部分和第 2 部分。
在這篇部落格文章中,我們將討論如何在 Arroy 中實作增量索引。增量指的是,當在樹狀結構中插入或刪除項目時,我們只需更新所需的部分,而無需重建所有內容。
在 Meilisearch,這至關重要,因為我們的使用者通常會定期傳送更新,而且他們的內容不斷變化。從頭開始重建所有內容的成本非常高,而且不會帶來良好的體驗;這就是為什麼我們在最新版本上花費了大量時間,使所有資料結構都支援增量索引,而 arroy 就是其中之一,我們也必須實作它!
理論
以下架構顯示了將文件新增和刪除到現有樹狀結構時,應發生的狀況的高階視圖。
提醒一下,arroy 是一個基於 LMDB 的向量儲存,LMDB 是一個內嵌的交易式記憶體映射鍵值儲存。它將您的向量儲存在由三種節點組成的樹狀結構中
- 分割節點 - 儲存關於其左右之間分散的項目 ID 的資訊。
- 後代節點 - 儲存多個項目 ID。
- 項目 - 單一向量。
對於以下範例,我們將假設後代節點不能包含超過兩個項目,並且 ID 越接近,項目在空間中就越接近。例如,項目1
與項目2
接近,但離項目10
較遠。
在這裡,我們嘗試插入項目2
和7
,並刪除項目3
和11
。
按照我們在搜尋中所做的操作,我們將要插入的項目分割在左右分割節點之間。但是,已刪除的項目不再存在於資料庫中!我們沒有它們相關的向量,因此我們需要迭代所有葉子節點來找到並刪除它們。
幸運的是,由於我們使用 Roaring Bitmap,此列表不會消耗太多記憶體,而且由於我們從不更新它,我們可以與每個執行緒共用它,而無需複製它 🎉
請注意要插入的項目如何在左右分割節點之間取得平衡。在下一步中,我們將遵循相同的流程,並在分割節點上分割兩個要插入的項目列表。
在這一步中,所有好事情都發生了。從左到右
- 第一個後代節點的項目
3
被移除,並新增了項目2
。 - 第二個後代節點變得太大,必須由新的分割節點取代。
- 項目
8
成為一個後代,其中包含項目7
和8
。 - 在刪除最後一個後代節點中的項目
10
之後,我們將其替換為指向項目的直接連結,以減少樹狀結構中的節點數量(並透過減少查詢次數來縮短搜尋時間)。
現在您已經了解了我們增量索引流程的所有步驟,我們仍然要對發生的事情做一些筆記
- 在前三個步驟中,我們必須讀取所有樹狀節點,預設情況下,這等於資料庫中的項目總數。
- 在最後一步中,我們必須寫入四個新的樹狀節點。以前我們會重寫整個樹狀結構。寫入操作是資料庫中最慢的操作,其數量已減少到最低限度。
- 該流程在單個樹狀結構上運作,這表示我們仍然可以對每個樹狀結構的計算進行多執行緒處理。
- ID 不再是循序的,這與並行寫入樹狀結構中描述的 ID 產生策略不相容。
我們如何修正 ID 產生?
在索引流程開始時,我們需要收集所有現有的樹狀結構 ID。
我們產生新節點所需資訊是
- 用於了解何時停止建構樹狀結構的樹狀節點總數。
- 我們想要重複使用的已用 ID 中的「洞」。當我們編輯樹狀結構時,會產生這種片段。
- 樹狀結構中存在的最高 ID,以產生下一個新 ID。
「簡單」的想法是為樹狀節點總數設定一個計數器。我們可以遞增該計數器以產生新的 ID。最困難的部分是挑選一個可在執行緒之間共用且沒有大型互斥鎖的可用 ID。
我花了一段時間才找到解決最後一點的方法;我實作了一個非常複雜的解決方案 (150loc),我最終在一個小時內從頭開始完全重寫,得到一個簡單且更有效率的解決方案。我們將了解我們在找到最終解決方案之前所經歷的所有想法。
共用可用 ID 的迭代器
首先想到最直接的想法是建立並共用所有可用 ID 的迭代器。
let mut available_ids = available_ids.into_iter(); roots.into_par_iter().for_each(|root| { // stuff let next_id = available_ids.next(); // stuff });
如果您熟悉 Rust,您會立即注意到,for_each
無法擷取 available_ids
的可變參考,因為我們使用 Rayon 的 into_par_iter
方法,該方法會以多執行緒方式執行迭代器上的下一個方法。這表示我們無法呼叫 .next()
。
透過使用互斥鎖(互斥 的簡稱),我們可以使其編譯
let mut available_ids = Mutex::new(available_ids.into_iter()); roots.into_par_iter().for_each(|root| { // stuff let next_id = available_ids.lock().unwrap().next(); // stuff });
這是安全的,並且會產生正確的結果,但由於所有執行緒都必須等待鎖上的彼此,因此無法良好擴展。
讓每個執行緒擁有自己的 ID 清單
當您需要移除鎖定時,常見的解決方案是事先分割工作,以便每個執行緒都能充分發揮其運算能力,而無需知道其他執行緒上發生的事情。這是我最初實作的解決方案。這個想法是將 Roaring Bitmap 展開為與要更新的樹狀結構一樣多的較小的 Roaring Bitmap。
以下函式可以將 Roaring Bitmap 分割成大小相等的 n
個子位元圖
pub fn split_ids_in(available: RoaringBitmap, n: usize) -> Vec<RoaringBitmap> { // Define the number of elements per sub-bitmap let chunk_size = available.len() / n as u64; let mut ret = Vec::new(); let mut iter = available.into_iter(); for _ in 0..(n - 1) { // Create a roaring bitmap of `chunk_size` elements from the iterator without consuming it let available: RoaringBitmap = iter.by_ref().take(chunk_size as usize).collect(); ret.push(available); } // the last element is going to contain everything remaining ret.extend(iter); ret }
有了此函式,就可以很簡單地使用每個樹狀結構的一個位元圖來使用 Rayon 並行迭代器上的 zip
方法進行更新
let available_ids = split_ids_in(available_ids, roots.len()); roots.into_par_iter().zip(available_ids).for_each(|(root, available_ids)| { let mut available_ids = available_ids.into_iter(); // stuff let next_id = available_ids.next(); // Here, we can use the available_ids without any locks or inter-thread synchronization // stuff });
當我們更新已存在的根樹狀結構時,這效果很好。但稍後,我們可能想要從頭開始建立新的樹狀結構,而且我們無法事先知道我們需要建立多少新的樹狀結構。這是一個問題,因為如果沒有此資訊,我們就不知道需要建立多少個子位元圖。
此時,我看不出有什麼簡單的方法可以解決這個問題,但我假設我們很少建立許多新的樹狀結構,而且所有新的樹狀結構都會使用大量的 ID。這表示,將所有 ID 給第一個新的樹狀結構可能會成功(如果沒有在生產環境中監控它,很難 100% 確定)。
最終解決方案
先前的解決方案很複雜,甚至沒有完美運作。當我瀏覽 Roaring Bitmap 的文件時,我看到了 select
方法,並立即了解我如何讓最初的方法運作。
此方法可讓您以有效的方式,依索引取得位元圖中的值。
例如,在一個位元圖中,若有以下數值:13, 14, 15, 98, 99, 100
select(0)
會回傳13
。select(3)
會回傳98
。select(5)
會回傳100
。select(6)
會回傳None
。
考量到這一點,如果我們將位元圖以唯讀方式分享給所有執行緒,並將位元圖中的索引以原子變數的方式共享,我們就可以讓多個執行緒同時以無鎖同步的方式取得可用的 ID。
一旦 select
方法回傳 None
,就表示我們可以停止從位元圖中挑選 ID,而是直接用舊方法從頭產生新的 ID。
即使呼叫 select
方法的速度非常快,仍然需要一些時間,因此一旦執行緒從該方法取得 None
回傳值,我們就會更新一個原子布林值,告訴我們是否位元圖中還有數值可以查找。如果沒有,我們就可以略過呼叫 select
,直接產生新的 ID。
基於以上考量,我們在第二部分展示的酷炫 ConcurrentNodeIds
結構變得稍微複雜了一些,但它仍然可以讓我們在沒有任何鎖定的情況下公平地產生 ID!
/// A concurrent ID generator that will never return the same ID twice. pub struct ConcurrentNodeIds { /// The current tree node ID we should use if no other IDs are available. current: AtomicU32, /// The total number of tree node IDs used. used: AtomicU64, /// A list of IDs to exhaust before picking IDs from `current`. available: RoaringBitmap, /// The current Nth ID to select in the bitmap. select_in_bitmap: AtomicU32, /// Tells if you should look in the roaring bitmap or if all the IDs are already exhausted. look_into_bitmap: AtomicBool, } impl ConcurrentNodeIds { /// Creates an ID generator returning unique IDs, avoiding the specified used IDs. pub fn new(used: RoaringBitmap) -> ConcurrentNodeIds { let last_id = used.max().map_or(0, |id| id + 1); let used_ids = used.len(); let available = RoaringBitmap::from_sorted_iter(0..last_id).unwrap() - used; ConcurrentNodeIds { current: AtomicU32::new(last_id), used: AtomicU64::new(used_ids), select_in_bitmap: AtomicU32::new(0), look_into_bitmap: AtomicBool::new(!available.is_empty()), available, } } /// Returns a new unique ID and increases the count of IDs used. pub fn next(&self) -> Result<u32> { if self.look_into_bitmap.load(Ordering::Relaxed) { let current = self.select_in_bitmap.fetch_add(1, Ordering::Relaxed); match self.available.select(current) { Some(id) => Ok(id), None => { self.look_into_bitmap.store(false, Ordering::Relaxed); Ok(self.current.fetch_add(1, Ordering::Relaxed)) } } } else { Ok(self.current.fetch_add(1, Ordering::Relaxed)) } } }
實際效能
我尚未執行大量的基準測試(我很想做,而且可能會稍後更新這篇文章),但在我針對數十萬個項目執行的基準測試中,以下是我得到的結果
- 平均而言,在三個批次的項目索引完成後,我們的速度提升了 10 倍以上。
- 我們觀察到之後從約 700 毫秒上升到約 3 秒的小幅增長,是由於項目數量增加時會建立新的樹狀結構所導致。
- 我認為這個基準測試代表了最壞情況。
- 我所有的項目 ID 都是隨機產生的,這表示重複/更新非常少,但總是會有新的插入和更多的寫入。
- 我的向量也是以均勻分佈隨機產生的,這表示不會像真實數據集中觀察到的那樣出現叢集。
此功能現在已在生產數據上使用,處理數百萬個文檔,我們看到在不到一分鐘的時間內,在 200 萬個項目的數據庫中增加了約 9000 個項目。
Meilisearch 是一個開源搜尋引擎,讓開發人員能夠打造最先進的使用者體驗,同時享受簡單直觀的 DX。
若想了解更多關於 Meilisearch 的資訊,您可以加入 Discord 社群,或訂閱電子報。您可以查看路線圖並參與產品討論來進一步了解該產品。