← 返回博客
跳至主要内容

使用 MUVERA 更高效的多向量嵌入

·阅读需 16 分钟
Roberto Esposito
Joon-Pil (JP) Hwang

Weaviate 1.31 实现了用于多向量嵌入的 MUVERA 编码算法。在本博文中,我们将深入探讨该算法的细节,包括 MUVERA 是什么、它的工作原理,以及它是否适合您的应用场景。

让我们先回顾一下什么是多向量模型,以及 MUVERA 旨在解决的挑战。

核心摘要
  • MUVERA 将多向量嵌入(如 ColBERT/ColPali)转换为单个固定尺寸的向量,从而大幅降低内存和计算成本
  • 在我们的测试中(基于 LoTTE 数据集):
    • 内存占用减少了约 70%
    • 导入时间从 20 多分钟缩短至 3-6 分钟
  • 关键权衡:
    • 召回质量会有所下降(可以通过增加 HNSW 的 ef 值来缓解)
    • 较高的 ef 值会降低查询吞吐量
  • 最适合以下场景:
    • 内存成本高昂的大规模部署
    • 可以容忍轻微召回率下降的用例
    • 需要更快索引速度的应用
  • Weaviate 1.31+ 版本已提供简单的配置选项

多向量嵌入面临的挑战

最先进的多向量模型通过捕捉比单向量模型更多的语义信息,可以显著提高检索性能。ColBERT 模型保留了文本中的 Token 级含义,而 ColPali/ColQwen 模型可以识别并保留来自图像不同部分的信息,例如 PDF 中的图表以及文本信息。

Single vector to multi-vector comparison
单向量与多向量对比

这些优势使得多向量模型非常适合许多用例。然而,由于其尺寸和相对复杂性,多向量嵌入与单向量嵌入相比,存在两个潜在的缺点。

挑战 1:内存占用

多向量嵌入包含多个向量,每个向量代表对象的一个部分,例如 Token(文本)或补丁 Patch(图像)。尽管多向量嵌入中的每个向量较小,但整体嵌入往往比典型的单向量嵌入更大。

Multi-vector embeddings memory comparison
多向量嵌入内存对比

这会导致使用时的内存占用更高,因为许多向量搜索系统使用像 HNSW 这样的内存索引。

大多少?如上图所示,多向量索引中的向量总数将比单向量索引多出 average_vectors_per_embedding / (ratio_of_vector_length) 倍。由于多向量嵌入每个文档可能包含数百到数千个向量,这个数值可能会非常庞大。

如果我们对 100 万个文档进行嵌入,每个文档约 100 个 Token,单向量嵌入模型(768 维单精度 32 位浮点数)可能需要 768 * 1M * 4 bytes = ~3.1GB 内存。另一方面,多向量嵌入模型(96 维)可能需要惊人的 96 * 100 * 1M * 4 bytes = ~40GB

Single vs multi-vector memory usage
单向量与多向量内存使用情况

当然,更高的内存使用也意味着更高的成本,无论是您自己的硬件还是通过 Weaviate Cloud 等云基础设施。

挑战 2:速度

由于尺寸和复杂性的增加,使用多向量嵌入的系统在导入和搜索速度方面也可能受到影响。

向量搜索涉及从海量嵌入中找到最相关的嵌入。HNSW 通过构建多层向量图来加速这一过程,从而快速导航到正确的向量区域并检索出结果。

多向量嵌入为此引入了额外的复杂性。在摄取时,每个对象必须有多个向量被索引到图中;在查询时,需要更多的比较来检索向量并计算它们的整体相似度。

给定文档嵌入 D 和查询嵌入 Q,通常使用 maxSim 算子来计算相似度:

sim(D,Q)=qQmaxdDqdsim(D,Q)=\sum_{q \in Q} \max_{d \in D} q \cdot d

这是一种非线性操作,它遍历每个查询 Token,并计算该查询 Token 与所有文档 Token 之间的相似度,选择最相似的一个。换句话说,MaxSim 会在所有文档术语中为查询术语搜索“最佳匹配”。

