调大 ef 救不了带过滤的向量检索

举报
yd_289817328 发表于 2026/11/08 09:05:23 2026/11/08
【摘要】 加了元数据过滤后向量检索召回暴跌,调大 ef_search 往往无效。用 5 万条向量实测后过滤、迭代扫描、遍历时过滤的召回与延迟,给出按过滤选择性选型的阈值。

RAG 和推荐系统里的向量检索几乎从不是"纯"的:只查当前租户的知识库、只查最近 90 天的工单、只查用户有权限的文档。加上过滤条件之后,不少团队发现召回率断崖下跌,第一反应是把 hnsw.ef_search 从 64 调到 256、512,然后发现没什么用。

下面用一组可复现的实验把这件事拆开看。实验设置:5 万条 64 维单位向量(随机高斯归一化),hnswlib,M=16,ef_construction=200,200 条查询,k=10,过滤字段是一个与语义无关的随机"租户"标签,选择性 s 由 tenant < 100 * s 控制。

一、后过滤的召回约等于选择性

后过滤是最直觉的做法:先让 HNSW 返回 top-10,再按元数据筛掉不合格的。三条策略的实测结果(召回率 / 单查询延迟):

选择性 后过滤 迭代放大 k 遍历时过滤
1% 0.010 / 0.31ms 0.965 / 7.73ms 1.000 / 10.65ms
5% 0.052 / 0.34ms 0.779 / 2.32ms 0.994 / 4.10ms
20% 0.190 / 0.51ms 0.531 / 0.95ms 0.883 / 1.69ms
50% 0.459 / 0.59ms 0.551 / 0.66ms 0.740 / 0.76ms

选择性 1% 时,后过滤的召回是 0.010。道理很朴素:top-10 里期望只有 0.1 条是合格的。你在 10 个结果里找一个 1% 命中率的东西,能找到一个都是运气。50% 时召回 0.459,基本就是"基线召回 × 选择性"再打个折。

这条规律可以先记住:后过滤的召回上限 ≈ 索引基线召回 × 选择性。它跟你的索引建得好不好关系不大,跟数据分布关系很大。

二、ef 管精度,不管供给

这是最容易踩的坑。ef_search 是图遍历时保留的候选堆大小,它决定的是"你算出来的这 10 个近邻有多准",不影响"这 10 个里有多少个合格"。

实测选择性 1%、k=10 固定:

  • ef=64:召回 0.011,0.22ms
  • ef=512:召回 0.012,1.61ms
  • ef=2048:召回 0.012,4.01ms

ef 放大 32 倍,召回从 0.011 挪到 0.012,延迟涨了 18 倍。同一份索引在无过滤时,ef 从 64 提到 512 可以把基线召回从 0.592 拉到 0.989——说明 ef 是有效的,只是它解决的不是你的问题。你把"最近的 10 个"算得更准了,而这 10 个本来就几乎全不合格。

换句话说:ef 控制精度,k 控制"过滤后的供给量"。选择性低的时候,缺的是供给,不是精度。

三、放大 k,也就是迭代扫描

不放大 ef,放大 k。same 实验里,k 从 10 直接给到 1280(ef 仍是 64),召回 0.011 → 0.949,延迟 0.22ms → 3.12ms。

pgvector 0.8 的 iterative index scan(SET hnsw.iterative_scan = relaxed_order 配合 hnsw.max_scan_tuples)、Qdrant 的 scan 参数,做的都是这件事:一批取不够就继续往图里走,直到凑够 k 个合格结果或触到上限。

工程上要注意两点:

  1. 必须设上限。 选择性趋近 0 时,迭代会退化成全表扫描。上限触发后返回不足 k 条,业务侧要能接受"宁可少返回也不拖垮延迟",而不是静默补空。
  2. 倍增起点别从 k 开始。 表里 1% 的 7.73ms 里,很大一部分浪费在 10→20→40→80 这几轮上。已知选择性低,直接从 256 或 512 起步,比从 10 翻倍更划算——代价是选择性高时多算了。

四、遍历时过滤:召回最好,延迟最差

hnswlib 的 knn_query(..., filter=...) 支持在遍历过程中丢弃不合格节点,pgvector 的 pre-filter 分区索引、Qdrant 的 filterable HNSW 都是这个方向。1% 时召回 1.000,延迟 10.65ms。

