跳到主内容

一文搞懂向量检索:HNSW/IVF 原理推导与 RAG 召回重排两阶段实战 ​

做一个 RAG(检索增强生成)系统,把文档切块、过一遍 embedding 模型,只是万里长征第一步。真正的挑战紧随其后:百万级向量库里,如何毫秒级找出与查询最相似的那几十条?精确最近邻搜索在这里行不通,必须引入近似最近邻(ANN)索引;而 ANN 召回的结果语义上又不够「准」,需要再用重排模型做二次精筛。IVF 与 HNSW 正是这一阶段使用最广泛的两种索引结构,「ANN 粗召回 + 交叉编码器重排」则是当前 RAG 检索管线的标准形态。

这篇文章把三个环节串成一条线讲透:IVF 与 HNSW 的结构原理与推导、两阶段管线的分工逻辑、以及可以直接跑通的简化实现。所有讲解围绕算法本身展开,不依赖特定向量数据库产品——掌握了原理,换任何引擎都只是换参数名。

问题背景:暴力检索为什么行不通 ​

设库里存了 N 条向量,每条维度 d。查询一次的暴力做法是把查询向量与全库 N 条逐一算内积或余弦相似度。粗算一下账:N = 1000 万、d = 768 时,单次查询是约 77 亿次乘加运算,即便有 SIMD 加持也在百毫秒到秒级徘徊,且代价随 N 线性增长——库再翻一倍,延迟翻一倍。而 RAG 的端到端响应要求在秒级内完成,检索只能占其中一小段;用户体量上来后,检索还要承受并发。

于是目标变得非常明确:允许牺牲一点点召回率(比如从 100% 降到 90%+),换两到三个数量级的速度提升。这就是 ANN 索引的全部动机——它不追求「绝对最近」,只追求「绝大多数时候返回真正的近邻」,并把判断成本从「全库扫描」压到「查一小部分」。用什么结构决定「查哪一小部分」,就是 IVF 与 HNSW 的分野。

思路推导一:IVF——用聚类把全库切成桶 ​

IVF(Inverted File,倒排文件索引)的思想直接借自文本检索的倒排表:先分组,再只搜相关的组。构建分两步。第一步,对全库向量跑 k-means 聚类,聚成 nlist 个簇,每个簇记录簇心与成员列表——语义上相近的向量自然落进同一个桶。第二步(查询时),拿到查询向量后先与 nlist 个簇心比较,挑出最近的 nprobe 个簇,然后只在这些簇内做精确的逐条比较。

复杂度随之从「全库 O(N·d)」降到「簇心筛选 O(nlist·d) + 簇内扫描 O((nprobe/nlist)·N·d)」。当 nlist 取 N 的平方根量级、nprobe 取个位数到几十时,实际参与比较的向量只占全库的千分之几,这就是 IVF 提速的来源。

这里的关键旋钮是 nprobe,它直接控制「牺牲多少召回」:nprobe = 1 最快,但查询向量若落在簇的边界上,真正的最近邻很可能在隔壁簇里,一漏就漏整个;nprobe 增大,扫的簇变多,召回率上升、延迟上升;nprobe = nlist 时退化为精确搜索。调参的本质就是在「边界漏检风险」与「扫描代价」之间找平衡点。IVF 还常与乘积量化(PQ)搭配,把簇内向量压成几十字节的编码来省内存,代价是距离计算引入量化误差——内存敏感的超大规模场景(十亿级)几乎都走这条路线。

思路推导二:HNSW——分层图上的贪心公路 ​

HNSW(Hierarchical Navigable Small World,分层可导航小世界图)走的是完全不同的路线:把全库组织成一张近邻图,查询时在图上贪心行走。朴素的 NSW 图有一个老问题——单层大图上贪心容易困在局部最优(走到一个「局部看起来最近」的节点就出不来),而且图大了以后从任意入口起步都要扫很多边。

HNSW 的解法借鉴跳表的分层思想,把图建成多层金字塔:上层稀疏、边长跨度大,像高速公路;底层稠密、边短,像城市街道。每个节点插入时按指数衰减的概率决定它最高出现在第几层,所以顶层往往只有个位数节点,越往下节点越多。查询从顶层入口出发:在当前层贪心移动到离查询向量更近的邻居,走不动了就下一层,把上一层找到的节点作为下一层的入口;抵达底层后再用一个宽度为 ef 的候选队列做束搜索,返回 ef 个近邻。效果是:先在稀疏层大步流星地「导航」到大致区域,再在稠密层精雕细琢——既绕开了局部最优,又免掉了全图扫描。

