向量相似性搜索 Part 4 —— HNSW 分层可导航小世界

向量相似性搜索 Part 4 —— HNSW 分层可导航小世界

引言

分层可导航小世界(HNSW)是一种用于近似搜索最近邻居的最先进算法。在其内部,HNSW构建了优化的图形结构,使其与之前讨论的其他方法非常不同。

HNSW的主要思想是构建这样一种图形,任何两个顶点之间的路径都可以在少量步骤内遍历。

关于著名的六度分隔规则的一个众所周知的类比与这种方法有关:所有人之间都有六个或更少的社交联系。

关键词: faiss,NN search, Nearest Neighbor Search, ANN, Approximate Nearest Neighbor
在这里插入图片描述

跳表

在继续讨论HNSW的内部工作之前,让我们首先讨论跳表和可导航小世界——HNSW实现中使用的关键数据结构。

skip list是一种概率数据结构,允许在排序列表中插入和搜索元素*O(logn)*一般。跳跃列表由多层链表构成。最底层有原始链表,其中包含所有元素。当移动到更高级别时,跳过的元素数量会增加,从而减少连接数量。

在这里插入图片描述
在跳跃列表中查找元素 20

某个值的搜索过程从最高级别开始,并将其下一个元素与该值进行比较。如果该值小于或等于该元素,则算法将继续处理下一个元素。否则,搜索过程下降到具有更多连接的较低层并重复相同的过程。最后,算法下降到最低层并找到所需的节点。

根据维基百科的信息,跳跃列表的主要参数p定义了一个元素出现在多个列表中的概率。如果某个元素出现在第 i层,那么它出现在第i+1层的概率等于p(p通常设置为 0.5 或 0.25 )。平均而言,每个元素都以*1 / (1 - p)*列表的形式呈现。

我们可以看到,这个过程比链表中普通的线性搜索要快得多。事实上,HNSW 继承了相同的思想,但它使用的不是链表,而是图。

可导航小世界(Navigable Small World)

可导航小世界是一个具有多对数**T = O(logᵏn)**搜索复杂度的图,它使用贪婪路由。

**路由(Routing)**是指从低度数顶点开始搜索过程,以高度数顶点结束的过程。由于低度顶点的连接很少,因此该算法可以在它们之间快速移动,以有效地导航到最近邻居可能所在的区域。

然后算法逐渐放大并切换到高度顶点,以找到该区域顶点中的最近邻居。

顶点Vertex有时也称为节点Node。

搜索

首先,通过选择入口点来进行搜索。为了确定算法要移动到的下一个顶点(或多个顶点),它会计算从查询向量到当前顶点的邻居的距离,并移动到最近的顶点。在某些时候,当算法无法找到比当前节点本身更接近查询的邻居节点时,算法会终止搜索过程。该节点作为查询的响应返回。

在这里插入图片描述
可导航小世界中的贪婪搜索过程。节点 A 用作入口点。它有两个邻居 B 和 D。节点 D 比 B 更接近查询。因此,我们移至 D。节点 D 有三个邻居 C、E 和 F。E 是距离查询最近的邻居,因此我们移至 E。最后,搜索过程将到达节点 L。由于 L 的所有邻居都比 L 本身更远离查询,因此我们停止算法并返回 L 作为查询的答案。

这种贪婪策略不能保证找到精确的最近邻居,因为该方法仅使用当前步骤的局部信息来做出决策。提前停止是该算法的问题之一。它尤其发生在搜索过程开始时,当时没有比当前节点更好的邻居节点。在大多数情况下,当起始区域有太多低度顶点时,可能会发生这种情况。

通过使用多个入口点可以提高搜索的准确性。
在这里插入图片描述提前停止。当前节点的两个邻居都离查询较远。因此,尽管存在离查询更近的节点,但算法仍返回当前节点作为响应。

构建

NSW 图是通过对数据集点进行打乱并将其逐个插入当前图中来构建的。插入新节点后,该节点通过边链接到距离它最近的M个顶点。

