土法炼钢兴趣小组的算法知识备份

【存储工程】向量存储与 ANN 索引

文章导航

分类入口
storage
标签入口
#vector-database#ann#hnsw#faiss#ivf#product-quantization#milvus

目录

给定一条 768 维的文本嵌入向量(Embedding),要从一亿条同维度向量中找出最相似的 10 条。暴力计算需要对每条向量做 768 次乘法和一次求和——一亿条就是 768 亿次浮点运算,单核 CPU 跑完需要几十秒。如果这个操作发生在每一次用户搜索请求中,系统根本无法响应。

这就是近似最近邻搜索(Approximate Nearest Neighbor, ANN)要解决的核心问题:在精度可接受的范围内,把检索时间从线性降到亚线性甚至对数级别。过去十年,随着深度学习把文本、图像、音频统一编码成高维向量,ANN 从学术研究走进了工程主战场。搜索引擎的语义召回、推荐系统的候选检索、RAG(Retrieval-Augmented Generation)管线的文档检索——底层都依赖高效的向量存储与索引。

本文从向量检索的数学基础出发,逐层拆解暴力搜索、KD-Tree、LSH、HNSW、量化等核心算法的设计原理与工程实现,再深入向量数据库的系统架构、FAISS 索引选型、HNSW 参数调优、基准测试方法论,最终落到 RAG 场景的实战。所有代码示例基于 FAISS 1.7.4 和 Milvus 2.3,Python 代码基于 Python 3.10。


一、向量检索问题

1.1 从离散符号到连续向量

传统信息检索基于关键词匹配。用户输入”如何优化数据库性能”,系统在倒排索引(Inverted Index)中查找包含”优化”“数据库”“性能”这些词条的文档。这种方法有一个根本缺陷:它无法理解语义。“提升 MySQL 查询速度”和”优化数据库性能”在语义上高度相似,但关键词交集为零。

嵌入模型(Embedding Model)解决了这个问题。它把文本、图像、音频等非结构化数据映射到一个高维连续向量空间(Vector Space),使得语义相似的对象在向量空间中距离相近。以文本为例:

# text-embedding 模型将文本映射为 768 维向量
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-base-zh-v1.5")
v1 = model.encode("如何优化数据库性能")  # shape: (768,)
v2 = model.encode("提升 MySQL 查询速度")  # shape: (768,)
v3 = model.encode("今天天气不错")          # shape: (768,)

# 余弦相似度:v1 与 v2 约 0.85,v1 与 v3 约 0.12

每个向量是一个浮点数数组,维度(Dimension)从 128 到 4096 不等。常见维度:

模型 维度 用途
OpenAI text-embedding-3-small 1536 通用文本嵌入
BAAI/bge-base-zh-v1.5 768 中文文本嵌入
CLIP ViT-B/32 512 图文跨模态嵌入
Cohere embed-v3 1024 多语言文本嵌入

1.2 相似度度量

向量检索的核心操作是计算两个向量之间的距离(Distance)或相似度(Similarity)。常用的度量函数有三种。

欧氏距离(Euclidean Distance / L2)

\[d(x, y) = \sqrt{\sum_{i=1}^{D} (x_i - y_i)^2}\]

值域为 \([0, +\infty)\),值越小越相似。适合绝对距离有物理意义的场景,例如图像特征向量。

余弦相似度(Cosine Similarity)

\[\text{sim}(x, y) = \frac{\sum_{i=1}^{D} x_i y_i}{\sqrt{\sum_{i=1}^{D} x_i^2} \cdot \sqrt{\sum_{i=1}^{D} y_i^2}}\]

值域为 \([-1, 1]\),值越大越相似。它只关注方向不关注幅度,适合文本嵌入。实际工程中,先将向量做 L2 归一化(Normalization),然后用内积(Inner Product)替代余弦相似度,因为归一化后两者等价,而内积的计算更快。

内积(Inner Product / IP)

\[\text{IP}(x, y) = \sum_{i=1}^{D} x_i y_i\]

当向量已经过 L2 归一化时,内积等于余弦相似度。当向量未归一化时,内积同时编码了方向相似性和幅度信息,适合推荐系统中需要考虑”热门度”的场景。

选择度量函数时需要注意:索引构建和查询必须使用同一种度量函数。在 FAISS 中,IndexFlatL2 用欧氏距离,IndexFlatIP 用内积。如果模型输出的向量没有归一化,用余弦相似度的语义去查 IndexFlatIP,结果会出错。

1.3 维度灾难

高维空间有一个反直觉的性质:随着维度增加,所有数据点之间的距离趋于相等。这就是维度灾难(Curse of Dimensionality)。

具体来说,在 D 维空间中随机采样 N 个点,当 D 足够大时:

\[\frac{d_{\max} - d_{\min}}{d_{\min}} \to 0\]

最近邻和最远邻的距离差异趋于零,“最近邻”的概念失去区分度。这意味着:

  1. 基于空间划分的索引结构(如 KD-Tree)在高维下退化为暴力搜索。
  2. 精确最近邻搜索(Exact Nearest Neighbor)在高维高数据量下不可行。
  3. 我们必须接受近似——用 (召回率)衡量搜索质量,换取可接受的延迟。
# 维度灾难的直观演示
import numpy as np

for dim in [2, 10, 100, 768]:
    points = np.random.randn(10000, dim).astype(np.float32)
    # 计算所有点到原点的距离
    dists = np.linalg.norm(points, axis=1)
    ratio = (dists.max() - dists.min()) / dists.min()
    print(f"dim={dim:4d}  max/min ratio={ratio:.4f}")

# 输出示例:
# dim=   2  max/min ratio=48.2371
# dim=  10  max/min ratio= 3.1825
# dim= 100  max/min ratio= 0.8694
# dim= 768  max/min ratio= 0.3012

维度从 2 增长到 768 时,最远点和最近点的距离比从 48 倍缩小到 1.3 倍。这解释了为什么在 768 维空间中,KD-Tree 的剪枝几乎不起作用。

1.4 检索质量度量

评估 ANN 算法的质量,核心指标是 (召回率):

\[\text{Recall@K} = \frac{|\text{ANN 返回的 K 个结果} \cap \text{真实的 K 个最近邻}|}{K}\]

例如,真实最近邻 Top-10 是 {A, B, C, D, E, F, G, H, I, J},ANN 算法返回的 Top-10 是 {A, B, C, D, E, F, G, H, X, Y},则 = 8/10 = 0.8。

工程中通常要求 >= 0.95,即 10 个结果中至少 9.5 个是真实最近邻。在 RAG 场景中,由于后续还有重排序(Reranking)环节,初始召回阶段可以适当放宽到 >= 0.90。


二、暴力搜索与 KD-Tree

2.1 暴力搜索

暴力搜索(Brute-Force Search / Flat Search)是最简单的基线:遍历所有向量,逐一计算距离,保留最小的 K 个。

# FAISS 暴力搜索
import faiss
import numpy as np

dim = 768
n = 1_000_000  # 一百万条向量
nq = 1         # 单条查询

