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 位图,此列表不会消耗太多内存,并且由于我们从不更新它,因此可以与每个线程共享,而无需复制它 🎉
请注意,要插入的项目如何在左右分割节点之间保持平衡。在下一步中,我们将遵循相同的过程,并在分割节点上分割两个要插入的项目列表。
这一步是所有精彩内容发生的地方。从左到右:
- 第一个后代节点移除了项目
3
,并添加了项目2
。 - 第二个后代节点变得过大,必须替换为一个新的分割节点。
- 项目
8
成为一个包含项目7
和8
的后代。 - 在最后一个后代节点中删除项目
10
后,我们将其替换为直接指向一个项目的链接,以减少树中的节点数量(并通过减少查找次数来缩短搜索时间)。
既然您已经看到了我们增量索引过程的所有步骤,我们仍然要对所发生的事情做一些说明:
- 在前三个步骤中,我们必须读取所有树节点,这默认等于数据库中项目的总数。
- 在最后一步中,我们必须写入四个新的树节点。以前我们会重写整个树。写入操作是数据库中最慢的操作,现在已减少到最低限度。
- 该过程在单个树上工作,这意味着我们仍然可以对每棵树的计算进行多线程处理。
- ID 不再是顺序的,这与 并行写入树 中描述的 ID 生成策略不太兼容。
我们是如何解决 ID 生成问题的?
在索引过程开始时,我们需要收集所有现有的树 ID。
我们生成新节点所需的信息是:
- 用于知道何时停止构建树的树节点总数。
- 已用 ID 中的“空洞”,我们希望重复利用它们。这种碎片化是在我们编辑树时产生的。
- 树中存在的最高 ID,用于生成下一个新 ID。
“简单”的想法是为树节点总数设置一个计数器。我们可以递增这个计数器来生成新的 ID。最具挑战性的部分是选择一个在线程之间共享且没有大型互斥锁的可用 ID。
我花了相当长的时间才找到解决最后一点的方案;我实现了一个相当复杂的解决方案(150 行代码),但最终在一小时内将其 完全从头重写 成一个更简单、更高效的方案。我们将回顾在找到最终解决方案之前我们所经历的所有想法。
共享可用 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 位图分解成与要更新的树数量相同的小型 Roaring 位图。
以下函数可以将一个 Roaring 位图分割成 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 都分配给 第一棵 新树 可能会奏效(在生产环境中不进行监控很难百分百确定)。
最终解决方案
之前的解决方案很复杂,甚至无法完美运行。当我浏览 Roaring 位图的文档时,我看到了 这个 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。
考虑到这一点,我们在第2部分中向您展示的酷炫的 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 是一个开源搜索引擎,使开发者能够构建最先进的体验,同时享受简单直观的开发体验。
了解更多 Meilisearch 相关信息,您可以加入 Discord 社区或订阅 时事通讯。您可以通过查看 路线图 和参与 产品讨论 来了解更多产品信息。