2806 字
约 9 分钟
18
S20-X03 · RAG 如何选择向量数据库:ES、pgvector、Qdrant、Milvus 的取舍

向量库选型最常见的失败路径是:先看排行榜挑了吞吐最高的那个,上线三个月后发现真正卡住的是"带租户过滤的查询召回率只有四成",而这个问题在任何公开 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. 自测

  1. 1000 万条 768 维 float32 向量,光本体占多少内存?还要乘哪些系数?
  2. ef_construction 与 ef_search 分别属于一次性成本还是每次查询成本?
  3. 过滤后子集只占 2%,为什么"先取 topK 再过滤"会直接失效?
  4. pre-filter 在 HNSW 上为什么会导致召回下降?
  5. 数据量低于 full_scan_threshold 时,索引为什么"没用上"?
  6. 已有 ES、向量八百万、QPS 三十,为什么通常不该引入专用向量库?
S20-X03 · RAG 如何选择向量数据库:ES、pgvector、Qdrant、Milvus 的取舍
https://www.clxhxhhr.top/posts/5678/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。