# 生成数据
xb = np.random.randn(n, dim).astype(np.float32)
xq = np.random.randn(nq, dim).astype(np.float32)

# 构建暴力搜索索引(无需训练)
index = faiss.IndexFlatL2(dim)
index.add(xb)

# 搜索 Top-10
D, I = index.search(xq, 10)
# D: 距离矩阵,shape (1, 10)
# I: 索引矩阵,shape (1, 10)

暴力搜索的时间复杂度是 O(N * D),空间复杂度是 O(N * D)。对于 100 万条 768 维 float32 向量:

在现代 CPU 上(AVX-512 指令集),单次查询约 50-100 ms。数据量到一亿条时,延迟达到 5-10 秒,无法满足在线服务的要求。

暴力搜索的优点是 = 1.0(精确结果)和零训练开销。在数据量小于 10 万条时,它往往是最优选择——没有索引构建时间,没有额外内存开销,代码最简单。

2.2 KD-Tree

KD-Tree(K-Dimensional Tree)是最经典的空间划分索引。它沿着各维度交替切分空间,将数据组织成一棵二叉树。

构建过程:

  1. 选择方差最大的维度作为切分维度。
  2. 找到该维度的中位数,作为切分点。
  3. 左子树存放该维度值小于中位数的点,右子树存放大于等于中位数的点。
  4. 递归执行,直到叶节点中的点数小于阈值。

查询过程:

  1. 从根节点开始,根据查询点在切分维度上的值决定先搜索左子树还是右子树。
  2. 搜索到叶节点后,将叶节点中的点加入候选集。
  3. 回溯时,检查另一侧子树的超平面距离。如果超平面距离小于当前第 K 近邻的距离,则进入另一侧子树搜索。
  4. 回溯完成后,候选集中保留距离最小的 K 个点。
# scikit-learn 的 KD-Tree 实现
from sklearn.neighbors import KDTree
import numpy as np

dim = 20  # KD-Tree 适合低维
n = 100_000

data = np.random.randn(n, dim).astype(np.float64)
tree = KDTree(data, leaf_size=40)

query = np.random.randn(1, dim).astype(np.float64)
dist, ind = tree.query(query, k=10)

KD-Tree 在低维(D < 20)时表现优秀,查询时间复杂度接近 O(log N)。但随着维度增加,剪枝效果急剧下降。在 D > 30 时,KD-Tree 的实际查询时间与暴力搜索相当甚至更慢——因为树的遍历本身引入了额外的指针追踪开销和缓存未命中(Cache Miss)。

维度 100K 数据量下 KD-Tree 查询时间 暴力搜索查询时间
3 0.02 ms 1.2 ms
10 0.15 ms 1.2 ms
30 0.95 ms 1.2 ms
100 2.1 ms 1.2 ms
768 15.3 ms 1.2 ms

结论:KD-Tree 不适合向量检索场景中常见的高维数据。我们需要专为高维设计的索引结构。

2.3 Ball Tree 与 VP-Tree

Ball Tree 和 VP-Tree(Vantage Point Tree)是 KD-Tree 的改进。Ball Tree 用超球体(Hypersphere)而非超平面划分空间,VP-Tree 选择一个支点(Vantage Point)并按到支点的距离划分。它们在中等维度(20-50)下比 KD-Tree 略好,但在 D > 100 时同样退化。这两种结构在向量数据库中基本不使用,但它们的思想影响了后来的层级聚类索引。


三、LSH 局部敏感哈希

3.1 核心思想

局部敏感哈希(Locality-Sensitive Hashing, LSH)是第一个在理论上解决高维近似最近邻问题的方法。它的核心思想是:设计一族哈希函数(Hash Function Family),使得相似的向量以高概率被映射到同一个桶(Bucket),不相似的向量以高概率被映射到不同的桶。

形式化定义:对于距离阈值 \(r\)、近似比 \(c > 1\)、概率 \(p_1 > p_2\),一个 \((r, cr, p_1, p_2)\)-敏感的哈希函数族 \(\mathcal{H}\) 满足:

3.2 基于随机超平面的 LSH

对于余弦相似度,最常用的哈希函数是随机超平面投影(Random Hyperplane Projection):

  1. 随机生成一个 D 维向量 \(r\)(各分量从标准正态分布采样)。
  2. 定义哈希函数 \(h(x) = \text{sign}(r \cdot x)\)。如果内积大于 0,哈希值为 1;否则为 0。
  3. \(L\) 个这样的哈希函数,把每个向量映射为一个 \(L\) 位的二进制签名(Signature)。

两个向量的签名的汉明距离(Hamming Distance)与它们的余弦相似度之间存在数学关系:

\[\Pr[h(x) = h(y)] = 1 - \frac{\theta}{\pi}\]

其中 \(\theta = \arccos(\text{sim}(x, y))\)

# LSH 随机超平面投影
import numpy as np

class RandomProjectionLSH:
    def __init__(self, dim, num_hashes, num_tables):
        self.num_tables = num_tables
        self.num_hashes = num_hashes
        # 每个哈希表使用不同的随机投影矩阵
        self.projections = [
            np.random.randn(num_hashes, dim).astype(np.float32)
            for _ in range(num_tables)
        ]
        self.tables = [dict() for _ in range(num_tables)]

    def _hash(self, vec, table_idx):
        proj = self.projections[table_idx] @ vec
        return tuple((proj > 0).astype(int))

    def insert(self, idx, vec):
        for t in range(self.num_tables):
            key = self._hash(vec, t)
            if key not in self.tables[t]:
                self.tables[t][key] = []
            self.tables[t][key].append(idx)

    def query(self, vec, k=10):
        candidates = set()
        for t in range(self.num_tables):
            key = self._hash(vec, t)
            if key in self.tables[t]:
                candidates.update(self.tables[t][key])
        return candidates  # 还需要对候选集做精确排序

3.3 多探测 LSH

基本 LSH 的问题是空间开销大:为了达到高召回率,需要大量哈希表。多探测 LSH(Multi-Probe LSH)通过在查询时探测多个相邻桶来缓解这个问题——不仅查询命中的桶,还查询汉明距离为 1、2 的相邻桶。这样可以用更少的哈希表达到相同的召回率。

3.4 LSH 的工程局限

LSH 在理论上有优雅的近似保证,但在工程实践中存在几个显著问题:

  1. 内存开销大。每个哈希表需要存储所有向量的哈希值和指针。为了达到 >= 0.95,通常需要 50-200 个哈希表,内存膨胀数倍。
  2. 召回率-速度权衡不如 HNSW。在相同的召回率下,LSH 的查询延迟通常比 HNSW 高一个数量级。
  3. 参数选择困难。哈希位数、哈希表数量、探测数量三个参数相互关联,很难在不跑实验的情况下选出最优组合。
  4. 不支持增量更新。哈希表的桶大小不均匀,删除和更新操作效率低。

