1. 召回阶段的利器
在推荐系统和RAG(Retrieval-Augmented Generation)系统中,召回阶段是检索大量候选项的关键环节。HNSW(Hierarchical Navigable Small World)作为一种高效的近似最近邻搜索(ANN)方法,在这些系统的召回阶段发挥重要作用。
1.1 推荐系统中的召回阶段
召回阶段用于从海量候选项(如商品、电影、文章等)中选取较小的一部分,以供后续排序阶段进一步筛选。
传统最近邻搜索(NNS)方法计算复杂度高,特别是在大规模数据集上难以实时响应,因此通常使用近似最近邻搜索(ANN)。
HNSW通过构建多层小世界图结构,使得查询时能够快速找到近似最近邻,比传统方法(如暴力搜索或k-d树)更高效,适合处理大规模数据。
例如,在个性化推荐系统中,当用户点击某个商品或文章时,系统可以利用HNSW快速找到与该物品相似的候选集合。
1.2 RAG系统中的召回阶段
RAG系统需要从外部知识库(如文档数据库、知识图谱、向量数据库)中检索相关内容,以增强生成式模型的回答能力。
由于文本嵌入通常存储为高维向量,计算精确最近邻(NNS)代价昂贵,因此通常采用ANN(Approximate Nearest Neighbor search,近似最近邻搜索)方法,如HNSW(Hierarchical Navigate Small Word)、FAISS等。
HNSW在RAG的向量检索任务中用于高效地从知识库中找出与用户问题相关的文本块,提高查询效率和生成回答的质量。
例如,在基于RAG的问答系统中,当用户提出问题时,系统会先计算问题的向量表示,然后利用HNSW从知识库中检索最相关的文档,最终将检索结果输入LLM(大语言模型)进行回答。
无论是在推荐系统还是RAG系统,召回阶段都是高效信息检索的关键,而HNSW作为一种流行的ANN方法,能够在大规模数据上实现低延迟、高召回率的近似最近邻搜索。
2. HNSW
2.1 HNSW基础
向量搜索索引通常分为以下四类:
-
基于哈希(Hash-based)
-
基于树(Tree-based)
-
基于聚类(Cluster-based)
-
基于图(Graph-based)
HNSW 属于基于图(Graph-based)的索引算法。它结合了两个核心概念:
概率跳表(Probability Skip List)
可导航小世界(Navigable Small World, NSW)图
2.1.1 概率跳表(Probability Skip List)
1. 传统链表的局限性
在计算机科学中,链表(Linked List)是一种基本的数据结构,其中每个元素(节点)都包含一个指向下一个元素的指针。然而,链表的一个主要问题是随机访问的时间复杂度是 O(n)。这意味着如果我们想在链表中查找某个元素,需要逐个遍历,效率较低。
2. 跳表(Skip List)的改进
跳表是一种改进版的链表,它通过引入多个层级(Level)来加速查找过程:
顶层索引连接跨度更大的节点,可以快速跳跃,减少搜索路径长度;
随着层级下降,索引的跨度逐渐变短,提供更精确的访问路径;
最底层是完整的原始链表,包含所有元素。
这种设计可以将查找时间复杂度从 O(n) 降低到 O(log n),但代价是增加了一些存储开销(从 O(n) 变为 O(n log n))。
在跳表中,我们可以看到:
最高层的链表跨越多个节点,提供长跳跃,使得搜索变得高效。
每下降一层,索引的跨度变短,逐渐精细化查找,最终在最底层找到目标元素。
(1)跳表的搜索过程
搜索从最上层开始,每次选择下一个节点,直到遇到比目标值 大的节点。
然后回退到上一个节点,并进入下一层继续搜索。
直到到达最底层,找到目标元素。
这种跳跃式搜索方式极大地减少了搜索所需的步骤,使得查询复杂度从 O(n)(单链表) 降至 O(log n)。
(2)跳表的插入过程
插入过程是概率性的:
-
先确定该元素首次出现在哪一层。
-
每一层的元素出现在更高层的概率是固定的(p)。
-
如果元素首次出现在层 l,它也会被加入到 l-1, l-2… 这些更底层。
平衡性分析:虽然可能出现某一层的索引结构不够理想(类似普通链表),但这种概率极低,因此跳表在大多数情况下都能保证高效的搜索性能。
2.1.2 可导航小世界(NSW)
跳表让我们理解了如何通过多层索引提高搜索速度,但 HNSW 还使用了可导航小世界(Navigable Small World, NSW)图,它是一个基于图的近似最近邻搜索(ANN)算法。
(1)NSW 的基本概念
NSW 主要用于在数据集中查找近似最近邻,其核心思想是:
每个节点在网络中有不同的连接,包括短程(短距离)、中程(中距离)和长程(长距离)连接。
查询时,我们从某个预定义的入口点出发,依次评估相邻节点,并跳跃到最接近目标的节点。
这个过程持续进行,直到找到最近邻。
(2)NSW 的搜索过程
-
从某个固定的入口(蓝色)点开始搜索(类似跳表的顶层)。
-
贪心选择当前与目标最相近的节点,并向其连接的下一个节点跳跃。
-
重复步骤 2,直到找到最接近查询向量的点。
这种搜索方式被称为贪心搜索(Greedy Search),其优势在于:
避免了暴力搜索的 O(n) 复杂度
通过长距离跳跃,快速接近目标
(3)NSW 在大规模数据集上的局限性
适用于小型 NSW(几百到几千个节点),但对于大规模数据(百万级),贪心搜索的效果会下降。
解决方法:
增加节点的连接数(短、中、长范围连接),增强全局可达性。
但这也会导致网络复杂度上升,进而增加搜索时间。
极端情况:如果每个节点都与所有其他节点连接,那么 NSW 退化为暴力搜索(O(n)),失去优化的意义。
2.1.3 NSW 如何应用于向量搜索
(1)NSW 适用于向量搜索
设想数据集中的所有向量都是 NSW 图中的节点。
长距离连接对应于不相似的向量,可以帮助快速跳跃到不同区域。
短距离连接对应于相似的向量,可以帮助精细搜索。
采用相似性度量(如 L2 距离、内积等)来决定相邻节点的连接方式。
(2)NSW 搜索示例
给定一个查询向量,我们从某个固定的入口点开始搜索。
逐步沿着最相似的向量跳跃,逐步收敛到最近邻。
2.2 HNSW详解
2.2.1 HNSW 背景
在进行向量搜索(Vector Search)或向量相似性搜索(Vector Similarity Search)时,我们通常面对数亿甚至数十亿个向量。普通的 NSW(Navigable Small World)在小规模数据上运行良好,但当数据规模增长时,NSW 结构的效率会降低,因此我们需要更好的图结构来提升搜索速度和准确性。
HNSW(分层可导航小世界)通过引入跳表(Skip List)的概念来扩展 NSW,使其适用于大规模数据集。HNSW 的关键思想是在 NSW 图的基础上构建多个层级:
• 最高层(Layer 2) 仅包含少量节点,并且具有长距离连接,使得搜索能够跨越较大的数据空间。
• 底层(Layer 0) 包含所有节点,连接距离较短,提供精细的最近邻搜索能力。
• 搜索过程从最上层开始,逐步向下层推进,最终在最底层找到最接近的邻居。
2.2.2 HNSW 结构解析
HNSW 结合了 NSW 图结构 和 跳表的分层策略,形成了一个高效的 ANN 搜索算法。
(1)HNSW 分层结构
上图展示了 HNSW 的多层结构:
Layer 2(最顶层): 仅包含少量节点,节点之间的连接跨度较大。
Layer 1(中间层): 连接密度更高,搜索精度提高。
Layer 0(底层): 包含所有节点,提供精确的最近邻搜索。
特性: 层级越高,节点数越少,但连接跨度越大,使得搜索可以先进行大范围跳跃,然后逐步精细化查找,最终找到最近邻。
(2)HNSW 搜索过程
• 步骤 1:从最高层(Layer 2)开始
• 选择一个预定义的入口点,在该层进行贪心搜索,找到当前层最接近查询向量的节点。
• 步骤 2:向下一层(Layer 1)移动
• 在较低层中,以上一层找到的最接近点作为起点,继续进行贪心搜索。
• 步骤 3:继续向底层(Layer 0)推进
• 这一过程不断重复,直到到达底层,在最精确的索引级别找到最近邻。
这种逐层向下的搜索方式借鉴了跳表的思想,使得搜索过程兼顾了全局跳跃能力(高层搜索)和局部精细查找能力(底层搜索)。
2.2.3 HNSW 的插入过程
HNSW 的插入过程与跳表类似:
- 在最高层查找最近邻
在最上层(例如 Layer 2),找到与新向量 v 最接近的邻居。
- 逐层向下查找最近邻
依次进入下一层(Layer 1、Layer 0),重复搜索过程,确保在各个层级都找到最近邻。
- 确定连接方式
设定最大连接数 M(HNSW 论文中使用 M=16 或 M=32),决定每个节点最多可以连接多少个邻居。
通常,邻居选择为当前层中最接近的 M 个节点,但也可以采用其他启发式策略。
注意: HNSW 不会强制约束同一层中节点之间的最小距离,因此可能会出现图结构不均衡的情况。但随着数据规模增大,这种情况的概率极低。
2.2.4. HNSW 层级控制
随机性:
在 HNSW 中,一个向量出现在高层的概率是指数递减的:
论文使用的计算公式是: floor ( − ln ( rand ( 0 , 1 ) ) ) \text{floor}(-\ln(\text{rand}(0,1))) floor(−ln(rand(0,1)))
其中,rand(0,1) 是 [0,1] 范围内的随机数,服从均匀分布。
这个公式意味着:
大多数节点仅出现在底层(Layer 0)。
极少数节点会出现在高层(Layer 1, Layer 2),从而起到”索引枢纽”的作用,连接更多远距离的点。
这与跳表的分层概率策略相似,使得搜索时能够快速跳跃,同时保证局部的精确性。
HNSW 采用层次结构(multi-layer hierarchy),每个向量被插入时,需要决定它会出现在哪些层。这由参数 L(层数) 和 m_L(level multiplier) 控制。
(1)层级分配的概率
每个向量的插入层数 由一个概率函数决定。
m_L(level multiplier) 是一个归一化因子,影响层级分配的概率:
m_L ≈ 0 时,所有向量都插入到Layer 0(底层),没有多层结构。
m_L 较大 时,更多向量会被分配到更高层。
经验法则(Rule of Thumb):
HNSW 的研究者发现最佳性能通常在 m L = 1 / l n ( M ) m_L = 1/ln(M) mL=1/ln(M)时取得,其中 M 是最大邻居数。
(2)层级分配的影响
降低 m_L(更多向量只出现在底层):
可以减少不同层之间的共享邻居(overlap)。
但会增加搜索路径的平均长度(因为高层更少)。
增加 m_L(更多向量分布在不同层):
能减少搜索的遍历次数,提高速度。
但不同层之间的邻居可能会重复,增加存储和计算成本。
2.2.5 HNSW 关键优化点
利用层级结构减少搜索路径:先在高层进行大范围搜索,再逐层向下细化搜索。
控制连接数 M 以优化效率:M 过小会影响搜索精度,M 过大会导致计算量增加。
概率性分层策略:保证大多数点在底层进行精细搜索,少量枢纽节点在高层提供快速跳跃能力。
2.2.6 HNSW vs 其他 ANN 方法
方法 | 数据结构 | 查询速度 | 构建成本 | 适用于大规模数据 |
---|---|---|---|---|
Brute Force | 线性搜索 | 慢(O(n)) | 低 | ❌ |
KD-Tree | 树结构 | 中等(O(log n)) | 低 | ❌ |
IVF (Inverted File) | 聚类索引 | 高 | 高 | ✔ |
HNSW | 分层图结构 | 极高 | 中等 | ✔✔ |
2.2.7 关键参数回顾
参数 | 作用 | 影响 |
---|---|---|
L(层数) | 控制 HNSW 的层级深度 | 层数越多,全局搜索能力越强,但索引构建时间增加 |
m_L(level multiplier) | 控制向量出现在高层的概率 | 低 m_L:大部分点在底层,搜索路径变长;高 m_L:点分布在多层,搜索更快 |
efConstruction | 控制构建时寻找的最近邻候选数 | 低 efConstruction:连接少,搜索快但可能不准确;高 efConstruction:连接多,搜索准确但索引大 |
M_max | 限制每个点的最大连接数 | 连接数过多会增加存储开销,但过少可能影响搜索质量 |
M_max0 | 仅作用于 Layer 0,限制底层最大连接数 | 底层通常需要更多连接,以提高搜索精度 |
2.3 HNSW 的实现解析
HNSW(Hierarchical Navigable Small Worlds)是一种基于图的**近似最近邻搜索(ANN)**方法,由于其复杂性,这里只实现一个基础版本。我们将逐步分析代码并详细解释其各个部分的逻辑。
2.3.1 创建数据集
首先,我们需要一个 128 维的向量数据集:
import numpy as np
dataset = np.random.normal(size=(1000, 128))
这会生成一个 1000 个样本、每个样本 128 维的标准正态分布向量数据集。
2.3.2 构建 HNSW 索引
HNSW 结构包含多个层,每一层都是一个 NSW 图。我们用一个 list of lists(即嵌套列表)来存储索引:
L = 5 # 5 层 HNSW
index = [[] for _ in range(L)]
- L = 5 代表 HNSW 结构有 5 层。
- index 是一个包含 L 个空列表的列表,每个列表代表 HNSW 的一层。
每个节点存储的信息
在每一层的图中,每个节点存储为一个 三元组 (3-tuple):
(vector, links, lower_layer_index)
- vector:向量数据。
- links:当前层的邻居节点索引列表。
- lower_layer_index:当前节点在下一层(更底层)对应的索引(最底层的这个值设为 None)。
2.3.3 搜索 HNSW 层
HNSW 的搜索过程是 逐层向下 进行的,因此我们先实现一个 单层搜索 方法 _search_layer。
搜索单层
def _search_layer(graph, entry, query, ef=1):
best = (np.linalg.norm(graph[entry][0] - query), entry) # 初始最优点
nns = [best] # 记录最近邻
visit = set(best) # 已访问的节点集合
candid = [best] # 候选点(优先队列)
heapify(candid)
while candid:
cv = heappop(candid) # 取出当前最接近的点
if nns[-1][0] < cv[0]: # 如果当前最近邻比候选更好,则停止
break
# 遍历候选点 cv 的所有邻居
for e in graph[cv[1]][1]:
d = np.linalg.norm(graph[e][0] - query)
if (d, e) not in visit:
visit.add((d, e))
# 仅插入比当前最近邻更好的点
if d < nns[-1][0] or len(nns) < ef:
heappush(candid, (d, e))
insort(nns, (d, e))
if len(nns) > ef:
nns.pop()
return nns
解释
- 该函数在指定 graph(当前层)中搜索 query 的最近邻。
- 优先队列(heap)+ 贪心搜索(Greedy Search):
- 采用 heap(小根堆)维护候选集合 candid。
- 使用 nns 存储当前找到的最近邻集合(大小最多 ef)。
- 搜索终止条件:
- 当 candid 为空(没有更多候选)。
- 当 nns 的最后一个点比 candid 里所有点都更优。
2.3.4 最高层搜索 HNSW
我们实现 search() 来进行多层搜索:
def search(index, query, ef=1):
if not index[0]: # 若索引为空,直接返回
return []
best_v = 0 # 设定初始最优点
for graph in index:
best_d, best_v = _search_layer(graph, best_v, query, ef=1)[0]
if graph[best_v][2]: # 若存在下一层,继续搜索
best_v = graph[best_v][2]
else:
return _search_layer(graph, best_v, query, ef=ef)
解释
- 从最高层开始搜索:
- 先从 index[0](最高层)进入。
- 在每一层调用 _search_layer() 找到 query 在该层的最近邻 best_v。
- 如果当前层有更低层,则继续进入更低层搜索。
- 到达底层后,返回最终搜索结果。
2.3.5 HNSW 插入策略
确定插入层
HNSW 采用随机层级分配:
def _get_insert_layer(L, mL):
l = -int(np.log(np.random.random()) * mL)
return min(l, L)
随机数取对数,确保高层节点较少,低层节点较多。
这与跳表的层级分布类似。
插入操作
def insert(vec, efc=10):
if not index[0]: # 若索引为空,直接初始化
i = None
for graph in index[::-1]:
graph.append((vec, [], i))
i = 0
return
l = _get_insert_layer(1/np.log(L))
start_v = 0
for n, graph in enumerate(index):
if n < l: # 在层 L 以上层搜索最近邻
_, start_v = _search_layer(graph, start_v, vec, ef=1)[0]
else:
node = (vec, [], len(index[n+1]) if n < L-1 else None)
nns = _search_layer(graph, start_v, vec, ef=efc)
for nn in nns:
node[1].append(nn[1]) # 建立邻居关系
graph[nn[1]][1].append(len(graph))
graph.append(node)
# 设置起始点为下一层最近邻
start_v = graph[start_v][2]
解释
-
若索引为空,直接插入所有层。
-
确定插入层 l。
-
找到 l 层的最近邻:
逐层搜索 query 在 l 以上层的最近邻 start_v。
- 在 l 层及以下插入 vec:
通过 _search_layer 找到最近邻,并建立邻居关系。
2.3.6 HNSW 代码封装
将所有代码封装成一个 HNSW 类:
from bisect import insort
from heapq import heapify, heappop, heappush
import numpy as np
class HNSW:
def __init__(self, L=5, mL=0.62, efc=10):
self._L = L
self._mL = mL
self._efc = efc
self._index = [[] for _ in range(L)]
def search(self, query, ef=1):
if not self._index[0]: return []
best_v = 0
for graph in self._index:
best_d, best_v = HNSW._search_layer(graph, best_v, query, ef=1)[0]
if graph[best_v][2]: best_v = graph[best_v][2]
else: return HNSW._search_layer(graph, best_v, query, ef=ef)
def insert(self, vec, efc=10):
if not self._index[0]:
i = None
for graph in self._index[::-1]: graph.append((vec, [], i)); i = 0
return
l = self._get_insert_layer()
start_v = 0
for n, graph in enumerate(self._index):
if n < l:
_, start_v = self._search_layer(graph, start_v, vec, ef=1)[0]
else:
node = (vec, [], len(self._index[n+1]) if n < self._L-1 else None)
nns = self._search_layer(graph, start_v, vec, ef=efc)
for nn in nns: node[1].append(nn[1]); graph[nn[1]][1].append(len(graph))
graph.append(node)
start_v = graph[start_v][2]
总结
• 搜索: 从顶层搜索,逐层向下寻找最近邻。
• 插入: 计算插入层,在该层及以下找到最近邻并建立连接。
• 索引存储: 采用 list of lists 结构,每层存储 (vector, neighbors, lower_layer_index)。
这样,我们就实现了一个简化版的 HNSW! 🚀