2024 年我给团队的知识库做了一个语义搜索:把几百万段文档转成 embedding 向量、存进向量数据库,用户搜一句话,我把这句话也转成向量,去库里找最相似的几条返回。怎么把它做准、做快?这件事我压根没多想。第一版我做得很顺手:向量检索嘛,不就是算余弦相似度、找最近的几个?我把所有文档 embedding 存进去,查询时算 query 和每条文档的相似度,取 top-k。就完事了。本地拿几千条文档一测——真不错:搜什么都准。我心里很笃定:"向量检索就是个精确的数学操作,把向量塞进去、查 top-k,能有什么坑?"可等知识库涨到几百万段、真正上线给人用,一串问题冒了出来。第一种最先把我打懵:库里明明躺着一条几乎和用户问题一模一样的文档,top-k 检索却死活没召回它。第二种最难缠:数据量小的时候召回挺准,数据涨到几百万之后,召回率肉眼可见地往下掉。第三种最头疼:我查资料说调大一个叫 efSearch 的参数能提升召回,我照做了,召回是好了,可单次查询从几毫秒一下涨到几百毫秒。第四种最莫名其妙:我重建了几次索引,每次重建完效果时好时坏,毫无规律。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"向量检索就是算余弦相似度、找最近的几个,这是个精确的数学操作"。这句话把向量检索当成了一道答案唯一、永远正确的数学题。可它根本不是。我脑子里,向量检索是一道精确的数学题:库里有 N 条向量,我算 query 和每一条的距离,排序,取最近的 k 个,答案唯一、确定、永远正确。可这个想法,只在 N 很小的时候成立。一旦 N 涨到几百万,"逐条算距离"这个精确做法,慢得根本没法用——一次查询要算几百万次向量距离。所以所有生产级的向量数据库,在百万级数据上用的根本不是这种暴力精确搜索,而是 ANN(Approximate Nearest Neighbor,近似最近邻),HNSW 是其中最主流的一种索引。这里 Approximate——"近似"——这两个字是全部要害:ANN 索引做的事,是用一个特殊的图结构,让查询不再和全库比对,而是沿着图的边"走"几步就到目标附近,用这个方式换回成百上千倍的速度;而代价,就是它给的 top-k 不再保证 100% 精确,它可能会漏掉个别本该召回的结果。所以向量检索从来不是一个"精确的数学操作",它是一个在"召回率"和"查询延迟"之间做权衡的工程问题:你想要更高的召回,就得付出更高的延迟;你想要更低的延迟,就得接受更低的召回。而控制这个权衡的旋钮,就是 HNSW 的几个参数。我以为我在用一个精确算法,其实我在用一个近似算法,却从没调过那几个决定它"近似到什么程度"的参数——库里的文档没被召回,不是 bug,是这个近似算法在默认参数下,本来就会漏。真正把向量检索做扎实,核心不是"把向量塞进去查 top-k",而是认清百万级向量检索用的是 HNSW 这类近似最近邻索引、它在召回率和延迟之间做权衡,要看懂 M、efConstruction、efSearch 三个参数各自管什么,用 recall@k 把召回率量出来,扫出召回率-延迟的权衡曲线,再根据自己的延迟预算选参数,并在数据规模变化时重新调参。这篇文章就把向量索引 HNSW 参数这个坑梳理一遍:为什么向量检索会"漏掉"明明存在的文档、HNSW 用分层图把暴力搜索变成了什么、M / efConstruction / efSearch 三个参数各管什么、召回率和延迟的权衡曲线怎么用、数据规模变化时索引为什么会退化,以及一些把向量索引做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为现在的向量数据库 API 包装得太友好了——add 进去、search 出来,两行代码,完全不提"近似"二字,让人误以为它和关系数据库的精确查询是一回事。它错得隐蔽,是因为小数据量下近似搜索几乎等于精确搜索:几千条向量时,HNSW 的图很小,搜索几乎能遍历到所有相关节点,召回率接近 100%,你根本看不出"近似"的存在。它只在数据量涨到百万级、且你从没调过参数时才暴露——那时召回率悄悄掉到八成、九成,而你还以为向量检索"应该是精确的"。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:库里有几乎一样的文档却没被召回;数据涨到百万后召回率下降;调大 efSearch 召回好了但延迟暴涨;重建索引效果时好时坏。
- 错误认知一:以为向量检索是精确的数学操作。真相是百万级数据上用的是近似最近邻,top-k 不保证精确。
- 错误认知二:以为召回率是固定的、由算法决定。真相是召回率由参数决定,是可以调、也必须调的。
- 错误认知三:以为存在一组"最优参数"。真相是只有"适合你延迟预算的权衡点",没有放之四海皆准的最优。
- 真相:向量检索是召回率和延迟的权衡工程,要靠 recall@k 度量、靠扫权衡曲线选参数、靠随数据规模重新调参来做扎实。
一、为什么向量检索会"漏掉"明明存在的文档
先把第一版那个检索摆出来。它就是字面意思——逐条算相似度,取最近的:
# 第一版:朴素的暴力向量检索(反面教材)
import numpy as np
# docs_emb: 库里所有文档的 embedding,形状 (N, dim)
def search_bruteforce(query_emb, docs_emb, top_k=5):
# 逐个算 query 和每一条文档的余弦相似度
sims = docs_emb @ query_emb / (
np.linalg.norm(docs_emb, axis=1) * np.linalg.norm(query_emb))
# 取相似度最高的 top_k 个
top_idx = np.argsort(-sims)[:top_k]
return top_idx, sims[top_idx]
# 这个写法 100% 精确,但每次查询都要和全库 N 条向量逐一比对
# N 是几千还行,N 是几百万时,单次查询要算几百万次向量距离 —— 慢到不可用
这段代码本身没有任何错误——它给出的 top-k 是数学上精确的最近邻。它唯一的问题是慢:复杂度是 O(N),N 一大就废。于是当我把它换成向量数据库,真正的认知断层就出现了——我以为我只是把同一个"精确操作"换了个更快的实现,但其实,向量数据库在百万级数据上,根本没有在做我那个精确操作。它做的是 ANN,近似最近邻。这两者不是"快慢之分",是"精确与近似之分"。我的暴力搜索保证"库里最近的那 k 个,一个不漏";而 ANN 索引,为了把 O(N) 降到 O(log N) 这个量级,它放弃了"一个不漏"这个保证——它沿着一个图结构走到目标附近就收手,目标附近那些没被它的路径"走到"的节点,哪怕其实更近,也会被漏掉。所以"库里明明有一条几乎一样的文档却没召回",在精确搜索里是不可能发生的 bug,在 ANN 里却是完全正常、符合预期的行为——它就是近似算法该有的样子。
这里要建立的第一个、也是最重要的认知是:当你用一个工具时,你必须搞清楚它解决问题的"本质属性"是什么——它给你的,是一个"精确保证",还是一个"近似的、有概率的、尽力而为的结果"?这两类东西,你对它们的预期、你验收它们的方式、你排查它们的思路,是完全不同的。我栽的跟头,根子就是把一个"近似工具"当成"精确工具"来用了:面对精确工具,"漏了一条"就是 bug,你该去查代码哪里写错了;面对近似工具,"漏了一条"是它的固有属性,你该去查的是"近似到什么程度"这个参数配得对不对。我却拿着排查 bug 的思路,去面对一个根本不是 bug 的现象,自然永远查不出名堂。这个"先分清精确还是近似"的判断,在工程里到处都用得上:一个缓存给你的是"可能过期的数据"而不是"绝对最新的数据";一个分布式系统给你的常常是"最终一致"而不是"强一致";一个大模型给你的是"概率上合理的回答"而不是"保证正确的答案";一个限流器给你的是"大致的速率控制"而不是"精确到个位的计数"。这些工具都极其有用,但它们提供的都是"近似保证"。用它们之前,你必须在心里把这件事认下来:它是近似的。一旦认下来,你看待它的"不完美"就从"这是个 bug,我要消灭它"变成了"这是它的属性,我要把它调到可接受的范围"——而后者,才是和近似工具正确的相处方式。
二、HNSW 是什么:用分层图把暴力搜索变成近似搜索
认清了向量检索是近似的,下一步就该看清:它到底"近似"在哪、为什么这样就快了。最主流的 ANN 索引叫 HNSW(Hierarchical Navigable Small World,分层可导航小世界图)。名字唬人,核心思想其实一句话能说清:它把所有向量组织成一个分层的图,查询时不和全库比对,而是沿着图的边一步步"走"到目标附近。
HNSW:一个分层的图,越往上层节点越稀疏
第 2 层(最稀疏): A ────────── F
│ │
第 1 层: A ──── C ──── F ──── H
│ │ │ │
第 0 层(全部节点): A-B-C-D-E-F-G-H-I-J-K-L ← 每条向量都在这一层
查询怎么走:
1. 从最上层一个入口点出发,在稀疏的图上"大步跳",快速逼近目标区域
2. 逐层往下,每层的图越来越密,搜索越来越精细
3. 到第 0 层,在目标附近的小范围里,找出最近的 k 个
关键:它不和全库比对,只沿着图的边"走"到目标附近 —— 这就是"近似"
这个分层结构,和有些人熟悉的"跳表"是同一个味道:上层稀疏,负责"大步快速逼近";下层稠密,负责"小步精细定位"。先粗后细,一层层逼近,查询复杂度就从 O(N) 降到了大约 O(log N)。代价也正在这里:查询走的是一条路径,不是一次遍历——路径走到哪算哪,路径旁边那些没被"走到"的更近的节点,就漏了。要建一个 HNSW 索引,用 hnswlib(很多向量库底层就是它或 FAISS)只要几行:
# 用 hnswlib 建一个 HNSW 索引(主流向量库底层大多是它或 FAISS)
import hnswlib
dim = 768
index = hnswlib.Index(space='cosine', dim=dim)
# 建索引:max_elements 是预估容量,M 和 ef_construction 是建图参数
index.init_index(max_elements=2_000_000, M=16, ef_construction=200)
index.add_items(docs_emb, ids=list(range(len(docs_emb))))
# 查询前要设 ef(也就是 efSearch),它控制查询时搜索的广度
index.set_ef(64)
labels, distances = index.knn_query(query_emb, k=5)
查询在这张分层图上是怎么逐层下沉的,画成流程是这样:
[mermaid]
flowchart TD
A[query 向量进来] --> B[从最上层的入口点出发]
B --> C[在当前层沿边走 移动到更近的节点]
C --> D{当前层还能走得更近吗}
D -->|能| C
D -->|不能| E{已经到第 0 层了吗}
E -->|没有| F[下沉一层 图变得更密]
F --> C
E -->|是| G[在第 0 层目标附近收集最近的 k 个]
这里要建立的认知是:HNSW 用分层图把 O(N) 降成 O(log N),它背后的思想,是一个值得你刻进脑子的、通用到不可思议的套路——"分层逼近":面对一个庞大的搜索空间,不要在最精细的粒度上去地毯式搜索,而是先建一个"粗"的视图、在粗视图上快速跳到大致区域,再切换到"细"的视图、在小范围里精确定位。HNSW 的上层稀疏图就是那个"粗视图",第 0 层稠密图就是那个"细视图"。这个套路你一旦认出来,会发现它无处不在:字典是按字母分区的,你查一个词,先翻到字母 S 那一区(粗),再在 S 区里精确找(细);数据库的 B+ 树索引,是先在上层稀疏的索引页定位(粗),再到下层的数据页(细);二分查找,每一步都把搜索范围砍半,本质也是不断地"粗筛"。它们的共同内核都是:用"先粗后细、逐层缩小范围"来对抗"规模"。为什么这个套路这么强?因为暴力搜索的代价随规模线性增长,N 翻倍代价就翻倍,迟早撑不住;而分层逼近的代价随规模对数增长,N 翻一千倍,代价才多几步。所以当你下次遇到一个"东西太多、一个个找太慢"的问题时,别急着优化"找单个东西"的速度,先抬头问一句:这个庞大的集合,能不能被组织成"由粗到细"的多个层次,让我先在粗的层次上快速跳、再到细的层次上精确找?能,往往就是从"线性"到"对数"的、数量级的飞跃。
三、三个关键参数:M、efConstruction、efSearch
HNSW 既然是近似的,那它"近似到什么程度"——召回率有多高、查询有多快——就由参数决定。需要你管的就三个:M、efConstruction、efSearch。先把它们各管什么、什么时候生效说清楚:
HNSW 的三个关键参数,各管一件事:
M 建图时,每个节点最多连多少条边
大 -> 图更密、召回更高,但内存更大、建索引更慢
常用 12 ~ 48,一般从 16 起步
efConstruction 建图时,为每个新节点搜索邻居的广度
大 -> 图的质量更高、召回更好,但建索引更慢
常用 100 ~ 500,只在建索引时生效一次
efSearch 查询时,每次搜索维护的候选集大小
大 -> 召回更高,但单次查询更慢
可在每次查询时动态调,是"线上调召回"的主旋钮
这三个参数,最该分清的是它们生效的时机。M 和 efConstruction 是建图参数——它们在你 add_items 建索引的那一刻就把图的结构定死了,事后想改,只能把整个索引推倒重建:
# 建索引参数:M 和 ef_construction 一旦建好就固定了,改它们要重建整个索引
index = hnswlib.Index(space='cosine', dim=768)
index.init_index(
max_elements=2_000_000,
M=24, # 每个节点的最大连边数,影响图的密度
ef_construction=200, # 建图时的搜索广度,影响图的质量
)
index.add_items(docs_emb, ids=doc_ids)
index.save_index('docs_hnsw.bin') # 建一次很贵,务必持久化下来
而 efSearch 是查询参数——它不碰图的结构,只决定"这一次查询要搜多宽",所以它可以随时改、每次查询都能用不同的值,完全不用重建索引。这让它成了你线上最顺手的那个旋钮:
# efSearch 是查询时参数,可以随时改,不用重建索引
# 它就是你在线上"用延迟换召回"的那个旋钮
index.set_ef(32) # 低 ef:快,但召回偏低
labels_fast, _ = index.knn_query(q, k=10)
index.set_ef(128) # 高 ef:召回高,但更慢
labels_acc, _ = index.knn_query(q, k=10)
# 注意:ef 必须 >= k,否则连 k 个结果都凑不齐
这里要建立的认知是:面对一个有多个参数的系统,最关键的功课,不是去背"每个参数的推荐值是多少",而是先把这些参数归类——尤其要分清哪些是"一次性的、改动代价极高的结构性参数",哪些是"可随时调整的、改动代价很低的运行时参数"。M 和 efConstruction 属于前者:它们定义了索引这个数据结构的骨架,一旦 add_items 完成,骨架就硬化了,你想动它,代价是重建整个百万级的索引——这在线上是一次伤筋动骨的操作。efSearch 属于后者:它只是查询时的一个临时设定,你想怎么调就怎么调,代价几乎为零。分清这个,直接决定了你的工程策略:对结构性参数,你必须在建索引之前就想清楚、留足余量、宁可保守(比如 M 和 efConstruction 宁可设得略大一点,多花点建索引的时间,换一个高质量、不用频繁重建的图);对运行时参数,你可以大胆地在线上试、动态地调,甚至根据不同场景给不同的值。把"高代价、难回退的决策"和"低代价、可随时改的决策"混为一谈,是工程里非常昂贵的错误——你会要么对着一个本可以随便试的参数畏手畏脚,要么对着一个改一次就要停机重建的参数草率拍板。任何系统拿到手,先问一句:这里哪些旋钮是焊死的,哪些是能随手拧的?把它们分开,你的每一个决策才会落在正确的谨慎程度上。
四、召回率和延迟的权衡曲线:怎么选参数
参数搞清楚了,真正的问题来了:efSearch 到底该设多少?想回答它,你得先有能力把召回率量出来——不能量,就只能拍脑袋。量召回率的办法很直接:拿第一节那个暴力搜索的精确结果当"标准答案",看 HNSW 的近似结果命中了多少:
# 评测召回率:拿暴力搜索的精确结果当"标准答案",看 HNSW 召回了多少
def recall_at_k(index, queries, docs_emb, k=10):
hit, total = 0, 0
for q in queries:
truth, _ = search_bruteforce(q, docs_emb, top_k=k) # 精确答案
approx, _ = index.knn_query(q, k=k) # HNSW 近似答案
hit += len(set(truth) & set(approx[0]))
total += k
return hit / total
print(f"recall@10 = {recall_at_k(index, eval_queries, docs_emb):.3f}")
# 这个数字才是你该盯的:它告诉你近似检索"漏"掉了多少本该召回的结果
有了这把尺子,就能干一件关键的事:扫一遍 efSearch,把"召回率"和"延迟"的关系曲线测出来。这条曲线,才是选参数的真正依据:
# 扫一遍 efSearch,把"召回率-延迟"的权衡曲线测出来
import time
for ef in [16, 32, 64, 128, 256, 512]:
index.set_ef(ef)
t0 = time.perf_counter()
recall = recall_at_k(index, eval_queries, docs_emb, k=10)
latency = (time.perf_counter() - t0) / len(eval_queries) * 1000
print(f"ef={ef:4d} recall@10={recall:.3f} avg_latency={latency:.2f}ms")
扫出来的结果,大概长这样:
扫 efSearch 扫出来的"召回率-延迟"权衡曲线(示例):
ef=16 recall@10=0.81 avg_latency=0.4ms
ef=32 recall@10=0.90 avg_latency=0.7ms
ef=64 recall@10=0.95 avg_latency=1.3ms
ef=128 recall@10=0.98 avg_latency=2.6ms
ef=256 recall@10=0.99 avg_latency=5.1ms
ef=512 recall@10=0.994 avg_latency=10.4ms
看清两件事:
1. 召回率越往高处,每提升一点点,延迟代价越大(边际收益递减)
2. 没有"最好的 ef",只有"最适合你延迟预算的 ef"
这里要建立的认知是:这张权衡曲线,要教给你的是一种比"调 HNSW 参数"重要得多的思维方式——面对一个有多个目标互相牵制的问题,不要去追问"最优解是什么",而要去把"权衡曲线"画出来,然后结合你的真实约束,在曲线上选一个点。第一版的我,潜意识里一直在找一个"最好的 efSearch"——一个能让召回率又高、延迟又低的完美数字。可这张曲线明明白白地告诉你:这个数字不存在。召回率和延迟是对立的,你不可能同时把两个都拉满,你能做的,只是在它们的对立曲线上,挑一个你能接受的位置。而"挑哪个位置",根本不是一个技术问题,是一个业务问题:如果你的向量检索是给一个实时对话用的,用户等不了,那你的约束是"延迟必须低于 5ms",那就在曲线上找满足这个延迟的、召回最高的点,可能是 ef=128;如果你的检索是给一个离线的数据分析任务用的,慢一点没关系但不能漏,那你的约束是"召回必须高于 99%",那就往 ef=256 甚至更高去选。同一条曲线,不同的业务约束,选出完全不同的点,而它们都对。这就是工程和做题的根本区别:做题有唯一最优解,工程里几乎所有重要决策,都是在一条权衡曲线上、根据约束做的取舍。所以训练自己:遇到"既要又要"的问题,第一步不是冥思苦想那个不存在的完美解,而是动手把权衡曲线测出来——曲线一旦摆在面前,加上你想清楚的业务约束,该选哪个点,往往就一目了然了。
五、数据规模变化时索引为什么会"退化"
还剩最后一个怪现象:数据量小时召回挺准,涨到几百万后召回率往下掉。这不是错觉。HNSW 的图,是在你建索引那一刻的数据规模下、用那一刻的 M 和 efConstruction 长出来的。当数据量从几万涨到几百万,图里的节点暴增,而每个节点的连边数还是建索引时定的那个 M——相对于膨胀了上百倍的图,这个 M 就显得太小了,图变得"相对稀疏",查询路径更容易在中途迷路、漏掉目标。也就是说,一组在小数据量下表现完美的参数,在大数据量下会自然退化。更麻烦的是增量插入和删除带来的图质量衰减:
# 增量插入:HNSW 支持往已有索引里继续 add_items,但有两个坑
# 坑一:容量。建索引时定的 max_elements 是硬上限,加满了要先扩容
index.resize_index(4_000_000) # 先扩容,再继续插
index.add_items(new_docs_emb, ids=new_ids)
# 坑二:删除。HNSW 的删除只是打个标记(mark_deleted),不会真正回收
index.mark_deleted(stale_id)
# 删改频繁的库,标记会越积越多、图越来越脏,要定期全量重建索引
这就解释了"重建索引效果时好时坏":如果你重建时没意识到数据规模已经变了、还沿用老的 M 和 efConstruction,那重建出来的图质量自然不稳定。正确的做法是:把数据规模当成参数的一部分——数据量上了一个数量级,就该重新评估 M(往大调,比如从 16 调到 32)、重新扫一遍权衡曲线、重新选 efSearch。
这里要建立的认知是:这一节真正要讲的,是一个特别容易被忽视的真相——参数没有"绝对正确"的值,任何一组参数的"正确",都是相对于某一个特定的环境而言的,而环境(尤其是数据规模)是会变的。第一版的我,潜意识里把"调好参数"理解成了一件一次性的事:我以为只要找到那组对的 M、efConstruction、efSearch,把它们写进配置,这事就永远了结了。可 HNSW 这个案例血淋淋地说明:我在三万条数据上调出的"完美参数",到三百万条数据上,会自己退化成"糟糕参数"——参数一个字没改,但它脚下的环境变了,于是它的"对"也就失效了。这件事的本质是:你调的从来不是"参数本身",你调的是"参数和环境之间的匹配关系"。环境变了,匹配关系就破了,参数就必须跟着重调。这个认知,远不止适用于 HNSW:你为某个数据量调优的数据库连接池大小、为某个并发量设定的线程池、为某个流量规模配置的缓存容量和限流阈值、为某个版本模型调好的 prompt——它们全都是"和当时环境匹配"的产物,环境一旦发生数量级的变化,它们全都需要重新审视。所以要建立的工程纪律是:任何一个被你"调好"的参数,都不该被当成永久的定论,而该被当成一个"在当前环境下成立的、需要随环境变化定期复查的假设"。把你系统里的关键参数列个清单,旁边标注上"它是基于多大的数据量/多高的并发/什么版本调的"——当那个前提条件发生重大变化时,这份清单会提醒你:该回来重新调参了。会调参的人很多,记得"环境变了要回来重新调参"的人,才算真的把参数这件事想透了。
六、工程里那些向量索引的坑
HNSW 的主体逻辑理顺了,落地时还有几个工程坑反复咬人。第一个,recall@k 必须做成持续的离线评测。召回率不会在日志里报错,它只是悄悄变差,你必须维护一批有标注的评测 query,定期跑 recall@k,跌破阈值就报警——否则你永远不知道检索质量在恶化。第二个,HNSW 解决的是"召回",不解决"相关性"。它只负责"在向量空间里把最近的几个找回来",但"向量空间里近"不等于"业务上相关"——如果你的 embedding 模型本身质量差,HNSW 把"错误的最近邻"百分百召回,结果还是错的。检索质量的上限,是 embedding 模型定的,HNSW 只决定你能多大程度逼近这个上限。第三个,距离度量(space)必须和 embedding 模型匹配:模型是按余弦相似度训练的,索引就得用 cosine,用错了度量,召回会一塌糊涂。第四个,内存要算账:HNSW 整个图要常驻内存,M 越大、向量越多、维度越高,内存占用越大,几百万条 768 维向量轻松吃掉几个 GB。第五个,删除要靠定期重建来清理:mark_deleted 只是打标记,删改频繁的库,脏标记会持续拖累图质量。把这些都接进监控,你才有数据去判断该不该调参、该不该重建:
向量检索上线后必须盯死的几个指标:
recall@k 离线用一批标注 query 定期评测,跌了立刻报警
query_latency_p99 查询延迟,别只看平均,近似搜索的长尾很真实
index_size_mb 索引占用的内存,M 越大它越大,逼近上限要扩容
deleted_ratio 被标记删除的占比,过高说明该重建索引了
empty_result_rate 返回结果不足 k 个的查询占比,常是 ef 设得比 k 还小
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"向量检索"这件事的总体判断——它的最终效果,是一条由多个独立环节串起来的链条决定的,而这条链条里,HNSW 参数调优只是其中一环,既不是第一环,也往往不是最关键的一环。这条链条至少是:用什么 embedding 模型(决定了向量空间本身好不好)→ 文档怎么切分(决定了被编码的是不是有意义的语义单元)→ 用什么距离度量(决定了"近"的定义对不对)→ HNSW 参数怎么调(决定了你能多大程度召回到该召回的)→ 召回之后要不要重排序(决定了最终给用户的顺序好不好)。第一版的我,一头扎进 efSearch 里调参,本质是犯了一个常见的错误——在一条长链条里,只盯着自己最近研究的那一环使劲,而忘了整条链条的存在。可链条的道理是无情的:整条链的效果,受限于最弱的那一环。如果我的 embedding 模型选得差,向量空间本身就乱,那我把 HNSW 的召回率从 90% 调到 99.9%,也只是把一堆"错误的最近邻"召回得更全而已,用户感受到的检索质量不会变好。这里要建立的通用认知是:当你在优化一个由多环节构成的系统时,在你对某一环投入大量精力之前,一定要先退后一步,把整条链条看一遍,判断当前真正的瓶颈在哪一环。把精力投在不是瓶颈的环节上,无论你做得多精细,对最终结果的提升都微乎其微。先找到最弱的那一环,再动手——这个"先定位瓶颈、再投入"的纪律,比任何单点的优化技巧都更能决定你的工程投入产出比。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 暴力检索 | 逐条算 query 和全库向量的距离取 top-k | 100% 精确但 O(N) 百万级数据慢到不可用 |
| ANN 近似最近邻 | 用特殊结构换速度 不保证 top-k 精确 | 会漏个别本该召回的结果 是固有属性非 bug |
| HNSW | 分层可导航小世界图 最主流的 ANN 索引 | 查询沿图走到目标附近 复杂度约 O(log N) |
| M | 建图时每个节点的最大连边数 | 建图参数 改它要重建整个索引 常用 16-48 |
| efConstruction | 建图时为新节点搜索邻居的广度 | 建图参数 只生效一次 影响图的质量 |
| efSearch | 查询时维护的候选集大小 | 查询参数 可随时调 是线上调召回的旋钮 |
| recall@k | 近似结果命中精确结果的比例 | 用暴力搜索当标准答案 必须持续评测 |
| 召回率-延迟权衡曲线 | 扫 efSearch 测出的两者关系曲线 | 没有最优参数 只有适合延迟预算的点 |
| 索引退化 | 数据规模涨上去后召回率自然下降 | 参数和数据规模绑定 数量级变化要重调 |
| 检索质量上限 | 由 embedding 模型质量决定 | HNSW 只决定多大程度逼近这个上限 |
避坑清单
- 认清百万级向量检索是近似的,漏掉个别文档是 ANN 的固有属性,不是 bug。
- 必须维护一批标注 query 持续评测 recall@k,召回率不会报错,只会悄悄变差。
- 分清建图参数和查询参数:M、efConstruction 改了要重建索引,efSearch 可随时调。
- 建索引前把 M 和 efConstruction 想清楚,宁可略大,换一个不用频繁重建的高质量图。
- 用扫权衡曲线的方式选 efSearch,而不是拍脑袋,先明确自己的延迟预算。
- 不存在最优参数,只有适合你业务约束的权衡点,实时场景和离线场景选法不同。
- 数据量涨一个数量级就重新调参,老参数会自然退化,M 往大调、重扫曲线。
- 距离度量要和 embedding 模型匹配,模型按余弦训练就用 cosine,用错召回崩溃。
- 删除靠定期全量重建清理,mark_deleted 只打标记,脏标记会持续拖累图质量。
- 检索效果差先查 embedding 模型和文档切分,HNSW 只管召回,质量上限在模型。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为向量检索是一道精确的数学题,把向量塞进去、查 top-k,答案唯一且永远正确。可百万级数据上的向量检索,用的根本不是精确的暴力搜索,而是 HNSW 这类近似最近邻索引——它用一个分层图,让查询沿着边走到目标附近,用一点点准确率换回成百上千倍的速度。"近似"二字才是它的本质:它会漏掉个别本该召回的文档,这不是 bug,是它该有的样子。我拿着"精确算法"的预期,去用一个"近似算法",自然处处不对劲。
真正把向量检索做扎实,工作量不在"写多复杂的检索代码",而在一次观念的转变:承认这是一个在召回率和延迟之间做权衡的工程问题,不是一个有唯一正确答案的数学问题。一旦接受这一点,该做的事就都浮现出来了——分清 M、efConstruction 这种焊死的建图参数和 efSearch 这种能随手拧的查询参数,用 recall@k 把召回率量出来,扫出召回率-延迟的权衡曲线,结合自己的延迟预算在曲线上挑一个点,并且记得数据规模涨上去之后要回来重新调。每一步都不复杂,难的是先承认:你手里的不是一把精确的尺子,而是一个需要你亲手校准的、近似的旋钮。
我后来常拿在一座大城市里找人来想这件事。暴力搜索,等于挨家挨户敲门问遍全城——绝对能找到,但城市一大就慢到没法用。HNSW 的分层图,像是这座城市的交通网:最上层是高速公路(稀疏),让你从城市这头大跨步地奔到那头的大致区域;中间层是主干道,下层是街巷(稠密),让你在目标小区附近精细地绕。你顺着这张路网走几步就到目标附近了,根本不用敲遍全城——快,就快在这里。而 efSearch,就是你愿意在目标小区附近"多绕几条街去找"的耐心:绕得越多,越不容易错过那个人(召回高),但花的时间也越长(延迟高)。M,则是这张路网修得多密——路网越密,越不容易迷路,但修路和养路的成本(内存、建索引时间)也越高。没有一张"完美"的路网,只有"适合这座城市规模和你时间预算"的路网。城市长大了,老路网就不够用了,得重修——这就是数据规模变化后要重新调参。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你拿几千条文档测,HNSW 的图很小,查询路径几乎能覆盖到所有相关节点,召回率高得和精确搜索没两样,你压根意识不到自己用的是个近似算法。它只在数据涨到百万级、真实流量打进来之后才暴露——召回率悄悄掉到八九成,而召回率这个东西又从不在日志里喊疼,它只是让用户慢慢觉得"这搜索怎么越来越不好用了"。所以别等用户开始抱怨搜不准,才想起去看召回率:做向量检索的第一天,就该把"我用的是近似算法、它的召回率是多少、我靠什么持续度量它"当成和选 embedding 模型同等重要的事来设计——recall@k 评测不该是一个"以后效果差了再补"的事项,而该是你写第一行检索代码时就搭好的标尺。把这把尺子在一开始就立起来,你才算真正跳出了那个把近似当精确、出了问题还在当 bug 查的坑。
—— 别看了 · 2026