在实际系统中,LSH 已经被 HNSW 和 IVF 系列算法全面替代。但 LSH 的核心思想——通过哈希把高维空间映射到低维空间进行粗筛——仍然影响着后续算法的设计。例如 E2LSH 库在学术界有广泛使用,但在生产向量数据库中几乎看不到 LSH 索引。


四、HNSW 图索引

4.1 从 NSW 到 HNSW

可导航小世界图(Navigable Small World, NSW)是一种基于图的 ANN 索引。它把每个向量视为图中的一个节点,节点之间通过边连接。查询时从一个入口节点出发,贪心地沿着边移动到距离查询向量更近的节点,直到无法再接近为止。

NSW 的关键性质来自小世界网络(Small World Network)理论:如果图同时具有局部连通性和远程跳跃连接(Long-Range Link),那么从任意节点到目标节点的贪心搜索路径长度是 O(log N)。

NSW 图的结构示意:

  节点间距离越近,连接概率越高
  少量远程连接提供"跳跃"能力

  [A] ---近邻--- [B] ---近邻--- [C]
   |               |               |
   |  远程连接      |               |
   +---------------+--- [F] ------+
                   |
  [D] ---近邻--- [E]

分层可导航小世界图(Hierarchical Navigable Small World, HNSW)是 Malkov 和 Yashunin 在 2016 年提出的改进。核心思想是把 NSW 图分成多层,类似跳表(Skip List):

查询时从最顶层的入口节点开始贪心搜索,到达该层的局部最近邻后下降到下一层,继续搜索。这样高层提供粗粒度的远程导航,底层提供精细的局部搜索。

4.2 构建算法

HNSW 索引的构建是逐点插入的:

HNSW-INSERT(hnsw, q, M, efConstruction):
  1. 为 q 随机选择一个层级 l = floor(-ln(random()) * mL)
  2. ep = 入口点
  3. 从最顶层 L 到 l+1 层:
     在当前层做贪心搜索,找到最近的 1 个节点
     将 ep 更新为该节点
  4. 从第 l 层到第 0 层:
     在当前层做 beam search,维护 efConstruction 大小的候选集
     从候选集中选择最多 M 个邻居连接到 q
     对每个新邻居 e,如果 e 的邻居数超过 M(或 Mmax),
       执行邻居裁剪(neighbor pruning)
  5. 如果 l > L,更新入口点为 q

几个关键设计:

# FAISS 构建 HNSW 索引
import faiss
import numpy as np

dim = 768
n = 1_000_000
M = 32              # 每个节点的邻居数
efConstruction = 200 # 构建时的搜索宽度

# 生成数据
xb = np.random.randn(n, dim).astype(np.float32)

# 构建 HNSW 索引
index = faiss.IndexHNSWFlat(dim, M)
index.hnsw.efConstruction = efConstruction
index.add(xb)  # 逐点插入,耗时较长

# 查询
index.hnsw.efSearch = 64  # 查询时的搜索宽度
xq = np.random.randn(1, dim).astype(np.float32)
D, I = index.search(xq, 10)

4.3 搜索算法

HNSW 的查询算法是分层贪心搜索:

HNSW-SEARCH(hnsw, q, K, efSearch):
  1. ep = 入口点
  2. 从最顶层 L 到第 1 层:
     在当前层做贪心搜索(beam width = 1),找到最近邻
     ep = 该最近邻
  3. 在第 0 层做 beam search(beam width = efSearch):
     维护两个优先队列:
       candidates(小顶堆,按距离升序)
       results(大顶堆,按距离降序,容量 efSearch)
     初始化:将 ep 加入 candidates 和 results
     循环:
       从 candidates 取出最近的候选节点 c
       如果 d(c, q) > results 中最远的距离,终止
       对 c 的每个邻居 e(未访问过):
         如果 d(e, q) < results 中最远的距离:
           将 e 加入 candidates 和 results
           如果 results 超过 efSearch,移除最远的
  4. 从 results 中返回最近的 K 个

搜索的时间复杂度约为 O(efSearch * M * log N)。efSearch 越大,搜索越精确但越慢。在实践中,efSearch = 64 通常能达到 >= 0.95。

4.4 HNSW 的优缺点

优点

  1. 搜索速度快。在百万级数据上,单次查询延迟通常在 1 ms 以内。
  2. 召回率高。通过调整 efSearch,可以在不重建索引的情况下调整精度-速度权衡。
  3. 不需要训练。插入即可查询,适合数据持续增长的场景。
  4. 支持增量插入。新数据可以直接插入现有索引,无需全量重建。

缺点

  1. 内存占用大。每个向量除了原始数据,还需要存储图的邻接表。对于 M=32,每个节点需要额外存储 64 个 int64 指针 = 512 字节。100万条 768 维 float32 向量,原始数据占 2.87 GB,HNSW 图结构额外占约 0.5 GB。
  2. 构建速度慢。每插入一个节点都需要做搜索操作,100万条数据构建时间在分钟级别。
  3. 不支持删除。标准 HNSW 不支持删除节点(删除会破坏图的连通性)。一些实现用”逻辑删除+标记”的方式处理,但长期使用后搜索质量会下降。
  4. 不支持 GPU 加速。HNSW 的图遍历本质上是串行的,难以利用 GPU 的并行计算能力。

我认为 HNSW 是当前综合表现最好的 ANN 索引结构,特别适合数据量在百万到千万级别、对延迟要求高的场景。但在亿级数据场景下,纯 HNSW 的内存开销过大,通常需要和量化技术结合使用。


五、量化

5.1 标量量化

量化(Quantization)的目标是用更少的字节表示每个向量,从而降低存储开销和距离计算开销。最简单的量化方式是标量量化(Scalar Quantization, SQ):把每个维度的 float32 值压缩为 int8 或 int4。

# 标量量化示意
import numpy as np

def scalar_quantize_int8(vectors):
    """将 float32 向量压缩为 int8"""
    vmin = vectors.min(axis=0)
    vmax = vectors.max(axis=0)
    scale = (vmax - vmin) / 255.0
    quantized = ((vectors - vmin) / scale).clip(0, 255).astype(np.uint8)
    return quantized, vmin, scale

def scalar_dequantize(quantized, vmin, scale):
    """从 int8 恢复 float32"""
    return quantized.astype(np.float32) * scale + vmin

float32 到 int8 的量化把存储空间压缩到 1/4,同时距离计算可以用整数运算加速。召回率损失通常在 1%-3% 以内。

5.2 乘积量化

乘积量化(Product Quantization, PQ)是 Jegou 等人在 2011 年提出的经典方法,也是 FAISS 的核心算法之一。核心思想是把高维向量切分成多个低维子向量,对每个子空间独立做聚类量化。

具体步骤:

  1. 将 D 维向量切分成 \(m\) 个子向量,每个子向量 \(D/m\) 维。
  2. 对每个子空间独立执行 K-Means 聚类,得到 \(k\) 个聚类中心(Centroid)。通常 \(k = 256\),这样每个子向量用 1 字节(8 位)编码。
  3. 每个原始向量被编码为 \(m\) 个字节的编码——从 \(D \times 4\) 字节压缩到 \(m\) 字节。