两个参数决定 HNSW 的行为:M 是每个节点连接的邻居上限(图密度,构建期定死),ef 是搜索时的候选队列宽度(查询期可调,效果类似 IVF 的 nprobe)。查询复杂度量级为 O(ef·log N);代价是内存——每个节点要存最多 M 条边,百万级图仅边表就是 GB 量级,这是 HNSW 与 IVF 最现实的差异。

把两种索引摆在一起看:

维度IVFHNSW
核心结构聚类分桶(倒排表)多层近邻图
查询路径簇心筛选 → 簇内扫描逐层贪心导航 → 底层束搜索
速度/召回旋钮nprobe(配合 nlist)ef(配合 M)
内存开销低,可配 PQ 压缩高,需存图边
增删行为追加容易,大规模重建代价高支持增量插入,删除多为标记惰性生效
召回-延迟曲线阶梯状(按簇跳变)平滑(ef 连续可调)

一句话选型:内存充足、更新频繁、要求延迟平滑可控,选 HNSW;十亿级规模、内存敏感,IVF + PQ 更现实。大量生产系统两者同存,按数据规模与更新模式分工。

两阶段流程:ANN 召回快而粗,重排精而慢 ​

有了 ANN 索引,检索是不是就到位了?RAG 实践给出的答案是否定的——粗召回的结果常常「形似而神不似」。原因在于 ANN 相似度建立在「双塔」式表示之上:查询与文档各自独立过一遍 embedding 模型,被各自压成一个向量,再算内积。两个向量之间没有任何信息交互,它衡量的是「两段文本在嵌入空间里像不像」,无法捕捉查询与候选之间细粒度的语义证据——「如何退货」和「退货政策说明」向量距离可能不近,「如何退货」和「一件退货引发的思考」反而可能很近。

重排模型(Reranker)换了一条路线:把「查询 + 候选文档」拼接成一对,整体送入交叉编码器(Cross-Encoder)做完整的注意力交互,输出一个相关性分数。查询的每个词都能「看到」候选文档的每个词,判分精度远超向量内积;代价是每一对都要跑一次前向计算,慢得多。快而粗与精而慢,正好构成两阶段的互补:

  1. 第一阶段:ANN 粗召回。 用 IVF 或 HNSW 从百万库里毫秒级捞出 top-50 到 top-200 候选——这一步的目标不是准,而是把「正确答案大概率在里面」做实。
  2. 第二阶段:交叉编码器重排。 对这几十上百条候选逐一精细打分,按分数取 top-5 左右送进 LLM 上下文。候选少,重排的慢可以承受;候选准,重排的精度优势才能发挥。

理解这个分工,比记住任何参数都重要:召回阶段负责「别漏」,重排阶段负责「排对」。两个阶段的模型可以完全独立——召回用双塔 embedding 模型,重排用专门的交叉编码器(如 bge-reranker 系列、Cohere Rerank 这类排序服务),各练各的活。

实操代码:简化 IVF 与两阶段流水线 ​

先用 NumPy 写一个简化版 IVF,把「聚类分桶 → 簇心筛选 → 簇内扫描」的骨架跑通(生产环境请使用 FAISS、Milvus 等成熟引擎的索引实现):

python
import numpy as np

def build_ivf(vectors: np.ndarray, nlist: int, iters: int = 10):
    """简化版 IVF 构建(语言:Python + NumPy):
    k-means 聚出 nlist 个簇心,再按最近簇心把全库分桶"""
    rng = np.random.default_rng(42)
    idx = rng.choice(len(vectors), nlist, replace=False)
    centroids = vectors[idx].copy()
    for _ in range(iters):                     # 朴素 k-means:分配 → 更新簇心
        dists = 1 - vectors @ centroids.T      # 归一化向量用余弦距离演示
        assign = dists.argmin(axis=1)
        for c in range(nlist):
            members = vectors[assign == c]
            if len(members):
                centroids[c] = members.mean(axis=0)
    dists = 1 - vectors @ centroids.T
    assign = dists.argmin(axis=1)
    buckets = [np.where(assign == c)[0] for c in range(nlist)]
    return centroids, buckets

def ivf_search(query: np.ndarray, centroids, buckets, vectors,
               nprobe: int = 3, topk: int = 100) -> list[int]:
    """IVF 查询:先选 nprobe 个最近簇,再在簇内精确打分"""
    near = (1 - query @ centroids.T).argsort()[:nprobe]   # 簇心筛选
    candidates = np.concatenate([buckets[c] for c in near])
    scores = vectors[candidates] @ query                  # 簇内精确比较
    return candidates[scores.argsort()[::-1][:topk]].tolist()