虽然这种方法很优雅,但这种计算是多向量嵌入特有的另一项开销。

为了提高多向量嵌入的性能,Google 的研究团队提出了 MUVERA(通过固定维度编码进行多向量检索),旨在通过使用固定编码来减少存储多向量嵌入的内存占用。

MUVERA 来救场

MUVERA 通过构建固定维度编码 (FDE) 将多向量嵌入编码为单向量嵌入,其长度与原始多向量嵌入中的向量数量无关。这减小了嵌入的尺寸,并提高了计算效率。

但是 MUVERA 是如何在最小化召回率损失的同时做到这一点的呢?

核心思路

MUVERA 将多向量嵌入 (DD) 作为输入,并将其转换为单向量嵌入 (dsingled_{single})

encode(xmulti)    xsinglex{D,Q}encode(x_{multi})\implies x_{single} \quad \quad x \in \{D, Q\}

为了判断这个函数是否“足够好”,我们希望最大化两个关键指标的相似度:

  • 编码后的嵌入 xsinglex_{single}(即单向量嵌入)的相似度,以及
  • 多向量嵌入 xmultix_{multi} 的相似度

这两者越相似,xsinglex_{single} 作为 xmultix_{multi} 的近似就越好。理想情况下,我们希望:

maxSim(D,Q)dsingleqsinglemaxSim(D,Q)\approx d_{single} \cdot q_{single}
wheredsingle=encode(D)qsingle=encode(Q)where \quad d_{single} = encode(D)\quad \land q_{single}=encode(Q)

这种转换将多向量嵌入的近似最近邻搜索问题简化为了仅涉及单向量嵌入的问题。

MUVERA high level overview
MUVERA 高层概览

回顾上面提到的 100 万个文档、每个文档 100 个向量的情况。如果没有 MUVERA 编码,我们将有 1 亿个向量需要索引。相反,使用 FDE 向量,我们将只需处理 100 万个向量,从而使 HNSW 图的尺寸仅为原来的 1%

那么,MUVERA 是如何实现这一目标的呢?

MUVERA 的工作原理

请记住,这里的主要目标是构建一个 FDE dsingled_{single},使其能够近似多向量嵌入 DD 的语义。

MUVERA 分四个步骤完成此操作:

  1. 空间分区 (Space partitioning)
  2. 降维 (Dimensionality reduction)
  3. 多次重复 (Multiple repetitions)
  4. 最终投影 (Final projection)

空间分区

第一步是将向量空间划分为 BB 个“桶 (buckets)”。这可以通过 K-Means 聚类或使用局部敏感哈希 (LSH) 函数来实现。

理想情况下,我们希望有一个分区函数 φ\varphi,对于给定的输入向量 xx,它将返回一个桶 ID k=φ(x)k=\varphi(x),其中 k=1,,Bk=1,\dots,B。以下是一个假设有 8 个聚类的示意图:

MUVERA steps 1 - space partitioning
MUVERA 步骤 1

MUVERA steps 2 - fill empty clusters
MUVERA 步骤 2

如图所示,每个向量都被分配给其中一个聚类。随后会进行两个进一步的计算——一个用于推导代表性(例如平均值或质心)子向量,另一个用于填充空聚类。

应该使用哪种函数进行空间分区?

正如我们所说,需要一个函数将 Rdim\mathbb{R}^{dim} 映射到 [B][B]

第一种方法可以是使用 K-Means 之类的聚类算法,它通过将点分配给最近的质心来对数据进行聚类。这种算法被广泛采用,但它有两个缺点:

  • 它需要一些数据进行训练
  • 它是“数据依赖型”的,其质量取决于数据的分布,而分布可能无法预先获知,并且可能会随时间发生偏移(例如在下游应用程序的整个生命周期中)

为了使分区函数独立于具体数据 (data oblivious),我们可能更倾向于局部敏感哈希 (LSH) 函数族。特别是 SimHash

