向量库选型别只看 QPS:过滤、更新与一致性才是分水岭
向量数据库的选型表里,QPS 和 Recall@10 几乎总被放在最醒目的位置,但它们对生产事故的解释力很弱。真正让 RAG 系统上线后“用着用着就不准了”的,通常是另外三件事:租户过滤把召回打到接近零、增量更新把写入放大成 IO 风暴、异步复制让“刚写就读不到”变成偶发空结果。这三件事都不会出现在单机 benchmark 的吞吐数字里。
一、过滤塌陷:返回的 topK 里一条都不属于该租户
HNSW 的检索是在分层图上做贪心游走:从上层入口点开始,每层在候选池(大小由 ef 控制)里找更近的邻居,逐层下沉到第 0 层,再从候选池取 topK。整个过程只依赖“距离”,不依赖“谓词”。过滤条件在哪个环节介入,决定了它在高选择性场景下的表现。
| 策略 | 做法 | 失效场景 |
|---|---|---|
| 后过滤 post-filter | 先取 topK(或 topK×n),再按谓词筛 | 谓词命中率低时,候选里几乎没有合格项,召回趋近 0 |
| 前过滤 pre-filter | 先算出合格 ID 集合,再在其中暴力扫描或走子集索引 | 合格集大时退化为全表扫描,延迟随数据量线性增长 |
| 过滤感知 filter-aware | 遍历时剪枝,只沿合格节点扩展;候选池按命中率自适应放大 | 图连通性被剪断时需要补偿机制(额外边、多轮迭代扫描) |
给一个可用于判断的量:设某租户数据占比 s,目标 topK=K。纯后过滤的期望合格结果数约等于 s×K。当 s=0.1%、K=10,期望值是 0.01 条——绝大多数查询会返回空或一两条。此时你在全库无过滤条件下测出的 Recall@10 可能是 0.95,在多租户过滤下只有 0.02,而 benchmark 只报了前者。
这就是“SaaS 多租户上量后召回突然变差”的典型成因:早期租户少,每租户占比高,问题被掩盖;租户数上千后 s 掉两个数量级,同一个查询从“能答”变成“答非所问”。
工程判据:
- 若 s×K 的量级低于 10,后过滤不可接受,必须走路由或过滤感知。
- 租户隔离优先用“路由”而不是“谓词”:collection/partition per tenant,或按 tenant_id 哈希的 partition key(如 Milvus 的 partition key 一类机制),让过滤在查询规划阶段变成“扫哪些分片”,而不是图遍历里的逐点判断。代价是租户数过多时的元数据和碎片开销,常见折中是“大租户独立分区 + 小租户共享分区 + key 路由”。
- 过滤条件是多维组合时(tenant + 时间 + 类目 + ACL 权限位),要确认引擎是否支持过滤感知遍历,以及结果不足 K 条时是否会自动扩大候选池继续扫(pgvector 较新版本引入的 iterative index scan、Qdrant 的 filterable HNSW 都属于这一类思路,具体版本支持需实测)。结果不足就返回一半,是召回塌陷最常见的表现形式。
特别注意权限位过滤:行级 ACL 几乎对每个查询都生效,且选择性极高,是最容易踩的一类。
区分两个说法:“支持过滤”是 API 能力,“过滤下不塌陷”是召回保证。文档里写的是前者,你要压的是后者。
二、写放大:更新一条向量的成本不等于 UPDATE 一行记录
插入路径大致是:写 WAL → 进内存缓冲 → 建索引。HNSW 的插入要在图上找 ef_construction 个候选邻居、连边,并对超额邻居做剪枝重连——这是一次带随机访问的图遍历,写的是索引结构的随机位置,不是顺序追加。
更新更贵。向量一变,它在图里的邻居关系就失效了,多数实现的实际动作是“标记旧点删除 + 插入新点”,成本接近两次写。旧点变成墓碑(tombstone),要等后台 compaction 才真正回收;回收之前它仍占内存、仍参与遍历、还要在结果里被过滤掉——删除越多,有效候选越稀,召回会随更新次数缓慢退化。
给个容量感:1536 维 float32 单条约 6KB,100 万条约 6GB 纯向量;HNSW 邻接表按 M=16 估,加上层级放大,整体常驻内存通常是原始向量的 1.2~1.5 倍。100 万条 1536 维,内存预算按 8~10GB 起算,还没算 payload 和副本。
写放在系统层面的体现是后台 compaction。分段存储的实现里,写入先落到小段,后台不断把小段合并成大段:重读旧段、重建索引、写新段、删旧段,实际写入字节数是逻辑写入的数倍(系数取决于分层策略)。这个过程与在线查询抢 IO 和 CPU,典型症状是“导入任务一跑,线上 p99 从 40ms 跳到 800ms”。
缓解手段按性价比排序:
- 批量导入不要逐条 insert。攒批(几千到几万条,按内存和超时权衡)走批量构建路径,一次性建图,比逐条插入快一个量级,产生的段也更少。
- 把高频变更的元数据挪出向量库。价格、库存、状态、时间戳这类字段热更新,等于把向量库当 KV 用,会持续触发删除+插入。正确做法是向量库存不可变的语义 ID 和稳定文本向量,检索拿到 ID 后回 OLTP 表或缓存取最新字段。这一条能砍掉写放大的主要来源。
- 需要原地更新时,考虑 append-only + 版本号 + 查询时按版本过滤,把随机写转成顺序写,再用周期性全量重建收敛墓碑。
- compaction 与索引重建限速,与查询做资源隔离(独立 IO 配额、错峰调度)。生产上盯三个指标:未合并段数、墓碑比例、后台任务队列长度——它们比 QPS 更早预示抖动。
三、可见性:“写入成功”的语义边界在哪里
“写入返回 200,紧接着查询读不到”,根因通常是三个不同层次的异步性被混为一谈:
- 索引构建异步:写入先落 WAL/日志,索引由后台异步构建。API 返回成功只代表“日志持久化了”,不代表“可检索”。
- 副本复制异步:主副本写完即返回,从副本还在追。读请求被负载均衡打到从副本,就读不到。
- 路由元数据缓存:新建的 collection/partition、新增节点,客户端或代理层的路由还没刷新。
三层的可见性语义不同,需要分开确认:写入接口有没有一致性级别参数(强一致 / 有界延迟 / 最终一致)?有没有显式刷新调用,代价多少?写入返回体是否带版本号或时间戳,能否作为读端的最小版本约束?读到过期数据时,引擎返回旧结果还是返回“未就绪”?
比“写后 sleep 500ms”更靠谱的做法是引入版本令牌:
# 写入侧:取回服务端分配的单调提交版本
res = client.upsert(collection, points, consistency="strong")
version = res.timestamp
# 读侧:把版本作为下界,服务端保证不返回更旧的状态
hits = client.search(
collection, vector,
filter={"tenant_id": tid},
consistency="bounded",
min_version=version, # 读己之所写
timeout_ms=200,
)
引擎不支持版本下界时,退一步:写入后的一小段时间内,把该租户的查询粘到主副本(sticky read),或维护“该租户最后写入版本”的缓存,读请求先做版本校验再决定路由。代价是延迟和主副本压力,但对“提交后立刻要看到”的交互式场景,通常比返工整条召回链更便宜。
另外两个容易忽略的点:
- 删除的可见性通常比写入更弱。删除是墓碑,检索结果里的过滤可能存在一致性窗口。涉及合规删除时,必须确认“删除生效”的语义边界,而不是相信 API 返回。
- 并发更新要带版本做条件写,只有更大版本才能覆盖,否则重试和乱序会写出数据回退。
四、按查询形态选型,而不是按 benchmark 排名
把候选系统按“你的查询形态”过一遍,顺序如下表(这是判断顺序,不是产品推荐):
| 形态特征 | 真正的瓶颈 | 选型侧重点 |
|---|---|---|
| 租户上千,单租户占比 <0.1% | 过滤下的召回 | 原生分区路由;能否按租户分片建索引 |
| 条件是多维组合(租户+时间+类目+ACL) | 谓词与图遍历的耦合 | 过滤感知遍历 + 结果不足时的迭代扫描 |
| 写入持续数百 TPS,且要边写边查 | 写放大与后台抖动 | 批量构建路径、段合并限速、读写资源隔离 |
| 数据量 <500 万且更新频繁 | 运维复杂度 | 关系库内向量扩展往往够用,事务与一致性直接复用 |
| 要求“提交即可见” | 可见性语义 | 一致性级别参数、版本下界、强一致读的延迟成本 |
| 向量 + 结构化过滤 + 聚合分析 | 查询混合度 | 带向量能力的搜索引擎/分析型库,避免两套系统做 join |
几个常见误区:
- “支持过滤”不等于“过滤下召回稳定”,必须拿自己的选择性分布去压。
- 数据量不大时,专用向量库的性能优势抵不过运维成本:多一套集群、备份、监控,以及一套新的一致性语义要理解。千万级以下、更新频繁、已有关系库的场景,先评估库内扩展。
- 分布式专用库的扩展性是为“超大规模 + 稀疏写入”设计的。负载特征是“中等规模 + 高频更新”时,它反而会在 compaction 和元数据开销上吃亏。
- 别按单机 benchmark 选分布式系统。benchmark 数据集往往无过滤、一次性灌入、查询均匀分布,恰好绕开了上面三个故障面。
五、压测:把选择性、可见性、抖动做成曲线
只有“QPS 3k、Recall@10 0.94”的报告,回答不了“上线后会不会塌”。补四组曲线:
- 固定过滤选择性下的召回曲线。取 0.01%、0.1%、1%、10%、100% 五档,每档同时算 Recall@10 和空结果率。空结果率是比 Recall 更直观的塌陷信号——用户感知到的就是“检索不到”。
- 写后可见延迟分布。写入时打时间戳,用探针持续以该向量检索自身,记录从写入返回到首次命中的时间差,报 p50/p99,不要报平均值。
- 后台任务并行下的查询抖动。在灌数据/更新/触发 compaction 的同时跑查询,记录 p99 与超时率,对比空闲基线的倍数。
- 混合负载拐点。读写比按 9:1、7:3、5:5 递增写入占比,找 p99 开始非线性上升的点,那就是容量水位。
Ground truth 必须在同一过滤条件下用暴力扫描算:
import numpy as np
SEL = [1e-4, 1e-3, 1e-2, 1e-1, 1.0]
K = 10
report = {}
for sel in SEL:
recalls, empties = [], []
for q in queries: # 固定种子生成的查询集
tids = sample_tenants(sel) # 按选择性抽取租户
mask = np.isin(doc_tenant, tids) # 同过滤条件的基准集
gt = np.argsort(dist(q, doc_vec[mask]))[:K]
hits = client.search(q, topk=K,
filter={"tenant_id": {"$in": tids}})
recalls.append(len(set(hits) & set(gt)) / K)
empties.append(1.0 if not hits else 0.0)
report[sel] = (np.mean(recalls), np.mean(empties))
print(report) # 重点看 sel<=0.1% 档的召回与空结果率
执行细节:
- 查询集按真实分布采样(重复问法、长尾问法、带全部默认过滤条件的),不要全用随机向量。
- 每轮压测前记录后台任务状态(未合并段数、墓碑比例、副本延迟),否则解释不了抖动来自哪里。
- 记录成本:每百万条向量的内存占用、副本数、冷启动加载索引到可服务的时间。冷启动时间只在故障恢复时才体现价值,但它决定了恢复时长。
- 压测数据的租户分布要贴近生产:长尾分布和均匀分布,会得出完全不同的结论。
落地建议
- 先量化过滤选择性,再谈选型。统计线上查询的 (租户数据占比, 过滤条件命中率) 分布,算出 s×K 的量级。低于 10 的查询占比超过 5%,就必须把“过滤下的召回”作为选型第一约束。
- 把可变字段挪出向量库。向量库只存不可变 ID + 稳定向量,价格、库存、状态回 OLTP 或缓存取。这一条同时降低写放大和一致性风险。
- 写入接口必须返回版本号,读路径支持版本下界或租户级 sticky read。把“刚写就读不到”从偶发 bug 变成可预期的语义,并在 SLO 里写明允许的可见延迟。
- 压测报告固定包含三张图:选择性-召回(含空结果率)曲线、写后可见延迟 p99、后台任务并行下的 p99 抖动倍数。缺任何一张,容量结论都不成立。
- 为 compaction 和冷启动预留预算。容量规划按峰值写入量的 3~5 倍预留合并 IO,并实测“重启到可服务”的耗时,把它写进故障恢复预案。
- 点赞
- 收藏
- 关注作者
评论(0)