Skip to content

Basic Indexing

原文链接:https://www.yuque.com/yangguangfanxing/nmhuv1/he56c9w42hccceo5

为什么需要索引?

向量数据库常见查询是 kNN:给定查询向量 ,找到距离它最近的若干向量。最直接的方法是 Flat / Brute Force Search:扫描所有向量,计算距离,然后返回 top-k。

暴力搜索的计算量大致为:

其中 是向量数量,是向量维度。它的优点是结果精确,缺点是每次查询都要比较全部向量,规模大时太慢。

索引的核心思想是:通过划分、压缩、随机化或图结构,减少查询时需要比较的候选向量数量。

代价是:可能增加误差、内存、构建成本和更新成本。

ANNS 索引的性能权衡

ANNS 是 Approximate Nearest Neighbor Search,即近似最近邻搜索。它主要在四个维度之间权衡:

指标含义
Search speed查询速度
Accuracy / Recall返回结果和真实最近邻的重合程度
Memory索引额外内存消耗
Build / update cost构建、插入、删除、重建的代价

最关键的是 速度-准确率权衡

  • 搜索更少候选:更快,但 recall 更低
  • 搜索更多候选:更准,但延迟更高

索引类型和参数配置决定系统落在权衡曲线上的位置。

评价指标

  1. Recall@K

Recall@K 表示返回的 个结果中,有多少是真实 top- 最近邻。

设:

  • :索引返回的 top- 集合
  • :真实 top- 最近邻集合

则:

例如 ,返回结果中有 4 个是真实最近邻,则:

即 80%。

  1. Latency 与 Throughput

单次查询延迟:

系统吞吐量:

为什么传统数据库索引不适合向量?

传统 RDBMS 索引,如 B-tree,适合有序属性;但向量是高维对象,没有天然的全局排序结构。

维度RDBMS 属性索引向量 ANNS 索引
索引粒度单个属性整个向量
查询准确性精确通常近似
自然结构有序、可排序无天然顺序
更新删除通常容易可能困难
是否依赖数据分布通常不依赖常依赖数据分布

因此向量索引需要新的技巧:

  1. 基于数据的划分:如 IVF (Inverted File Index)聚类索引
  2. 有损压缩:如 PQ (Product Quantization)/ SQ 量化
  3. 随机化:如 LSH
  4. 图结构搜索:如 HNSW(Hierarchical Navigable Small World) / DiskANN

主要索引类型

类型代表方法基本思想
Cluster-basedIVF, PQ将空间划分成多个簇,只搜索相关簇
Graph-basedHNSW, DiskANN把相似向量连成图,通过图遍历搜索
LSHE2LSH, FALCONN让相似向量高概率进入同一哈希桶
TreeRP tree, ANNOY层次化划分向量空间

工程上还要考虑:索引是否驻留内存、是否需要周期性重建、是否支持增量更新、删除是物理删除还是 tombstone、是否有误差界限等。

Flat Index:暴力搜索也是一种索引

Flat Index 直接存储所有向量。查询时扫描表、计算距离、排序或维护优先队列,返回 top-k。

优点

优点说明
精确返回真实最近邻
更新快插入后立即可见,无复杂维护
无需重建没有复杂结构退化
过滤查询容易可以先按属性过滤再扫描
GPU 友好距离计算高度并行
额外内存低不需要复杂索引结构

缺点与适用场景

缺点是查询成本随 增长。适合小规模集合、需要精确结果、更新频繁、过滤复杂或有强 GPU 支持的场景。

单核 CPU 在百万(1M)向量、较高维度时 QPS 很低;但多 GPU 系统可以把 Flat Search 扩展到更大规模。

ANN Benchmark 的启示

ANN Benchmark 对比显示:

  • Exact Search with BLAS:准确率 100%,但 QPS 低
  • IVF:可带来数十倍加速
  • HNSW / DiskANN / Vamana:可带来百倍到数百倍加速

结论是:实际系统中,少量 recall 损失通常可以换来数量级的查询加速。

LSH:局部敏感哈希

1. 普通哈希 vs LSH

普通哈希希望不同对象尽量落到不同桶,减少冲突;LSH 则希望相近对象更容易落入同一桶。

哈希类型目标
普通哈希最小化冲突
LSH让相近向量高概率冲突

2. LSH 的核心性质

理想的 LSH 函数 满足:

3. 插入与查询