SimHash 算法依赖于几个简单的步骤:

  1. 采样 ksimk_{sim}(由用户选择的参数)个高斯向量 g1,,gksimRdimg_1,\dots,g_{k_{sim}} \in \mathbb{R}^{dim}

  2. 计算向量 xx 最近的桶时,我们将计算 xx 与这 ksimk_{sim} 个向量的点积。根据这 ksimk_{sim} 个距离,如果对应距离大于 0,我们将第 i 位(i=1,,ksimi=1,\dots,k_{sim})设置为 1,否则设置为 0。

    从数学角度来看,我们有:

    φ(x)=(1(g1x),,1(gksimx)< fence="true">)\varphi(x)=\left(1(g_1 \cdot x), \dots,1(g_{k_{sim}}\cdot x)\right)
    where1(gix)=1ifgix>01(gix)=0otherwisewhere \quad 1(g_i \cdot x) = 1 \quad if \quad g_i \cdot x >0 \newline \quad \quad 1(g_i \cdot x) = 0 \quad otherwise

    φ(x)\varphi(x) 将是一个由 ksimk_{sim} 位组成的数字。我们可以拥有的桶总数为 B=2ksimB=2^{k_{sim}}

从现在开始,在对 MUVERA 算法的描述中,我们将考虑 SimHash 作为分区函数。

现在分区函数已经准备就绪,我们可以为每个桶 k=1,,Bk=1,\dots,B 创建一个子向量 dkd_k,如下所示:

dk=1Dφ1(k)dD,φ(d)=kdd_k = \frac{1}{|D \cap \varphi^{-1}(k)|} \sum_{d \in D, \varphi(d)=k}d

向量 dkd_k 包含了所有属于聚类 φ(d)=k\varphi(d)=k 的 Token 嵌入 dDd \in D 之和。此外,我们还有一个归一化因子,用于计算有多少个来自 DD 的 Token 嵌入被映射到了聚类 kk

空间分区步骤对于查询编码是相同的,除了没有乘法项。

qk=qQ,φ(q)=kqq_k = \sum_{q \in Q, \varphi(q)=k}q

我们为每个桶创建一个子向量 dkd_k,最终我们将得到 BB 个子向量 d1,,dBd_1,\dots,d_B。为了获得单个向量 dsingled_{single},我们将连接所有的 dkd_k 子向量。

dsingle=(d1,d2,,dB)d_{single} = (d_1,d_2,\dots,d_B)

现在让我们考虑一下结果的维度。假设原始 Token 嵌入的维度是 dimdim,那么 dsingled_{single} 的长度将为 BdimB* dim

在没有向量落入特定簇的情况下,会分配一个最近的向量给该簇来计算子向量,从而确保没有簇为空。

降维

为了减少对 Token 嵌入维度的依赖,下一步是进行降维。这通过应用随机线性投影使生成的子向量 dkd_k 变小,从而有助于管理最终编码向量的长度。

给定参数 dprojd_{proj},我们创建一个带有 ±1\pm1 的随机矩阵 Sdproj×dimS^{d_{proj} \times dim},用于降低每个子向量的维度。

对于每个 dkd_k,我们计算矩阵-向量乘法:

ψ(dk)=1dprojSdk\psi(d_k)=\frac{1}{\sqrt{d_{proj}}} \cdot S \cdot d_k

生成的子向量长度将为 dprojd_{proj}

现在 FDE 向量将变为:

dsingle=(ψ(d1),ψ(d2),,ψ(dB))d_{single}=(\psi(d_1), \psi(d_2),\dots,\psi(d_B))

注意,维度将不再是 BdimB*dim,而是 BdprojB *d_{proj},其中 dproj<<dimd_{proj} << dim

MUVERA steps 3 - dimensionality reduction
MUVERA 步骤 3

