搜「怎么做接口限流」,向量库却把「怎么做缓存」的段落递了上来
先说我踩的坑。我给团队搭了个内部知识库问答,底层用向量库做语义检索。有天同事搜「怎么做接口限流」,系统却把「怎么做缓存」的几段话排在了最前面。他当场发飙:你这检索是不是坏掉了,我问限流你给我缓存干什么。
我第一反应也是:向量库不行,换一个。后来我把整条链路拆开看,才发现锅不在检索这一层。这一篇就把这件事讲清楚,顺便回答一个更普遍的问题:同样都是算余弦相似度,为什么有的向量库查得飞快,有的查着查着就把不同意图的也捞上来了。
先说代价:为什么不能每次都暴力扫一遍
向量检索的本质,是给一个查询向量 q,从库里 N 条向量里找出最相似的 k 条。最直白的做法是暴力扫描:拿 q 和每一条算一次余弦(或欧氏距离),排个序取前 k。这就是 O(n·d) 每查询,n 是向量条数,d 是维度。
问题在规模。真实 embedding 通常是 768 到 1536 维。假设库里有 1000 万条、每条 768 维,一次暴力查询要做约 76.8 亿次乘加。即便用 SIMD 向量化,业界基准给出的是 20 到 50 毫秒一条查询(这是研究基准口径,不是我厂里的实测,别当承诺)。到了十亿级,暴力直接不可用。
近似最近邻(ANN)就是为这件事生的:用一点点召回损失,换几十上百倍的提速。下面是暴力、IVF、HNSW、LSH 几个家族的典型区间对比(区间来自公开基准,具体数值看你的数据和调参)。
图上右边那张表里,暴力是 100% 召回但延迟高;IVF 用分区把内存压到很低;HNSW 召回和延迟都好但内存高(要存图边);LSH 实现简单但召回下限偏低。左边那块图直观表达了区别:暴力是红框罩住全部点逐条算,ANN 是只沿一条窄路径走。
量级感受一下(同样研究基准口径):10M 乘 768 维,HNSW 在同等召回下能做到 1 毫秒以内,相比暴力大约是 30 到 100 倍加速;ann-benchmarks 项目里 HNSW 在 SIFT1M 上常见 5000 到 20000 QPS 同时守住 95% 召回。这些数字不是承诺,是让你心里有个数:规模一大,ANN 不是可选项,是必选项。
ANN 到底怎么加速:IVF 分区,和 HNSW 多层图
两个最主流的机理,我各画了一边。
IVF(倒排文件)的思路是先把空间切成 nlist 个簇,每个簇记一个质心,查询时只挑最近的 nprobe 个簇,在那些簇内部做暴力。nprobe 越大,扫得越全,召回越高,但也越慢。nprobe 等于 nlist 时,它退化成暴力,召回回到 100%。
HNSW(分层可导航小世界图)的思路是建一张多层图:上层稀疏、下层稠密,查询从顶层入口出发,逐层贪心往下跳,每层只保留一小撮候选(由 efSearch 控制)。查询复杂度大约是 O(log n) 跳数。efSearch 越大,候选留得越多,越准也越慢。
这两个旋钮(IVF 的 nprobe、HNSW 的 efSearch)讲的是一个道理:召回率是个旋钮,不是常数。我把这个用成簇数据在脚本里实跑了一遍,nprobe 从 1 走到 40,召回从 0.353 一路爬到 1.000,而扫描的向量数从 50 线性涨到 2000(延迟代理)。这就是你要调的那个权衡曲线,不是玄学。
设置: N=2000 向量, dim=64, 10 个语义簇, nlist=40 簇, k=5
nprobe | 扫描簇数 | 召回率@5 | 延迟代理(扫描向量)
1 | 1 | 0.353 | 50.0
2 | 2 | 0.460 | 100.0
4 | 4 | 0.573 | 200.0
8 | 8 | 0.687 | 400.0
16 | 16 | 0.853 | 800.0
40 | 40 | 1.000 | 2000.0
顺带说一句,为什么我用「成簇数据」而不是纯随机数据做这个实验。真实语料里语义相近的文档在向量空间本来就是聚成一团的,成簇数据才贴近这个现实;纯随机的各向同性数据会让召回曲线难看且失真。这本身也是个提醒:嵌入有没有把相似内容聚到一起,直接决定了检索层能有多省事。
recall@k 是什么,以及最底下那层地基
recall@k 的定义很朴素:先用精确暴力检索得到「真实的Top-k近邻」,再看近似索引返回的Top-k里命中了几个,占真实近邻的比例。它隔离的是索引层(IVF/HNSW 的近似误差),和嵌入模型无关。这点很关键,后面要用来甩锅。
我把召回、延迟、内存三件事画成了一个互相牵制的三角,再加上最底下那层地基。
三角里没有免费午餐:HNSW 召回好、延迟低,但内存高(要存图边);IVF 配上乘积量化(PQ)能把内存压到很低,代价是召回和延迟中庸;LSH 实现最简单,召回下限也最低。选哪个,看你手里是内存-bound 还是召回-bound。
但三角再怎么调,都救不了最底下那层地基:嵌入层。两段文本是不是相似,先由嵌入模型决定它们的向量位置。如果模型把「接口限流」和「接口缓存」放成了邻居,那么无论你用多快的 ANN,它都只会忠实地把邻居递上来。检索层只加速「找最近邻」,改不了「什么算最近」。
我用字符级词袋(纯教学替代,真实模型是稠密语义向量,但检索机制完全一样)复现了这个现象。查询「接口限流的令牌桶算法怎么做」,即便用精确暴力检索,Top4 里也只有 3 条限流,第 4 条已经是缓存:
查询: 接口限流的令牌桶算法怎么做 (真实意图=限流)
暴力(精确)检索 top4:
[限流] cos=1.000
[限流] cos=0.645
[限流] cos=0.538
[缓存] cos=0.524
注意,这是精确暴力,没有任何近似误差。也就是说,跨意图捞取在这条链路里已经发生了,和 ANN 半点关系都没有。再叠上 IVF(nprobe=2),Top4 里限流数量还降到了 2 条,ANN 又在上面加了一层漏召回。两类毛病叠在一起,所以你既换了向量库、又调了参数,该捞错还是捞错。
三个生产环境的坑
坑一,把答非所问全怪向量库。我开头就犯了这个错。先确认一件事:跨意图、近义混淆,九成是嵌入层的问题。治本是换更强的嵌入模型,或者在检索后面接一层 reranker 做二次精排,而不是在检索层反复调 nprobe。
坑二,带元数据过滤时召回静默塌缩。真实业务几乎都要过滤,比如「在分类=技术的文档里搜相似向量」。后过滤的做法是先搜全局 Top-k 再删掉不符的,一旦过滤器很严(比如只有 1% 文档命中),返回可能不足 k 甚至直接空,而延迟图表看起来还很快,问题被藏起来了。正解是预过滤(像 Qdrant 的 payload index 在图遍历时就 pruning 候选),或者把候选数归一化到 min(k, 符合条件的候选数) 再谈召回。研究基准里(AIMultiple 的七引擎测试)也提到,带强过滤时各引擎的 Recall@10 仍能守住 0.96 到 1.00,前提是过滤做对了。
坑三,维度灾难。维度越高,向量之间的距离越趋同,区分度下降,ANN 本来就有的一点近似误差会被放大,看起来就是「查着查着把不相关的也捞进来」。该降维降维,该选对距离度量(余弦还是内积还是欧氏)就选对,别一股脑塞 1536 维。
生产数字怎么看
我把查到的一些公开基准数字摆在这里,全部是「研究或案例口径」,不是对任何一家产品的承诺:
- FAISS 文档与多篇解析:IVF-PQ 内存极低,HNSW 召回与延迟综合最好,LSH 流式插入友好。
- 10M 乘 768 维:暴力约 20 到 50 毫秒每查询,HNSW 同等召回约 1 毫秒以内(约 30 到 100 倍加速)。
- ann-benchmarks:HNSW 在 SIFT1M 上常见 5000 到 20000 QPS 同时 95% 召回;efSearch 从 16 提到 256,召回@10 大约从 90% 升到 99.5%。
- 七引擎 RAG 基准(2026):在 Recall@10 约 0.95 时,各引擎 nDCG@10 只差 0.014,说明数据库层的近似误差对最终答案质量的影响其实很小,真正的差距在嵌入层。
这些数字帮你建立量级直觉,落地时请在你自己的数据和硬件上重测。
什么场景不适合上 ANN
数据量很小(比如库里不到一百万条)时,暴力扫描可能就够了,上 ANN 反而多一层复杂度和近似误差。需要 100% 精确返回的场景(比如精确去重、强一致性的精确匹配)也不该用近似索引。需要强一致性带过滤的检索,请优先确认用的是预过滤而非后过滤。
复现
数据源:脚本内置合成数据(成簇随机向量 + 字符级词袋语料),无需外部下载,零密钥、零第三方依赖,跨机器可复现。
运行命令:
cd code/repro
python3 vector_retrieval_core.py --self-test # 六项断言全部 PASS
python3 vector_retrieval_core.py # demo_ann + demo_intent
python3 vector_retrieval_sim.py # nprobe 扫描 + 数据量扫描
精确预期输出(来自 code/repro/EXPECTED_OUT.txt,self-test 段需逐行一致):
=== self-test ===
[1] cosine 自相似=1 PASS
[2] 暴力检索 top1=自身, 余弦在[0,1] PASS
[3] IVF nprobe=nlist 召回=1.000 PASS
[4] IVF nprobe=1 召回<1.000 (近似损失) PASS
[5] 嵌入确定性(同输入同向量) PASS
self-test PASS
脚本里嵌的是字符级词袋而非真实 embedding,只为零依赖演示检索机制;真实模型产出的维度(768 到 1536)不影响这里的分区、召回、近似损失逻辑。
结尾
回到开头那个场景:同事搜「怎么做接口限流」,系统递上来「怎么做缓存」。你现在应该能脱口而出:先别换向量库,去查嵌入层是不是把近义意图放成了邻居;再查过滤是不是后过滤在悄悄吃掉召回。你踩过类似的坑吗,是嵌入的锅还是检索的锅,评论区告诉我,最好带一段你自己的查询和返回。