向量数据库内核设计: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 万向量以内,跨分片搜索结果归并排序。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