在这里插入图片描述
顺序插入节点(从左到右),M = 2。每次迭代时,都会将一个新顶点添加到图中并链接到其 M = 2 个最近邻居。蓝线表示与新插入节点连接的边。

在大多数情况下,长距离边很可能在图构建的开始阶段就被创建出来。它们在图导航中起着重要作用。

在构造开始时插入的元素的最近邻居的链接后来成为网络枢纽之间的桥梁,这些枢纽保持了整体图形的连通性,并允许在贪婪路由期间对跳数进行对数缩放。–
Yu. A. Malkov、DA Yashunin

从上图的例子中,我们可以看出一开始添加的长距离边AB的重要性。想象一下,一个查询需要从相对较远的节点A和 I 遍历一条路径。有了边AB,就可以直接从图的一侧导航到另一侧,从而快速完成此操作。

随着图中顶点数量的增加,新连接到新节点的边的长度变短的可能性也随之增加。

HNSW

HNSW基于与skiplist 调表和navigable small world (NSW) 可导航小世界相同的原理。它的结构代表了一个多层图,顶层的连接较少,底层的区域更密集。

搜索(Search)

搜索从最高层开始,每次在各层节点中贪婪地找到局部最近邻时,都会向下一级进行搜索。最终,在最低层找到的最近邻居就是查询的答案。

在这里插入图片描述
与 NSW 类似,HNSW 的搜索质量可以通过使用多个入口点来提高。不是在每一层上只找到一个最近邻居,而是找到efSearch (超参数)__ 与查询向量最近的最近邻居,并将这些邻居中的每一个用作下一层的入口点。

复杂度

原论文中,在任何层上找到最近邻居所需的操作数量都受到一个常数的限制。考虑到图中所有层的数量是对数的,我们得到的总搜索复杂度为O(logn)。

构建

选择最大层数

HNSW中的节点是按顺序一一插入的。每个节点都被随机分配一个整数l,表示该节点可以出现在图中的最大层。例如,如果l = 1,则只能在第 0 层和第 1 层上找到该节点。作者为每个节点随机选择l ,其具有由非零乘数 mL 归一化的指数衰减概率分布(exponentially decaying probability distribution)(mL= 0导致HNSW 中的单层和未优化的搜索复杂度)。通常,大多数l值应等于 0,因此大多数节点仅存在于最低级别。mL的较大值增加节点出现在较高层的概率。
在这里插入图片描述
每个节点的层数 l 是按指数衰减概率分布随机选择的。

在这里插入图片描述
基于归一化因子 mL 的层数分布。水平轴表示均匀(0, 1) 分布的值。

为了实现可控层次结构的最佳性能优势,不同层上的邻居之间的重叠(即也属于其他层的元素邻居的百分比)必须很小。— Yu. A. Malkov, D. A. Yashunin。

减少重叠的方法之一是减少mL。但重要的是要记住,减少mL平均也会导致在每层的贪婪搜索期间进行更多的遍历。这就是为什么必须选择一个能够平衡重叠和遍历次数的mL值。

该论文的作者建议选择mL的最佳值,即等于1 / ln(M)。该值对应于跳跃列表的参数p = 1 / M,即层之间的平均单个元素重叠。

插入

为节点分配值l后,其插入有两个阶段:

  1. 该算法从上层开始,贪婪地寻找最近的节点。然后找到的节点将用作下一层的入口点,并且搜索过程将继续。一旦到达第l层*,*插入就进入第二步。
  2. 从第l层开始,算法在当前层插入新节点。然后它的行为与之前步骤 1 中的相同,但不是只查找一个最近邻居,而是贪婪地搜索efConstruction(超参数)最近邻居。然后从efConstruction邻居中选择M 个,并构建从插入节点到它们的边。之后,算法下降到下一层,找到的每个efConstruction节点都充当入口点。当新节点及其边被插入到最低层 0 后,算法终止。

在这里插入图片描述
在 HNSW 中插入一个节点(蓝色)。新节点的最大层数随机选择为 l = 2。因此,节点将插入第 2、1 和 0 层。在每一层上,节点都将与其 M = 2 个最近邻居相连。