# FAISS 乘积量化
import faiss
import numpy as np

dim = 768
n = 1_000_000
m = 48      # 子空间数量,每个子空间 768/48 = 16 维
nbits = 8   # 每个子空间用 8 位编码,即 256 个聚类中心

# 生成数据
xb = np.random.randn(n, dim).astype(np.float32)
xt = xb[:100_000]  # 训练集(用于学习聚类中心)

# 构建 PQ 索引
index = faiss.IndexPQ(dim, m, nbits)
index.train(xt)   # 学习聚类中心
index.add(xb)     # 编码并添加向量

# 查询
xq = np.random.randn(1, dim).astype(np.float32)
D, I = index.search(xq, 10)

PQ 的压缩比非常可观:768 维 float32 向量原始大小 3072 字节,PQ-48 编码后只有 48 字节,压缩比 64 倍。代价是召回率下降——PQ-48 在百万数据集上的 通常在 0.50-0.70 之间。

距离计算使用查找表(Lookup Table)加速。查询时先为每个子空间预计算查询子向量到 256 个聚类中心的距离,存入 \(m \times 256\) 的查找表。然后对每个数据库向量,只需做 \(m\) 次查表和加法操作,而不是 \(D\) 次乘法。

5.3 IVF:倒排文件索引

倒排文件索引(Inverted File Index, IVF)是一种粗量化(Coarse Quantization)方法。它先用 K-Means 把向量空间划分成 \(nlist\) 个 Voronoi 单元格(Cell),每个单元格对应一个倒排列表(Inverted List),存放属于该单元格的所有向量。

查询时,先找到距离查询向量最近的 \(nprobe\) 个单元格,然后只在这些单元格对应的倒排列表中搜索。

# FAISS IVF 索引
import faiss
import numpy as np

dim = 768
n = 1_000_000
nlist = 4096     # 聚类中心数量
nprobe = 64      # 查询时探测的单元格数量

xb = np.random.randn(n, dim).astype(np.float32)
xt = xb[:100_000]

# 构建 IVF + Flat 索引(倒排列表中存储原始向量)
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFFlat(quantizer, dim, nlist)
index.train(xt)
index.add(xb)

# 查询
index.nprobe = nprobe
xq = np.random.randn(1, dim).astype(np.float32)
D, I = index.search(xq, 10)

IVF 的搜索范围从全量 N 缩小到 \(N \times nprobe / nlist\)。当 \(nlist = 4096\)\(nprobe = 64\) 时,只需搜索约 1.5% 的数据。

5.4 IVF-PQ:组合索引

IVF 和 PQ 可以组合使用:IVF 负责粗筛,PQ 负责倒排列表中的精细搜索和压缩存储。这是 FAISS 中最实用的索引组合。

# FAISS IVF-PQ 组合索引
import faiss
import numpy as np

dim = 768
n = 10_000_000  # 一千万条向量
nlist = 16384
m = 48          # PQ 子空间数

xb = np.random.randn(n, dim).astype(np.float32)
xt = xb[:500_000]

quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8)
index.train(xt)
index.add(xb)

# 查询
index.nprobe = 128
xq = np.random.randn(1, dim).astype(np.float32)
D, I = index.search(xq, 10)

IVF-PQ 在一千万数据上的典型表现:

配置 内存占用 查询延迟
Flat(暴力) 28.7 GB 500 ms 1.00
IVF4096,Flat 28.7 GB 8 ms 0.98
IVF4096,PQ48 0.6 GB 2 ms 0.72
IVF16384,PQ48 0.6 GB 1.5 ms 0.68
IVF4096,PQ48 + Refine 28.7 + 0.6 GB 5 ms 0.95

最后一行的 Refine 是指:先用 IVF-PQ 做粗排取 Top-100,再用原始向量精排取 Top-10。这种两阶段检索是工程中最常用的方案。

5.5 OPQ 与残差量化

正交乘积量化(Optimized Product Quantization, OPQ)通过一个正交变换矩阵对向量做预处理,使得各子空间的量化误差更均匀,从而提高 PQ 的精度。在 FAISS 中用 OPQMatrix 实现:

# OPQ 预处理 + IVF-PQ
import faiss

dim = 768
m = 48
nlist = 4096

# OPQ 变换矩阵
opq = faiss.OPQMatrix(dim, m)

# 组合索引
index = faiss.index_factory(dim, "OPQ48_768,IVF4096,PQ48")

残差量化(Residual Quantization, RQ)是 PQ 的推广。PQ 只做一层量化,RQ 做多层级联:第一层量化原始向量,第二层量化第一层的残差,第三层量化第二层的残差。每一层都进一步减小量化误差。FAISS 2.0 引入了 ResidualQuantizer,在相同压缩比下召回率比 PQ 高 5-10 个百分点。

5.6 ScaNN

ScaNN(Scalable Nearest Neighbors)是 Google 在 2020 年提出的量化方法。它的核心创新是各向异性向量量化(Anisotropic Vector Quantization):在量化时不是最小化重建误差,而是最小化内积估计误差。具体来说,ScaNN 允许量化向量在”平行于查询方向”的分量上放大,从而在内积度量下获得更好的排序精度。

在 ann-benchmarks 的公开评测中,ScaNN 在 Recall-QPS 权衡曲线上长期处于前列,尤其在高召回率(>0.95)区间优势明显。


六、向量数据库架构

6.1 为什么需要向量数据库

FAISS 是一个索引库(Library),不是数据库。它缺少以下能力:

  1. 持久化。FAISS 索引默认在内存中,进程重启后丢失。虽然可以用 faiss.write_index 写入磁盘,但不支持事务和崩溃恢复。
  2. 分布式。单机内存有限,一亿条 768 维 float32 向量需要 287 GB,单机放不下。
  3. 元数据过滤。实际检索往往需要同时满足向量相似度和标量条件,例如”找出与查询最相似的、且 category=‘技术’ 且 date > 2024-01-01 的文档”。
  4. CRUD。生产系统需要支持增量插入、删除、更新,以及实时可见性。
  5. 多租户。SaaS 场景需要数据隔离。

向量数据库(Vector Database)在索引库之上提供了完整的数据库能力。

6.2 Milvus 架构

Milvus 是目前社区最活跃的开源向量数据库之一,由 Zilliz 公司开发。它采用存算分离(Disaggregated Storage and Compute)架构:

Milvus 2.x 架构:

  SDK / gRPC
      |
  [Access Layer]
      |   负载均衡、请求路由
      |
  [Coordinator Service]
      |   Root Coord: 元数据管理、DDL
      |   Query Coord: 查询节点调度
      |   Data Coord: 数据节点调度
      |   Index Coord: 索引构建调度
      |
  [Worker Nodes]
      |   Query Node: 加载索引到内存,执行搜索
      |   Data Node: 接收写入,生成 segment
      |   Index Node: 异步构建索引
      |
  [Storage Layer]
      |   日志存储: Pulsar / Kafka(WAL)
      |   对象存储: MinIO / S3(segment 文件)
      |   元数据存储: etcd