应用随机投影看起来可能很……随意!然而,这是使 MUVERA 生效的“秘诀”之一。原作者选择了服从 Johnson-Lindenstrauss 引理的随机矩阵特定分布,这反过来能够保留我们所关注的向量间的点积。

多次重复

为了提高近似步骤带来的准确性,我们可以多次重复上述过程(空间划分 + 降维),并将所有向量拼接在一起。

假设我们重复划分和降维步骤 RrepsR_{reps} 次,获得不同的单一向量,我们可以将它们拼接,从而只得到一个单一向量。

dsingle=(dsingle1,dsingle2,,dsingleRreps)d_{single}=(d_{single_1}, d_{single_2}, \dots, d_{single_{R_{reps}}} )

生成的向量维度将为 RrepsBdprojR_{reps}*B*d_{proj}

最终投影

拼接所有单一向量得到的向量可能会非常长。为了降低其维度,我们可以对整个向量应用最终的随机投影,其过程与步骤 2 中描述的相同。使用随机矩阵 Sdfinal×dimS^{d_{final} \times dim},其中 dfinald_{final} 是一个参数:

FDE(D)=ψfinal(dsingle)FDE(D)=\psi_{final}(d_{single})

这将再次降低最终 FDE 的维度。

MUVERA 参数

在算法描述过程中,我们看到了四个参数:

  • ksimk_{sim}:采样的正态分布向量数量,我们将拥有的桶(buckets)数量为 B=2ksimB=2^{k_{sim}}
  • dprojd_{proj}:代表空间中某个桶的子向量维度
  • RrepsR_{reps}:划分步骤和维度步骤执行的次数
  • dfinald_{final}:FDE 向量的最终维度 - FDE(D)RdfinalFDE(D)\in\mathbb{R}^{d_{final}}

说到底,这些是用户视角下的关键可调参数。在 Weaviate 的实现中,我们选择了能够满足大多数使用场景的合理默认值。

但一如既往,你可以根据特定需求进行调整。但在你动手之前,以下是来自我们内部测试的一些实际结果。

注意

在 Weaviate 的实现中,我们没有实现最终投影,因为我们发现与增加的步骤相比,它产生的好处有限。因此,用户可选的参数是 k_simd_projr_reps

MUVERA 的影响

那么 MUVERA 的效果是什么?换句话说——它能为你带来什么?

为了评估这一点,我们使用了 LoTTE 基准测试,特别是 lotte-lifestyle,它由大约 11.9万个文档 组成。每个文档都使用 colbertv2.0 编码,每个文档平均生成 130 个向量。

这将产生 1500万个 维度为 128 的向量。这意味着存储的浮点数总数将达到 19亿个,导致约 8 GB 的内存消耗。这看起来可能不算极端,直到你意识到这只是一个较小的数据集——而且这甚至还没有加上 HNSW 图本身的消耗!

另一方面,如果我们启用具有以下参数的 MUVERA:

  • ksim=4k_{sim}=4
  • dproj=16d_{proj}=16
  • Rreps=10R_{reps}=10

我们将使用一个维度为 2560 = 2^4*16*10 的向量来编码每个文档,最终存储 3.04亿个 浮点数。内存占用瞬间节省了近 80%

除此之外,启用 MUVERA 时,我们将拥有一个包含 11.9万 个节点的 HNSW(分层导航小世界图),而不启用它时,图将有 1500万 个节点,每个节点都有许多(例如 32-128)指向其他节点的边。以 maxConnections 值为 128 为例,这可以节省数十 GB 的内存。

看看下面来自我们实验的示例输出。我们比较了四种方案:

  • 原始多向量嵌入 + SQ
  • MUVERA 结合标量量化 (SQ)
Heap allocation without using MUVERA + sq vs. MUVERA + sq
不使用 MUVERA + sq 与使用 MUVERA + sq 的堆分配对比

Import time without using MUVERA + sq vs. MUVERA + sq
不使用 MUVERA + sq 与使用 MUVERA + sq 的导入时间对比