插入向量

查询向量 :计算 ,取同一 bucket 中的候选向量,再计算真实距离并 rerank。

单个哈希表容易漏掉近邻,因此通常使用 个哈希表:

这样可以降低错误概率,但会增加内存和查询时间。

具体来说:

  1. 每个向量会被哈希L次(使用L个不同的哈希族)
  2. 每个哈希结果存入对应的哈希表
  3. 查询时,查询向量也会被哈希L次,在L个表中查找

查询时的具体步骤:

  1. 并行计算:计算查询向量个哈希表中的哈希值
  2. 桶查找:在个表中查找对应的哈希桶
  3. 合并去重:合并所有桶中的向量,去除重复
  4. 重新排序:对候选向量进行精确距离计算和排序
  5. 返回结果:返回top-k最近邻

这里的例子:

向量:x, y, q (查询点)
哈希表:g1, g2, g3, g4, g5 (L=5)

构建阶段:
1. 向量x → 计算5次哈希 → 存入5个表
   g1(x)=3, g2(x)=1, g3(x)=4, g4(x)=2, g5(x)=1
   
2. 向量y → 计算5次哈希 → 存入5个表
   g1(y)=2, g2(y)=3, g3(y)=1, g4(y)=4, g5(y)=3

查询阶段:
查询向量q → 计算5次哈希 → 在5个表中查找
g1(q)=3 → 在表g1的桶3中找到x
g2(q)=3 → 在表g2的桶3中找到y
g3(q)=4 → 在表g3的桶4中找到x
g4(q)=2 → 在表g4的桶2中找到x
g5(q)=3 → 在表g5的桶3中找到y

候选集:{x, y}

然后重新排序

多 Hash 优点:

  1. 概率提升

单个哈希函数 h(x)找到最近邻的概率有限。假设:

  • 单个哈希函数找到最近邻的概率 = p
  • 使用L个独立哈希表,找到最近邻的概率 =

示例

如果 p = 0.5 (50%概率找到最近邻)
L=1: 概率 = 0.5
L=2: 概率 = 1 - (1-0.5)² = 0.75
L=3: 概率 = 1 - (1-0.5)³ = 0.875
L=10: 概率 ≈ 0.999
  1. 降低假阴性率
  • 假阴性:真正的最近邻没有被找到
  • 多个哈希表显著降低假阴性率

4. LSH 的优缺点

优点:增量更新快、有理论误差界和内存界、通常不需要因数据变化而重建。

缺点:实际 VDBMS 中较少使用,高维特别是 时性能较差,内存开销较高,综合效果常不如 HNSW / IVF。

IVF:Inverted File Index / 聚类索引

IVF的“倒排”思路(高效):它为每个聚类中心建立一个列表,这个列表里存放着所有属于这个聚类的向量

  • 中心点1 ->
  • 中心点2 ->

IVF 也称 clustering index。它先把向量空间划分为若干 cell / cluster,每个 cluster 有一个 centroid。

1. 构建流程

  1. 选择 cluster 数量
  2. 用 k-means 等方法学习 centroids
  3. 对每个向量 ,找到最近 centroid
  4. 放入对应 cell

K-means 的目标是最小化所有样本点到其所属簇中心的距离平方和:

其中:

  • 表示第 个簇
  • 表示第 个簇的中心点
  • 表示样本点到簇中心的欧氏距离平方

K-means 算法伪代码:

输入:
  数据集 D = {x_1, x_2, ..., x_n}
  聚类数量 K
  最大迭代次数 max_iter

输出:
  K 个簇 C_1, C_2, ..., C_K
  K 个簇中心 μ_1, μ_2, ..., μ_K

步骤:
1. 随机初始化 K 个簇中心 μ_1, μ_2, ..., μ_K (也可以使用更稳定的初始化方法,如 K-means++)

2. 对于 t = 1 到 max_iter:

   a. 分配样本:
      对每个样本 x_i:
          计算 x_i 到每个簇中心 μ_j 的距离
          将 x_i 分配到最近的簇 C_j

   b. 更新簇中心:
      对每个簇 C_j:
          μ_j = C_j 中所有样本点的均值

   c. 判断是否收敛:
      如果簇中心或样本分配不再变化:
          停止迭代

3. 返回最终的簇和簇中心

