如今几乎每个数据库都提供了向量搜索,也都很容易给出一个看似亮眼的吞吐或延迟数字。但单独的数字无法说明索引是否真的更快:数据集、向量维度、过滤条件、构建参数、并发模型,以及最关键的召回率,都会改变结果。Percona 围绕这一问题构建了 vector-bench,目标是让不同引擎的向量索引测试具备可复现、可核查的上下文。
一条“性能数字”缺了什么
比较向量检索引擎时,最常见的误区是只看 QPS 或 p99 延迟。近似最近邻(ANN)索引的本质是用精度交换搜索成本:把搜索参数调得更激进,延迟通常会下降,但返回的结果可能离真实最近邻更远。
因此,一次可解释的基准至少应固定并记录以下条件:
- 数据集:向量数量、维度、分布,以及是否已归一化。
- 距离度量:余弦、内积与欧氏距离的结果不能混在同一张排行榜里。
- 索引构建参数:例如 HNSW 的
M、ef_construction,它们影响内存、构建时间与后续查询质量。 - 查询参数:例如 HNSW 的
ef;这通常是延迟和召回率之间最直接的调节杆。 - 工作负载:批量大小、并发数、是否带元数据过滤、冷热缓存状态。
- 质量指标:至少报告
recall@k,否则无法判断“更快”是否只是少找到了正确答案。
特别是跨数据库对比时,还要记录版本、硬件、存储介质、容器资源限制和客户端所在位置。客户端与数据库不在同一台机器时,网络排队可能足以掩盖索引差异。
把召回率放回延迟曲线
更有价值的结果不是“引擎 A 为 12,000 QPS”,而是一组曲线:在相同的 recall@10 下,各引擎的 p50、p95、p99 延迟和吞吐分别是多少。
可以把测试拆成两个阶段:
- 用精确搜索或预先计算的 ground truth 得到每个查询的真实 Top-K。
- 对每个 ANN 配置执行相同查询,计算召回率,再记录延迟与吞吐。
recall@k 的计算并不复杂:ANN 返回的前 k 个 ID 中,有多少出现在精确搜索的前 k 个 ID 中,再对全部查询取平均。它不会覆盖排序质量的全部细节,但足以避免把低质量结果包装成性能优势。
一个可运行的最小 HNSW 对照实验
下面的示例不依赖某个数据库产品,用 hnswlib 构建一个可重复的最小实验。它生成固定随机种子的单位向量,使用 NumPy 暴力搜索作为 ground truth,再分别以多个 ef 值查询 HNSW。这样可以直观看到搜索参数如何改变召回率和单次查询延迟。
安装依赖:
python -m pip install numpy hnswlib
保存为 bench_hnsw.py 后运行 python bench_hnsw.py:
import time
import hnswlib
import numpy as np
SEED = 42
DIM = 128
NUM_ITEMS = 20_000
NUM_QUERIES = 200
K = 10
rng = np.random.default_rng(SEED)
data = rng.standard_normal((NUM_ITEMS, DIM), dtype=np.float32)
queries = rng.standard_normal((NUM_QUERIES, DIM), dtype=np.float32)
# 对余弦距离,先归一化,避免向量模长成为额外变量。
data /= np.linalg.norm(data, axis=1, keepdims=True)
queries /= np.linalg.norm(queries, axis=1, keepdims=True)
# NumPy 精确搜索:只用于小规模基准的 ground truth。
exact_scores = queries @ data.T
truth = np.argpartition(-exact_scores, K - 1, axis=1)[:, :K]
truth_sets = [set(row.tolist()) for row in truth]
index = hnswlib.Index(space="cosine", dim=DIM)
index.init_index(max_elements=NUM_ITEMS, ef_construction=200, M=16)
index.add_items(data, np.arange(NUM_ITEMS))
for ef in (10, 25, 50, 100, 200):
index.set_ef(ef)
# 预热,避免把首次执行成本混进统计值。
index.knn_query(queries[:20], k=K)
started = time.perf_counter()
labels, _ = index.knn_query(queries, k=K)
elapsed = time.perf_counter() - started
hits = sum(
len(set(found.tolist()) & expected)
for found, expected in zip(labels, truth_sets)
)
recall_at_k = hits / (NUM_QUERIES * K)
latency_ms = elapsed * 1000 / NUM_QUERIES
print(
f"ef={ef:3d} "
f"recall@{K}={recall_at_k:.4f} "
f"avg_latency={latency_ms:.3f}ms "
f"qps={NUM_QUERIES / elapsed:.1f}"
)
运行前可按实际场景修改 DIM、NUM_ITEMS、K 和向量来源。不要把这段随机数据的绝对 QPS 外推到生产环境;它的作用是建立一套测量纪律,并验证同一环境中“参数、召回率、延迟”三者的关系。
对于数据库或专用向量引擎,可以沿用同一个原则:使用同一份向量文件、同一批 query ID、同一份 ground truth,并将每次运行的索引 DDL、构建耗时、索引大小、查询参数和原始结果一并保存。类似 vector-bench 的工具价值也在于把这些通常遗漏的条件变成测试输入和输出的一部分。
不要忽略构建与运维成本
在线查询只是索引生命周期的一部分。HNSW 等图索引往往能提供低延迟查询,但可能消耗更多内存,构建速度和写入吞吐也会受参数影响。生产选型还应单列评估:
- 全量建索引耗时与峰值资源占用;
- 增量写入、删除和重建期间的查询表现;
- 索引文件大小、内存常驻量与恢复时间;
- 带过滤条件时的召回率和延迟;
- 数据量增长后,参数是否仍能满足延迟目标。
只测“索引已建好、缓存已热、没有过滤”的理想读取场景,通常不足以指导线上架构决策。
用可复现的比较代替排行榜
采用向量检索基准时,先定义业务可接受的最低召回率,再在该约束下比较延迟、吞吐和成本。把代码、数据版本、配置、机器规格和原始输出放进版本控制或实验存档,使其他人能够重跑并质疑结果。
真正有用的结论通常不是某个引擎永远最快,而是:在指定数据、质量目标和资源预算下,哪一种索引与参数组合更适合当前工作负载。没有这些前提条件,再大的性能数字也缺少工程意义。