if __name__ == "__main__":
    # 模拟 1 万条 64 维归一化向量;生产中换成真实 embedding
    rng = np.random.default_rng(7)
    db = rng.normal(size=(10_000, 64))
    db /= np.linalg.norm(db, axis=1, keepdims=True)
    centroids, buckets = build_ivf(db, nlist=100)
    q = db[123] + rng.normal(scale=0.05, size=64)   # 库内向量加噪当查询
    q /= np.linalg.norm(q)
    recall_ids = ivf_search(q, centroids, buckets, db, nprobe=3, topk=100)
    print("粗召回候选数:", len(recall_ids))

再把重排阶段接上,就是一条完整的 RAG 两阶段流水线。重排打分函数在这里用占位函数演示结构,生产中替换为交叉编码器模型的推理调用:

python
def rag_two_stage(query: str, chunks: list[str],
                  embed_fn, rerank_score_fn,
                  recall_k: int = 100, final_k: int = 5) -> list[str]:
    """RAG 两阶段检索流水线:ANN 粗召回 → 交叉编码器重排

    embed_fn: 文本转向量(生产中调用 embedding 模型服务)
    rerank_score_fn: (query, chunk) -> 相关性分数
    (生产中接 bge-reranker / Cohere Rerank 等交叉编码器,
      与召回阶段的 embedding 模型是两套独立模型)
    """
    q_vec = embed_fn(query)
    # 第一阶段:ANN 粗召回,宁多勿漏——正确答案必须留在候选池里
    cand_ids = ivf_search(q_vec, centroids, buckets, db,
                          nprobe=5, topk=recall_k)
    # 第二阶段:逐候选交叉打分。精度来源是查询与文档在同一模型里
    # 做全注意力交互,而不是比较两个各自压缩后的向量
    scored = sorted(cand_ids,
                    key=lambda i: rerank_score_fn(query, chunks[i]),
                    reverse=True)
    return [chunks[i] for i in scored[:final_k]]     # 送入 LLM 上下文

复杂度合计:召回阶段 O(nlist·d + (nprobe/nlist)·N·d);重排阶段 O(recall_k) 次前向,且交叉编码器对长度 L 的输入是 O(L²) 的自注意力开销——这正是重排候选要控制在百条以内的原因:数量再上去,延迟就从「可承受」滑向「不可用」。两阶段的规模比例(百万库召回百条、百条精排取五条)构成了延迟与质量的完整预算表。

避坑总结 ​

  1. 送重排的召回数量别抠门。召回 10 条里没有正确答案,重排模型再强也无力回天;常见配比是从几百万库里召回 50~200 条,重排后取 3~10 条进上下文。宁可多花几毫秒召回,不要让正确答案死在第一阶段。
  2. 参数调优必须对着评测集做。准备带标注的查询集,量化 recall@k、MRR 等指标,nprobe、ef 从小往大试,先守住召回率下限,再压延迟;不建评测集直接调参,等于盲调。
  3. 换 embedding 模型必须重建索引。新旧模型的向量空间互不相通,新旧向量混在一个库里,相似度计算直接失真——这是最隐蔽也最致命的事故来源之一。
  4. HNSW 的内存要提前预算。每条向量要挂最多 M 条边,百万级数据仅边表就是 GB 量级;内存敏感的场景要么降 M、要么转向 IVF + PQ 路线,量入为出。
  5. 带过滤条件的检索要单独设计。按 metadata 先过滤后检索时,最近的簇可能整簇被过滤掉,导致候选池空转;选型时确认引擎支持「过滤感知」的 ANN 检索,否则边界场景召回会莫名塌方。
  6. 别用精确检索的标准评估 ANN。上线前抽样对比精确 top-k 与 ANN top-k 的重合率,把「牺牲了多少召回」量化成数字写进文档,而不是事后凭感觉排查。

写在最后 ​

向量检索这一课的完整逻辑链是:精确搜索不可行 → 用结构换速度(IVF 的聚类分桶、HNSW 的分层图导航)→ 结构换来的速度牺牲了语义精度 → 用交叉编码器重排补回来。ANN 索引与重排模型不是二选一的竞争关系,而是同一条流水线上分工明确的两道工序。理解了「快而粗」与「精而慢」各自该站在哪一环,RAG 检索质量的优化就从玄学变成了工程:先守住召回率的下限,再压检索延迟的上限,每一步都有数字可依。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。