如何高效查询向量数据库:分层可导航小世界算法(HNSW)

40 阅读10分钟

如何高效查询向量数据库:分层可导航小世界算法(HNSW)

HNSW(Hierarchical Navigable Small World)可能是专为高维空间中的最近邻搜索而设计的最有效、最高效的索引方法之一。其核心思想是构建一个图结构,其中每个节点代表一个数据向量,边根据节点的相似性进行连接。HNSW 以这样一种方式组织图表,通过有效地浏览图表来找到近似的最近邻居,从而促进快速搜索操作。但在我们理解 HNSW 之前,必须先理解NSW(可导航小世界算法),它是 HNSW 算法的基础。【AI大模型教程】

什么是可导航图

如果我们将这些向量表示为图的顶点,则彼此靠近的顶点(即相似度高的向量)应该作为邻居连接。也就是说,即使两个节点没有直接连接,也应该可以通过遍历其他顶点到达它们。这意味着我们必须创建一个可导航的图(Navigable Graph)。更正式地说,为了使图可导航,每个顶点都必须有邻居;否则,就无法到达某些顶点。

此外,虽然拥有邻居有利于遍历,但同时,我们希望避免每个节点都有太多邻居的情况。理想情况下,我们希望有一个类似于小世界网络的可导航图,其中每个顶点只有有限数量的连接,并且两个随机选择的顶点之间的平均边遍历次数较低。

这种类型的图表对于大型数据集中的相似性搜索非常有效。

如果这一点清楚了,我们就能理解可导航小世界(Navigable Small World,NSW)算法是如何工作的。

这在搜索期间的内存、存储和计算复杂性方面可能会造成很大的成本。

NSW

NSW的构建

NSW 的第一步是图的构建,我们称之为G。这是通过随机打乱向量并以随机顺序插入顶点来构建图形来完成**的。**当向图G中添加新顶点V时,它会与图G中距离它最近的K 个现有顶点共享一条边。为了演示方便,这里假设 K=3。首先,我们插入第一个顶点A。由于此时图中没有其他顶点,因此A保持未连接状态。

接下来,我们添加顶点B,并将其连接到A ,因为A是唯一存在的顶点,并且无论如何它都会位于最接近的K个顶点之一。现在该图有两个顶点{A, B}。

接下来,当顶点C被插入时,它会同时连接到A和B。对于顶点,同样的过程也会发生D。

现在,当顶点E插入图中时,它仅连接到最近的K=3个顶点,在本例中是A、B和D。

这个顺序插入过程持续进行,逐步构建 NSW 图。好消息是,随着越来越多的顶点被添加,在图构建的早期阶段形成的连接可能会成为更长距离的链接,这使得通过很少的跳跃即可导航长距离。

NSW的搜索

在上面构建的NSW图G中,搜索过程采用简单的贪婪搜索方法进行,该方法在每一步都依赖于局部信息。假设我们想要找到下图中黄色节点的最近邻居:

为了开始搜索,会随机选择一个入口点,这也是该算法的精妙之处。换句话说,NSW 的一个关键优势在于可以从图中的任何顶点发起搜索G。假设我们选择节点A作为入口点:

在选定初始点之后,算法会迭代地寻找距离查询向量Q最近的邻居(即连通的顶点)。例如,在本例中,顶点A有邻居B,C,D,E。因此,我们将计算这些邻居与查询向量Q的距离(或相似度,无论你选择什么作为度量标准)。在这种情况下,节点C最近,因此我们从节点A移动到节点C。

节点C的未评估邻居仅为H,其结果更接近查询向量,因此我们现在移动到节点 H。

重复此过程,直到找不到更接近查询向量的邻居,这为我们提供了图中查询向量的最近邻居。基于 NSW 的搜索仍然是近似的,不能保证我们总能找到最近的邻居,并且它可能会返回非常次优的结果。

NSW的局限性

例如,考虑下面的图,其中节点A是入口点,黄色节点是我们需要最近邻的向量:

按照上述最近邻搜索的程序,我们将评估节点A的邻居: B和 C。显然,两个节点都比节点A距离查询向量更远。因此,该算法返回节点A作为最终的最近邻居。这个结果举例最优解显然还有很明显的距离。

为了避免这种情况,建议使用多个入口点重复搜索过程,这当然会消耗更多时间。

跳表(Skip List)

跳表的基本原理

在理解 HNSW 之前,我们需要先理解一下跳表(Skip List)

跳过列表,即跳表,是一种数据结构,可以高效地搜索已排序列表中的元素。它类似于链表,但增加了一层“跳过指针”,从而可以更快地遍历。

