向量库选型最常见的失败路径是:先看排行榜挑了吞吐最高的那个,上线三个月后发现真正卡住的是"带租户过滤的查询召回率只有四成",而这个问题在任何公开 benchmark 里都不存在。
所以选型不是选最快的检索引擎,是在六个约束里找那个不需要你改业务模型的组合。顺序:先把约束写成数字 → 再看索引原理能否满足 → 最后看过滤语义。反过来选,必然返工。
1. 六个维度:先把约束写成数字
| 维度 | 要回答的问题 | 量化口径 | 会翻盘的阈值 |
|---|---|---|---|
| 数据量 | 条数与维度 | N × d × 4B(float32) |
千万级以上开始吃专用架构红利 |
| 过滤复杂度 | 等值还是多条件组合 | 过滤后子集占比(选择性) | 子集 < 1% 时暴力扫更快 |
| 混合检索 | 是否要 BM25 + 向量 | 有无强专名/型号/代码查询 | 有则优先同栈能兼做的方案 |
| 运维成本 | 能否维护多组件 | 新增有状态组件数 | 只有 DBA → pgvector |
| 一致性 | 向量与业务数据同事务 | 删除是否必须实时可见 | 强一致 → 同库方案 |
| 成本 | 内存单价 vs 磁盘 | 常驻内存 GB / 月 | HNSW 全内存的账单常被低估 |
先把最基础的一条算清楚——纯向量本身要多少内存:
def vector_gb(n: int, d: int, bpe: int = 4) -> float:
return n * d * bpe / 1024**3 # 不含索引结构与副本
for n in (1_000_000, 10_000_000, 100_000_000):
print(f"{n:>13,} 条 768 维 float32 -> {vector_gb(n, 768):8.2f} GB")
典型输出:
1,000,000 条 768 维 float32 -> 2.75 GB
10,000,000 条 768 维 float32 -> 27.49 GB
100,000,000 条 768 维 float32 -> 274.88 GB
图结构另算:邻接表与 M 成正比且每层都存,整体常在向量本体的 1.2–2 倍(按实现实测)。看到 100M 条这个数,就该知道"全内存 HNSW"不是默认项。
2. 四种方案的定位与短板
| 方案 | 定位 | 强项 | 短板(真实卡点) |
|---|---|---|---|
| Elasticsearch | 搜索栈顺带做向量 | BM25 原生、filter 成熟、高亮与聚合齐全 | 向量性能非第一目标;堆与段合并压力大 |
| pgvector | 关系库里的向量列 | 同事务、同备份、同权限,零新增组件 | 单机扩展上限;HNSW 构建慢且全内存 |
| Qdrant | 专用向量库(Rust) | payload filter 与检索结合紧,稀疏向量、量化 | 新组件与新运维经验;分布式能力按版本/授权 |
| Milvus | 云原生向量数据库 | 存算分离、十亿级、多索引类型 | 组件多(etcd/对象存储/消息队列),小数据量过度设计 |
一句话记法:ES 是"搜索里能放向量",pgvector 是"数据库里能放向量",Qdrant 是"只为向量而生",Milvus 是"为规模而生"。
3. 索引原理:HNSW 与 IVF-PQ 在拿什么换什么
近似检索的本质是同一件事:用召回率换延迟和内存,区别只在换法。
3.1 HNSW:多层可导航小世界图
点以指数衰减概率分配到高层,查询从顶层贪心下降,在底层做 ef_search 宽度的候选扩展。
| 参数 | 作用 | 调大的代价 | 调小的代价 |
|---|---|---|---|
M |
每点每层保留的邻居数 | 内存线性涨、构建变慢 | 图连通性差,召回掉 |
ef_construction |
构建时候选宽度 | 建索引时间显著增加 | 图质量差,事后补不回来 |
ef_search |
查询时候选池 | 延迟涨,近似线性 | 召回掉,过滤后尤其 |
ef_construction 是一次性成本,ef_search 是每次查询成本:构建期大方,查询期精打细算。
3.2 IVF-PQ:先聚类再压缩
IVF 用 k-means 把向量分到 nlist 个桶,查询只扫最近 nprobe 个;PQ 把 d 维切成 m 段、每段 8bit 码本编码,内存压到约 1/m。
| 维度 | HNSW | IVF-PQ |
|---|---|---|
| 内存 | 高(本体 + 图) | 低(压缩码 + 原始向量可落盘) |
| 召回 | 高,对过滤鲁棒 | 中,依赖 nprobe 与码本质量 |
| 构建 | 慢,增量插入友好 | 快,但新增数据要重训或落增量桶 |
| 适合 | 千万级内、低延迟 | 亿级、内存受限、可接受召回损失 |
内存够就 HNSW,不够才 PQ。PQ 的召回损失是结构性的,调 nprobe 只能补回一部分。
3.3 建索引
import psycopg
DDL = """
CREATE INDEX CONCURRENTLY idx_docs_emb ON docs
USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 200);
"""
with psycopg.connect("postgresql://USER:PASS@PG_HOST:5432/app") as conn:
conn.execute(DDL)
full_scan_threshold 这类参数常被忽略:低于阈值的 collection 走暴力扫描,反而更准。小数据量下"建了索引却没用上"不是 bug,是设计。
4. ef_search / nprobe 的实测影响
别信默认值,只需要一条曲线:横轴参数,纵轴 recall@10,副轴 p95 延迟。
def sweep(ef_values, index, queries, gt, k=10):
for ef in ef_values:
index.set_ef(ef)
hit = sum(len(set(h.ids(index, q, k)) & set(g[:k])) / k
for q, g in zip(queries, gt)) / len(queries)
print(f"ef={ef:<4} recall@{k}={hit:.3f}")
典型输出(示意,非基准):
ef=32 recall@10=0.812
ef=64 recall@10=0.907
ef=128 recall@10=0.958
ef=256 recall@10=0.981
| 参数 | 增大后的收益 | 增大后的成本 | 起点建议 |
|---|---|---|---|
ef_search |
召回上升,边际递减 | 延迟近似线性 | 从 64 起,取曲线拐点 |
nprobe |
召回上升,补偿压缩损失有限 | 扫描桶数线性增 | nlist 的 5%–10% |
M |
召回与连通性 | 内存、构建时间 | 16 / 32 两档够用 |
num_candidates(ES) |
过滤场景决定召回上限 | 延迟 | 至少 k 的 10 倍 |
过滤场景要单独扫一遍曲线。 无过滤时 ef=64 够用,子集只剩 2% 时可能要 ef=256——因为图在子集上被切碎了。
5. filter 与向量检索的执行顺序:召回损失就藏在这里
| 策略 | 执行方式 | 召回 | 延迟 | 风险 |
|---|---|---|---|---|
| pre-filter | 先滤出 ID 集再检索 | 理论上最高 | 子集小时低 | 子集上图不连通,召回骤降 |
| post-filter | 先 topK 再筛 | 低 | 最低 | 过滤后不足 k 条 |
| 索引内过滤 | 遍历图时判断条件 | 高 | 中 | 依赖引擎实现,需实测 |
用 ES 时,filter 通过 num_candidates 表达"愿意为过滤多花多少候选预算":
{
"knn": {
"field": "embedding",
"query_vector": [0.013, -0.221, 0.087],
"k": 10,
"num_candidates": 200,
"filter": {"bool": {"must": [{"term": {"tenant_id": "t_42"}}],
"must_not": [{"term": {"status": "archived"}}]}}
},
"size": 10,
"_source": ["doc_id", "title", "section_path"]
}
num_candidates 是 ES 8.x 的 kNN 参数(按 8.x 文档核对):太小过滤后凑不满 k,太大延迟上升。pgvector 侧则是 WHERE + SET LOCAL hnsw.ef_search:
BEGIN;
SET LOCAL hnsw.ef_search = 200; -- 过滤越狠,这里越要给足
SELECT id, title, 1 - (embedding <=> $1) AS score
FROM docs
WHERE tenant_id = $2 AND status <> 'archived'
ORDER BY embedding <=> $1
LIMIT 10;
COMMIT;
过滤后子集低于 1% 时,暴力扫描往往比走索引更快也更准——这时该关索引,而不是继续加大参数。
6. 何时不该引入专用向量库
引入新组件的代价不是部署一次,是备份、监控、升级、演练、值班手册各加一份。先问:现有栈真的不够吗?
| 现状 | 结论 | 理由 |
|---|---|---|
| 已有 ES,向量 < 千万,QPS < 几十 | 用 ES dense_vector |
混合检索天然同栈,少一个组件省一年运维 |
| 数据在 Postgres,强一致 | pgvector | 同事务删除/更新,不会"文档删了向量还在" |
| 亿级、多租户隔离、独立扩缩容 | Milvus / Qdrant 集群 | 分片与副本能力值这个运维成本 |
| 检索是核心,要量化与稀疏向量 | Qdrant | 单一职责,参数面比 ES 干净 |
| 无 SRE、只有一名后端 | pgvector | 复用现有备份与监控 |
这张表的作用不是给出答案,而是把"要不要引入新组件"变成可被反驳的论证。写不下来时,答案通常是"先不引入"。
7. 排错:五种"选了却不好用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 建了索引但查询很慢 | 数据低于 full_scan_threshold,走暴力扫描 |
先确认执行计划,别急着调 ef |
| 带 filter 召回骤降 | pre-filter 在子集上图不连通,ef 不够 |
提 ef/num_candidates,或改索引内过滤 |
结果条数少于 k |
post-filter 把候选筛空 | 扩大候选池,或把过滤下推到检索 |
| 内存涨不回落 | 邻接表 + 段合并峰值 + 副本数叠加 | 按"本体 × 1.5 × 副本"重算容量 |
| 更新后仍召回旧内容 | 向量写入与业务更新不同步 | 同库事务,或写入加幂等键与版本号 |
第二、三条同根:你当过滤是事后筛选,引擎当它是搜索空间的定义。
8. 边界:这些情况本就该换思路
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 语料几万条 | 内存暴力算余弦 | 无网络开销,毫秒级,零运维 |
| 查询以精确匹配为主(订单号、型号) | BM25 / 倒排 + 规则 | 向量在这类查询上本就不占优 |
| 强一致多表联查 | 关系库 + pgvector | 跨库一致性靠补偿逻辑不可靠 |
| 只判"能否找到",不要求排序质量 | 关键词检索 | embedding 收益不抵成本 |
| 十亿级但预算有限 | IVF-PQ + 原始向量落盘 | 全内存 HNSW 的账单不可持续 |
选型可以分阶段演进:先 pgvector 跑通链路、积累评测集,等 recall 与延迟的瓶颈真的出现在检索层再迁移;到那时手上的评测集、慢查询分布、过滤选择性统计,才是换架构的依据。
9. 自测
- 1000 万条 768 维 float32 向量,光本体占多少内存?还要乘哪些系数?
ef_construction与ef_search分别属于一次性成本还是每次查询成本?- 过滤后子集只占 2%,为什么"先取 topK 再过滤"会直接失效?
- pre-filter 在 HNSW 上为什么会导致召回下降?
- 数据量低于
full_scan_threshold时,索引为什么"没用上"? - 已有 ES、向量八百万、QPS 三十,为什么通常不该引入专用向量库?