核心设计决策:

  1. 日志即数据(Log as Data)。所有写入先写入消息队列(Pulsar 或 Kafka),再由 Data Node 消费并写入对象存储。这保证了写入的持久性和一致性。
  2. Segment 化存储。数据按 segment 组织,每个 segment 是一个独立的数据块(类似 LSM-Tree 的 SSTable)。Growing segment 接收写入,sealed segment 触发索引构建。
  3. 索引与数据分离。Index Node 异步构建索引,构建完成后通知 Query Node 加载。写入不阻塞查询。
  4. 读写分离。Data Node 处理写入,Query Node 处理查询,各自独立扩缩容。
# Milvus Python SDK 基本操作
from pymilvus import (
    connections, Collection, FieldSchema,
    CollectionSchema, DataType, utility
)

# 连接
connections.connect("default", host="localhost", port="19530")

# 定义 Schema
fields = [
    FieldSchema(name="id", dtype=DataType.INT64,
                is_primary=True, auto_id=True),
    FieldSchema(name="title", dtype=DataType.VARCHAR,
                max_length=512),
    FieldSchema(name="category", dtype=DataType.VARCHAR,
                max_length=64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR,
                dim=768),
]
schema = CollectionSchema(fields, description="document embeddings")
collection = Collection("documents", schema)

# 创建索引
index_params = {
    "index_type": "IVF_FLAT",
    "metric_type": "IP",
    "params": {"nlist": 4096},
}
collection.create_index("embedding", index_params)

# 插入数据
import numpy as np
data = [
    ["文章标题1", "文章标题2"],           # title
    ["技术", "产品"],                     # category
    np.random.randn(2, 768).tolist(),     # embedding
]
collection.insert(data)

# 带过滤的向量搜索
collection.load()
results = collection.search(
    data=[np.random.randn(768).tolist()],
    anns_field="embedding",
    param={"metric_type": "IP", "params": {"nprobe": 128}},
    limit=10,
    expr='category == "技术"',  # 标量过滤
    output_fields=["title", "category"],
)

6.3 Qdrant 架构

Qdrant 是用 Rust 编写的向量数据库,设计目标是单机高性能和易部署。

核心特点:

  1. HNSW + 量化。默认索引类型是 HNSW,支持标量量化和乘积量化以降低内存占用。
  2. Payload 过滤。支持丰富的标量过滤条件,过滤和向量搜索在同一个查询中执行。Qdrant 的过滤策略是先根据过滤条件估算满足条件的向量数量,再决定是”先过滤后搜索”还是”搜索时过滤”。
  3. WAL + Segment。写入先写 WAL,再写入 segment。Segment 分为可写的 mutable segment 和只读的 immutable segment,后者触发索引构建。
  4. 分布式方案。通过 Raft 共识协议实现分片(Shard)间的数据复制,支持水平扩展。
# Qdrant REST API 创建集合
curl -X PUT 'http://localhost:6333/collections/documents' \
  -H 'Content-Type: application/json' \
  -d '{
    "vectors": {
      "size": 768,
      "distance": "Cosine"
    },
    "optimizers_config": {
      "indexing_threshold": 20000
    },
    "quantization_config": {
      "scalar": {
        "type": "int8",
        "always_ram": true
      }
    }
  }'

6.4 Weaviate 架构

Weaviate 使用 Go 编写,特色是内置向量化(Vectorizer Module)——可以直接传入原始文本,Weaviate 内部调用嵌入模型生成向量。索引层面同样使用 HNSW,存储层使用自研的 LSM-based 存储引擎。

Weaviate 的模块化设计支持多种向量化后端(OpenAI、Cohere、Hugging Face),适合快速搭建原型。但在生产场景中,向量化和检索解耦是更好的实践——把向量化放在应用层,避免向量数据库成为瓶颈。

6.5 Pinecone 架构

Pinecone 是托管式(Fully Managed)向量数据库,不开源。用户无需关心索引类型选择、参数调优、集群运维,只需通过 API 写入和查询。

Pinecone 的核心卖点是低运维成本和开箱即用的性能。2023 年推出的 Serverless 架构进一步降低了使用门槛——按查询次数计费,不按集群资源计费。

但 Pinecone 的局限也很明显:无法自定义索引参数,无法控制数据存储位置,成本在大规模场景下显著高于自建方案。

6.6 向量数据库选型

维度 Milvus Qdrant Weaviate Pinecone
语言 Go + C++ Rust Go 未知(闭源)
开源 是(Apache 2.0) 是(Apache 2.0) 是(BSD-3)
默认索引 IVF/HNSW/DiskANN HNSW HNSW 未知
分布式 原生支持 Raft Raft 托管
最大数据量 亿级 千万级(单机) 千万级 未公开
标量过滤 支持 支持 支持(GraphQL) 支持
GPU 索引 支持(CAGRA) 不支持 不支持 未知
适用场景 大规模生产 中小规模、易部署 快速原型 免运维

我认为在生产环境中,如果数据量超过 5000 万条且需要水平扩展,Milvus 是当前最成熟的选择。如果数据量在千万以下且团队规模小,Qdrant 的单机性能和部署简便性更有吸引力。Pinecone 适合团队没有基础设施运维能力、且预算充足的场景。Weaviate 适合需要快速验证想法的原型阶段。


七、FAISS 索引类型选择

7.1 FAISS 索引体系

FAISS(Facebook AI Similarity Search)是 Meta 开源的向量相似度搜索库。它提供了丰富的索引类型,可以通过 index_factory 字符串灵活组合。

# index_factory 字符串语法
import faiss

dim = 768

# 暴力搜索
index = faiss.index_factory(dim, "Flat")

# IVF + Flat
index = faiss.index_factory(dim, "IVF4096,Flat")

# IVF + PQ
index = faiss.index_factory(dim, "IVF4096,PQ48")

# OPQ 预处理 + IVF + PQ
index = faiss.index_factory(dim, "OPQ48_768,IVF4096,PQ48")

# HNSW
index = faiss.index_factory(dim, "HNSW32")

# IVF + HNSW 粗量化器 + PQ
index = faiss.index_factory(dim, "IVF4096_HNSW32,PQ48")

# 标量量化
index = faiss.index_factory(dim, "IVF4096,SQ8")

index_factory 字符串由三部分组成:预处理(可选)+ 倒排索引(可选)+ 编码方式。

7.2 各索引类型对比

索引类型 训练 内存 构建速度 查询速度 Recall 增量插入
Flat 慢(线性) 1.0 支持
IVF,Flat 支持
IVF,PQ 支持
IVF,SQ8 中快 支持
HNSW 支持
IVF_HNSW,PQ 中高 支持

7.3 选型决策树

根据数据量和约束条件选择索引类型:

数据量 < 10 万?
  |-- 是 --> 用 Flat(暴力搜索足够快)
  |-- 否 -->
      |
      内存是否充足(能放下全量原始向量)?
        |-- 是 -->
        |    |
        |    需要增量插入?
        |      |-- 是 --> HNSW32(M=32,无需训练)
        |      |-- 否 --> IVF4096,Flat(训练后批量导入)
        |
        |-- 否 -->
             |
             Recall@10 要求 >= 0.9?
               |-- 是 --> IVF4096,SQ8(4 倍压缩,召回损失小)
               |-- 否 --> IVF4096,PQ48(64 倍压缩,召回损失大)
               |
             需要进一步提升精度?
               --> OPQ 预处理 或 Refine 两阶段检索

7.4 GPU 索引

FAISS 支持将部分索引类型放到 GPU 上执行,利用 GPU 的并行计算能力加速暴力搜索和 IVF 搜索。

# GPU 索引
import faiss

dim = 768
n = 10_000_000

# CPU 索引
cpu_index = faiss.index_factory(dim, "IVF4096,PQ48")

# 迁移到 GPU
gpu_res = faiss.StandardGpuResources()
gpu_index = faiss.index_cpu_to_gpu(gpu_res, 0, cpu_index)

# 多 GPU
gpu_index = faiss.index_cpu_to_all_gpus(cpu_index)

GPU 索引在批量查询(Batch Query)场景下优势明显——单卡 A100 上,IVF4096,PQ48 对 10000 条查询的吞吐量可达 CPU 的 10-50 倍。但 GPU 内存有限(80 GB),大规模数据仍需要分片。

NVIDIA 在 2023 年推出的 CAGRA(CUDA Accelerated Graph-based Approximate nearest neighbor)索引是专为 GPU 设计的图索引,在 GPU 上的性能显著优于 HNSW 的 GPU 移植版本。Milvus 2.3 已经集成了 CAGRA。

7.5 磁盘索引

当数据量太大无法全部放入内存时,需要磁盘索引(On-Disk Index)。FAISS 提供了 OnDiskInvertedLists,把 IVF 索引的倒排列表存储在磁盘上,只在内存中保留粗量化器和查找表。

# 磁盘索引
import faiss

dim = 768
nlist = 65536

# 构建并训练索引
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(quantizer, dim, nlist, 48, 8)
# ...训练和添加数据...

# 将倒排列表写入磁盘
faiss.write_index(index, "index_ivfpq.faiss")

# 使用内存映射加载
index = faiss.read_index("index_ivfpq.faiss",
                         faiss.IO_FLAG_MMAP)

DiskANN(微软研究院 2019 年提出)是另一种磁盘索引方案。它基于 Vamana 图(一种类 HNSW 的图结构),将图的邻接表和量化后的向量存储在 SSD 上,查询时每个搜索步骤只需要一次随机 SSD 读取。在 NVMe SSD 上,DiskANN 对十亿级数据可以达到 5 ms 以内的延迟和 0.95 以上的召回率。Milvus 已经集成了 DiskANN 索引。


八、HNSW 参数调优

8.1 三个核心参数

HNSW 有三个核心参数,它们的含义和影响如下:

M(每层最大邻居数)

efConstruction(构建时搜索宽度)

efSearch(查询时搜索宽度)

8.2 参数对性能的影响

以 100 万条 768 维向量为例,不同参数组合的性能表现:

M efConstruction efSearch 查询延迟 构建时间 内存占用
16 100 32 0.88 0.3 ms 8 min 3.1 GB
16 100 64 0.94 0.5 ms 8 min 3.1 GB
16 100 128 0.97 0.9 ms 8 min 3.1 GB
32 200 32 0.92 0.4 ms 18 min 3.4 GB
32 200 64 0.96 0.7 ms 18 min 3.4 GB
32 200 128 0.98 1.2 ms 18 min 3.4 GB
64 400 64 0.97 1.0 ms 45 min 3.9 GB
64 400 128 0.99 1.8 ms 45 min 3.9 GB

几个关键观察:

  1. efSearch 是调优的第一抓手。它不需要重建索引,可以在运行时动态调整。先固定 M 和 efConstruction,通过调 efSearch 找到满足 Recall 要求的最小值。
  2. M 的边际效益递减。M 从 16 增大到 32,Recall 提升显著;从 32 到 64,提升变小,但内存和构建时间代价变大。
  3. efConstruction 对最终查询质量的影响大于直觉预期。构建质量差的索引,即使 efSearch 开很大也无法弥补。建议 efConstruction >= 2 * M。

8.3 调优策略

实际工程中的 HNSW 参数调优步骤:

第一步:确定内存预算和延迟约束
  例如:内存 < 4 GB,p99 延迟 < 2 ms,Recall@10 >= 0.95

第二步:选择 M
  内存充足 --> M = 32 或 64
  内存紧张 --> M = 16
  每个节点的额外内存 = (2M + M * avg_layers) * 8 字节

第三步:选择 efConstruction
  efConstruction = max(M * 2, 128)
  如果构建时间不是瓶颈,可以开到 400

第四步:用真实数据构建索引

第五步:遍历 efSearch 值,画 Recall-Latency 曲线
  for efSearch in [16, 32, 64, 128, 256, 512]:
      测量 Recall@10 和 p99 延迟
  找到满足 Recall 要求的最小 efSearch 值

第六步:如果无法同时满足 Recall 和延迟要求
  --> 增大 M 或 efConstruction,重新构建索引
  --> 或者考虑 HNSW + SQ8 量化减少内存

8.4 HNSW 与量化结合

在内存受限的场景下,HNSW 可以与标量量化或乘积量化结合使用:

# HNSW + SQ8(标量量化到 int8)
import faiss

dim = 768
M = 32

# SQ8 将 float32 压缩为 int8,内存减少 75%
index = faiss.IndexHNSWSQ(dim, faiss.ScalarQuantizer.QT_8bit, M)
index.hnsw.efConstruction = 200

# 添加数据(自动量化)
xb = np.random.randn(1_000_000, dim).astype(np.float32)
index.train(xb)
index.add(xb)

# 查询
index.hnsw.efSearch = 64
D, I = index.search(xq, 10)

HNSW + SQ8 把每个向量从 3072 字节压缩到 768 字节,同时图结构保持不变。Recall 损失通常在 1-2 个百分点以内,是工程中很好的内存-精度折中方案。


九、向量数据库基准测试

9.1 基准测试的陷阱

向量数据库的基准测试充满了误导性的数字。常见的陷阱包括:

  1. 只看 QPS 不看 Recall。高 QPS 可以通过降低 efSearch 或 nprobe 轻松实现,但代价是 Recall 大幅下降。正确的做法是在固定 Recall 下比较 QPS。
  2. 只测离线批量查询。把 10000 条查询批量提交给 GPU 索引,QPS 看起来很高。但在线服务是单条查询、要求低延迟,这两种场景的性能差异可达 10 倍以上。
  3. 不测过滤场景。纯向量搜索的性能数据对生产系统参考价值有限。加上 category = '技术' AND date > '2024-01-01' 这样的标量过滤后,性能可能下降 50% 以上。
  4. 训练数据和查询数据分布相同。用训练集的子集做查询,Recall 会虚高。应该用分布外(Out-of-Distribution)的查询向量测试。
  5. 不考虑索引构建时间和资源消耗。有些索引在查询端表现优秀,但构建需要几小时的 GPU 计算,这在需要频繁更新数据的场景中是不可接受的。