常见停止条件包括:

  1. 簇中心不再变化:

  1. 样本的簇分配不再变化
  2. 目标函数变化小于阈值:

  1. 达到最大迭代次数

如果未收敛,则重复 a 和 b

2. 查询流程

  1. 对查询向量 找最近 centroid
  2. 只扫描该 cell 中的向量
  3. 返回 top-k

3. 跨簇近邻问题

真实近邻可能在相邻 cluster 中。如果只搜索最近 cell,就会漏掉它。解决方法是增加 probe 数量

  • :最快,但更容易漏
  • :更准,但更慢

4. 随时间退化

IVF 的 cluster 基于构建时的数据分布。持续插入后,数据分布可能改变,导致边界错误增多、cluster list 不均衡,因此通常需要周期性重建:

5. IVF 参数与性质

参数含义影响
cell / cluster 数量越大,单个 cell 越小,搜索更快,但可能更易漏近邻
查询 probe 的 cell 数越大,recall 越高,但查询更慢

优点:内存开销低、更新快、新向量立即可见、容易扩展。

缺点:不是最快,添加数据会降低准确率,需要周期性重建。

HNSW:层次化可导航小世界图

HNSW 全称 Hierarchically Navigable Small Worlds,是工业界最常见、效果很强的 ANN 图索引之一。是 ANN indexes 的 crown jewel。

HNSW 结合两个思想:

1. NSW:Navigable Small World

NSW 图中节点是向量,边连接相近向量。图中既有短边也有长边,所以平均路径较短:

查询时从 entry point 开始贪心搜索:不断移动到更接近 的邻居,直到不能继续改善。

2. Skip List 层次结构

Skip List 的高层用于长距离跳跃,低层用于精细搜索。HNSW 把向量图做成多层:高层稀疏、边长;低层稠密、边短。

Flip coin, if heads stop:这是在插入新节点时决定该节点应该上升到哪一层的随机化过程。

插入节点X后:
第一次抛硬币:
    正面(Heads)→ 停止,X只存在于L0层
    反面(Tails)→ 将X提升到L1层,继续抛硬币

第二次抛硬币(如果在L1层):
    正面(Heads)→ 停止,X存在于L0和L1层
    反面(Tails)→ 将X提升到L2层,继续抛硬币

第三次抛硬币(如果在L2层):
    正面(Heads)→ 停止,X存在于L0、L1、L2层
    反面(Tails)→ 将X提升到L3层...
    
如此反复,直到抛到正面为止

HNSW 查询过程

  1. 从最高层 entry point 开始
  2. 在当前层做 greedy search
  3. 找到局部最优点后下降一层
  4. 重复直到 layer 0
  5. 在 layer 0 扩展搜索,返回 top-k。在最底层,要扩展搜索到 efSearch 个邻居。

由于 out-degree 被限制,搜索复杂度通常可接近:

底层使用 控制扩展候选数:

  • 越大,recall 越高
  • 越大,查询越慢

在最底层,算法不再是简单的"移动到最近的邻居",而是:

def search_layer_0(query, entry_point, efSearch):
    """
    在最底层(layer 0)的搜索过程
    """
    # 初始化候选列表和结果列表
    candidates = MinHeap()  # 最小堆,按距离排序
    results = MaxHeap()     # 最大堆,保持efSearch个最近邻

    # 从入口点开始
    candidates.push(entry_point, distance(query, entry_point))
    results.push(entry_point, distance(query, entry_point))

    # 扩展搜索直到候选列表为空
    while not candidates.empty():
        # 取出最近的候选节点
        current = candidates.pop()

        # 获取当前节点的所有邻居(在layer 0)
        neighbors = get_neighbors_in_layer_0(current)

        for neighbor in neighbors:
            if neighbor not in visited:
                dist = distance(query, neighbor)
                visited.add(neighbor)

                # 如果结果列表未满或距离更近
                if len(results) < efSearch or dist < results.max_distance():
                    candidates.push(neighbor, dist)
                    results.push(neighbor, dist)

                    # 保持结果列表大小为efSearch
                    if len(results) > efSearch:
                        results.pop_farthest()

    return results.get_all()  # 返回efSearch个最近邻

搜索过程示例

假设 efSearch = 3,查询点 Q

Layer 2: 
  入口点 A → 移动到最近的邻居 B

Layer 1:
  从 B 开始 → 移动到最近的邻居 C

