概念解析
HNSW 全称 Hierarchical Navigable Small World,分层可导航小世界图,是一种图结构的近似最近邻索引
它把每个向量看成图里的一个节点,节点之间连边。图分成很多层
最底层(第 0 层)包含全部节点、边很密,负责精细查找
越往上节点越少、边越稀疏但每条边跨得越远,像高速公路一样负责快速逼近目标附近
查询时从最顶层进入,贪心游走(每一步都跳到离查询点更近的邻居),逐层下降,到最底层做一次精细搜索,返回最相似的 topK 个结果
它用一点点精度损失(比如 95% 召回)换来了毫秒级的检索速度,是目前企业级 RAG 向量索引的主流选择
细节
三个核心参数
M
每个节点最多允许连多少条边
M 大 = 社交网络更稠密 = 找人时不容易走丢(召回率高)
要存的"好友关系"更多(内存大)
建网时维护关系更费劲(建索引慢)
M 小 = 网络稀疏,可能出现"孤岛"
某些人和谁都连不上,查询时贪心游走会走进死胡同(陷入局部最优,召回率掉)
工程经验值:一般 16~64。向量维度高、数据语义复杂交叉,就需要更大的 M
从默认 16 调到 18,召回率涨了约 2 个点,内存增量约 10%,再往上调收益就很小了
M=18 就是边际收益拐点
efConstruction
只在建索引时起作用
这个参数只影响离线建索引的时间和图的质量,完全不影响线上查询速度
它"可以往大了给"——离线多花一倍时间没关系,图建好了一辈子受益
从 100 提到 240,召回上限从约 96% 提到 98%+,代价只是离线建索引时间翻倍
插入一个新节点时,算法要为它找"最近的 M 个邻居"来连边,找邻居的过程本身也要在图上搜索,efConstruction 就是这个搜索的宽度(候选列表大小)
新搬来一个住户,你要帮他物色好朋友。efConstruction=240 就是你先筛出 240 个候选人慢慢考察,再从中选最好的 18 个加好友。考察面越宽,选的朋友越靠谱(图质量越高、召回上限越高),但考察过程越耗时(建索引越慢)
必须满足 efConstruction > M(候选池必须比最终要选的邻居数大,否则没得挑)
efSearch
只在查询时起作用
查询时在图里搜索要维护一个"候选列表",efSearch 就是这个列表的大小——探索越宽,越不容易错过真正的最近邻(召回高),但算的点多(延迟高)
找人时同时跟进 128 条线索 vs 只跟进 10 条——线索多找得准但慢
efSearch 是运行时参数,随时可调,不需要重建索引
它才是"线上召回/延迟的调节旋钮"
大促期间延迟敏感就调小,夜间批处理追求质量就调大
Milvus 建议 efSearch 取值范围在 topK 到 2000 之间(至少不能比你要的 topK 小,否则候选都不够返回)
M 和 efConstruction 在建索引时一次性决定了这张图的召回上限
efSearch 是查询时决定你能逼近这个上限多少的旋钮
调大 efSearch 只能接近上限,永远突破不了
改 M 或 efConstruction 必须重建索引,改 efSearch 不用
层数采样公式
分层是怎么分的
L = floor(-ln(uniform) × mL),其中 mL = 1/ln(M)
每插入一个新节点,要决定它最高出现在第几层
就像跳表里每个元素随机决定自己有几层"塔"
分层金字塔(指数衰减概率决定层数、顶层稀疏跑长途、底层稠密做精查、图高 log N)
跳表解决一维有序数据的快速查找,HNSW 把它的分层思想借到高维向量空间——链表换成相似度图,硬币换成 1/M 的指数衰减,查找换成图上的贪心游走
ln 是自然对数。-ln(uniform) 是一个指数分布的随机数
HNSW 里"图高是 log(N) 级"的含义——百万条数据,图也就十几层高,查询只需要走十几步。
均匀随机数经过 -ln 变换后,小值出现概率大、大值出现概率小
uniform 越接近 1(容易抽到),结果越接近 0;uniform 越接近 0(很难抽到),结果越大
floor 是向下取整。结果就是大部分节点算出 L=0(只待在第 0 层),少数算出 L=1,极少数算出 L=2——不用任何人设计,"金字塔"自己就长出来了。每层节点数大约是下层的 1/M
mL(level multiplier,层乘数)是衰减系数
取 1/ln(M)。它控制"每往上一层,概率缩小多少倍"
整体效果:节点 100% 出现在第 0 层;约 1/M 的概率出现在第 1 层;1/M² 到第 2 层……以 M=18 为例,18 个节点里约 1 个能上第 1 层,324 个里约 1 个能上第 2 层
层数是按指数衰减概率随机采的,每一层节点数约是下一层的 1/M
0 层边上限 2M、其余层 M
第 0 层允许最多 2M 条边,第 1 层及以上只允许 M 条
为什么第 0 层放宽一倍?因为第 0 层包含全部节点、是最终精细搜索发生的地方——召回率直接由这一层的连通质量决定,多给一倍连接额度
层只是路过导航,M 条就够。当一个节点的邻居数超上限时,会触发多样性剪枝
多样性剪枝"是 HNSW 召回高的隐藏关键
。当一个节点边数超上限要剪枝时,不是简单地"留最近的 M 个邻居"——那样邻居会挤在同一个方向上,图就"偏科"了
选邻居时要求方向分散,如果候选邻居 B 离已选中的邻居 A 比离新节点还近,就丢掉 B。效果是每个节点的邻居均匀分布在各个方向上,无论从哪个方向查询过来都有路可走
内存账
768 维 float32 单条 ≈3KB,图开销 10-20%
同一条向量,FP32 → FP16 → int8 → int4,存储依次减半:3KB → 1.5KB → 768B → 384B。GPTQ-Int4 量化能把 32B 模型从 ~64G 压到 ~18G,靠的就是权重从 FP16 压到 4 bit
embedding 模型(bge-base-zh-v1.5)把一段文本编码成 768 个浮点数,这就是"768 维向量"
维度由 embedding 模型本身的结构决定,选定模型就定死了
模型越大,维度越高,语义表达越细腻,但存储和检索成本也越高(768→1536,存储直接翻倍)。
固定"带来的三条铁律
同一个 Collection 里所有向量必须同维度、同模型
维度不同根本存不进去
维度相同但模型不同(比如两个都是 768 维的不同模型),也不能混
因为两个模型的向量活在不同的语义空间里。模型 A 输出的 [0.1, 0.8, ...] 和模型 B 输出的 [0.1, 0.8, ...],数字看着像,含义完全不通
混着检索等于鸡同鸭讲,结果全是噪声
query 侧和入库侧必须用同一个模型
入库时用 bge-base 编码文档,查询时也必须用 bge-base 编码用户问题
两边必须在同一个语义空间里,距离才有意义
换模型 = 全量重建
升级到更好的模型,旧向量全部作废,所有文档要用新模型重新 embedding
RAG 项目里成本最高的一次迁移,所以选型时要一步到位想清楚,别指望以后随便换
例外情况
新模型(如 OpenAI text-embedding-3 系列、部分 Qwen 模型)支持 Matryoshka(套娃)表示
允许把输出截断,比如 1536 维只用前 512 维,牺牲一点精度换一半存储。但这是模型专门训练过才支持的特性,不是随便什么模型都能砍维度
同库同模型、查询入库同模型、换模型要全量重建
float32 = 每个浮点数占 4 字节。所以一条向量的原始大小 = 768 × 4 = 3072 字节 ≈ 3KB
HNSW 除了存向量本体,还要存图结构(每个节点的邻居列表,也就是一堆指针/ID)。这部分额外开销大约是向量本体的 10-20%。
举例:100 万条向量 → 向量本体 ≈ 3GB,加图结构 ≈ 3.3~3.6GB,
全部常驻 QueryNode 内存(HNSW 查询时索引必须在内存里)
这就是为什么"HNSW 最大的缺点是吃内存"
Service Level Agreement,服务等级协议
recall@10 ≥ 95%、P99 < 50ms
recall@10(召回率@前10)
该找回来的找回来了没有
95% 是工业界"可用"的门槛线
对一条测试 query,先用暴力算法(FLAT)算出真正最相似的前 10 条作为标准答案;再看 HNSW 返回的前 10 条里,有多少条在标准答案里。10 条里命中 9.5 条,recall@10 就是 95%
P99 < 50ms
把一段时间所有查询的耗时从小到大排队,第 99% 位置那个查询的耗时低于 50 毫秒
100 个查询里 99 个都在 50ms 内返回
为什么看 P99 不看平均?平均值会被大量快查询拉低,掩盖长尾
用户体验恰恰由最慢的那批决定
调参的全部意义就是:在满足 SLA 的前提下,把成本(内存/机器)压到最低
索引家族对比
FLAT(暴力检索)
不做任何索引,查询时把 query 向量和库里每一条向量算一遍距离,排序取前 K。精度 100%
耗时随数据量线性增长,10 亿条数据单次查询要 10 秒以上
用途
数据量极小(10 万以内)时图省事直接用
给别的索引当"标准答案"——测 HNSW 的 recall@10 就是拿 FLAT 的结果做基准
IVF_FLAT(倒排聚类索引)
建索引时用 K-means 聚类算法把全部向量分成 nlist 个簇(每个簇有一个中心点)
查询时先找到离 query 最近的 nprobe 个簇中心,只在这几个簇里做暴力搜索
搜索范围从全库缩到几个簇,速度大增
nlist(建索引时分几个簇,经验值 4×√N,比如 100 万数据分 4000 个簇
nprobe(查询时探几个簇,经验取 nlist 的 10-20%)——nprobe 就是 IVF 的召回/速度旋钮,探的簇越多越准越慢
缺点:高维向量下聚类效果变差,召回率掉得明显;而且如果目标向量恰好落在没被探到的相邻簇里,就永远找不到了(边界效应)
IVF_PQ
在 IVF 分簇基础上再加 PQ(Product Quantization,乘积量化)
量化就是"有损压缩"
把 768 维的 float32 向量切成若干段,每段用一个小码本近似表示,最终每个向量从 3KB 压到几百字节(压缩 4-16 倍)距离计算变成查表,极快
代价是量化误差导致召回率再降一档
用途:亿级以上、内存装不下原始向量的海量场景(如全网图片检索)
DISKANN
微软提出的方案,思想是"图结构照样建,但放在 SSD 磁盘上,内存里只放一份压缩过的向量用于导航
SSD 比内存便宜一个数量级,换来的是查询要走磁盘 IO,延迟从毫秒级涨到几十毫秒级
用途:十亿级、内存彻底装不下的场景
HNSW+PQ
折中方案——图结构保留在内存(保证导航质量),向量本体用 PQ 压缩(省掉大头内存)
内存装不下原始向量但又不舍得放弃 HNSW 的高召回时选它
归一化后用 IP(内积)而不是直接用 COSINE(余弦)
L2 归一化
内积(IP / dot product)
对应位置相乘再相加
IP(a,b) = a₁b₁ + a₂b₂ + ... + a₇₆₈b₇₆₈
余弦相似度(COSINE)
内积再除以两个向量的模长
cos(a,b) = IP(a,b) / (|a| × |b|)
其中 |a| = √(a₁² + a₂² + ... + a₇₆₈²) (向量的"长度")
余弦 = 内积 ÷ 两个长度。除模长的作用是把"长度"这个因素消掉,只留"方向"信息
归一化 = 把向量每个分量都除以它自己的模长,使新向量长度恰好 = 1
2 维小例子:a = (3, 4),模长 = √(9+16) = 5,归一化后 a' = (0.6, 0.8),验证长度 √(0.36+0.64) = √1 = 1。方向没变,长度被拉成 1
归一化之后,两个公式变成同一个东西
归一化后 |a'| = |b'| = 1,代入余弦公式
cos(a',b') = IP(a',b') / (1 × 1) = IP(a',b')
假设不归一化,直接算余弦,每比较一次要算三样东西:IP(a,b)、|a|、|b|——注意算一个模长和算一次内积的计算量是同级的(都是 768 次乘法级别),所以一次余弦 ≈ 3 次内积的成本 + 一次除法。
归一化之后,每比较一次只算 1 次内积。单次比较省 2/3 的计算量
再叠一层硬件红利:内积"乘-加-乘-加"的模式正好是<font color="#ec7270"><b> CPU SIMD 指令的主场——AVX-512 的寄存器一次能装 512 bit = 16 个 float32,一条指令同时算 16 对乘法;FMA(乘加融合)指令把"乘完再加"压成一个时钟周期</b></font>。768 维的内积 ≈ 48 条 SIMD 指令就扫完。而模长里的开平方(√)和除法在硬件上是出了名的慢(几十个时钟周期)。
归一化这个操作在向量入库时做一次就行(离线成本),线上每次查询都省掉——这叫**"计算左移"**,把能离线做的事绝不放到线上
内存装不下的出路
MMAP → HNSW+PQ → DiskANN
先试 MMAP(内存映射)
按需、按页(4KB 小格子)流动
边用边换、热的留下、冷的出去"
1.启动时:什么都不加载,只是建立"虚拟地址 → 磁盘文件"的映射关系(程序以为自己有 300GB 内存可用,其实内存里一页都没有);
2.查询访问到某块数据时:发现这页不在内存 → 触发"缺页",OS 把这一页从磁盘读进内存(就这一步慢,毫秒级);
3.内存快满时:OS 自动把最近最久没用的页踢出去(LRU 策略),给新页腾地方;
4.反复查询后:经常被访问的"热点"页(比如热门文档的向量、图的上层节点)自然沉淀在内存里,冷数据永远留在磁盘——内存变成了一个自动管理的大缓存。
再试 HNSW+PQ
向量本体压缩 4-16 倍,内存需求直接砍一个数量级,还能保住图索引的高召回特性
到百亿级,PQ 也救不了,这时就得上最后手段:分片(数据切成多份,多个 QueryNode 各装一部分,查询时广播再合并结果)
最后 DiskANN
彻底把家搬到 SSD 上,内存只留导航用的压缩向量——这是内存和体验都让步到极致的终极方案,适合十亿级
能用内存解决就别量化,能量化解决就别上磁盘
调优心得
调优的本质不是"让系统更好",是"在效果、速度、成本不可能三角里,把配置推到你业务需要的那条边上"
第 0 步:先准备好三样东西(动手前的前提)
评估集
从真实业务问题里抽的测试题,比如 1000 条用户真实问过的 query
每条 query 的"标准答案"(ground truth)
这条 query 真正应该被检索到的 top10 文档块是哪 10 个
用 FLAT 暴力检索——它不带任何近似,把 query 和库里每一条向量都算一遍距离,排出来的 top10 就是"真理"
一个可复现的测试环境
数据量、机器配置固定,不然曲线没法比
第 1 步:调 efConstruction(画第一条曲线)
固定 M=18,efConstruction 取一档一档试:100 → 150 → 200 → 240 → 300 → 400
用这个 efConstruction 重建一次索引
它是建索引参数,每改一次都要重新建图,一次可能几十分钟到几小时
建好后跑评估集,记下 recall@10
同时记下这次建索引花了多长时间
横轴构建时间(或参数值),纵轴召回率。你会看到曲线先陡后平
从 100 到 240,召回率蹭蹭涨(96→98.2);240 之后,参数翻了快一倍,召回率只动了 0.1
曲线从"爬坡"变成"平台"的那个转折点,就是拐点(knee point)
第 2 步:调 efSearch(画第二条曲线)
efConstruction=240 的图建好之后不再动了(上限已锁死),开始调查询侧的 efSearch:32 → 64 → 128 → 256 → 512
这步不用重建索引,改的是运行时参数,所以每档只要跑一遍评估集,几分钟就出结果<br>
每档记两个数:recall@10 和 P99 延迟
这次曲线的横轴是延迟。约束条件摆上来:recall ≥ 95%、P99 < 50ms。看表
efSearch=64:召回 94.2%,不达标,pass;
efSearch=128:召回 95.6% ✓,延迟 28ms ✓ ——达标且成本最低的点;
efSearch=256:召回只多 0.5,延迟却翻倍到 55ms,超 SLA 了。
曲线也是先陡后平——efSearch 是"查询时跟进多少条线索",线索从 32 加到 128 时漏检大幅减少
图的上限被 efConstruction 锁在 98.2%,efSearch 开到 512 也只摸到 96.3%,永远逼近、永不到达——这就是"efSearch 只能逼近上限不能突破"的实证
固化 + 留档
两个参数写进配置(efConstruction=240 建图、efSearch=128 上线)
两张曲线图和评估报告存档
efConstruction 只影响离线,那为什么不直接拉到 1000?
① 构建时间近似线性增长,240→1000 意味着建索引时间翻 4 倍,全量重建一次从几小时变成一天,影响迭代效率;
② 图质量还受数据分布本身限制,efConstruction 过了拐点后召回提升只有 0.1% 量级——投入产出不成比例
业务突然说答错太多,要马上提召回,怎么办
不用重建索引:直接调大 efSearch(比如 128→256),用延迟换召回
灰度发布验证延迟可接受后固化
同时启动离线流程:评估是否要把 efConstruction 调高重建索引(治本)。
HNSW 最怕什么场景
① 内存装不下(图+向量全驻内存)——出路 MMAP/PQ/DiskANN
② 高频删除(删除是打标记不是真删,删多了图里全是"墓碑",导航质量退化,需要定期重建)
③ 数据分布剧变(比如知识库从财经文档整体换成法律文档,按旧分布建的图导航效率下降)
每周跑固定评估集盯 recall 曲线,发现持续阴跌就安排一次重建索引
通常不只是索引问题——embedding 模型可能都要换(bge-base-zh 在通用中文上不错,但法律领域有专门微调的模型)
千万级数据,你怎么向别人证明你的召回率真有 95%
采样验证:随机抽 1 万条真实 query,每条用 FLAT 暴力检索算出真正的 top10(标准答案)
再和 HNSW 返回的 top10 比对,算平均命中率。FLAT 跑 1 万条虽然慢,但这是离线一次性工作,跑得动
高频删除细讲
Milvus 里执行 delete(ids=[...]) 时,不是立刻把那条向量从 HNSW 图里抠掉
因为从一个图索引里真删一个节点,要把它所有邻居的边重新接一遍,代价很大
在 Segment 里给这条记录打个"已删除"的标记(bitset 位图,一个比特位),查询时照常导航,只是结果里把带标记的过滤掉
删得多了,图里一堆"墓碑节点"——它们还占着内存、还参与导航(贪心跳转可能跳到墓碑上再绕开),导航效率和质量都在缓慢退化
应对手段(按从轻到重)
容忍:删除率 <10% 基本无感,不用管
compact(压缩整理):Segment 层面做合并,把带标记的记录物理清除(这是 Segment 级的,不动图结构)
定期重建索引:删除量大或导航明显变慢时,用新 efConstruction 重建一次图,墓碑全部清掉——这就是为什么"高频删除"场景要规划定期重建,一般夜间低峰跑