9.2 ann-benchmarks

ann-benchmarks 是最广泛使用的 ANN 算法基准测试框架。它使用标准数据集,在固定的 Recall 水平下比较各算法的 QPS。

标准数据集:

数据集 维度 数据量 度量 来源
SIFT-1M 128 100万 L2 SIFT 特征描述符
GloVe-200 200 119万 角距离 GloVe 词向量
MNIST 784 6万 L2 手写数字图像
NYTimes-256 256 29万 角距离 新闻文章嵌入
Deep-1B 96 10亿 L2 深度学习特征

在 SIFT-1M 数据集上,各算法的典型表现( = 0.95 时的 QPS,单线程):

算法 QPS
HNSW (hnswlib) 5000-8000
ScaNN 6000-10000
FAISS IVF-PQ 3000-5000
Annoy 1000-2000
FLANN 500-1500
LSH 200-500

需要注意的是,SIFT-1M 只有 128 维,在更高维度(如 768 维)的数据集上,各算法的相对排序可能发生变化。

9.3 VectorDBBench

VectorDBBench(由 Zilliz 开发)是面向向量数据库系统(而非单纯算法库)的基准测试框架。它测试完整的数据库操作链路:建表、写入、构建索引、查询,并支持带过滤的查询场景。

# 运行 VectorDBBench
pip install vectordb-bench
python -m vectordb_bench \
  --db milvus \
  --dataset cohere-1m \
  --case search_performance

VectorDBBench 的测试结果与 ann-benchmarks 有显著差异,因为它包含了网络延迟、序列化开销、过滤执行等数据库层面的开销。

9.4 构建自己的基准测试

生产环境中,通用基准测试的参考价值有限。建议构建贴合实际场景的基准测试:

# 自定义基准测试框架示例
import time
import numpy as np
from dataclasses import dataclass

@dataclass
class BenchResult:
    recall_at_10: float
    p50_latency_ms: float
    p99_latency_ms: float
    qps: float
    index_build_time_s: float
    memory_usage_mb: float

