📑 本页目录(点开跳转)
10 · 向量检索与 ANN
⏱ 20 分钟 | ⭐ 核心 | 🔨 有代码
🎯 一句话
有了用户向量和 1 亿个物品向量,怎么在 10 毫秒内找出最近的 1000 个? 答案:放弃「精确」,接受「差不多」 —— 这就是 ANN(近似最近邻)。
😵 先看问题有多难
用户向量 u (64 维)
物品向量 v₁, v₂, ..., v₁₀₀₀₀₀₀₀₀ (各 64 维)
要找:和 u 点积最大的前 1000 个
暴力算法:
1 亿次点积 × 64 次乘法 = 64 亿次浮点运算
单核 CPU ≈ 几秒钟
❌ 超预算 100 倍以上
ANN 的交易:牺牲 1–5% 的准确率,换 100–1000 倍的速度。
💡 为什么这个交易划算? 召回本来就是「宁滥勿缺」的粗筛,漏掉排名第 998 的物品,对最终结果几乎没影响。 后面还有粗排和精排会重新把关。
🗺️ 四大类 ANN 方法
- 全算一遍,100% 准确
- ✅ 精确 ❌ 慢
- 👉 物品 < 10 万时直接用它,别折腾
- 先把 1 亿个向量聚成 4096 个簇
- 查询时只搜最近的几个簇
- ✅ 内存友好、可调 ⚠️ 边界物品可能漏
- 把向量组织成"多层高速公路网"
- 从最上层大步跳,逐层下降精细搜索
- ✅ 又快又准(召回率 95%+ 且 亚毫秒)
- ❌ 内存占用大、构建慢、不易增删
- 把 64 维 float32 (256字节) 压成 8 字节
- ✅ 内存降 32 倍 ❌ 精度损失
- 👉 常和 IVF 组合:IVF-PQ,用于超大规模
🕸️ HNSW:图解「多层高速公路」
HNSW 的两个关键参数:
| 参数 | 含义 | 调法 |
|---|---|---|
M |
每个节点连几个邻居 | 16–48。大 → 准但费内存 |
efConstruction |
建索引时搜索深度 | 100–500。大 → 索引质量好但建得慢 |
efSearch |
查询时搜索深度 | ⭐ 线上调这个。大 → 准但慢。这是你的「精度-延迟」旋钮 |
🔨 动手:三个工具,够用一辈子
工具 1:Faiss(Meta 出品,工业标准)
# pip install faiss-cpu (GPU 版: faiss-gpu)
import faiss
import numpy as np
d = 64 # 向量维度
n = 1_000_000 # 物品数
item_vecs = np.random.random((n, d)).astype('float32')
# ⭐ 关键:内积检索前必须 L2 归一化,这样内积 = 余弦相似度
faiss.normalize_L2(item_vecs)
# ---------- 方案 A:小规模(< 10 万),直接暴力 ----------
index = faiss.IndexFlatIP(d) # IP = Inner Product
index.add(item_vecs)
# ---------- 方案 B:中大规模,IVF ----------
nlist = 4096 # 聚类中心数,经验值 ≈ 4*sqrt(n)
quantizer = faiss.IndexFlatIP(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT)
index.train(item_vecs) # ⭐ IVF 必须先训练(学聚类中心)
index.add(item_vecs)
index.nprobe = 32 # ⭐ 查几个簇。这是精度-速度旋钮
# ---------- 方案 C:HNSW,追求低延迟 ----------
index = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT)
index.hnsw.efConstruction = 200
index.add(item_vecs)
index.hnsw.efSearch = 64 # ⭐ 线上调这个
# ---------- 方案 D:超大规模 + 内存紧张,IVF-PQ ----------
m, nbits = 8, 8 # 分 8 段,每段 8 bit → 8 字节/向量
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)
index.train(item_vecs)
index.add(item_vecs)
# ---------- 查询(都一样) ----------
user_vec = np.random.random((1, d)).astype('float32')
faiss.normalize_L2(user_vec)
scores, ids = index.search(user_vec, k=100)
print("Top-10 物品 ID:", ids[0][:10])
# ---------- 保存 / 加载 ----------
faiss.write_index(index, "items.index")
index = faiss.read_index("items.index")
工具 2:hnswlib(更轻量,支持增量插入)
# pip install hnswlib
import hnswlib
index = hnswlib.Index(space='cosine', dim=64)
index.init_index(max_elements=1_000_000, ef_construction=200, M=32)
index.add_items(item_vecs, ids=np.arange(n))
index.set_ef(64) # 查询精度旋钮
labels, distances = index.knn_query(user_vec, k=100)
工具 3:向量数据库(要持久化、过滤、分布式时)
| 产品 | 特点 |
|---|---|
| Milvus / Zilliz | 开源,功能全,支持标量过滤,云原生 |
| Qdrant | Rust 写的,轻量快,过滤能力强 |
| Weaviate | 内置多种 embedding 模型 |
| pgvector | PostgreSQL 插件。数据量不大时首选——省掉一整个新组件 |
🔧 选型建议: - < 10 万向量 →
IndexFlatIP(暴力),别过度设计 - 10 万 – 1000 万 → HNSW(Faiss 或 hnswlib) - > 1000 万,内存吃紧 → IVF-PQ - 需要「过滤 + 持久化 + 分布式」→ 向量数据库 - 已经有 Postgres → 先试 pgvector
🎛️ 怎么评估你的 ANN 索引
唯一重要的指标:Recall@K(相对暴力检索)
def measure_recall(ann_index, exact_index, queries, k=100):
"""ANN 找出的 top-k 里,有多少在暴力检索的 top-k 里"""
_, ann_ids = ann_index.search(queries, k)
_, exact_ids = exact_index.search(queries, k)
recalls = [len(set(a) & set(e)) / k for a, e in zip(ann_ids, exact_ids)]
return np.mean(recalls)
# 典型目标:Recall@100 ≥ 0.95,同时 P99 延迟 < 10ms
调参流程:
1. 固定索引类型,画出 「Recall vs 延迟」曲线(扫 efSearch 或 nprobe)
2. 在延迟预算内取 Recall 最高的点
3. 如果达不到目标,换索引类型或加机器
Recall@100
1.0 ┤ ╭────────
0.95┤ ╭─────╯ ← 通常这里性价比最高
0.9 ┤ ╭─────╯
0.8 ┤ ╭────╯
0.6 ┤╭──╯
└┬────┬────┬────┬────┬────► 延迟(ms)
1 2 5 10 20
⚠️ 工业落地的四个真实问题
① 向量要多久更新一次?
- 物品向量:新物品不断产生。通常「天级全量重建 + 分钟级增量插入」
- 用户向量:必须实时算(用户刚看了什么会立刻影响兴趣)→ 线上跑用户塔
② 怎么做「带条件的检索」?
比如「只推 30 天内发布的、类目=数码的」。
| 方案 | 说明 |
|---|---|
| 过滤后检索 (Pre-filter) | 先筛出子集再暴力搜。子集小时最好 |
| 检索后过滤 (Post-filter) | 先 ANN 取 top-1000,再过滤。⚠️ 可能过滤完不够数 |
| 分区索引 | 按类目/时间分别建索引,查询时只查相关分区 ⭐ 工业常用 |
| 原生过滤 | Milvus/Qdrant 支持在图遍历时带条件 |
③ 内存怎么算
1 亿向量 × 64 维 × 4 字节 (float32) = 25.6 GB ← 光向量
HNSW 的图结构还要额外 ~M×8 字节/向量 ≈ +25 GB
────────────────────────────────────────
总计 ~50 GB → 需要大内存机器或分片
用 PQ 压缩后:1 亿 × 8 字节 = 0.8 GB ✅ 单机轻松
④ 多路召回怎么合并
20 路召回各返回一批,分数不在同一个尺度上,不能直接比大小。
✅ 常见做法: - 每路按配额取 top-N(如每路 200 个),不比较分数 - 或者做分数归一化后合并 - 或者干脆交给粗排统一打分(最常见)⭐
🧩 一个容易忽略的点:向量检索 ≠ 只能用于召回
同样的技术也用在: - 相似物品推荐(「相关推荐」位) - 去重(新发布的内容和已有内容是否重复) - 冷启动:新物品用内容 Embedding 找相似老物品,继承它们的统计特征 ⭐ - RAG / 搜索:完全相同的技术栈
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 数学原理 05 | 高维空间里距离会趋同,这正是精确检索没救、只能退而求「近似」的根因 |
| ML基础 06 | IVF 的倒排桶就是 k-means,PQ 的子空间码本也是聚类 |
| 智能体 11 | RAG 用的是完全相同的技术栈,只是把物品 Embedding 换成了文本 Embedding |
✅ 检查点
- 为什么召回可以接受「近似」而不是精确检索?
- HNSW 的核心思想是什么?线上调哪个参数控制精度-延迟?
- 用 Faiss 做内积检索前,必须做什么预处理?为什么?
- 1000 万向量、内存充裕、追求低延迟 → 选什么索引?
- ANN 索引的核心评估指标是什么?
- 用户向量和物品向量的更新频率一样吗?
👀 答案
1. 召回本来就是粗筛,漏掉排名靠后的物品对最终结果影响极小,且后面还有粗排精排把关。用 1-5% 的召回率损失换 100-1000 倍速度非常划算。 2. 多层图结构,上层稀疏用于长距离跳跃,逐层下降精细搜索(像先看省级地图再看街道)。线上调 `efSearch`。 3. L2 归一化。归一化后内积等价于余弦相似度,避免向量长度影响结果。 4. HNSW(Faiss IndexHNSWFlat 或 hnswlib)。 5. Recall@K —— ANN 结果和暴力检索结果的重合比例。目标通常 ≥0.95 且 P99 延迟在预算内。 6. 不一样。物品向量天级重建+增量插入;用户向量必须线上实时计算,因为用户兴趣随时在变。🛑 可以停在这里
⚡ 走神救援
ANN = 牺牲 1-5% 准确率换 100-1000 倍速度。四类方法:Flat(暴力,<10万用它)、IVF(聚类,调nprobe)、HNSW(多层图,主流,调efSearch)、PQ(量化压内存)。工具:Faiss / hnswlib / 向量数据库。内积检索前必须 L2 归一化。评估指标 Recall@K vs 延迟曲线。物品向量天级更新,用户向量实时算。⭐ 选索引先看规模,不是看谁先进:10 万以下直接 Flat 暴力最好——又快又准还零调参,别为了显得专业上 HNSW;亿级则要先算内存:1 亿 × 64 维 float32 就是 25.6 GB,HNSW 的图结构还得再加约 25 GB,而 PQ 压完只剩 0.8 GB,单机就能扛。⭐ 调参只有一条流程:固定索引类型,扫
efSearch/nprobe画出 「Recall@K vs 延迟」曲线,在延迟预算内取 Recall 最高的那个点(典型目标是 Recall@100 ≥ 0.95 且 P99 < 10ms)。⚠️ 带条件的检索最容易踩坑:Post-filter 很可能过滤完就不够数了,工业上更常按类目/时间做分区索引,只查相关分区。⭐ 多路召回各自的分数不在同一个尺度上,不能直接比大小——常见做法是每路按配额取 top-N,或者干脆全交给粗排统一打分。
下一节 👉 11-重排与多样性.md