主题
向量数据库:HNSW 为什么快,Milvus/Qdrant/PGVector 怎么选
本文是 AI 应用系统学习系列的 L2 核心篇。前置:34. RAG 全链路:从文档到答案。 学完可以配合面试题食用:02. RAG 流水线
先算笔账:为什么必须上索引
第 34 篇 RAG 全链路 里,检索那一步是"把用户问题转成向量,去库里找最近的几条"。当时一句带过,现在把这笔账算清楚。
100 万条文档向量,每条 1024 维。用户问一句话,把问题向量跟这 100 万条逐个算余弦相似度,取 top 10。用 numpy 暴力算:100 万 × 1024 次乘加,一次约 10 亿次浮点运算。就算 AVX 指令把单核吞吐拉到几十 GFLOPS,一次查询也是几十毫秒起步,QPS 上去以后 CPU 直接打满。这还只是 100 万条,线上知识库到 1 亿条时暴力扫一遍要按秒计。
类比:图书馆找书,暴力检索是把全馆每本书翻一遍再挑最像的。索引就是给书编目——按类别上架(聚类)、给热门书之间拉参考卡(近邻图)。查的时候不用翻全馆,沿着卡片走几步就到了。
向量索引的思路就这一句:预先组织好向量之间的关系,查询时只看一小部分候选。代价是召回率不再 100%——索引可能漏掉个别真正最近的邻居。工程上反复权衡的就是"漏多少能接受、换回多少速度"。
IVFFlat:先分桶,再查最近的几个桶
IVFFlat 是最直白的索引。建索引时把全部向量用 k-means 聚成 nlist 个簇,每个簇记一个中心点。查询时先算问题向量离哪几个中心最近,只在这 nprobe 个簇里逐条暴力算相似度。
100 万条、nlist=1024、nprobe=16:一次查询只扫约 1/64 的数据,延迟直接降一个量级。
两个参数的调法:
nlist:建议取数据量的平方根附近,100 万条对应 1000 上下。太大则每个簇太小、建索引慢;太小则每个簇太大、扫描量降不下来。nprobe:速度和召回的旋钮。nprobe=1最快但只查一个桶,问题向量落在桶边界附近时就漏了;调到 16、32,召回率上去了,延迟也上去了。
IVFFlat 的坑在数据分布变化。簇中心是建索引时定死的,之后新数据持续写入而分布慢慢漂移,边界附近的向量会越来越容易查漏。所以它一般要定期重建,或者给足 nprobe 余量。
HNSW:把跳表的思路搬到图上
HNSW(Hierarchical Navigable Small World)是现在多数场景的默认选择。它分两层看:单层是一张近邻图,每个向量是一个节点,跟自己最近的若干个点连边;多层则借鉴跳表——底层包含全部节点,往上每层节点越来越少,越往上"跨度"越大。类比地图导航:底层是街区小路,高层是环线高速,先上高速冲到目标附近,再下小路找准确位置。
查询从最顶层开始,贪心前进:看当前节点在顶层的邻居里谁离目标最近,跳过去,走不动了往下一层,继续贪心。顶层节点稀疏,几步就能横跨全库;到了底层再做精细的局部搜索。
mermaid
flowchart TD
Q["查询向量 q"] --> L2["第 2 层:3 个节点<br/>大步跳跃,快速接近目标区域"]
L2 --> L1["第 1 层:40 个节点<br/>缩小包围圈"]
L1 --> L0["第 0 层:全部 100 万节点<br/>efSearch 控制的局部精确搜索"]
L0 --> R["返回 top-k"]三个参数,各管一件事:
M:每个节点连多少条边。管图的"路网密度"。16 是常用起点,调大召回升、内存和建索引时间跟着涨——每条边都是要存的真实内存。efConstruction:建索引时每个新节点的候选邻池大小。管"施工质量"。100-200 起步,这个值只影响建索引速度和图的质量,不影响查询速度。efSearch:查询时的候选池大小。管"查询时看多少候选"。必须 ≥ top-k,常用 50-200。调它就是调召回和延迟的交换比。
HNSW 成默认配置的原因:同样召回 95%+ 时,延迟比 IVFFlat 稳得多——查询路径长度可预测,没有 IVFFlat 那种"恰好落在烂桶里"的抖动。代价两条:内存吃得凶,100 万条 1024 维向量本体约 4 GB(float32),加上图边(100 万 × 16 边 × 8 字节 ≈ 128 MB 起步)和索引元数据,实际占用往往 1.5 倍以上;删除麻烦,HNSW 的图结构里节点被大量边引用,物理删除要么打标记、查询时跳过(内存不还),要么触发整图重建。
量化:内存不够时的那道减法
向量本体是内存大头。1024 维 float32 一条 4 KB,1 亿条就是 400 GB,HNSW 图再乘 1.5 倍,单机直接放不下。量化索引的思路是把 4 字节浮点压成更短的表示:
- SQ(标量量化):每个维度从 float32 压成 int8,4 倍压缩。精度损失小,多数场景召回几乎不掉。
- PQ(乘积量化):把 1024 维切成 64 段,每段 16 维用 k-means 压成一个 8 位码本编号,一条向量从 4 KB 压到 64 字节,64 倍压缩。代价明显:召回掉 5-15 个点,需要配合训练和重排(先粗筛再精排)兜底。
PQ 压到 64 倍还把召回拉回来的常用组合拳是 IVF-PQ + 重排:先用压缩表示召回 top 100,再用原始向量精排取 top 10。磁盘资源紧张、库又大到放不下原向量时,这是标准配置。
判断标准一句话:内存够就上 HNSW 原始精度,内存差 4 倍以内用 SQ,差 10 倍以上才考虑 PQ + 重排的复杂度。
元数据过滤:pre-filter 和 post-filter
真实 RAG 检索从来不是纯向量查询,典型请求长这样:"只搜『2025 年年报』这个文档里的内容"。这就是元数据过滤,它的执行顺序有讲究。
post-filter:先向量检索拿 top-k,再按元数据把不满足的扔掉。问题:如果过滤条件很严(比如命中的文档只占 1%),top 50 里可能一条都剩不下。用户看到的不是"相关结果",是空结果。
pre-filter:先用元数据筛出候选集,再在候选集内做向量检索。没有空结果问题,但传统索引结构下,"在 1 万个指定文档里找向量近邻"没法直接用 HNSW 的图遍历(图是按全库建的,走两步就跳出过滤集了),可能退化成对过滤集的暴力扫描——过滤集大时反而慢。
各家对这个问题解法不同,也是选型差异点之一:Qdrant 的过滤索引做得最完整,可过滤字段自带倒排结构,pre-filter 后仍能沿图走且在过滤基数高时自动切换策略;Milvus 支持分区(partition key),把常用过滤字段建为分区可物理隔离数据;PGVector 直接借助 SQL 的 WHERE,行级过滤天然是 pre-filter,但同样的图退化问题存在。经验规则:过滤条件命中占比低于 5% 时,优先选过滤索引做得好的引擎(Qdrant),或在 Milvus/PGVector 里按该字段做分区。
动手实操
三步走:先手搓暴力检索做基线,再用 hnswlib 建索引对比,最后给 PGVector 的建表和查询 SQL。
python
# pip install numpy hnswlib
import time
import numpy as np
import hnswlib
rng = np.random.default_rng(42)
N, D, K = 1_000_000, 1024, 10
data = rng.standard_normal((N, D)).astype(np.float32) # 1M 条 1024 维
query = rng.standard_normal((1, D)).astype(np.float32)
# --- 基线:暴力余弦检索 ---
t0 = time.perf_counter()
data_norm = data / np.linalg.norm(data, axis=1, keepdims=True) # 归一化后点积=余弦
q = query / np.linalg.norm(query)
sims = data_norm @ q # 1M 次点积
top = np.argpartition(-sims, K)[:K] # 只取前 K,避免全排序
t1 = time.perf_counter()
print(f"暴力检索: {t1-t0:.1f}s") # 单核约 0.3-1s,视 CPU 而定不归一化的话点积就不是余弦相似度,这行是新手最常见的静默错误。跑完这个基线你就有了体感:几十毫秒的"向量检索 SLA"在暴力扫描下根本不存在。
python
# --- HNSW:建索引 + 查询 + 召回率对比 ---
index = hnswlib.Index(space='cosine', dim=D)
t0 = time.perf_counter()
index.init_index(max_elements=N, ef_construction=200, M=16)
index.add_items(data, num_threads=8) # 建索引:只做一次
t1 = time.perf_counter()
print(f"建索引: {t1-t0:.0f}s")
index.set_ef(100) # 查询候选池,管召回率
t0 = time.perf_counter()
labels, dists = index.knn_query(query, k=K)
t1 = time.perf_counter()
print(f"HNSW 查询: {(t1-t0)*1000:.2f}ms") # 单核毫秒级
# 召回率 = 索引结果与暴力结果的交集占比
recall = len(set(labels[0]) & set(top)) / K
print(f"召回率: {recall:.0%}") # 通常 90-100%把 set_ef 从 100 降到 20 再跑一遍,你会看到延迟降但召回掉——这就是那个旋钮的手感。建索引约 1-3 分钟、单次查询毫秒级、召回 95% 上下,是 100 万条数据下 M=16 / efConstruction=200 / efSearch=100 的典型成绩。
sql
-- PGVector:百万级以下知识库的省事之选
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (
id bigserial PRIMARY KEY,
content text NOT NULL,
source text NOT NULL, -- 元数据过滤字段
embedding vector(1024) NOT NULL
);
-- HNSW 索引:m 和 ef_construction 含义同 hnswlib
CREATE INDEX ON docs
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- 查询:pre-filter 天然由 WHERE 表达
SET hnsw.ef_search = 100;
SELECT id, source, embedding <=> '[...]'::vector AS dist
FROM docs
WHERE source = '2025年报' -- 先过滤再检索
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;<=> 是 PGVector 的余弦距离操作符。WHERE + ORDER BY 组合写起来就是普通 SQL,业务代码不用引新客户端——这是"已用 PG 就先用 PGVector"的核心理由。
常见误区与小结
- 上来就部署 Milvus 集群。百万级以下 PGVector 或 Qdrant 单机足够,多一个分布式组件就多一份运维负担;规模门槛(亿级、多副本、高写入)到了再迁移不迟。
- 只看延迟不看召回率。把 efSearch 调到个位数,延迟漂亮,检索质量已经塌了,而 RAG 答案质量下降往往几周后才被用户抱怨发现。
- 余弦检索不做归一化。用点积当相似度却不归一化,排序结果混入向量长度偏差;OpenAI 等家的 embedding 输出已归一化,自训模型要自己确认。
- 把高选择性过滤交给 post-filter。top-k 里被过滤得一条不剩时,用户看到的是空结果,解法是 pre-filter + 过滤索引或分区。
- 忽略删除成本。HNSW 删除只打标记不还内存,频繁增删的知识库要么定期重建,要么选对删除更友好的存储(如 DiskANN 系或带物理删除的引擎)。
在整条学习路径上,本文补上了第 34 篇 RAG 全链路 中"检索"环节的底层账:暴力扫描为什么不行、IVFFlat 和 HNSW 分别怎么组织数据、量化怎么换内存、过滤在索引的哪一层生效。下一篇 48. RAG 文档处理 往上游走:文档进来先经过哪些清洗和切分,才轮到本文的向量库登场。
参考
- hnswlib 官方仓库与算法论文:Malkov & Yashunin, Efficient and robust approximate nearest neighbor search using HNSW graphs(arXiv:1603.09320)
- Qdrant 文档:Filtering 与 HNSW 参数章节(qdrant.tech/documentation)
- PGVector 官方 README:索引类型与操作符(github.com/pgvector/pgvector)