选择构造参数值

原始论文提供了关于如何选择超参数的一些有用的见解:

  • 根据模拟,M的最佳值在 5 到 48 之间。较小的M值往往更适合较低召回率或低维数据,而较高的 M 值更适合高召回率或高维数据。
  • efConstruction的值越高,意味着随着探索的候选者越多,搜索就越深入。然而,它需要更多的计算。作者建议选择这样一个efConstruction值,使训练期间的召回率接近0.95-1。
  • 此外,还有一个重要参数Mₘₐₓ——一个顶点可以拥有的最大边数。除此之外,还存在相同的参数Mₘₐₓ₀,但对于最低层Lay 0是单独的。建议为Mₘₐₓ选择接近2 * M的值。大于2 * M的值可能会导致性能下降和内存使用过多。同时,Mₘₐₓ = M导致在高召回率下表现不佳。
候选者启发式选择

上面注意到,在节点插入期间,选择efConstruction候选者中的M个来为它们构建边。让我们讨论选择这M 个节点的可能方法。

朴素方法采用M个最接近的候选者。然而,它并不总是最佳选择。下面是一个演示它的例子。

想象一个具有下图结构的图表。如您所见,共有三个区域,其中两个区域未相互连接(左侧和顶部)。因此,例如,从A点到B点需要穿过另一个区域的很长的路径。以某种方式连接这两个区域以实现更好的导航是合乎逻辑的。

在这里插入图片描述
​ 节点 X 被插入到图中。目标是将其最佳地连接到其他 M = 2 点。

然后将节点X插入到图中,并且需要链接到M = 2 个其他 顶点。

在这种情况下,朴素方法直接采用M = 2 个最近邻居(B和C)并将X连接到它们。尽管X与其真正的最近邻居相连,但这并不能解决问题。让我们看看作者发明的启发式方法。

启发式不仅考虑节点之间的最近距离,还考虑图上不同区域的连通性。

启发式选择第一个最近邻居(在我们的例子中为B)并将插入的节点 (X) 连接到它。然后,该算法按顺序选取另一个最接近的最近邻居 ©,并仅当该邻居到新节点 (X) 的距离小于从该邻居到所有已连接顶点的任何距离时,才为其构建一条边(B) 到新节点 (X)。之后,算法继续到下一个最近邻,直到建立M条边。

回到示例,启发式过程如下图所示。启发式选择B作为 X 的最近邻并构建边BX。然后算法选择C作为下一个最近的最近邻。然而,这次BC < CX。这表明将边CX添加到图中并不是最佳的,因为已经存在边BX并且节点B和C彼此非常接近。对于节点D和E进行相同的类比。之后,算法检查节点A。这次,它满足BA > AX 的条件。结果,新边AX和两个初始区域彼此连接。

在这里插入图片描述
左边的例子使用了朴素方法。右侧的示例使用启发式选择,这会导致两个初始不相交区域相互连接。

复杂度

与搜索过程相比,插入过程的工作方式非常相似,没有任何可能需要非常数操作的显着差异。因此,插入单个顶点需要O(logn)的时间。为了估计总复杂度,应考虑给定数据集中所有插入节点n的数量。最终,HNSW 构建需要O(n * logn)时间。

将 HNSW 与其他方法相结合

HNSW 可以与其他相似性搜索方法一起使用,以提供更好的性能。最流行的方法之一是将其与倒排文件索引和乘积量化 ( IndexIVFPQ ) 结合起来,这在本系列文章的其他部分中进行了描述。

在此范例中,HNSW 扮演IndexIVFPQ 粗量化器(coarse quantizer)的角色,这意味着它将负责查找最近的 Voronoi 分区,因此可以缩小搜索范围。为此,必须在所有 Voronoi 质心上构建 HNSW 索引。当给出查询时,HNSW 用于查找最近的 Voronoi 质心(而不是像以前那样通过比较到每个质心的距离进行强力搜索)。之后,查询向量在相应的 Voronoi 分区内进行量化,并使用 PQ 码计算距离。

