想象你拿着一家餐厅的照片来到一座陌生城市,想找到和照片最像的那家店。最笨的办法是挨家挨户推门问,店少还行,店一多就走到天黑;聪明人会用三种办法:先看按菜系分区的地图直奔川菜区;或给朋友打电话,让朋友推荐朋友;再或者,把随身携带的厚厚店面介绍压缩成名片上的几个关键词。这三种问路智慧,恰好对应向量数据库的三大索引:IVF 分区粗筛、HNSW 朋友网络导航、PQ 向量压缩。本文就用这套“城市问路”的框架,把“向量长什么样、索引为什么快、三个方案怎么权衡、真实项目怎么选”讲得明明白白。
掌握 HNSW、IVF、PQ 的原理与取舍,你就能在 RAG、相似检索、向量数据库项目中做出快、准、省的索引选型决策,直接提升系统性能与落地效率。
目录
可视化演示
对比展示HNSW分层导航、IVF倒排索引和PQ乘积量化三种向量检索算法的工作原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
HNSW分层导航 多层图结构加速检索 贪心搜索逐层逼近目标 O(log n) 查询复杂度
暴力检索困境:挨家挨户问路
想象你手拿一张餐厅照片,站在陌生城市街头,想找一家和照片最像的店。最朴素的办法是挨家挨户推门问“你家像我家这张照片吗”。店少的时候完全可行——十家店十分钟问完;可当城市里有几百万家店,走到天黑也问不完。向量检索的困境,正是从“挨家挨户”开始的。
在向量数据库里,“店”就是一条向量:一串数字坐标,常见的是把文本、图片、商品用神经网络编码成 128 维或 768 维的 embedding。“像不像”由向量距离衡量,最常用的是欧氏距离(两点之间真实的直线远近)和余弦相似度(方向是否一致)。把库里每条向量和查询向量逐一比一遍,数学上叫线性扫描(brute force),这也是保证 100% 正确的“标准答案”。
线性扫描的可怕之处在于耗时 = 向量条数 × 每条比对成本。百万条 768 维向量,一次查询要算上亿次乘加,在线上毫秒级延迟要求下完全不可接受。这正是索引存在的意义:花一点预处理成本,把“每问必扫全城”变成“瞄一眼地图就知道去哪”。
所以,真实的向量数据库不会真的挨家挨户问。它要么先把城市分区(IVF),要么先经营一张朋友网络(HNSW),要么把每家店的介绍压缩成名片(PQ)。下面三条路,逐一拆解。
- N 是向量总条数库里有多少条向量,暴力检索就要过多少遍,N 翻倍耗时翻倍。
- × 是相乘每一轮比对都要做满 D 维度的距离计算,“条数 × 维度”就是总计算量。
- D 是向量维度768 维的向量意味着单次比对要算 768 次乘加,维度越高单次越贵。
- O() 是增长量级记号它描述“耗时如何随规模增长”——这里是线性增长,规模一上来就失控。
IVF 倒排索引:按菜系分区的地图
城市里如果有一张“菜系分区地图”,问路就轻松多了:先确认照片里的菜是川菜,直接翻到川菜区,在这个区里挨家比就行,其他区不用踏足。IVF(Inverted File Index,倒排文件索引)干的就是这件事——给高维空间画分区地图。
IVF 的第一步是聚类(clustering):用 K-Means 把全部向量聚成 k 簇,每簇算出一个质心 c_i。质心可以理解成每个区的“地标代表口味”。建索引时,每条向量被分配到离自己最近的质心,然后写进该质心名下的倒排列表(inverted list);查询时,先把查询向量和所有质心算一遍距离,找到最近的几个质心,只在这几个列表里扫描候选向量。
搜索时打开几个列表,由参数 nprobe 决定:nprobe=1 最快,但答案可能刚好落在隔壁区被漏掉;nprobe 增大,召回率上升,耗时也同步上升。向量分到哪个簇,公式上就是一个“找最近质心”的过程:
IVF 的聪明之处在于“以区代城”:建索引时多花了聚类的功夫,查询时就只用和少数几个区的向量比对。nprobe 就是你的活动范围——允许跨几个区追查,找得更全,代价是腿脚更累。
- x 是待处理的向量无论是入库还是查询,都要先回答“它属于哪个区”。
- c_i 是第 i 个质心K-Means 聚类产出的“地标”,每个区一个代表点。
- ‖x−c_i‖₂ 是欧氏距离两点的直线距离,衡量向量和地标有多像。
- argmin 是“取最小”把 k 个距离比完,选最小的那个作为归属。
HNSW 图索引:朋友推荐的朋友
问路还有第二种哲学:不翻地图,给朋友打电话。“我找一家像照片这样的店,你认识吗?”朋友说“我不确定,但我认识一个美食博主”,美食博主又推荐了甜品店老板——几步之内,答案浮现。这就是社交网络里的“六度分隔”直觉。
HNSW(Hierarchical Navigable Small World,层级可导航小世界图)把向量组织成一个分层的图。顶层节点很少,全是“社交达人”(枢纽节点),一跳就能跨大半个城市;越往下层节点越多、连接越密,像熟人圈子里聊细节。查询从顶层某个入口节点开始,像顺着朋友链一样不断下钻。
搜索过程是贪心的:站在当前节点,看它所有邻居里谁离查询向量最近,就移动过去;一直走到“邻居都不如我更近”为止。因为上层节点跨度大,往往走几十步就能从全城“滑”到目标附近,再进下层精确定位,总步数大约是 log N 量级。每一步的决策可以写成:
建图的过程也暗合交友逻辑:新向量插入时,先找到最近的几个节点,连上边,像新朋友加入朋友圈后认识几个熟人。连接数量由参数 M 控制。图索引查询快、召回高,代价是边要额外占内存,建图也慢。
- N(x) 是当前节点的邻居集合图上和当前节点直接相连的所有点。
- q 是查询向量你想找的那家“最像照片”的店。
- ‖y−q‖₂ 是邻居到查询的距离和 q 越近的邻居,越像正确的方向。
- argmin 选择最近邻居只迈向“看起来最像”的那一步,贪心但不回头。
PQ 量化:把名片做薄
城市里的店多到名片册塞不下。聪明的做法是把每家店厚厚的介绍压成名片上的几个关键词:菜系、辣度、人均价位——问路时先翻名片判断“值不值得登门”,省得每家都进门深聊。PQ(Product Quantization,乘积量化)就是给向量做“名片”。
PQ 把一条 d 维向量切成 m 段子向量,每一段单独用 K-Means 学一个小码本(codebook),每段向量用码本里最近的码字编号来表示。比如 128 维向量切成 8 段,每段 16 个码字,每一段只需 4 bit,整条向量从 128×4 字节压缩成 8×4 bit=4 字节,内存直接缩到十六分之一。
难点在于:压缩后怎么算距离?PQ 不用还原原始向量,而是“查表加和”:查询时预先算出查询向量的每一段到该段所有码字的距离,形成 8 张小表;每条候选向量的距离 = 它 8 个码分别查表得到的 8 个距离之和。这个近似距离公式是:
PQ 本身有精度损失,但它从来不是单打独斗:工程上常把 IVF 当“分区粗筛”,PQ 当“名片压缩”,先缩小区间再快速比名片,最后对少量候选还原原始向量精排。就像先根据菜系锁定几个街区,再翻名片挑出三五家,最后登门验证——精度不掉,速度内存双赢。
- x^(j) 是第 j 段子向量把完整向量切成 m 个片段。
- d_j 是第 j 段的距离函数查该段码本得到“查询片段 × 码字”的距离表。
- 求和符号 Σ把 m 段的查表距离加起来,近似真实距离。
- tilde{d} 是近似距离带波浪号的 d 表示“约等于”,PQ 是有损压缩。
性能三方权衡:速度、召回、内存
选择问路方式,就像选择交通工具:地铁快但可能不到门口,共享单车灵活但费腿,步行最准但最慢。没有绝对最优的工具,只有和场景匹配的取舍。向量索引的取舍围绕三个指标:检索速度(QPS)、召回精度(Recall)、内存占用。
暴力扫描内存最省——除原始向量外不需要任何额外结构,但速度最慢。IVF 内存很克制:只多存 k 个质心和倒排指针,速度中等,召回由 nprobe 控制。HNSW 速度和召回通常最好:毫秒级返回、召回接近 100%,但图结构(每节点若干条边 + 多层)要额外吃内存,构建时间也最长。PQ 不改变搜索策略,只负责把向量压薄,因此常作为“省内存外挂”叠加在 IVF 或 HNSW 上。
怎么量化“好不好”?召回率用 Recall@K 衡量:让索引返回 TopK 结果,看其中有多少真的在暴力搜索的标准答案里。公式很直白:
真实项目里几乎没有人只追求一个指标:线上服务要快,离线评估要准,服务器内存有上限。于是“组合拳”成了常态——HNSW+PQ 拿速度又省内存,IVF+PQ 在大规模场景求平衡。快、准、省,先想清楚你要哪两个,再选工具。
- R_topK 是索引返回的 TopK 结果问路系统推荐的 K 家店。
- R_gt 是暴力搜索的标准答案挨家挨户问出来的真实 TopK。
- ∩ 是交集、|·| 是集合大小推荐名单里有几家真的在标准答案里。
- 除以 K 归一化得到的 0~1 分数,就是“漏掉了多少正确答案”的指标。
选型落地指南:按出行目的选工具
出差赶飞机选地铁,周末闲逛骑单车,问路方式没有好坏,只有合不合适。向量索引选型也一样,先回答四个问题:数据量多大?延迟预算多少毫秒?内存上限多少 GB?召回率底线是多少?
给一个经验坐标:数据量在百万级以下,直接上 HNSW,延迟低至毫秒、召回接近满分,实现也简单;千万级且内存紧张,IVF-PQ 是主流,先用 nlist 分区粗筛再用 PQ 压缩;亿级以上或 GPU 集群,IVF 的批量聚类结构对并行更友好;必须精确结果时,暴力扫描或“索引召回 + 原始向量精排”兜底。
参数怎么起步?IVF 的聚类数 nlist 大约取 sqrt(N),nprobe 从 1、5、10、20 扫一遍,画召回曲线取“拐点”;HNSW 的邻居数 M 取 16~64,搜索宽度 efSearch 越大越准但越慢。评估务必先建小批量 benchmark,把“延迟-召回”曲线拿到手再上线。粗筛候选量有一个直白的估计式:
记住问路三件套:分区地图(IVF)、朋友网络(HNSW)、名片压缩(PQ)。绝大多数场景都是它们的组合拳——先想清楚数据规模和资源上限,再定方案、跑曲线、调参数,索引选型就稳稳落地了。
- K 是聚类总数(nlist)城市被划分成的区数量,K 越大每区店越少。
- N 是向量总数全城的店数。
- N/K 是每区平均向量数假设均匀分布,每区大约有这么多条向量。
- nprobe 是搜索的聚类数允许跨几个区找,直接乘进候选集。
AI 应用场景
场景一:RAG 知识库的向量检索底座
阶段:检索增强生成(RAG)中的召回阶段
解决的问题:RAG 系统要把用户问题与数百万条文档 embedding 比对,传统暴力扫描延迟数百毫秒,无法支撑对话体验。用 HNSW 索引后延迟降到个位数毫秒,配合 PQ 压缩内存,知识库可以全量驻留内存。
具体案例:某企业知识库有 800 万条文档向量(768 维)。选型时先以 HNSW(M=32, efSearch=64)跑 benchmark,Recall@10=98.5%,单查询 6ms;后叠加 PQ(m=16, k=256)内存从 21GB 降到 4GB,召回仅跌 0.8%。
场景二:电商“找同款”实时推荐
阶段:商品向量 Embedding 与在线相似检索
解决的问题:用户在详情页点“找相似”,要求 50ms 内返回同款。IVF 的 nprobe 调参可以灵活控制延迟与召回,适合流量峰值波动大的电商场景。
具体案例:某电商 5000 万商品向量,采用 IVF(nlist=4096, nprobe=16)+ PQ 压缩,平均查询 8ms,Recall@10 92%;双十一大促时通过下调 nprobe 到 8 保住延迟 SLA。
场景三:以图搜图与内容去重
阶段:图像向量化后的离线/在线检索
解决的问题:图片向量维度高、数量大,纯内存放不下。IVF-PQ 组合让“亿级图片索引”在单机内存可承载,配合 GPU 批量查询提升吞吐。
具体案例:某内容平台用 ResNet 提取 1.2 亿张图片的 512 维向量,IVF(nlist=65536, nprobe=32)+ PQ(m=32, k=256)建索引,内存占用约 38GB,单卡 GPU 批量查询吞吐 4000 QPS,准确率 91%。
Python 演示
import timeimport numpy as np rng = np.random.default_rng(42)X = rng.normal(size=(2000, 32)).astype("float32")X /= np.linalg.norm(X, axis=1, keepdims=True)Q = rng.normal(size=(10, 32)).astype("float32")Q /= np.linalg.norm(Q, axis=1, keepdims=True) def kmeans(rows, k, iters=12): c = rows[rng.choice(len(rows), k, replace=False)] for _ in range(iters): a = np.argmin(((rows[:, None] - c[None]) ** 2).sum(axis=2), axis=1) for j in range(k): m = rows[a == j] if len(m): c[j] = m.mean(axis=0) return c def brute(q, topk=5): return set(np.argsort(((X - q) ** 2).sum(axis=1))[:topk]) class IVF: def __init__(self, nlist=16): self.nlist = nlist self.lists = [[] for _ in range(nlist)] def fit(self, rows): self.cent = kmeans(rows, self.nlist) a = np.argmin(((rows[:, None] - self.cent[None]) ** 2).sum(axis=2), axis=1) for i, j in enumerate(a): self.lists[j].append(i) def search(self, q, nprobe, topk=5): d = ((self.cent - q) ** 2).sum(axis=1) cand = np.concatenate([np.array(self.lists[j]) for j in np.argsort(d)[:nprobe]]) dd = ((X[cand] - q) ** 2).sum(axis=1) return set(cand[np.argsort(dd)[:topk]]) class PQ: def __init__(self, m=8, k=16): self.m, self.k, self.codes = m, k, None def fit(self, rows): seg = rows.shape[1] // self.m self.books = [kmeans(rows[:, j * seg:(j + 1) * seg], self.k) for j in range(self.m)] cols = [] for j in range(self.m): sub = rows[:, j * seg:(j + 1) * seg] cols.append(np.argmin(((sub[:, None] - self.books[j][None]) ** 2).sum(axis=2), axis=1)) self.codes = np.stack(cols, axis=1) def search(self, q, topk=5): seg = X.shape[1] // self.m adc = np.zeros(self.codes.shape[0], dtype="float32") for j in range(self.m): tbl = ((self.books[j] - q[j * seg:(j + 1) * seg]) ** 2).sum(axis=1) adc += tbl[self.codes[:, j]] return set(np.argsort(adc)[:topk]) gts = [brute(q) for q in Q]t0 = time.time()[brute(q) for q in Q]print("brute total time=%.4fs" % (time.time() - t0)) ivf = IVF(16)ivf.fit(X)for nprobe in (1, 2, 4): t0 = time.time() hits = sum(len(gts[i] & ivf.search(q, nprobe)) for i, q in enumerate(Q)) print("IVF nprobe=%d recall@5=%.2f time=%.4fs" % (nprobe, hits / 50, time.time() - t0)) pq = PQ(8, 16)pq.fit(X)t0 = time.time()hits = sum(len(gts[i] & pq.search(q)) for i, q in enumerate(Q))print("PQ m=8 k=16 recall@5=%.2f time=%.4fs" % (hits / 50, time.time() - t0))
X /= np.linalg.norm(X, axis=1, keepdims=True)对向量做 L2 归一化,让欧氏距离和余弦相似度等价,保证实验干净。a = np.argmin(((rows[:, None] - c[None]) ** 2).sum(axis=2), axis=1)向量化计算每个点到所有质心的距离并取最近,是 K-Means 和 IVF 分区的核心。self.lists[j].append(i)把向量 ID 写进对应质心的倒排列表,这就是“分区地图”的落账动作。d = ((self.cent - q) ** 2).sum(axis=1)查询时先算查询向量到所有质心的距离,找到最近的几个区。cand = np.concatenate([np.array(self.lists[j]) for j in np.argsort(d)[:nprobe]])把 nprobe 个区的候选 ID 合并成候选集,代表“跨几个区找”。self.books = [kmeans(rows[:, j * seg:(j + 1) * seg], self.k) for j in range(self.m)]每个子空间独立训练码本,PQ 切段学习的核心。tbl = ((self.books[j] - q[j * seg:(j + 1) * seg]) ** 2).sum(axis=1)预计算查询片段到该段所有码字的距离表。adc += tbl[self.codes[:, j]]每条候选向量按自己的码查表加和,得到近似距离(ADC 累加距离)。hits = sum(len(gts[i] & ivf.search(q, nprobe)) for i, q in enumerate(Q))用交集统计召回命中数,除以 50 得到 Recall@5。
常见误区
误区以为向量索引和数据库 B+ 树一样,插入后自动保持最优,不需要重建或维护。
正解IVF 的质心基于旧数据训练,数据分布漂移后新向量会被分到错误的簇,倒排列表失衡,检索变慢变不准;HNSW 插入过多也会让图连接退化。需要周期性重建或增量更新,就像城市新区建设后,地图要重新分区、朋友关系要重新维护。
为何常见:传统数据库隐藏了索引维护细节,让人误以为向量索引也全自动、免维护。
误区调参时把 nprobe 拉到最大,觉得搜得越全越安全,反正准确率优先。
正解nprobe 翻倍意味着候选集翻倍,延迟几乎线性上涨,而召回提升有边际递减——从 1 到 4 收益明显,从 16 到 64 收益很小。正确做法是画“延迟-召回”曲线选拐点,就像问路时多跨几个区确实更稳,但跨 50 个区不如直接全城搜。
为何常见:只看召回不看延迟,忽略了算力是有限的,线上 SLA 会被拖垮。
误区听说 HNSW 是 SOTA 就全项目无脑上,认为它任何场景都碾压 IVF。
正解GPU 场景下 IVF 的批量聚类结构吞吐常优于 HNSW;数据量小到几万条时暴力都快过任何索引;索引构建开销、内存占用都要算进总账。选型看数据规模、硬件和延迟预算,而不是名气,就像城市里地铁不是所有出行都最优。
为何常见:把单点技术优势当成全局优势,忽略了场景约束和资源成本。
误区PQ 一压缩就“糊了”,担心线上精度崩,不愿意在正式环境使用。
正解PQ 的误差是可控的:子段越多(m 越大)越接近原始向量;它通常只负责粗筛召回候选,最后由原始向量精排,最终精度损失很小,内存却省一个量级。就像名片上几个关键词足够决定要不要登门,进门后再细聊,完全不影响最终判断。
为何常见:拿 PQ 的近似距离直接当最终结果用,误用导致精度差,却把锅甩给方法本身。
面试题
Q1. 暴力线性扫描的时间复杂度是?
- A.O(N×D),随库容量和维度线性增长← 正确答案
- B.O(log N),与库容量规模呈对数关系
- C.O(N²),与向量数量成平方关系
- D.O(1),与库容量大小完全无关
答案:A。暴力扫描要拿查询向量和库中 N 条向量逐一比对,每条比 D 维,总开销 O(N×D)。log N 是树形或图索引的加速效果,平方和常数都不符合线性扫描的实际流程。
Q2. IVF 建索引的第一步是?
- A.用 K-Means 聚类划分向量空间← 正确答案
- B.构建多层可导航的小世界图
- C.将向量切分并训练子码本
- D.直接把向量按 ID 排序存储
答案:A。IVF 的核心是先聚类得到 k 个质心,再按质心分区建立倒排列表。B 是 HNSW,C 是 PQ,D 只是排序并没有实现“按相似度分区”。
Q3. 增大 nprobe 会带来什么效果?
- A.召回率上升,检索耗时增加← 正确答案
- B.召回率下降,检索耗时减少
- C.召回率和耗时同时减少
- D.召回率和耗时都不再变化
答案:A。nprobe 表示查询时搜索几个最近聚类,跨区越多候选集越大,召回越高、耗时越长,且收益边际递减。B、C、D 的方向或关系描述都不符合这一规律。
Q4. HNSW 查询时的搜索策略是?
- A.从入口出发贪心走向最近的邻居← 正确答案
- B.在每个聚类中心之间往返比较
- C.对全部节点做一次深度优先遍历
- D.随机选择邻居直到时间耗尽
答案:A。HNSW 从顶层入口开始,每步在邻居里选离查询最近的节点前进,直到邻居不再更近。B 是 IVF 思路,C 遍历全部节点失去索引意义,D 随机搜索无法保证质量。
Q5. PQ 将 128 维向量切为 8 段、每段 16 个码字,每条向量编码后占多少 bit?
- A.32 bit← 正确答案
- B.64 bit
- C.128 bit
- D.256 bit
答案:A。每段 16 个码字需要 log2(16)=4 bit,8 段共 8×4=32 bit。64、128、256 分别对应每段 8/16/32 bit 或段数翻倍的情况,不符合题意。
Q6. 千万级向量、内存受限场景,最优组合通常是?
- A.IVF 粗筛加 PQ 压缩向量← 正确答案
- B.纯暴力扫描保证最高精度
- C.只建单层均匀网格索引
- D.直接把所有向量加载到显存
答案:A。千万级纯暴力太慢,网格索引对高维空间效率差,全量进显存不现实且浪费。IVF 先分区缩小候选范围,PQ 把向量压薄以降低内存,是海量高维检索的经典组合。
Q7. 关于 HNSW,下列说法正确的有?
- A.上层节点少、跨度大,用于快速定位← 正确答案
- B.查询时只沿邻居贪心移动,不回退← 正确答案
- C.构建过程无需计算任何向量距离
- D.图结构会额外占用一份内存← 正确答案
答案:ABD。HNSW 分层设计,上层稀疏节点负责大跨度导航,查询用贪心策略不回退,图边结构确实额外吃内存。构建时要算向量距离决定连边,C 明显错误。
Q8. 关于 PQ 量化,说法正确的有?
- A.每段子向量用独立码本编码← 正确答案
- B.距离通过查表加和近似得到← 正确答案
- C.编码过程完全没有信息损失
- D.码本数量决定向量总维度
答案:AB。PQ 把向量切成 m 段,每段独立训练码本,查询时查表加和得近似距离。量化必然有损,C 错;向量维度由原始模型决定,与码本数量无关,D 错。
Q9. 某系统要求 Recall@10 = 95%,当前 IVF 只到 88%,最先应该调什么?
- A.增大 nprobe 并重测召回曲线← 正确答案
- B.把聚类数 nlist 降到最小值
- C.删掉一部分低质量向量重建
- D.把距离度量换成曼哈顿距离
答案:A。nprobe 直接控制搜索范围,是提升 IVF 召回最直接的旋钮,配合“延迟-召回”曲线观察拐点。降低 nlist 会让每区向量更多但质心粗糙,删数据换度量都是治标不治本甚至有害。
Q10. 关于三种索引的权衡,正确的有?
- A.HNSW 通常延迟低但内存开销大← 正确答案
- B.IVF 内存小但召回受 nprobe 影响← 正确答案
- C.PQ 压缩向量会带来一定精度损失← 正确答案
- D.三种索引可以任意组合无需调参
答案:ABC。HNSW 高速高召回但图结构耗内存;IVF 结构轻量,召回由 nprobe 控制;PQ 有损压缩必然带来精度损失。组合(如 IVF-PQ、HNSW-PQ)都需要重新跑 benchmark 调参,D 错。