向量数据库内核设计:HNSW 索引原理与亿级向量检索优化
向量数据库内核设计:HNSW 索引原理与亿级向量检索优化
一、暴力搜索的终结:当亿级向量遇上毫秒级延迟要求
向量检索的核心问题是近似最近邻搜索(ANN):在 N 个 d 维向量中,找到与查询向量距离最近的 Top-K 个结果。暴力搜索的时间复杂度是 O(Nd),当 N 达到亿级、d 为 768(BERT 嵌入维度)时,单次查询需要 7.68 亿次浮点运算,延迟在秒级。这对实时推荐、语义搜索、RAG 检索增强等场景完全不可接受。
更深层的问题在于,向量检索的精度与速度之间存在不可调和的矛盾。精确最近邻搜索(Exact NN)在亿级数据集上的延迟是秒级,而业务要求的 P99 延迟通常在 10-50ms。唯一出路是近似搜索——牺牲一定精度换取数量级的速度提升。但"近似"到什么程度可以接受?召回率 95% 和 99% 对下游任务的影响差异巨大,在 RAG 场景中,5% 的召回缺失可能导致关键文档未被检索,直接拉低大模型的回答质量。
向量数据库的内核设计,就是在"速度-精度-内存"这个三维约束空间中寻找最优解。HNSW(Hierarchical Navigable Small World)算法是当前工业界最主流的 ANN 索引结构,在速度和精度之间取得了最佳平衡。
二、HNSW 索引的核心机制:多层导航图与贪心路由
HNSW 的设计灵感来自六度分隔理论:在一个精心构建的图结构中,任意两个节点之间可以通过少量跳转到达。HNSW 将这个思想扩展为多层图结构,上层是稀疏的"高速公路",下层是稠密的"精确路网"。
flowchart TD
subgraph L2["第 2 层(最稀疏)"]
n2a["Node A"] --- n2b["Node B"]
end
subgraph L1["第 1 层(中等密度)"]
n1a["Node A"] --- n1b["Node B"]
n1a --- n1c["Node C"]
n1b --- n1d["Node D"]
n1c --- n1d
end
subgraph L0["第 0 层(全量节点)"]
n0a["A"] --- n0b["B"]
n0a --- n0c["C"]
n0b --- n0d["D"]
n0b --- n0e["E"]
n0c --- n0f["F"]
n0d --- n0e
n0d --- n0g["G"]
n0e --- n0h["H"]
n0f --- n0g
n0g --- n0h
end
n2a -.->|投影| n1a
n2b -.->|投影| n1b
n1a -.->|投影| n0a
n1b -.->|投影| n0b
n1c -.->|投影| n0c
n1d -.->|投影| n0d
style L2 fill:#e8f4fd,stroke:#2196f3
style L1 fill:#fff3e0,stroke:#ff9800
style L0 fill:#e8f5e9,stroke:#4caf50
层级构建规则。 每个向量插入时,通过一个概率函数决定它出现在哪些层。层级 l 的分配公式为 l = floor(-ln(uniform(0,1)) * mL),其中 mL = 1/ln(M),M 是每个节点的最大邻居数。这意味着第 0 层包含所有节点,第 1 层约包含 1/M 的节点,第 2 层约包含 1/M² 的节点,以此类推。这种指数衰减的层级分布,保证了上层图的稀疏性,使得跨区域跳转只需少量步数。
搜索过程。 查询从最高层的入口节点开始,在当前层执行贪心搜索:每一步移动到距离查询点最近的邻居节点,直到无法找到更近的邻居为止。然后下降到下一层,以上一层找到的最近节点为起点继续贪心搜索。到达第 0 层后,执行更精细的搜索(通常使用优先队列,即 beam search),最终返回 Top-K 结果。
邻居选择策略。 这是 HNSW 性能的关键。每个节点的邻居数上限为 M,当插入新节点导致邻居数超限时,需要裁剪。HNSW 使用"简单选择"(simple selection)或"启发式选择"(heuristic selection)。启发式选择优先保留那些"方向多样性"的邻居——即不仅距离近,而且与其他邻居的方向差异大的节点。这避免了邻居聚集在同一个方向,导致搜索时需要更多跳数才能覆盖其他方向。
三、亿级向量检索的生产级优化实现
3.1 量化压缩:从 FP32 到 PQ 的内存优化
import numpy as np
from dataclasses import dataclass
@dataclass
class ProductQuantizer:
"""乘积量化器(Product Quantization)
设计意图:将 d 维向量拆分为 m 个子空间,每个子空间独立聚类为 256 个中心点,
原始向量用 m 个 uint8 编码表示,压缩比为 d*4 / m*1(FP32→uint8)。
例如 d=768, m=48 时,压缩比为 768*4/48 = 64 倍"""
m: int # 子空间数量
k_sub: int = 256 # 每个子空间的聚类中心数(uint8 上限)
codebooks: np.ndarray = None # 形状 (m, k_sub, d_sub)
@property
def d_sub(self) -> int:
"""每个子空间的维度"""
assert self.codebooks is not None, "需先训练码本"
return self.codebooks.shape[2]
def fit(self, vectors: np.ndarray, n_iter: int = 20):
"""在训练集上学习码本"""
d = vectors.shape[1]
assert d % self.m == 0, f"维度 {d} 必须能被子空间数 {self.m} 整除"
d_sub = d // self.m
self.codebooks = np.zeros((self.m, self.k_sub, d_sub), dtype=np.float32)
for i in range(self.m):
# 提取第 i 个子空间的向量
sub_vectors = vectors[:, i * d_sub : (i + 1) * d_sub]
# K-Means 聚类,使用 k-means++ 初始化避免空簇
centroids = self._kmeans_plusplus_init(sub_vectors, self.k_sub)
for _ in range(n_iter):
# 分配步骤:每个向量归属最近的中心
distances = self._compute_distances(sub_vectors, centroids)
labels = np.argmin(distances, axis=1)
# 更新步骤:重新计算中心
for j in range(self.k_sub):
mask = labels == j
if np.any(mask):
centroids[j] = sub_vectors[mask].mean(axis=0)
self.codebooks[i] = centroids
def encode(self, vectors: np.ndarray) -> np.ndarray:
"""将向量编码为 PQ 码,每个子空间用 uint8 表示"""
n = vectors.shape[0]
d_sub = vectors.shape[1] // self.m
codes = np.zeros((n, self.m), dtype=np.uint8)
for i in range(self.m):
sub_vectors = vectors[:, i * d_sub : (i + 1) * d_sub]
distances = self._compute_distances(sub_vectors, self.codebooks[i])
codes[:, i] = np.argmin(distances, axis=1)
return codes
def compute_distance_table(self, query: np.ndarray) -> np.ndarray:
"""预计算查询向量与所有码本中心的距离表
这是 PQ 加速的核心:将 d 维距离计算降为 m 次查表 + m 次加法"""
d_sub = query.shape[0] // self.m
dist_table = np.zeros((self.m, self.k_sub), dtype=np.float32)
for i in range(self.m):
sub_query = query[i * d_sub : (i + 1) * d_sub]
# 一次计算查询子向量与该子空间所有中心的距离
diff = self.codebooks[i] - sub_query[np.newaxis, :]
dist_table[i] = np.sum(diff ** 2, axis=1)
return dist_table
@staticmethod
def _kmeans_plusplus_init(data: np.ndarray, k: int) -> np.ndarray:
"""k-means++ 初始化,避免随机初始化导致的空簇问题"""
n = data.shape[0]
centroids = np.zeros((k, data.shape[1]), dtype=np.float32)
# 随机选择第一个中心
idx = np.random.randint(n)
centroids[0] = data[idx]
for i in range(1, k):
# 计算每个点到最近中心的距离
dists = np.min(
np.sum((data[:, np.newaxis, :] - centroids[np.newaxis, :i, :]) ** 2, axis=2),
axis=1
)
# 按距离的平方概率选择下一个中心
probs = dists / dists.sum()
idx = np.random.choice(n, p=probs)
centroids[i] = data[idx]
return centroids
@staticmethod
def _compute_distances(vectors: np.ndarray, centroids: np.ndarray) -> np.ndarray:
"""批量计算向量到中心的欧氏距离平方"""
# 利用 (a-b)^2 = a^2 + b^2 - 2ab 展开避免循环
v_sq = np.sum(vectors ** 2, axis=1, keepdims=True)
c_sq = np.sum(centroids ** 2, axis=1, keepdims=True).T
cross = vectors @ centroids.T
return v_sq + c_sq - 2 * cross
3.2 HNSW 搜索的并发安全与内存管理
// HNSWSearch 在并发环境下执行 ANN 搜索
// 设计意图:搜索是只读操作,允许多个查询并发执行,
// 但插入操作需要获取写锁,搜索与插入互斥
type HNSWIndex struct {
nodes []Node
maxLevel int
entryPoint uint64
M int // 每层最大邻居数
efSearch int // 搜索时的 beam width
mu sync.RWMutex
}
// Search 执行近似最近邻搜索
func (h *HNSWIndex) Search(query []float32, topK int) ([]uint64, []float32, error) {
h.mu.RLock()
defer h.mu.RUnlock()
// 从最高层开始贪心搜索
curNode := h.entryPoint
curDist := h.computeDistance(query, h.nodes[curNode].Vector)
for level := h.maxLevel; level > 0; level-- {
changed := true
for changed {
changed = false
// 遍历当前节点的邻居,寻找更近的节点
for _, neighbor := range h.nodes[curNode].Neighbors[level] {
dist := h.computeDistance(query, h.nodes[neighbor].Vector)
if dist < curDist {
curDist = dist
curNode = neighbor
changed = true
}
}
}
}
// 第 0 层使用 beam search,efSearch 控制 beam width
// efSearch >= topK,通常设为 topK 的 2-4 倍以保证召回率
candidates := NewPriorityQueue(h.efSearch)
visited := NewBitSet(len(h.nodes))
result := NewPriorityQueue(topK)
candidates.Push(curNode, -curDist) // 负距离实现最大堆
visited.Set(curNode)
for candidates.Len() > 0 {
nodeID, negDist := candidates.Pop()
dist := -negDist
// 如果候选距离已经大于结果集中最远点的距离,提前终止
if result.Len() >= topK && dist > result.MaxDist() {
break
}
// 扩展邻居
for _, neighbor := range h.nodes[nodeID].Neighbors[0] {
if visited.Get(neighbor) {
continue
}
visited.Set(neighbor)
nDist := h.computeDistance(query, h.nodes[neighbor].Vector)
if result.Len() < topK || nDist < result.MaxDist() {
candidates.Push(neighbor, -nDist)
result.Push(neighbor, nDist)
}
}
}
return result.IDs(), result.Distances(), nil
}
四、HNSW 的架构权衡与适用边界
内存消耗是 HNSW 最大的短板。 HNSW 需要将全量向量数据加载到内存中,因为图遍历的每一步都需要随机访问邻居节点。亿级 768 维 FP32 向量需要约 286GB 内存,加上图结构的邻居指针(每层每节点 M 个邻居),总内存轻松超过 400GB。PQ 量化可以将内存压缩到 48GB 左右,但代价是距离计算精度下降,召回率通常降低 3-8 个百分点。
构建时间与增量更新的矛盾。 HNSW 的构建是批量友好的,全量构建亿级索引需要数小时。但增量插入的效率远低于批量构建,因为每次插入需要在多层图中搜索邻居并建立连接,单次插入延迟在毫秒级。对于写入量大的场景(如每秒万级向量插入),HNSW 的写入吞吐量成为瓶颈。解决方案是将写入先缓冲到内存中的增量段,定期合并到主索引。
距离度量对图质量的影响。 HNSW 的搜索质量高度依赖距离度量的选择。余弦相似度需要先对向量归一化,然后使用内积距离。但归一化操作改变了原始向量的分布特征,可能导致图结构在边界区域的连接质量下降。对于未归一化的向量,使用 L2 距离更稳定,但 L2 距离在高维空间中存在"维度灾难"——所有点对之间的距离趋于一致,区分度下降。
HNSW 不适用的场景。 当向量维度超过 2000 时,HNSW 的图遍历效率急剧下降,因为高维空间中邻居的区分度太低,贪心搜索容易陷入局部最优。此时应考虑基于量化的方法(如 IVF-PQ),通过聚类先缩小搜索范围,再在子集上精确计算。
五、总结
HNSW 通过多层导航图结构,将亿级向量检索的延迟从秒级降低到毫秒级,同时保持 95% 以上的召回率。其核心优势在于图遍历的对数级跳数和贪心路由的高效性。乘积量化(PQ)解决了内存瓶颈,将存储压缩 6-64 倍,代价是距离计算精度和召回率的轻微下降。
落地路线建议:第一步,根据向量维度和数据规模选择索引类型——亿级以下、维度 768 以内优先 HNSW,更高维度或更大规模考虑 IVF-PQ 混合方案;第二步,参数调优优先确定 M(16-64)和 efSearch(topK 的 2-4 倍),通过召回率基准测试验证;第三步,引入 PQ 量化控制内存,m 值选择 d/16 到 d/8 之间,在压缩比和精度间取平衡;第四步,实现增量写入缓冲和定期合并机制,避免写入瓶颈;第五步,部署向量数据的热备和分片策略,单分片控制在 5000 万向量以内,跨分片搜索结果归并排序。
更多推荐



所有评论(0)