在这里插入图片描述
通过在 Voronoi 质心之上构建 HNSW 中的最近邻居来选择最近的 Voronoi 质心。

当仅使用倒排文件索引时,最好不要将 Voronoi 分区的数量设置得太大(例如 256 或 1024),因为会执行强力搜索来查找最近的质心。通过选择较少数量的 Voronoi 分区,每个分区内的候选数量会变得相对较大。因此,该算法可以快速识别查询的最近质心,并且其大部分运行时间都集中在查找 Voronoi 分区内的最近邻居上。

然而,将 HNSW 引入工作流程需要进行调整。考虑仅在少量质心(256 或 1024)上运行 HNSW:HNSW 不会带来任何显著的好处,因为在向量数量较少的情况下,HNSW 在执行时间方面的表现与简单的蛮力搜索相对相同。此外,HNSW 需要更多内存来存储图结构。

这就是为什么在合并 HNSW 和倒排文件索引时,建议将 Voronoi 质心的数量设置得比平时大得多。这样,每个 Voronoi 分区内的候选数就会变得小得多。

这种模式的转变导致了以下设置:

  • HNSW 可以在对数时间内快速识别最近的 Voronoi 质心。
  • 之后,在各个 Voronoi 分区内进行详尽搜索。这应该不是什么麻烦,因为潜在候选者的数量很少。

Faiss 实践

# 创建HNSW索引
M = 16        # 每个节点的最大出边数
ef_construction = 200  # 构建时的候选邻居数
index_hnsw = faiss.IndexHNSWFlat(dimension, M)
index_hnsw.hnsw.efConstruction = ef_construction

# HNSW不需要训练阶段,直接添加向量
start_time = time.time()
index_hnsw.add(database_vectors)
end_time = time.time()
print(f"HNSW索引构建时间: {end_time - start_time:.4f} 秒")
print(f"HNSW索引中的向量数量: {index_hnsw.ntotal}")

# 设置搜索参数
ef_search = 128  # 搜索时的候选邻居数,影响召回率和速度
index_hnsw.hnsw.efSearch = ef_search

# 执行搜索
start_time = time.time()
distances_hnsw, indices_hnsw = index_hnsw.search(query_vectors, k)
end_time = time.time()

print(f"HNSW查询耗时: {end_time - start_time:.4f} 秒")
print("\nHNSW前3个查询结果:")
for i in range(3):
    print(f"查询向量 {i}:")
    print(f"  距离: {distances_hnsw[i]}")
    print(f"  索引: {indices_hnsw[i]}")

# 计算与精确结果的召回率
correct_hits = 0
for i in range(num_queries):
    for j in range(k):
        if indices_hnsw[i][j] in indices[i]:
            correct_hits += 1

recall = correct_hits / total_hits
print(f"\nHNSW召回率: {recall:.4f}")

关键参数说明:

  • M参数:控制图中每个节点的最大出边数量。较大的M值提高搜索精度但增加内存消耗和构建时间。典型值为16-64。
  • efConstruction:构建索引时使用的候选邻居数量。较大的值提高索引质量但增加构建时间。典型值为100-500。
  • efSearch:搜索时的候选集大小。较大的值提高召回率但降低搜索速度。典型值从50开始,可根据需要增加。

结论

在本文中,我们研究了一种鲁棒算法,该算法特别适用于大型数据集向量。通过使用多层图表示和候选启发式选择,其搜索速度可以有效扩展,同时保持良好的预测精度。还值得注意的是,HNSW 可以与其他相似性搜索算法结合使用,使其非常灵活。

Reference

所有图片来源 原文链接

https://vividfree.github.io/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/2017/08/05/understanding-product-quantization

https://yongyuan.name/blog/ann-search.html

https://towardsdatascience.com/similarity-search-product-quantization-b2a1a6397701/

https://www.pinecone.io/learn/series/faiss/

https://github.com/facebookresearch/faiss

原论文
Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs IEEE Trans

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值