def benchmark_index(index, xb, xq, gt, k=10):
    """
    index: FAISS index
    xb: 数据库向量
    xq: 查询向量
    gt: ground truth(暴力搜索结果)
    """
    # 构建索引
    t0 = time.time()
    if hasattr(index, 'train'):
        index.train(xb)
    index.add(xb)
    build_time = time.time() - t0

    # 查询延迟
    latencies = []
    all_results = []
    for i in range(len(xq)):
        t0 = time.time()
        D, I = index.search(xq[i:i+1], k)
        latencies.append((time.time() - t0) * 1000)
        all_results.append(I[0])

    # 计算 Recall@K
    recalls = []
    for i in range(len(xq)):
        hit = len(set(all_results[i]) & set(gt[i][:k]))
        recalls.append(hit / k)

    latencies.sort()
    return BenchResult(
        recall_at_10=np.mean(recalls),
        p50_latency_ms=latencies[len(latencies)//2],
        p99_latency_ms=latencies[int(len(latencies)*0.99)],
        qps=len(xq) / sum(l/1000 for l in latencies),
        index_build_time_s=build_time,
        memory_usage_mb=index.sa_code_size() * len(xb) / 1e6
            if hasattr(index, 'sa_code_size') else -1,
    )

关键是:

  1. 使用真实的查询分布,而非从训练集随机采样。
  2. 测量 p99 延迟而非平均延迟——在线服务看的是长尾。
  3. 包含带过滤的查询场景。
  4. 考虑并发查询下的性能表现。

十、向量存储在 RAG 中的应用

10.1 RAG 管线中的向量检索

检索增强生成(Retrieval-Augmented Generation, RAG)是当前大语言模型(Large Language Model, LLM)应用中最重要的架构模式。向量存储在 RAG 管线中承担”检索”环节的核心角色。

RAG 管线:

  用户提问
      |
  [嵌入模型] --> 查询向量(768 维)
      |
  [向量检索] --> Top-K 相关文档片段
      |
  [重排序(可选)] --> 重新排序候选文档
      |
  [上下文拼接] --> system prompt + 检索结果 + 用户问题
      |
  [LLM 生成] --> 最终回答

向量检索的质量直接决定了 RAG 系统的最终效果。如果检索阶段漏掉了关键文档,后续的 LLM 再强也无法生成正确答案——这是”垃圾进,垃圾出”(Garbage In, Garbage Out)原则在 RAG 中的体现。

10.2 文档分块策略

在向量检索之前,需要把原始文档切分成适合嵌入的块(Chunk)。分块策略对检索质量影响很大:

固定长度分块:按 token 数量切分,通常 256-512 token 一个块,相邻块之间有 50-100 token 的重叠(Overlap)。

# 固定长度分块
def fixed_length_chunking(text, chunk_size=512, overlap=100):
    tokens = tokenizer.encode(text)
    chunks = []
    start = 0
    while start < len(tokens):
        end = min(start + chunk_size, len(tokens))
        chunk_tokens = tokens[start:end]
        chunks.append(tokenizer.decode(chunk_tokens))
        start += chunk_size - overlap
    return chunks

语义分块:在句子边界或段落边界切分,保持语义完整性。可以使用滑动窗口 + 相邻句子嵌入相似度来检测语义断点。

递归分块:先按段落分,如果段落太长再按句子分,如果句子还是太长再按固定长度分。LangChain 的 RecursiveCharacterTextSplitter 实现了这种策略。

分块大小的选择是一个权衡:

10.3 混合检索

纯向量检索有一个明显的弱点:对精确关键词匹配(特别是专有名词、型号、编号)不够敏感。例如,搜索”CVE-2024-1234”,向量检索可能返回语义相关但不包含这个 CVE 编号的文档。

混合检索(Hybrid Search)结合向量检索和关键词检索(通常是 BM25)来弥补这个缺陷:

# 混合检索伪代码
def hybrid_search(query, vector_store, bm25_index, k=10, alpha=0.7):
    """
    alpha: 向量分数的权重,1-alpha 是 BM25 分数的权重
    """
    # 向量检索
    query_embedding = embed_model.encode(query)
    vector_results = vector_store.search(query_embedding, k=k*2)

    # BM25 关键词检索
    bm25_results = bm25_index.search(query, k=k*2)

    # 分数归一化(Min-Max)
    v_scores = normalize(vector_results.scores)
    b_scores = normalize(bm25_results.scores)

    # 融合分数
    all_docs = {}
    for doc_id, score in zip(vector_results.ids, v_scores):
        all_docs[doc_id] = alpha * score
    for doc_id, score in zip(bm25_results.ids, b_scores):
        all_docs[doc_id] = all_docs.get(doc_id, 0) + (1 - alpha) * score

    # 按融合分数排序,返回 Top-K
    sorted_docs = sorted(all_docs.items(), key=lambda x: -x[1])
    return sorted_docs[:k]

Milvus、Qdrant 和 Weaviate 都原生支持混合检索。在实际评测中,混合检索的 Recall 通常比纯向量检索高 5-15 个百分点,尤其在包含专有名词的查询上优势明显。

10.4 重排序

初始检索(向量检索或混合检索)通常返回 Top-50 到 Top-100 的候选文档,然后用交叉编码器(Cross-Encoder)重排序到 Top-5 或 Top-10。

# 使用 Cross-Encoder 重排序
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(query, candidates, top_k=5):
    """
    candidates: [(doc_id, doc_text), ...]
    """
    pairs = [(query, doc_text) for _, doc_text in candidates]
    scores = reranker.predict(pairs)

    ranked = sorted(
        zip(candidates, scores),
        key=lambda x: -x[1]
    )
    return [(doc_id, text, score)
            for (doc_id, text), score in ranked[:top_k]]

交叉编码器和双塔嵌入模型的区别:双塔模型独立编码查询和文档,然后用内积/余弦计算相似度,适合大规模召回;交叉编码器同时编码查询和文档(拼接后送入 Transformer),能捕获更细粒度的交互,但计算成本高——对 100 条候选文档做重排序需要 100 次 Transformer 前向传播,约 200-500 ms。所以重排序只能对小规模候选集执行。

10.5 向量检索的工程实践

在生产 RAG 系统中,向量检索环节的工程实践:

1. 嵌入模型的选择

选择嵌入模型时,MTEB(Massive Text Embedding Benchmark)排行榜是重要参考。但排行榜上的分数是在通用数据集上测的,和具体业务场景的相关性有限。建议在自己的数据集上做 A/B 测试。

2. 索引类型的选择

文档数量 < 10 万         --> Flat(暴力搜索)
文档数量 10 万 - 500 万  --> HNSW(M=32,efSearch=64)
文档数量 500 万 - 5000 万 --> IVF4096,SQ8 或 HNSW + SQ8
文档数量 > 5000 万       --> 分布式向量数据库 + IVF-PQ/DiskANN

3. 向量维度的取舍

更高的维度不一定带来更好的检索效果。在 768 维和 1536 维之间,Recall 的差异通常在 1-2 个百分点,但存储和计算成本翻倍。如果模型支持 Matryoshka 表示学习(如 OpenAI text-embedding-3),可以在部署时将向量截断到更低维度,以换取速度和存储的优势。

4. 数据更新策略

RAG 系统通常需要持续接入新文档。对于 HNSW 索引,增量插入无需重建,但长期插入后图的质量会退化。建议定期(如每周)对索引做全量重建。对于 IVF 系列索引,新数据可以追加到现有倒排列表中,但如果数据分布发生显著变化,需要重新训练聚类中心。

5. 多向量检索

对于长文档,可以为每个文档生成多个向量(按分块),查询时返回匹配的分块,再通过 doc_id 回溯到原始文档。这种方案需要向量数据库支持按 doc_id 去重——Milvus 的 group_by 功能可以实现这一点。

10.6 端到端性能优化

一个 RAG 查询的端到端延迟分解:

阶段 典型延迟
查询嵌入 10-50 ms
向量检索(Top-100) 1-10 ms
重排序(Top-100 到 Top-5) 200-500 ms
LLM 生成(流式首 token) 300-1000 ms
总计 500-1500 ms

向量检索本身的延迟在整个管线中占比很小。真正的瓶颈在重排序和 LLM 生成。优化策略:

  1. 减少重排序候选集。如果向量检索的 已经足够高,就没必要取 Top-100 再重排。
  2. 异步嵌入。如果系统需要处理多个查询或子查询,可以并行执行嵌入和检索。
  3. 缓存嵌入结果。对于重复出现的查询,缓存查询向量可以省去嵌入计算的延迟。
  4. 预取和预热。在系统启动时预加载索引到内存,避免首次查询的冷启动延迟。

10.7 向量存储的未来方向

几个值得关注的技术趋势:

  1. 稀疏-稠密混合表示。SPLADE 等模型同时输出稠密向量和稀疏向量,在单一模型中融合了语义检索和关键词检索的优势。向量数据库需要支持多向量类型的联合索引。

  2. 向量压缩与量化的持续演进。从 PQ 到 RQ 到 ScaNN,量化方法在不断提升压缩比和精度的权衡。RaBitQ(2024)等新方法将二值量化的内存效率和高精度结合起来,有望进一步降低向量存储的成本。

  3. GPU 原生索引。CAGRA、GGNN 等专为 GPU 设计的图索引,利用 GPU 的高带宽和并行能力,在批量查询场景下性能远超 CPU 索引。随着 GPU 内存容量增长,GPU 原生向量数据库可能成为高性能场景的主流选择。

  4. 与关系数据库的融合。pgvector(PostgreSQL 扩展)、SQLite-vec 等项目把向量索引嵌入传统关系数据库,用户无需引入额外的向量数据库组件。对于数据量在百万以下的场景,这种方案显著简化了架构。

  5. 流式向量索引。当前大部分向量索引都是面向批量写入优化的,对于实时流入的数据(如社交媒体帖子、日志事件),需要低延迟的流式索引能力。Milvus 的 growing segment 机制是这个方向的尝试。


参考资料

  1. Jegou, H., Douze, M., & Schmid, C. (2011). “Product Quantization for Nearest Neighbor Search.” IEEE Transactions on Pattern Analysis and Machine Intelligence, 33(1), 117–128.
  2. Malkov, Y. A. & Yashunin, D. A. (2020). “Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs.” IEEE Transactions on Pattern Analysis and Machine Intelligence, 42(4), 824–836.
  3. Guo, R., Sun, P., Lindgren, E., Geng, Q., Simcha, D., Chern, F., & Kumar, S. (2020). “Accelerating Large-Scale Inference with Anisotropic Vector Quantization.” Proceedings of ICML.
  4. Subramanya, S. J., Devvrit, Kadekodi, R., Krishaswamy, R., & Simhadri, H. V. (2019). “DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node.” Proceedings of NeurIPS.
  5. Johnson, J., Douze, M., & Jegou, H. (2021). “Billion-Scale Similarity Search with GPUs.” IEEE Transactions on Big Data, 7(3), 535–547.
  6. Wang, J., Yi, X., Guo, R., Jin, H., Xu, P., Li, S., … & Xu, T. (2021). “Milvus: A Purpose-Built Vector Data Management System.” Proceedings of ACM SIGMOD.
  7. Aumuller, M., Bernhardsson, E., & Faithfull, A. (2020). “ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms.” Information Systems, 87, 101374.
  8. FAISS Wiki. https://github.com/facebookresearch/faiss/wiki
  9. Milvus Documentation. https://milvus.io/docs
  10. Qdrant Documentation. https://qdrant.tech/documentation/
  11. Weaviate Documentation. https://weaviate.io/developers/weaviate

上一篇: 时序存储引擎 下一篇: 数据湖表格式

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · db / storage

数据库内核实验索引

汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。


By .