Layer 0:
  从 C 开始,但不再只找最近的邻居 D
  
  扩展过程:
  1. 查看 C 的所有邻居:D, E, F, G
  2. 计算 Q 到每个邻居的距离
  3. 保持最近的 3 个(efSearch=3)作为候选
  4. 从这 3 个候选继续扩展它们的邻居
  5. 重复直到找不到更近的点

HNSW 插入过程

插入向量 时:

  1. 随机选择最高层
  2. 从顶层入口开始搜索
  3. 在每一层找到邻近候选集合
  4. 插入该层
  5. 连接到最好的 个邻居
  6. 修剪边,避免 out-degree 膨胀
  7. 下降到下一层继续

整体步骤

INSERTION (OVERVIEW) 流程:

1. 为向量 x 随机选择最高层 L
   - 例如:L=3 表示 x 会出现在第 0、1、2、3 层

2. 从最高层 L 开始,逐层向下插入:
   a. 在每一层执行贪心搜索,找到 x 在该层的最近邻
   b. 创建 x 在该层的节点(如果尚未存在)
   c. 将 x 连接到该层的邻居
   d. 修剪连接,避免出度过大

步骤1:随机选择最高层

def random_level(max_level, mL=1/ln(M)):
    """
    随机生成向量的最高层
    mL 通常为 1/ln(M),其中 M 是基础层每个节点的平均连接数
    """
    level = 0
    # 以概率 1/mL 继续上升
    while random.random() < 1/mL and level < max_level:
        level += 1
    return level

重要:这个随机层数决定了向量出现在哪些层。比如随机到 L=2,那么向量会出现在:

  • Layer 2(最高层)
  • Layer 1
  • Layer 0(最底层,所有向量都存在)

步骤2:逐层插入

从最高层开始向下插入

def insert_vector(x, max_level, M, efConstruction):
    # 1. 随机选择最高层
    L = random_level(max_level)
    
    # 2. 初始化入口点(通常是第一个插入的向量)
    entry_point = get_global_entry_point()
    
    # 3. 从最高层 L 向下到第 0 层
    for current_layer in range(L, -1, -1):
        # 在当前层搜索最近邻
        neighbors = search_layer(x, entry_point, efConstruction, current_layer)
        
        # 创建 x 在当前层的节点(如果尚未存在)
        create_node_at_layer(x, current_layer)
        
        # 将 x 连接到找到的邻居
        connect_to_neighbors(x, neighbors, current_layer, M)
        
        # 修剪连接,保持每个节点最多 M 个连接
        trim_connections(x, current_layer, M)
        
        # 更新 entry_point 为当前层搜索的结果
        # (用于下一层的搜索,加速搜索过程)
        entry_point = neighbors[0]  # 最近的邻居作为下一层的入口点

关键细节解释

  1. "Create node and connect to neighbours in layer" 每一层都需要节点:即使物理上可能只有一个向量对象,但在每一层的图结构中都需要有一个节点表示。 连接是分层的:每一层的连接是独立的,一个向量在不同层可能有不同的邻居集合
  2. "Search output used as entry point of next layer" 这是 HNSW 的重要优化:上一层的搜索结果(最近的节点)作为下一层搜索的起点,这显著加速了搜索过程。
# 插入时的搜索流程
entry_point = global_top_layer_entry

for layer in range(top_layer, 0, -1):
    # 在当前层搜索
    nearest = greedy_search_at_layer(x, entry_point, layer)
    # 记录搜索结果,用于下一层
    entry_point = nearest
  1. "Expand: multiple entry points → better recall" 在某些实现中,为了提高召回率,可能会使用多个入口点:
# 使用多个入口点
entry_points = get_top_k_nearest(x, k=efConstruction, layer=current_layer)
for entry in entry_points:
    # 从每个入口点开始搜索
    candidates.extend(search_from_entry(x, entry, layer))
  1. "Trim edge lists → avoid inflating out-degree" 为了防止节点的连接数过多,需要修剪:
def trim_connections(node, layer, M):
    """修剪连接,保持最多 M 个邻居"""
    neighbors = get_neighbors(node, layer)
    
    if len(neighbors) > M:
        # 按距离排序,保留最近的 M 个
        sorted_neighbors = sort_by_distance(node, neighbors)
        keep_neighbors = sorted_neighbors[:M]
        
        # 更新连接
        set_neighbors(node, keep_neighbors, layer)

