🏠 总目录📚 本教程 10 · 向量检索 ANN
📑 本页目录(点开跳转)

10 · 向量检索与 ANN

20 分钟 | ⭐ 核心 | 🔨 有代码


🎯 一句话

有了用户向量和 1 亿个物品向量,怎么在 10 毫秒内找出最近的 1000 个? 答案:放弃「精确」,接受「差不多」 —— 这就是 ANN(近似最近邻)。

顶层:稀疏,跨度大中层底层:全部向量找到先在稀疏的顶层大步跳,再逐层下沉细找⭐ 这样只碰了几十个点,而不是全部一亿个 —— 代价是「近似」:它找到的可能是第 2 近而不是第 1 近。工业界普遍接受这个折中
先在稀疏的顶层大步跳,再逐层下沉细找 —— 只碰几十个点而不是全部一亿个。⚠️ 代价是「近似」:它找到的可能是第 2 近而不是第 1 近,工业界普遍接受这个折中。

😵 先看问题有多难

   用户向量 u (64 维)
   物品向量 v₁, v₂, ..., v₁₀₀₀₀₀₀₀₀ (各 64 维)

   要找:和 u 点积最大的前 1000 个

   暴力算法:
     1 亿次点积 × 64 次乘法 = 64 亿次浮点运算
     单核 CPU ≈ 几秒钟
     ❌ 超预算 100 倍以上

ANN 的交易:牺牲 1–5% 的准确率,换 100–1000 倍的速度。

💡 为什么这个交易划算? 召回本来就是「宁滥勿缺」的粗筛,漏掉排名第 998 的物品,对最终结果几乎没影响。 后面还有粗排和精排会重新把关。


🗺️ 四大类 ANN 方法

① 暴力 Flat
  • 全算一遍,100% 准确
  • ✅ 精确 ❌ 慢
  • 👉 物品 < 10 万时直接用它,别折腾
② 聚类 / 倒排 IVF
  • 先把 1 亿个向量聚成 4096 个簇
  • 查询时只搜最近的几个簇
  • ✅ 内存友好、可调 ⚠️ 边界物品可能漏
③ 图索引 HNSW ⭐ 目前的主流
  • 把向量组织成"多层高速公路网"
  • 从最上层大步跳,逐层下降精细搜索
  • ✅ 又快又准(召回率 95%+ 且 亚毫秒)
  • ❌ 内存占用大、构建慢、不易增删
④ 量化 PQ / OPQ
  • 把 64 维 float32 (256字节) 压成 8 字节
  • ✅ 内存降 32 倍 ❌ 精度损失
  • 👉 常和 IVF 组合:IVF-PQ,用于超大规模

🕸️ HNSW:图解「多层高速公路」

只有少数节点AZ第 2 层(稀疏,长跳)ADKPTZ第 1 层(中等)ABCDEFGHIJKLMNOP...Z第 0 层(全部节点)查询过程(找离 Q 最近的点):1. 从第 2 层的入口 A 出发,大步跳到最接近 Q 的节点2. 下降到第 1 层,在该节点附近继续找更近的3. 下降到第 0 层,精细搜索邻居4. 返回 top-k💡 就像找地址:先看省级地图 → 市级 → 街道
该看的是那几条虚线:同一个节点(A、D、K、P、T、Z)在多层里重复出现 —— 上层就是下层的稀疏采样,这才是「多层高速公路」的字面含义。⭐ 查询顺着虚线一层层往下掉,每降一层搜索范围就收紧一圈。

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 延迟」曲线(扫 efSearchnprobe) 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基础 06IVF 的倒排桶就是 k-means,PQ 的子空间码本也是聚类
智能体 11RAG 用的是完全相同的技术栈,只是把物品 Embedding 换成了文本 Embedding

✅ 检查点

  1. 为什么召回可以接受「近似」而不是精确检索?
  2. HNSW 的核心思想是什么?线上调哪个参数控制精度-延迟?
  3. 用 Faiss 做内积检索前,必须做什么预处理?为什么?
  4. 1000 万向量、内存充裕、追求低延迟 → 选什么索引?
  5. ANN 索引的核心评估指标是什么?
  6. 用户向量和物品向量的更新频率一样吗?
👀 答案 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

打卡记录保存在你的浏览器里,首页能看到总进度