向量库选型别只看 QPS:过滤、更新与一致性才是分水岭

举报
上云 发表于 2026/11/09 08:59:30 2026/11/09
【摘要】 从多租户过滤下的召回塌陷、增量更新的写放大、主从延迟导致的读不到三类生产故障切入,给出按查询形态选型的方法,以及覆盖过滤选择性、写后可见延迟、后台抖动三组曲线的压测方案。

向量数据库的选型表里,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”。

缓解手段按性价比排序:

  1. 批量导入不要逐条 insert。攒批(几千到几万条,按内存和超时权衡)走批量构建路径,一次性建图,比逐条插入快一个量级,产生的段也更少。
  2. 把高频变更的元数据挪出向量库。价格、库存、状态、时间戳这类字段热更新,等于把向量库当 KV 用,会持续触发删除+插入。正确做法是向量库存不可变的语义 ID 和稳定文本向量,检索拿到 ID 后回 OLTP 表或缓存取最新字段。这一条能砍掉写放大的主要来源。
  3. 需要原地更新时,考虑 append-only + 版本号 + 查询时按版本过滤,把随机写转成顺序写,再用周期性全量重建收敛墓碑。
  4. compaction 与索引重建限速,与查询做资源隔离(独立 IO 配额、错峰调度)。生产上盯三个指标:未合并段数、墓碑比例、后台任务队列长度——它们比 QPS 更早预示抖动。

三、可见性:“写入成功”的语义边界在哪里

“写入返回 200,紧接着查询读不到”,根因通常是三个不同层次的异步性被混为一谈:

  1. 索引构建异步:写入先落 WAL/日志,索引由后台异步构建。API 返回成功只代表“日志持久化了”,不代表“可检索”。
  2. 副本复制异步:主副本写完即返回,从副本还在追。读请求被负载均衡打到从副本,就读不到。
  3. 路由元数据缓存:新建的 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”的报告,回答不了“上线后会不会塌”。补四组曲线:

  1. 固定过滤选择性下的召回曲线。取 0.01%、0.1%、1%、10%、100% 五档,每档同时算 Recall@10 和空结果率。空结果率是比 Recall 更直观的塌陷信号——用户感知到的就是“检索不到”。
  2. 写后可见延迟分布。写入时打时间戳,用探针持续以该向量检索自身,记录从写入返回到首次命中的时间差,报 p50/p99,不要报平均值。
  3. 后台任务并行下的查询抖动。在灌数据/更新/触发 compaction 的同时跑查询,记录 p99 与超时率,对比空闲基线的倍数。
  4. 混合负载拐点。读写比按 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% 档的召回与空结果率

执行细节:

  • 查询集按真实分布采样(重复问法、长尾问法、带全部默认过滤条件的),不要全用随机向量。
  • 每轮压测前记录后台任务状态(未合并段数、墓碑比例、副本延迟),否则解释不了抖动来自哪里。
  • 记录成本:每百万条向量的内存占用、副本数、冷启动加载索引到可服务的时间。冷启动时间只在故障恢复时才体现价值,但它决定了恢复时长。
  • 压测数据的租户分布要贴近生产:长尾分布和均匀分布,会得出完全不同的结论。

落地建议

  1. 先量化过滤选择性,再谈选型。统计线上查询的 (租户数据占比, 过滤条件命中率) 分布,算出 s×K 的量级。低于 10 的查询占比超过 5%,就必须把“过滤下的召回”作为选型第一约束。
  2. 把可变字段挪出向量库。向量库只存不可变 ID + 稳定向量,价格、库存、状态回 OLTP 或缓存取。这一条同时降低写放大和一致性风险。
  3. 写入接口必须返回版本号,读路径支持版本下界或租户级 sticky read。把“刚写就读不到”从偶发 bug 变成可预期的语义,并在 SLO 里写明允许的可见延迟。
  4. 压测报告固定包含三张图:选择性-召回(含空结果率)曲线、写后可见延迟 p99、后台任务并行下的 p99 抖动倍数。缺任何一张,容量结论都不成立。
  5. 为 compaction 和冷启动预留预算。容量规划按峰值写入量的 3~5 倍预留合并 IO,并实测“重启到可服务”的耗时,把它写进故障恢复预案。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。