给定一条 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\]
最近邻和最远邻的距离差异趋于零,“最近邻”的概念失去区分度。这意味着:
- 基于空间划分的索引结构(如 KD-Tree)在高维下退化为暴力搜索。
- 精确最近邻搜索(Exact Nearest Neighbor)在高维高数据量下不可行。
- 我们必须接受近似——用 Recall@K(召回率)衡量搜索质量,换取可接受的延迟。
# 维度灾难的直观演示
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 算法的质量,核心指标是 Recall@K(召回率):
\[\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},则 Recall@10 = 8/10 = 0.8。
工程中通常要求 Recall@10 >= 0.95,即 10 个结果中至少 9.5 个是真实最近邻。在 RAG 场景中,由于后续还有重排序(Reranking)环节,初始召回阶段可以适当放宽到 Recall@100 >= 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 向量:
- 存储空间:100万 * 768 * 4 字节 = 2.87 GB
- 单次查询计算量:100万 * 768 = 7.68 亿次浮点运算
在现代 CPU 上(AVX-512 指令集),单次查询约 50-100 ms。数据量到一亿条时,延迟达到 5-10 秒,无法满足在线服务的要求。
暴力搜索的优点是 Recall@K = 1.0(精确结果)和零训练开销。在数据量小于 10 万条时,它往往是最优选择——没有索引构建时间,没有额外内存开销,代码最简单。
2.2 KD-Tree
KD-Tree(K-Dimensional Tree)是最经典的空间划分索引。它沿着各维度交替切分空间,将数据组织成一棵二叉树。
构建过程:
- 选择方差最大的维度作为切分维度。
- 找到该维度的中位数,作为切分点。
- 左子树存放该维度值小于中位数的点,右子树存放大于等于中位数的点。
- 递归执行,直到叶节点中的点数小于阈值。
查询过程:
- 从根节点开始,根据查询点在切分维度上的值决定先搜索左子树还是右子树。
- 搜索到叶节点后,将叶节点中的点加入候选集。
- 回溯时,检查另一侧子树的超平面距离。如果超平面距离小于当前第 K 近邻的距离,则进入另一侧子树搜索。
- 回溯完成后,候选集中保留距离最小的 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}\) 满足:
- 如果 \(d(x, y) \leq r\),则 \(\Pr[h(x) = h(y)] \geq p_1\)
- 如果 \(d(x, y) \geq cr\),则 \(\Pr[h(x) = h(y)] \leq p_2\)
3.2 基于随机超平面的 LSH
对于余弦相似度,最常用的哈希函数是随机超平面投影(Random Hyperplane Projection):
- 随机生成一个 D 维向量 \(r\)(各分量从标准正态分布采样)。
- 定义哈希函数 \(h(x) = \text{sign}(r \cdot x)\)。如果内积大于 0,哈希值为 1;否则为 0。
- 用 \(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 在理论上有优雅的近似保证,但在工程实践中存在几个显著问题:
- 内存开销大。每个哈希表需要存储所有向量的哈希值和指针。为了达到 Recall@10 >= 0.95,通常需要 50-200 个哈希表,内存膨胀数倍。
- 召回率-速度权衡不如 HNSW。在相同的召回率下,LSH 的查询延迟通常比 HNSW 高一个数量级。
- 参数选择困难。哈希位数、哈希表数量、探测数量三个参数相互关联,很难在不跑实验的情况下选出最优组合。
- 不支持增量更新。哈希表的桶大小不均匀,删除和更新操作效率低。
在实际系统中,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):
- 第 0 层(最底层)包含所有节点,每个节点与最多 \(2M\) 个邻居相连。
- 第 1 层包含约 \(N / m_L\) 个节点(\(m_L\) 通常为 \(e^{1/\ln(M)}\))。
- 第 \(l\) 层包含约 \(N / m_L^l\) 个节点。
- 最顶层只有少数几个节点。
查询时从最顶层的入口节点开始贪心搜索,到达该层的局部最近邻后下降到下一层,继续搜索。这样高层提供粗粒度的远程导航,底层提供精细的局部搜索。
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
几个关键设计:
- 层级选择用指数衰减概率,保证每层的节点数量呈指数递减。
- 邻居裁剪不是简单保留最近的 M 个,而是使用启发式方法(Heuristic Pruning):优先保留方向多样性好的邻居,避免邻居都聚集在同一方向。这对搜索质量影响极大。
# 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
通常能达到 Recall@10 >= 0.95。
4.4 HNSW 的优缺点
优点:
- 搜索速度快。在百万级数据上,单次查询延迟通常在 1 ms 以内。
- 召回率高。通过调整
efSearch,可以在不重建索引的情况下调整精度-速度权衡。 - 不需要训练。插入即可查询,适合数据持续增长的场景。
- 支持增量插入。新数据可以直接插入现有索引,无需全量重建。
缺点:
- 内存占用大。每个向量除了原始数据,还需要存储图的邻接表。对于 M=32,每个节点需要额外存储 64 个 int64 指针 = 512 字节。100万条 768 维 float32 向量,原始数据占 2.87 GB,HNSW 图结构额外占约 0.5 GB。
- 构建速度慢。每插入一个节点都需要做搜索操作,100万条数据构建时间在分钟级别。
- 不支持删除。标准 HNSW 不支持删除节点(删除会破坏图的连通性)。一些实现用”逻辑删除+标记”的方式处理,但长期使用后搜索质量会下降。
- 不支持 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 + vminfloat32 到 int8 的量化把存储空间压缩到 1/4,同时距离计算可以用整数运算加速。召回率损失通常在 1%-3% 以内。
5.2 乘积量化
乘积量化(Product Quantization, PQ)是 Jegou 等人在 2011 年提出的经典方法,也是 FAISS 的核心算法之一。核心思想是把高维向量切分成多个低维子向量,对每个子空间独立做聚类量化。
具体步骤:
- 将 D 维向量切分成 \(m\) 个子向量,每个子向量 \(D/m\) 维。
- 对每个子空间独立执行 K-Means 聚类,得到 \(k\) 个聚类中心(Centroid)。通常 \(k = 256\),这样每个子向量用 1 字节(8 位)编码。
- 每个原始向量被编码为 \(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 在百万数据集上的 Recall@10 通常在 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 在一千万数据上的典型表现:
| 配置 | 内存占用 | 查询延迟 | Recall@10 |
|---|---|---|---|
| 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),不是数据库。它缺少以下能力:
- 持久化。FAISS
索引默认在内存中,进程重启后丢失。虽然可以用
faiss.write_index写入磁盘,但不支持事务和崩溃恢复。 - 分布式。单机内存有限,一亿条 768 维 float32 向量需要 287 GB,单机放不下。
- 元数据过滤。实际检索往往需要同时满足向量相似度和标量条件,例如”找出与查询最相似的、且 category=‘技术’ 且 date > 2024-01-01 的文档”。
- CRUD。生产系统需要支持增量插入、删除、更新,以及实时可见性。
- 多租户。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
核心设计决策:
- 日志即数据(Log as Data)。所有写入先写入消息队列(Pulsar 或 Kafka),再由 Data Node 消费并写入对象存储。这保证了写入的持久性和一致性。
- Segment 化存储。数据按 segment 组织,每个 segment 是一个独立的数据块(类似 LSM-Tree 的 SSTable)。Growing segment 接收写入,sealed segment 触发索引构建。
- 索引与数据分离。Index Node 异步构建索引,构建完成后通知 Query Node 加载。写入不阻塞查询。
- 读写分离。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 编写的向量数据库,设计目标是单机高性能和易部署。
核心特点:
- HNSW + 量化。默认索引类型是 HNSW,支持标量量化和乘积量化以降低内存占用。
- Payload 过滤。支持丰富的标量过滤条件,过滤和向量搜索在同一个查询中执行。Qdrant 的过滤策略是先根据过滤条件估算满足条件的向量数量,再决定是”先过滤后搜索”还是”搜索时过滤”。
- WAL + Segment。写入先写 WAL,再写入 segment。Segment 分为可写的 mutable segment 和只读的 immutable segment,后者触发索引构建。
- 分布式方案。通过 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(每层最大邻居数)
- 第 0 层每个节点最多 \(2M\) 条边,其他层最多 \(M\) 条边。
- M 越大,图的连通性越好,召回率越高,但内存占用和构建时间也越大。
- 典型取值:16-64。
efConstruction(构建时搜索宽度)
- 插入新节点时,搜索候选邻居的 beam width。
- efConstruction 越大,找到的邻居质量越高,索引质量越好,但构建时间越长。
- 必须 >= M。
- 典型取值:100-400。
efSearch(查询时搜索宽度)
- 查询时的 beam width,控制搜索精度与速度的权衡。
- efSearch 越大,召回率越高,延迟越大。
- 可以在查询时动态调整,无需重建索引。
- 典型取值:32-256。
8.2 参数对性能的影响
以 100 万条 768 维向量为例,不同参数组合的性能表现:
| M | efConstruction | efSearch | Recall@10 | 查询延迟 | 构建时间 | 内存占用 |
|---|---|---|---|---|---|---|
| 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 |
几个关键观察:
- efSearch 是调优的第一抓手。它不需要重建索引,可以在运行时动态调整。先固定 M 和 efConstruction,通过调 efSearch 找到满足 Recall 要求的最小值。
- M 的边际效益递减。M 从 16 增大到 32,Recall 提升显著;从 32 到 64,提升变小,但内存和构建时间代价变大。
- 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 基准测试的陷阱
向量数据库的基准测试充满了误导性的数字。常见的陷阱包括:
- 只看 QPS 不看 Recall。高 QPS 可以通过降低 efSearch 或 nprobe 轻松实现,但代价是 Recall 大幅下降。正确的做法是在固定 Recall 下比较 QPS。
- 只测离线批量查询。把 10000 条查询批量提交给 GPU 索引,QPS 看起来很高。但在线服务是单条查询、要求低延迟,这两种场景的性能差异可达 10 倍以上。
- 不测过滤场景。纯向量搜索的性能数据对生产系统参考价值有限。加上
category = '技术' AND date > '2024-01-01'这样的标量过滤后,性能可能下降 50% 以上。 - 训练数据和查询数据分布相同。用训练集的子集做查询,Recall 会虚高。应该用分布外(Out-of-Distribution)的查询向量测试。
- 不考虑索引构建时间和资源消耗。有些索引在查询端表现优秀,但构建需要几小时的 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 数据集上,各算法的典型表现(Recall@10 = 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_performanceVectorDBBench 的测试结果与 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,
)关键是:
- 使用真实的查询分布,而非从训练集随机采样。
- 测量 p99 延迟而非平均延迟——在线服务看的是长尾。
- 包含带过滤的查询场景。
- 考虑并发查询下的性能表现。
十、向量存储在 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
实现了这种策略。
分块大小的选择是一个权衡:
- 块太大(>1000 token):嵌入向量的语义被稀释,检索精度下降,且占用更多 LLM 上下文窗口。
- 块太小(<100 token):缺乏上下文信息,嵌入向量的语义表达不完整。
- 经验值:256-512 token 是多数场景下的合理起点。
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 生成。优化策略:
- 减少重排序候选集。如果向量检索的 Recall@20 已经足够高,就没必要取 Top-100 再重排。
- 异步嵌入。如果系统需要处理多个查询或子查询,可以并行执行嵌入和检索。
- 缓存嵌入结果。对于重复出现的查询,缓存查询向量可以省去嵌入计算的延迟。
- 预取和预热。在系统启动时预加载索引到内存,避免首次查询的冷启动延迟。
10.7 向量存储的未来方向
几个值得关注的技术趋势:
稀疏-稠密混合表示。SPLADE 等模型同时输出稠密向量和稀疏向量,在单一模型中融合了语义检索和关键词检索的优势。向量数据库需要支持多向量类型的联合索引。
向量压缩与量化的持续演进。从 PQ 到 RQ 到 ScaNN,量化方法在不断提升压缩比和精度的权衡。RaBitQ(2024)等新方法将二值量化的内存效率和高精度结合起来,有望进一步降低向量存储的成本。
GPU 原生索引。CAGRA、GGNN 等专为 GPU 设计的图索引,利用 GPU 的高带宽和并行能力,在批量查询场景下性能远超 CPU 索引。随着 GPU 内存容量增长,GPU 原生向量数据库可能成为高性能场景的主流选择。
与关系数据库的融合。pgvector(PostgreSQL 扩展)、SQLite-vec 等项目把向量索引嵌入传统关系数据库,用户无需引入额外的向量数据库组件。对于数据量在百万以下的场景,这种方案显著简化了架构。
流式向量索引。当前大部分向量索引都是面向批量写入优化的,对于实时流入的数据(如社交媒体帖子、日志事件),需要低延迟的流式索引能力。Milvus 的 growing segment 机制是这个方向的尝试。
参考资料
- 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.
- 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.
- 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.
- 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.
- Johnson, J., Douze, M., & Jegou, H. (2021). “Billion-Scale Similarity Search with GPUs.” IEEE Transactions on Big Data, 7(3), 535–547.
- 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.
- Aumuller, M., Bernhardsson, E., & Faithfull, A. (2020). “ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms.” Information Systems, 87, 101374.
- FAISS Wiki. https://github.com/facebookresearch/faiss/wiki
- Milvus Documentation. https://milvus.io/docs
- Qdrant Documentation. https://qdrant.tech/documentation/
- Weaviate Documentation. https://weaviate.io/developers/weaviate
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据库研究前沿】向量索引深度:HNSW、DiskANN、SPANN 原理对比
系统拆解 HNSW、DiskANN/Vamana、SPANN 三类主流 ANN 索引的原理、构建算法、查询流程与工程参数,并覆盖 IVF-PQ、ScaNN 的位置,最后给出 FAISS/Milvus/pgvector/Qdrant 的选型与一份 200 行 numpy HNSW 复现。
HNSW:图索引如何击败树索引
HNSW 是当前向量检索的事实标准算法。
乘积量化与 IVF-PQ:十亿向量的工业级检索
当向量数据集大到连 HNSW 都装不下内存,IVF-PQ 是最后一道防线。
数据库内核实验索引
汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。