召回满分不奇怪:过滤把搜索空间压到 500 条,图遍历几乎等于在这个子图上做穷举。代价在于 ef 预算被不合格节点消耗——走进一片全是别的租户的区域,邻居全被滤掉,但扩展次数照付,产出为零。50% 选择性时它仍然比后过滤好(0.740 vs 0.459),延迟却只贵 0.17ms,这是它最舒服的区间。

另一个容易被忽略的变量是过滤子集在图上的空间分布。同样 1% 的选择性,如果租户的向量在语义空间里聚成簇(同租户文档本来就相似),遍历时容易在几步内走进自己那片区域,延迟从 10.65ms 降到 5.72ms,迭代扫描从 7.73ms 降到 2.95ms。反过来,如果过滤字段和语义完全无关(比如按创建时间筛),子集散落在全图,两条路都变慢。所以"我们的过滤字段有没有语义相关性"这个问题,比"选哪种策略"更值得先回答。

五、怎么选

把选择性当成一等级指标来量,再按区间选:

  • s > 50%:后过滤,ef 调到 256 左右。简单,延迟最低,别过度设计。
  • 5% < s < 50%:迭代扫描,设 scan 上限。
  • s < 5% 且字段低基数(租户、环境、业务线):物理分区,每个分区独立索引。这是唯一能把延迟压回基线的方案。
  • s < 5% 且字段高基数(文档 id、用户 id):分不了区,只能迭代扫描,或者接受遍历过滤的延迟。

分区方案的真实代价要说清楚:一万个租户建一万个 HNSW 索引,内存和后台合并都扛不住。实践中通常分两层——活跃租户独立索引,冷租户共享一个大索引走迭代扫描。分层的阈值按访问频次定,不按租户数量定。

最后是监控。每条查询记录两个数:过滤后的候选通过率、以及实际扫描的节点数。通过率跌破阈值就自动切策略,比在上线后靠用户投诉发现召回掉了要靠谱得多。

附:可运行的对照实验

依赖 pip install numpy hnswlib,跑 200 条查询对比三种策略:

import numpy as np, hnswlib, time

rng = np.random.default_rng(7)
N, D, K, NQ = 50000, 64, 10, 200
X = rng.normal(size=(N, D)).astype(np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)
Q = rng.normal(size=(NQ, D)).astype(np.float32)
Q /= np.linalg.norm(Q, axis=1, keepdims=True)
tenant = rng.integers(0, 100, size=N)
mask = tenant < 1                      # 1% 选择性

idx = hnswlib.Index(space='ip', dim=D)
idx.init_index(max_elements=N, ef_construction=200, M=16)
idx.add_items(X, np.arange(N))
idx.set_ef(64)

sub = np.flatnonzero(mask)
G = [set(sub[np.argsort(-(X[sub] @ q))[:K]].tolist()) for q in Q]

def run(name, fn):
    acc, t = [], 0.0
    for q, g in zip(Q, G):
        t0 = time.perf_counter()
        pred = fn(q)
        t += time.perf_counter() - t0
        acc.append(len(pred & g) / K)
    print(f'{name}: recall={np.mean(acc):.3f} {t / NQ * 1e3:.2f}ms')

run('post', lambda q: {int(i) for i in idx.knn_query(q.reshape(1, -1), k=K)[0][0]
                       if mask[i]})

def iter_scan(q):
    k, got = K, []
    while True:
        lab, dist = idx.knn_query(q.reshape(1, -1), k=min(k, N))
        got = sorted((d, int(i)) for i, d in zip(lab[0], dist[0]) if mask[i])
        if len(got) >= K or k >= N:
            break
        k *= 2
    return {i for _, i in got[:K]}

run('iter', iter_scan)
run('walk', lambda q: {int(i) for i in idx.knn_query(
    q.reshape(1, -1), k=K, filter=lambda i: bool(mask[i]))[0][0]})

这段代码跑出来的数字会随随机种子小幅波动,但三者的相对关系很稳定:后过滤召回等于选择性,迭代扫描能把召回拉回来但延迟翻十倍以上,遍历过滤召回最好、延迟最差。选哪个,取决于你的过滤选择性落在哪个区间,以及你更怕召回掉还是更怕 P99 抖动。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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