链表如下所示:

在跳过列表中,每个元素(或节点)包含一个值和一组前向指针,可以“跳过”列表中的多个元素。

这些前向指针在列表内创建了多个层,例如上图的 Layer 0, Layer 1, Layer 2。每个级别代表不同的“跳过距离”。一般情况下,使用概率方法决定每层必须保留的节点。基本思想是,节点以递减的概率包含在较高层中,从而导致较高级别的节点较少,而底层始终包含所有节点。更具体地说,在构建跳过列表之前,每个节点都会被随机分配一个整数L,该整数表示该节点在跳过列表数据结构中可以存在的最大层数。具体操作如下:

uniform(0,1)生成 0 到 1 之间的随机数。floor()将结果向下舍入为最接近的整数。是一个层乘数常数,用于调整层之间的重叠。增加此参数会导致更多重叠。例如,如果一个节点的 L=2,这意味着它必须存在于 Layer 0, Layer 1, Layer 2中。

跳表举例

现在,让我解释一下跳过列表如何加速搜索过程。假设我们想在这个列表中找到元素50。

如果我们使用典型的链表,我们将从第一个元素开始,然后逐个扫描每个节点以查看并检查它是否与50匹配。看看跳过列表如何帮助我们优化这个搜索过程。我们从顶层Layer 2开始,检查同一层中下一个节点对应的值,即65。

由于65>50, 它是一个单向链表,我们必须向下一级Layer 1进行查询,我们检查同一层中下一个节点对应的值,即36。

由于50>36和,移动到与值36相对应的节点是明智的。现在再次在Layer 1中检查同一层中下一个节点对应的值,即65。

由于65>50, 它是一个单向链表,我们必须向下一级。我们到达了Layer 0,可以按照平常的方式跳跃。如果我们在不构建跳跃表的情况下遍历链表,我们就会进行5跳跃:

但是使用跳过列表,我们可以在3跳跃中完成相同的搜索:

虽然在这个例子中,将跳数从减少5到3听起来可能不是一个很大的改进,但值得注意的是典型的矢量数据库有数百万个节点。因此,此类改进可以快速扩展并带来运行时优势。

HNSW

同样的,我们使用随机算法构建多层级的HNFW,一般情况下,先使用 与 NSW相同的构图算法构建 Layer 0,然后以一定的概率丢掉一些节点,得到 Layer 1,类似的,可以得到Layer 2, Layer 3等。为了方便我们以图例进行解释。假设我们通过这个步骤得到了以下HNSW 图:

让我们了解近似最近邻搜索的工作原理。假设我们想要找到下图中黄色向量的最近邻:

我们从顶层的入口点开始搜索Layer 2:

我们探索A的连通邻居,看看哪个节点与黄色节点最近。在这一层中,最近的节点是C。该算法贪婪地探索一层中顶点的邻域。在此过程中,我们始终朝着查询向量移动。当在某一层中找不到更接近查询向量的节点时,我们移动到下一层,同时将最近邻居(在本例中为C)视为下一层的入口点:

邻域探索的过程再次重复。我们探索C 的邻居并贪婪地移动到最接近查询向量的特定邻居:

同样,由于在Layer 1 中不存在更接近查询向量的节点,我们移动到下一层,同时将最近邻居(在本例中为F)视为下一层的入口点。但这次,我们达到了Layer 0。因此,在这种情况下将返回近似最近邻。当我们移至Layer 0并开始探索其邻域时,我们注意到它没有更接近查询向量的邻居:

因此,与节点 F 对应的向量作为近似最近邻返回,巧合的是,这也恰好是真正的最近邻。

HNSW 和 NSW的比较

在上述搜索过程中,仅经过2跳数即可返回查询向量的最近邻居。

让我们看看找到 NSW 的最近邻需要多少跳。为了简单起见,我们假设由 NSW 构建的图就是HNSW Layer 0 所表示的图:

我们之前以节点A作为入口点, 为了方便对比,我们也在这里同样选择 A。我们从节点A开始,探索其邻居,然后移动到E最接近查询向量的节点:

从节点E,我们移动到节点B,它比节点E更接近查询向量。

接下来,我们探索节点B的邻居,并注意到节点I最接近查询向量,因此我们现在跳到该节点:

由于节点I无法找到与其相连且比自身更近的任何其他节点,因此该算法返回该节点I作为最近邻居。

该算法不仅需要更多跳数(这里是 3)来返回最近邻,而且还返回了不太理想的最近邻。

相比之下,HNSW 经过的跳数更少,并返回了更准确、更优化的最近邻。