QPS without using MUVERA + sq vs. MUVERA + sq
不使用 MUVERA + sq 与使用 MUVERA + sq 的 QPS 对比

Recall without using MUVERA + sq vs. MUVERA + sq
不使用 MUVERA + sq 与使用 MUVERA + sq 的召回率对比

测试设置

实验已在以下机器上运行:

  • CPU/GPU: AMD Ryzen 7 PRO 8700GE w/ Radeon 780M 显卡
  • RAM: 64GB

明显的优势:内存与摄取速度

正如预期的那样,内存占用大幅减少,从基准的大约 12 GB 降至 MUVERA 的 1 GB 以下。

这对金钱意味着什么?嗯,在超大规模云服务商中,一台 12 GB 内存的服务器与一台 1 GB 内存的服务器之间的计算成本差异每年可能达到数万甚至数十万美元。

如果你的数据集大小在数千万或更多(正如我们许多用户的数据集那样),这可能已经是选择 MUVERA 的强大动力。

不仅如此,导入数据所需的时间在这里也大不相同。这里我们指的是对象数据被添加到 Weaviate 以及向量被添加到 HNSW 索引所花费的时间。由于添加多向量嵌入需要将数十或数百个向量添加到索引中,这会产生显著的开销。

使用 MUVERA,这一开销再次显著降低。在基准情况下,添加 LoTTE 数据集中的约 11万个对象需要 20 多分钟(即仅约 100 个对象/秒),但在各种 MUVERA 场景下,时间缩短到了约 3-6 分钟。

同样,在考虑大规模工作时——你可能会认为导入一百万个对象需要约 3 小时是可以接受的。如果不可接受,那可能是考虑 MUVERA 的另一个强大动机。

值得注意的是,在相同的 ef 值下,启用 MUVERA 也会增加查询吞吐量,因为每个对象只需要处理一个向量而不是多个向量。但是——这并不完全是同类比较。原因如下:

代价:召回率与查询吞吐量

正如我们在图表中看到的,MUVERA 的主要缺点是召回率方面的损失。这个缺点看起来可能特别具有挑战性,因为多向量模型最初正是因为能实现极高的召回率而备受青睐。

然而,其中一些损失可以通过 HNSW 搜索设置来缓解。如图所示,在查询设置中设置更高的 ef 值(例如 >512)可以将召回率提高到 80% 以上,在 2048 时超过 90%。

由于 ef 增加了检索到的候选集,它确实会产生减少查询吞吐量(以每秒查询数或 qps 衡量)的连锁反应。

换句话说——启用 MUVERA 的主要权衡将是召回率的降低,以及由于需要使用更高的 ef 值而导致的吞吐量相应降低。

事情从来没那么简单,对吧?😅 从这些图表中可以清楚地看出,使用 MUVERA 确实很有必要。然而,具体选择将很大程度上取决于你的优先级。

总结

MUVERA compared
MUVERA 比较

总之,MUVERA 为大规模处理多向量模型提供了一条引人注目的前进道路。

通过将多向量表示转换为固定维度的编码,它在保持相对较强的检索质量的同时,实现了显著的内存节省和查询速度提升。

Weaviate 对 MUVERA 的实现使得能够进一步通过量化压缩这些编码,以便进行大规模生产部署,极大地降低了多向量嵌入所需的成本和开销。

一如既往——使用 Weaviate 实现(自 Weaviate 1.31 起可用)可能是这一切中最简单的部分。你可能会惊讶地发现,只需几行代码即可在 Weaviate 集合中启用 MUVERA。

如果你的用例可以从多向量嵌入中受益,并且可能涉及不容忽视的数据集规模,那么 MUVERA 可能是适合你的解决方案之一。我们鼓励你尝试一下。

准备开始构建了吗?

请查看 快速入门教程,或使用 Weaviate Cloud (WCD) 的免费试用版构建令人惊叹的应用程序。

不想错过另一篇博文?

注册我们的双周时事通讯以保持更新!


提交后,我同意 服务条款 隐私政策.