最高层通常按类似对数分布采样:

构建时还使用 控制候选数量,候选越多,构建越慢,但图质量通常越高。

可视化示例

假设插入向量 x,随机选择的最高层 L=2:

插入前:
Layer 2: A ----- B
Layer 1: A - C - B - D
Layer 0: A-C-B-D-E-F

插入 x(L=2):
1. 在 Layer 2 插入:
   - 搜索:找到最近邻 A
   - 创建节点:x
   - 连接:x ←→ A
   Layer 2 变为:A - x - B

2. 在 Layer 1 插入:
   - 搜索:以 A 为入口点,找到最近邻 C
   - 创建节点:x
   - 连接:x ←→ C(可能还有其他邻居)
   Layer 1 变为:A - C - x - B - D

3. 在 Layer 0 插入:
   - 搜索:以 C 为入口点,找到最近邻 E
   - 创建节点:x
   - 连接:x ←→ E(和附近的其他节点)
   Layer 0 变为:A-C-B-D-E-x-F

与搜索过程的对比

操作插入搜索
入口点从全局入口点开始从全局入口点开始
层遍历从最高层向下到 0 层从最高层向下到 0 层
每层操作创建节点 + 连接邻居贪心搜索
复杂度O(log n)O(log n)

实际实现注意事项

  1. 节点表示。在实际代码中,一个向量在不同层的节点通常是同一个对象的不同"视图":
class HNSWNode:
    def __init__(self, id, vector):
        self.id = id
        self.vector = vector
        self.neighbors = {}  # layer -> [neighbor_ids]
        self.max_level = 0   # 该节点出现的最高层
  1. 内存优化

由于每个节点在不同层都有连接信息,内存占用可能较大。可以优化:

# 使用数组存储连接,而不是字典
self.neighbors_by_layer = []  # 索引是层数
  1. 并发控制

在多线程环境中插入时需要同步控制:

def insert_concurrent(x):
    with insert_lock:
        # 获取当前全局状态
        entry_point = get_entry_point()
        # 执行插入
        do_insert(x, entry_point)

HNSW 的优缺点与参数

优点

  • 准确率高
  • 查询速度快
  • 速度-准确率权衡接近工业界最优
  • 适合相对静态数据
  • 比 LSH 更节省内存
  • 理论上支持增量插入

缺点

  • 内存高于 IVF / PQ
  • 插入比查询慢
  • 删除可能破坏图连通性,常使用 tombstone
  • 更新会导致内存膨胀或准确率下降
  • 参数较多,需要调优

关键参数

  1. M(每层最大连接数)
  • 影响图密度和搜索精度
  • 通常设置在 12-48 之间
  • 越大:召回率越高,但内存和计算成本增加
  1. efConstruction(构建时的扩展因子)
  • 影响构建质量
  • 通常设置在 100-200 之间
  • 越大:构建的图质量越高,但构建时间越长
  1. mL(层数分布参数)
  • 控制节点在各层的分布
  • 通常 mL = 1/ln(M)
参数含义影响
每个节点连接的邻居数量查询时间、插入时间、内存、recall
最大出度控制图稠密度与内存
查询时底层扩展候选数查询时间、recall
构建时搜索候选数构建时间、图质量、recall

索引总结与选择

索引优点缺点
Flat精确、低内存、更新快、无需重建大数据集不可行
IVF速度尚可、准确率好、内存低、参数少不是最快,需要重建
HNSW速度极佳、准确率极佳内存膨胀、更新困难、参数多
LSH有误差界、更新快、无需重建高维性能差、内存高、实际较少使用

选择索引时应考虑:

  • 数据集大小
  • 向量维度
  • 更新频率与动态性
  • 新数据是否需要立即可见
  • 查询速度要求
  • Recall 要求
  • 内存预算

核心结论

  1. 向量数据库性能高度依赖 ANNS 索引。
  2. 索引本质是在速度、准确率、内存和更新成本之间做权衡。
  3. Flat Search 精确且简单,但大规模时太慢。
  4. LSH 理论优雅,但实际 VDBMS 中较少使用。
  5. IVF 简单、内存低、更新快,但需要重建。
  6. HNSW 是当前最重要的图索引之一,速度和准确率优秀,但内存和更新成本较高。
  7. 没有万能索引,必须根据数据规模、维度、更新频率、freshness、recall 和内存预算选择。

用心记录,持续成长