算法可视化与交互学习平台

Vector Search:从暴力搜索到向量数据库Vector Search: From Brute Force to ANN Indexes

承接 No.14 已生成的 query / chunk vectors,从逐项打分与 Top-K heap 建立 exact baseline;再用 100→1,000→10,000→100,000 chunks 的浏览器实测撞上 O(Nd) 规模墙,亲手操作 HNSW 与 IVF,并用 Recall@K、候选访问数和尾延迟理解 ANN 的质量—成本权衡,最终封装成 RAG 可复用的 vector index search contract。

RAG & AgentsIntermediateFree
1

No.14 交来的不是答案,而是一张 N×d 向量表

No.14 已经把文本编码成同一语义空间中的向量:query 是 **q∈ℝᵈ**,每个 document chunk 是 **dᵢ∈ℝᵈ**。现在问题从“怎样得到坐标”变成了“怎样从 N 个坐标中找到最接近 q 的 K 个”。

offline: chunks → embed → normalize → vector records → build index online: query → same embedding contract → search → Top-K chunks

本模块先保留一条绝对可靠的 exact / brute-force baseline,再一步步减少 query 真正访问的候选数。**ANN 通常不是把距离公式算得更粗糙,而是聪明地避免查看全部向量。**

2

No.14 → No.15 → No.16:先分清三层边界

模块负责回答稳定交付物
No.14 Embedding文本怎样映射到可比较的向量?model / dimension / pooling / normalization 契约
No.15 Vector Search怎样以可控延迟找回近邻?exact baseline、ANN index、search + evaluation contract
No.16 RAG Pipeline怎样切分知识、重排证据并生成有引用的回答?chunking、rerank、context、citation 与回答评估
N = indexed chunks,而不是原始文件数 100,000 documents × 10 chunks/document = 1,000,000 vectors No.15 固定 embedding contract,隔离研究 search/index 行为; No.16 再改变 chunk size、overlap 与上下文策略。
3

检索目标:返回索引与向量,而不是把 query 变成另一个 query

query q 保持不变;argmax 返回最佳 document/chunk 的索引 i*,再由索引取回 d* 与 metadata。实际 RAG 通常返回有序的 K 个 chunk IDs,而不是只返回一个向量。

在线 query embedding
online query embedding
第 i 个已索引 chunk vector
indexed vector for chunk i
索引中的 chunk 数
number of indexed chunks
按相似度排序的 Top-K 索引集合
ordered top-k neighbor identifiers

打分和取回是两步

index 内部只需要 vector ID 与搜索结构;返回 ID 后再读取 text、document_id、source、ACL 等 metadata。

vector_id → record store → chunk text + metadata

Top-K 不等于 ANN

Top-K 只是从分数中选择结果;exact scan 与 HNSW、IVF 都必须完成 Top-K。ANN 解决的是候选从哪里来。

过滤会改变搜索问题

language、tenant、time range、ACL 等 filter 可能在搜索前、搜索中或搜索后执行;过晚过滤可能让返回数量不足。

4
01 · Metric contract + exact Top-K

实验一:同一 query,Cosine / Inner Product / L2 为什么会给出不同 Top-K

这是“真实计算 + 人工二维教学向量”的 exact-search 实验:切换 metric 或缩放文档 B 会即时重算每个 document 的 cosine、inner product、L2 与完整排名,改变 Top-K 只移动截断线。卡片会把当前文档逐项代回三个公式,解释方向、长度、坐标距离、名次和入选结果;下方 Python 再展示大规模时的 size-K heap。

这是真实演示吗?

计算、排序和交互都是真实的;语料向量是刻意简化的

  • 真实部分:切换 metric 或拖动 B 时,页面会用当前坐标重新计算 5 个文档的 dot、cosine、L2 并重新排序;改变 K 只移动截断线,不会改动任何分数或名次。结果没有写死。
  • 简化部分:q 与 A–E 是人为设计的二维 teaching vectors,便于手算和看图;它们不是语言模型现场编码出来的 384 维 embedding。
  • 本卡边界:浏览器会对全部 5 个向量逐一打分并完整排序,这是 exact brute-force search;下方 Python 再展示大规模时如何用 size-K heap 避免全排序。这里还没有使用 HNSW、IVF 或向量数据库。
先读懂输入
q = [1.00, 0.45]
D = [d_A, d_B, d_C, d_D, d_E]
目标 = 按同一种 metric 排序后取前 K 个

二维向量可以同时看成从原点出发的箭头平面中的坐标点。Cosine 更关注箭头方向,L2 更关注点的位置,Inner Product 同时受到方向和长度影响。

受控条件:当前使用 raw、未 L2-normalize 的向量,故意保留不同长度来观察三种 metric 的差异;二维只为可视化,同样的公式会逐坐标应用到 384D / 768D embedding。

① 输入

固定一个 query q,并准备 5 个 document vectors。

② 全部打分

对 A–E 每一个向量都执行当前 metric,没有跳过候选。

③ 按规则排序

Cosine / IP 从大到小;L2 从小到大。

④ 截取 Top-K

只保留排序前 K 个;K 不会改变原始分数。

先看结果,再解释原因

同一批当前向量,三把尺子的实时排行榜

点击任意排行榜即可切换下面的详细视图

注意有序结果和集合的区别:Vector Search 返回的是带 rank 的有序 Top-K。两种 metric 即使选中了相同 IDs,也可能顺序不同;K=1 时最容易直接看出三种规则分别选择 A、B、C。

第 1 步 · 选择排序规则

把向量看成从原点射出的箭头,只比较夹角方向。范围是 −1 到 1,越接近 1 表示方向越一致;向量 B 只改变长度时,cosine 不变。

当前:Cosine similarity · 数值越大越靠前
当前 B:dB=[1.25, 0.82],‖dB‖₂=1.495。拖动只会把 B 沿同一条射线缩短或拉长,方向不变。
s > 0 时:cos(q, s·B) = cos(q, B)
qᵀ(s·B) = s·(qᵀB)
L2(q, s·B) 没有缩放不变性
当前第一名:A · 方向几乎相同,长度较短
A 与 q 的夹角方向最接近,cosine=1.000。
Top-1 = [A]
线段表示从原点出发的箭头 · 圆点表示箭头终点
原点 O
query
A
C
B
E
D
当前按 Cosine similarity 排序(数值越大越靠前点击文档名称可查看它的逐项计算
RankChunkcos(q,d)qᵀdL2Selected
#11.0000.8640.309✓ Top-K
#20.9991.2240.057未入选
#30.9881.6190.447未入选
#40.8270.8310.616未入选
#50.2940.3121.231未入选
把当前数字代回公式

逐项检查文档 A 为什么排在第 1

默认跟随当前第一名;也可以固定一个文档,再切换 metric 比较同一组数字如何得到不同名次。

① Inner product · 相乘再相加
qᵀd_A
= (1.00 × 0.72)
  + (0.45 × 0.32)
= 0.864

没有除以向量长度,所以把同方向的 d 拉长,乘积和通常也会增大。

② Cosine · 再除以两个长度
‖q‖₂ = √(1.00² + 0.45²) = 1.097
‖d_A‖₂ = 0.788
cos(q,d)
= 0.864 / (1.097 × 0.788)
= 1.000

分母会抵消整体缩放,因此 B 沿同一方向变长或变短时,cosine 保持不变。

③ L2 · 两点之间的直线距离
L2(q,d_A)
= √((1.00 − 0.72)²
  + (0.45 − 0.32)²)
= 0.309

自然距离是越小越好。代码为了复用“取最大值”的 heap,会暂时使用 −L2;表格仍显示直观的正距离。

当前排序依据
Cosine similarity
数值越大越靠前
文档 A 当前名次
#1 / 5
方向几乎相同,长度较短
Top-1 判定
已入选
因为 #1 ≤ K

建议按这个顺序亲手验证

1. 先选 Cosine

拖动 B:它沿射线移动,但 cosine 列应保持不变。

2. 切到 Inner Product

继续放大 B:长度进入分数,B 更容易升到第一名。

3. 切到 L2

观察 C:它的终点最靠近 q,因此通常成为第一名。

4. 只改变 Top-K

分数和名次都不动,只有“入选/未入选”的截止线移动。

Cosine 在问什么?
方向像不像
qᵀd / (‖q‖‖d‖),范围 −1…1,越大越好
Inner product 在问什么?
方向 × 长度
qᵀd,无固定上界,越大越好
L2 在问什么?
坐标离多远
‖q−d‖₂,从 0 开始,越小越好
三种 metric 并不总会给出不同排名。当前故意保留不同向量长度,才方便看见差异。如果 q 与所有 d 都先做 L2 normalization,那么 cosine = inner product,且 L2² = 2 − 2·inner product,三者会得到相同排序。下一张公式卡会严格推导这个关系。
可直接运行:同一个 Top-K,三种 metric 只改评分契约

完整脚本内含 query、5 个 documents、main 入口与三种 metric 的 Top-3 输出;复制保存为 .py 后即可运行。代码用 heapq 维护 size-K 候选,展示 N 很大时无需把全部 N 个结果排序的写法。

import heapq
import numpy as np

def top_k(query, documents, k=3, metric="cosine"):
    q = np.asarray(query, dtype=np.float32)
    d = np.asarray(documents, dtype=np.float32)
    if d.ndim != 2 or q.shape != (d.shape[1],):
        raise ValueError("query and document dimensions must match")
    if not 1 <= k <= len(d):
        raise ValueError("k must be between 1 and the document count")

    if metric == "cosine":
        q = q / np.maximum(np.linalg.norm(q), 1e-12)
        d = d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12)
        values = d @ q                    # larger is better
        ranking_scores = values
    elif metric == "inner_product":
        values = d @ q                    # magnitude still matters
        ranking_scores = values
    elif metric == "l2":
        values = np.linalg.norm(d - q, axis=1)  # smaller is better
        ranking_scores = -values           # negate only for one max-heap API
    else:
        raise ValueError(metric)

    # O(N log K): never sort all N rows when K is small.
    top_ids = heapq.nlargest(
        k,
        range(len(ranking_scores)),
        key=lambda index: (float(ranking_scores[index]), -index),
    )
    return [(index, float(values[index])) for index in top_ids]

def main():
    labels = ["A", "B", "C", "D", "E"]
    query = np.array([1.00, 0.45], dtype=np.float32)
    documents = np.array([
        [0.72, 0.32],   # A: almost the same direction, shorter
        [1.25, 0.82],   # B: aligned and longer
        [1.04, 0.41],   # C: closest coordinate position
        [-0.12, 0.96],  # D: different direction
        [0.48, 0.78],   # E: medium direction and distance
    ], dtype=np.float32)

    print("query:", [round(float(value), 3) for value in query])
    for metric in ("cosine", "inner_product", "l2"):
        results = top_k(query, documents, k=3, metric=metric)
        rule = "larger is better" if metric != "l2" else "smaller is better"
        print(f"
{metric} Top-3 ({rule})")
        for rank, (index, value) in enumerate(results, start=1):
            print(f"  #{rank} document {labels[index]}: {value:.6f}")

if __name__ == "__main__":
    main()
5A

单位向量上的关键等价:Cosine = IP,L2 排序也相同

当 query 与 documents 都 L2-normalize 后,最大化 cosine、最大化 inner product、最小化 squared L2 会得到同一排序。raw vectors 不具备这个保证,因此 embedding 生成、索引 metric 与在线 query normalization 必须作为一个版本化契约。

内积,越大越相似
inner product
余弦相似度
cosine similarity
平方欧氏距离,越小越相似
squared Euclidean distance

为什么数据库配置不能随便改

用 cosine 构建的语义与 raw inner product 的排序目标可能不同。变更 normalization 或 metric 后要重新验证,通常也要重建索引。

平方根可以省略

L2 与 squared L2 单调同序;只为排序时无需开平方。

argmin ||q-d||₂ = argmin ||q-d||₂²
5B

Exact Search:Top-K heap 省掉全排序,却省不掉全扫描

一次 exact Top-K 查询分成两段:先让 query 与搜索范围内的全部 N 个 vectors 逐一算分,再用容量为 K 的 min-heap 只保留当前最好的 K 个结果。heap 把“对 N 个分数全部排序”改成“维护 K 个候选”,却不会让任何 vector 跳过算分;因此通常占主导的全扫描 Θ(Nd) 仍然存在。右侧公式单独计算原始向量数据需要的字节数。

一次 query 执行 exact Top-K search 的渐进工作量函数。它描述工作量怎样随 N、d、K 增长,不是直接预测多少毫秒;硬件、缓存、并行度与实现常数仍会影响实测延迟。
asymptotic work of one exact top-k query, not measured wall-clock time
本次搜索范围内必须逐一比较的 vector 数。通常等于已索引 chunk 数;若先做 metadata pre-filter,则指过滤后仍需精确扫描的数量。
vectors that must be scored in this search scope
每个 embedding 的坐标数,也叫向量维度。计算一次 cosine、inner product 或 L2,都必须读取并处理这 d 个坐标。
embedding dimension, or coordinates processed per score
最终要返回的近邻数量,也是 Top-K heap 的最大容量,满足 1 ≤ K ≤ N;检索中通常 K 远小于 N。
requested neighbor count and heap capacity
全量算分阶段的紧确量级:N 个 vectors × 每个 d 个坐标。Θ 表示上下界同阶,即 exact search 无法在最坏或通常扫描流程中绕过这 N·d 级工作。
tight bound for scoring every coordinate of every vector
容量为 K 的二叉 heap 的高度;候选进入或替换堆顶时,最多沿约 log₂K 层调整。复杂度记号中对数底数只差常数,通常省略。
height of the size-k heap; the logarithm base is asymptotically irrelevant
Top-K 选择阶段的上界:N 个分数都要与堆顶比较,每次真正插入或替换最多花 O(log K)。它替代了全排序的 O(N log N)。
upper bound for maintaining the top-k heap across n scores
仅存放原始 vector 坐标所需的内存字节数,不含 vector ID、文本与 metadata、heap、ANN 索引、对齐和运行时对象开销。
raw vector-payload memory in bytes, excluding index and metadata overhead
每个坐标的存储字节数:float32 为 4 B,float16 / bfloat16 为 2 B,int8 为 1 B;量化方案还可能需要额外 scale 等参数。
storage bytes per coordinate

先算分,再选 Top-K:加号连接的是两段顺序工作

第一项回答“所有候选的分数怎样得到”,第二项回答“已有 N 个分数后怎样只留下最好的 K 个”。heap 只优化第二段,所以不能把 O(N log K) 误当成整个 exact search 的唯一成本。

① score all: N vectors × d coordinates → Θ(Nd) ② keep Top-K: N score checks × ≤ log₂K heap levels → O(N log K) ③ total: scoring work + selection work

heap 到底省掉了什么

全排序会保存并排序全部 N 个分数;size-K heap 始终只维护 K 个最好候选。两种方法都已为 N 个 vectors 算过分,差别只发生在结果选择阶段。

full sort: Θ(Nd) + O(N log N), result memory O(N) Top-K heap: Θ(Nd) + O(N log K), heap memory O(K)

代入 100K × 384D、K=10、float32

一次 query 仍要处理 3,840 万个坐标;heap 的高度只有约 log₂10≈3.32,但它是在全量算分之后维护结果。仅原始向量就约 153.6 MB,每次冷扫描都可能受内存带宽和缓存命中影响。

Nd = 100,000 × 384 = 38,400,000 coordinate values M_vectors = 100,000 × 384 × 4 B = 153,600,000 B ≈ 146.5 MiB

渐进式不是秒表公式

Θ 与 O 隐去了常数:SIMD / BLAS、CPU 或 GPU、内存布局、batching、缓存、并发都会改变毫秒数。因此下一张实验卡会在当前设备上真实测量延迟,而这里负责解释为什么规模增长后工作量必然上升。

ANN 的真正切入点

把全体集合 D 换成更小的候选集合 C(q),只对候选做最终 metric 排序。

C(q) ⊂ D, |C(q)| << N
6
02 · Real brute-force scale benchmark

实验二:100 → 1,000 → 10,000 → 100,000 chunks,亲自撞上规模墙

用固定 seed 的 synthetic normalized vectors 隔离搜索算法本身,不伪装成编码了 100,000 篇真实文本。一次点击依次生成同维度语料,并真实执行 brute-force inner product + Top-K heap;观察当前设备上的 p50 / p95 延迟、坐标运算量与向量内存如何增长。

这里不是预制曲线。点击运行后,浏览器会先在计时区间之外生成确定性的 L2-normalized Float32 vectors,再对每个规模真实执行全量 inner-product scan 与 size-K min-heap;表格显示多次运行的中位数。
向量维度 d
Top-K heap

计时会受设备、浏览器、电源模式与后台负载影响;请比较增长趋势,不把单次毫秒数当成生产 SLA。

Exact scan latency

median · current device
100
chunks
1,000
chunks
10,000
chunks
100,000
chunks
运行后逐行出现真实测量结果。
O(N · d)

暴力搜索的候选访问数随 chunk 数 N 线性增长;Top-K heap 不会消除这次全量扫描。

O(N · d + N log K)

把所有分数全排序是 O(N log N);维护 K 个候选只需小根堆,但距离计算仍是主成本。

documents × chunks/document

向量索引检索的是 chunks。100K documents × 10 chunks 已变成 1M vectors,这就是 RAG 很快需要 ANN 的原因。

代码回扣:全量扫描 + size-K heap 的真实基线

生产评测先保留这条 exact baseline;它既是小库可用实现,也是 Recall@K 的 ground truth 生成器。

import heapq
from time import perf_counter
import numpy as np

def exact_cosine_topk(matrix, query, k):
    # Offline contract: rows and query are already L2-normalized.
    heap = []
    for doc_id, vector in enumerate(matrix):          # N visits
        score = float(vector @ query)                 # d multiply-adds
        item = (score, doc_id)
        if len(heap) < k:
            heapq.heappush(heap, item)
        elif score > heap[0][0]:
            heapq.heapreplace(heap, item)             # log K
    return sorted(heap, reverse=True)

for n in [100, 1_000, 10_000, 100_000]:
    docs = np.random.default_rng(7).normal(size=(n, 32)).astype("float32")
    docs /= np.maximum(np.linalg.norm(docs, axis=1, keepdims=True), 1e-12)
    query = docs[0]
    started = perf_counter()
    result = exact_cosine_topk(docs, query, k=10)
    print(n, (perf_counter() - started) * 1000, "ms", result[:2])
7

ANN 的核心承诺:用更小的候选集换速度,而不是偷换 metric

Exact: score(q, dᵢ) for every dᵢ ∈ D → Top-K ANN: navigate / partition → C(q) ⊂ D → score candidates → Top-K 希望:|C(q)| ≪ |D| 代价:ExactTopK(q) 中的一些向量可能不在 C(q)
不会自动改变会改变
embedding model、dimension、normalization候选生成方式与访问顺序
最终使用的 cosine / IP / L2 契约可能漏掉的 exact neighbors
Top-K 返回接口build time、index memory、query latency
8
03 · HNSW graph navigation

实验三:HNSW 怎样从稀疏高层长跳,再在第 0 层扩大候选

点击图中任意位置移动 query,切换 layer 并拖动 efSearch。实验真实执行分层 greedy descent 与底层 best-first 扩展,同时对照 exact Top-K,直接看到访问节点数、路径与漏召回怎样变化。先从 layer、节点最高层 hᵢ、生产随机抽层公式与当前实验的确定性分层规则讲起,再进入 squared L2 原子计算,并用动态计算账本逐轮观察高层距离比较、Layer 0 candidate queue、Wef 结果集和停止条件。

HNSW 是什么

Hierarchical Navigable Small World:可导航的小世界分层图

每个已索引 vector 是一个节点,相近 vectors 之间建立。多数节点只在 layer 0,少量节点还会出现在更高层;高层节点少、边跨度长,负责快速跨越全局,底层节点密、负责局部精查。查询不是扫描全部 vectors,而是沿图只给遇到的候选计算距离,因此得到的是近似近邻。

H · Hierarchical同一个节点按自己的最高层 hᵢ,参与 L0 到 Lhᵢ 的多张邻接图;L0 有全部节点,层号越高只是节点子集越稀疏,不是多出新的向量维度。
NSW · Small World短边连局部,少量长边让远处也能用少数 hop 到达。
ANN · Approximate少访问换低延迟;没有被图遍历触达的真近邻可能漏掉。
先离线建图,再在线搜索

索引不是 query 到来后临时生成

  1. ① 为节点抽取最高层 hᵢ:每个 vector 插入时只抽一次随机数。hᵢ=0 表示只在 L0;hᵢ=2 表示同一节点参与 L0、L1、L2。它不是由坐标大小或离 query 的距离计算出来的。
  2. ② 寻找候选邻居:从旧图最高层入口出发,逐层搜索新节点附近的候选;efConstruction 越大,建图看得越宽。
  3. ③ 连边并剪枝:每层最多保留约 M 条兼顾距离与方向多样性的连接,通常还建立反向边。
  4. ④ 查询复用图:在线 query 只调整 efSearch,不会重新分层或重建边。
参数分工:MefConstruction 是 build-time,影响图质量、构建成本与内存;efSearch 是 query-time,控制一次搜索愿意保留多少候选,且应满足 efSearch ≥ K
先把“层”和“层高”讲清楚

层是同一批向量的稀疏导航图;层高是单个节点能出现到哪一层

HNSW 里的 layer 不是 embedding 的某个维度,也不是按距离切出的区间,更不是一个聚类桶。它是一张只保留部分节点及其连接的邻接图。每个 vector 都属于最底层 L0;节点的最高层 hᵢ 决定它还会不会继续出现在 L1、L2……中。

先避免一个命名陷阱:这里的“层高 hᵢ”指最高层编号,不是参与层数。hᵢ=0 不代表没有层,而是只参与 L0,共 1 层;hᵢ=2 表示参与 L0、L1、L2,共 hᵢ+1=3 层。整个索引的全局最高层写作 Lmax=maxᵢhᵢ,它不是另一次随机采样。

1 · 先看一个只有 6 个节点的例子

假设各节点最高层为:h₀=0、h₁=2、h₂=0、h₃=1、h₄=0、h₅=1。

L2v1
L1v1 · v3 · v5
L0v0 · v1 · v2 · v3 · v4 · v5
Vℓ = {vᵢ | hᵢ ≥ ℓ}
V2 ⊆ V1 ⊆ V0

例如 h₁=2,所以 v1 同时参与 L0、L1、L2;它仍是同一个 vector,只是在三层分别拥有邻接边。h₀=0 的 v0 则只在 L0。相邻两层的节点集通常会变小,但随机情况下也可能暂时相同,所以集合关系写成 ⊆。查询从最高非空层 Lmax 的入口开始。

2 · 生产 HNSW 怎样抽出 hᵢ

插入第 i 个节点时抽一次均匀随机数 U∈(0,1),再用指数衰减得到最高层。常见写法是:

U ~ Uniform(0, 1)
hᵢ = floor(−ln(U) × mL)
typical: mL = 1 / ln(M)

U 越小越可能得到较高层,但小 U 本身很少出现。采用典型 mL=1/ln(M) 时,节点至少到达第 ℓ 层的概率约为 P(hᵢ≥ℓ)=M−ℓ,所以节点数随层号指数下降,而不是人为规定每层固定多少个。

不同向量库可能把 mL 单独配置,或用等价的 geometric sampling 实现;具体 API 会不同,但“每个节点只抽一次、越高概率指数下降”这一结构不变。

出现在该层 P(hᵢ≥ℓ)N=100,000 时的期望节点数
L01100,000
L11/16约 6,250
L21/256约 391
L31/4,096约 24
代入一次:M=16 时 mL≈0.361。若 U=0.02,则 hᵢ=floor(−ln(0.02)×0.361)=floor(1.41)=1,因此这个节点出现在 L0 与 L1,不出现在 L2。表中的概率表示“能到达这一层”,不是“最高层恰好等于这一层”。

3 · 当前 48 点实验为了可复现,没有使用随机抽层

hᵢ = 2, if id mod 16 = 0
hᵢ = 1, else if id mod 4 = 0
hᵢ = 0, otherwise
L2 · 3 nodesIDs 0、16、32;它们也同时属于 L1 和 L0。
L1 · 12 nodes所有能被 4 整除的 IDs,包含上面的 3 个 L2 节点。
L0 · 48 nodes全部向量节点,最终 best-first search 在这里展开。

这个规则形成 48→12→3,即每升一层保留 1/4,目的是让小图仍有足够高层节点可观察;它不模拟上方 M=16 时每层约保留 1/16 的生产采样。若 48 点真的按 M=16 抽样,L1 期望仅 3 个,L2 期望仅 0.1875 个,常常根本没有可展示的 L2。实验因此忠实演示“嵌套层 + 查询流程”,但没有伪装成生产层高分布。层高在建索引时决定一次,移动 query 或拖动 efSearch 都不会重新计算它。

一次 query 到底计算什么
demo metric: d²(q, vᵢ)
= (qₓ − xᵢ)² + (qᵧ − yᵢ)²
smaller distance → nearer node

二维教学图使用 squared L2,省掉不影响排序的平方根。生产 HNSW 会在 d 维 embedding 上调用索引约定的 cosine、inner product 或 L2;核心没有变:每触达一个节点,就计算一次同一 metric,再决定是否沿它的边继续走。

1 · 最高层入口先算入口节点与 q 的距离,把它作为当前最佳位置。
2 · 高层 greedy计算当前节点所有邻居的距离;若最小值更好就移动,否则原地下降一层。
3 · Layer 0 best-first总是先展开距离最小的未处理候选,并把新邻居放入候选队列与容量为 efSearch 的结果集。
4 · 停止并返回当最近未展开候选都差于结果集最远项时停止,再从结果集中取距离最小的 K 个。
q=(qₓ,qᵧ)红色 query;点击图后这两个坐标改变。
vᵢ=(xᵢ,yᵢ)编号 i 的向量节点;节点坐标参与真实距离计算。
Nℓ(v)节点 v 在 layer ℓ 的出邻居集合;搜索只能沿这些边发现新候选。
Wef当前保留的至多 efSearch 个近候选;最远项构成剪枝边界。
greedy move on layer ℓ: v ← arg min{d(q,u) | u ∈ {v} ∪ Nℓ(v)}
layer 0 stop: min distance(candidate queue) > max distance(Wef), when |Wef| = efSearch
这张图真实执行 HNSW 的教学化搜索骨架:“高层贪心定位 → 下沉 → layer 0 best-first 扩展”。为了让搜索路径稳定可见,教学图只用 48 个二维点,以确定性层高和每层有向 k-NN 出邻居简化建图,画面则用无箭头线段表示连接。生产 HNSW 还包含随机层高、双向连接、M / efConstruction、邻居多样性剪枝与删除维护;因此这里忠实演示查询逻辑,但不冒充完整的生产建索引过程。
Visible layer
高层更稀疏,像高速公路;layer 0 才包含全部向量。切换这里只改变观察视角,不会跳过其他层的搜索。
点击空白处移动 query
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
q
● amber moved / expanded● blue discovered● teal Top-K○ white exact Top-K
Recall@K
100%
3/3 exact neighbors
Visited
21/48
18 layer-0 discovered · 12 expanded
Search trace
  1. 1. Entry:从稀疏最高层的固定入口开始。
  2. 2. Greedy descent:只要邻居更靠近 q 就移动,并逐层下沉。
  3. 3. Layer 0:按距离扩展候选,efSearch 越大越不易陷入局部区域。
  4. 4. Return:从访问集合中取最近 K 个;这一步可能漏掉 exact neighbors。
entry = v0 (L2) → v36 (L0)
ANN = [2, 36, 20]
exact = [2, 36, 20]
efSearch = 12
当前 query 的计算账本

把图上的一条路径还原成每次距离比较与队列变化

移动红色 q 或调整 efSearch 后,下方数字会重新计算。距离统一显示 squared L2;✓ 表示新邻居进入候选队列和 Wef,× 表示它比当前结果集边界更差而被剪掉。

q = (0.820, 0.240)
d²(q, v0) = 0.0831
High-layer entry
v0
layer 2 · d²=0.0831
Layer-0 entry
v36
after greedy descent · d²=0.0034
Search width
Wef ≤ 12
return Top-3 · evaluated 21/48 nodes
A · 高层 greedy descent

每轮比较“当前节点 + 当前层邻居”。只有找到严格更近的邻居才移动;没有更近邻居并不代表已经找到全局最近点,只表示可以从同一节点下降到更密的一层继续找。

L2 · current v0d²=0.0831
neighbors = v32:0.2187 · v16:0.5485
没有邻居更近:停在 v0,随后从这个节点下降一层。
L1 · current v0d²=0.0831
neighbors = v36:0.0034 · v24:0.1931 · v32:0.2187 · v44:0.2673
选择更小距离:v0 → v36(0.0831 → 0.0034)
L1 · current v36d²=0.0034
neighbors = v20:0.0085 · v0:0.0831 · v24:0.1931 · v32:0.2187
没有邻居更近:停在 v36,随后从这个节点下降一层。
B · Layer 0 best-first expansion

candidate min-queue 决定下一步展开谁;Wef 保存目前距离最小的至多 efSearch 个节点,并用其中最远项作为剪枝边界。二者用途不同,不能把 efSearch 理解成最终返回数量。

#1 pop v36d²=0.0034 · old boundary=0.0034
new neighbors = ✓v20:0.0085 · ✓v2:0.0034 · ✓v18:0.0228 · ✓v39:0.0168 · ✓v47:0.0444
next queue = [v2, v20, v39, v18, v47]Wef nearest = [v2, v36, v20, v39, v18, v47] · size 6/12
#2 pop v2d²=0.0034 · old boundary=0.0444
new neighbors = ✓v15:0.0514
next queue = [v20, v39, v18, v47, v15]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 7/12
#3 pop v20d²=0.0085 · old boundary=0.0514
new neighbors =
next queue = [v39, v18, v47, v15]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 7/12
#4 pop v39d²=0.0168 · old boundary=0.0514
new neighbors = ✓v42:0.0804
next queue = [v18, v47, v15, v42]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 8/12
#5 pop v18d²=0.0228 · old boundary=0.0804
new neighbors =
next queue = [v47, v15, v42]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 8/12
#6 pop v47d²=0.0444 · old boundary=0.0804
new neighbors =
next queue = [v15, v42]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 8/12
#7 pop v15d²=0.0514 · old boundary=0.0804
new neighbors = ✓v7:0.0693 · ✓v41:0.1336 · ✓v5:0.1257 · ✓v13:0.0742
next queue = [v7, v13, v42, v5, v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
#8 pop v7d²=0.0693 · old boundary=0.1336
new neighbors =
next queue = [v13, v42, v5, v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
#9 pop v13d²=0.0742 · old boundary=0.1336
new neighbors = ✓v0:0.0831 · ×v29:0.1663 · ×v24:0.1931
next queue = [v42, v0, v5, v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
#10 pop v42d²=0.0804 · old boundary=0.1257
new neighbors = ×v11:0.1902
next queue = [v0, v5, v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
#11 pop v0d²=0.0831 · old boundary=0.1257
new neighbors =
next queue = [v5, v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
#12 pop v5d²=0.1257 · old boundary=0.1257
new neighbors = ×v26:0.1979 · ×v21:0.2726
next queue = [v41]Wef nearest = [v2, v36, v20, v39, v18, v47, …] · size 12/12
停止:最近未展开候选 v41 的 d²=0.1336,已经差于结果集最远项的 d²=0.1257。
为什么 efSearch 往右通常提高 Recall? Wef 容量变大后,较早看起来不够好的分支不会那么快被剪掉,搜索有机会绕过局部近邻,触达另一片可能包含真 Top-K 的区域;代价是更多距离计算、更大的队列和更高延迟。它提高的是“找到真近邻的概率”,不是修改 distance metric。
Recall@3 = |ANN Top-3 ∩ Exact Top-3| / 3 = 3/3 = 100.0%
可直接运行:HNSW 高层 greedy + layer 0 best-first search

完整脚本内含 9 个二维 vectors、三层手工图、query、main 入口、ANN/Exact Top-K 与 Recall 输出;复制保存为 .py 后即可运行。界面用排序数组展示队列,这段代码则使用 candidates min-heap 与 results max-heap。

from heapq import heappush, heappop
import numpy as np

def squared_l2(query, vector):
    delta = query - vector
    return float(delta @ delta)

def greedy_descent(query, entry_id, vectors, neighbors):
    """ef=1 search used on each sparse upper layer."""
    current = entry_id
    moves = []
    while True:
        current_distance = squared_l2(query, vectors[current])
        best_id, best_distance = current, current_distance
        for neighbor_id in neighbors.get(current, []):
            distance = squared_l2(query, vectors[neighbor_id])
            if (distance, neighbor_id) < (best_distance, best_id):
                best_id, best_distance = neighbor_id, distance
        if best_id == current:
            return current, moves
        moves.append((current, best_id, current_distance, best_distance))
        current = best_id

def search_layer(query, entry_ids, ef, vectors, neighbors):
    """Best-first layer search with candidates min-heap and results max-heap."""
    visited = set(entry_ids)
    candidates = []      # (distance, id): nearest expandable candidate first
    results = []         # (-distance, id): farthest retained result first

    for node_id in entry_ids:
        distance = squared_l2(query, vectors[node_id])
        heappush(candidates, (distance, node_id))
        # Negate both fields: heap root is the farthest node; for a distance
        # tie, the larger ID is considered worse and discarded first.
        heappush(results, (-distance, -node_id))

    while candidates:
        candidate_distance, candidate_id = heappop(candidates)
        worst_result_distance = -results[0][0]
        worst_result_id = -results[0][1]
        if len(results) >= ef and (candidate_distance, candidate_id) > (worst_result_distance, worst_result_id):
            break

        for neighbor_id in neighbors.get(candidate_id, []):
            if neighbor_id in visited:
                continue
            visited.add(neighbor_id)
            distance = squared_l2(query, vectors[neighbor_id])
            worst_result_distance = -results[0][0]
            worst_result_id = -results[0][1]
            if len(results) < ef or (distance, neighbor_id) < (worst_result_distance, worst_result_id):
                heappush(candidates, (distance, neighbor_id))
                heappush(results, (-distance, -neighbor_id))
                if len(results) > ef:
                    heappop(results)  # discard the farthest retained node

    ordered = sorted(
        (-negative_distance, -negative_id)
        for negative_distance, negative_id in results
    )
    return ordered, visited

def hnsw_search(query, vectors, layers, entry_id, ef_search, k):
    if len(vectors) == 0 or not layers:
        raise ValueError("vectors and layers must not be empty")
    if not 1 <= k <= len(vectors):
        raise ValueError("k must be between 1 and the vector count")
    if ef_search < k:
        raise ValueError("ef_search must be at least k")
    if not 0 <= entry_id < len(vectors):
        raise ValueError("entry_id is outside the vector table")

    current = entry_id
    descent_trace = []
    for layer in range(len(layers) - 1, 0, -1):
        current, moves = greedy_descent(query, current, vectors, layers[layer])
        descent_trace.extend((layer, *move) for move in moves)

    retained, visited = search_layer(
        query,
        entry_ids=[current],
        ef=ef_search,
        vectors=vectors,
        neighbors=layers[0],
    )
    return retained[:k], descent_trace, visited

def main():
    # A tiny hand-built 3-layer graph. Production HNSW builds these edges.
    vectors = np.array([
        [0.0, 0.00], [1.0, 0.15], [2.0, -0.05],
        [3.0, 0.10], [4.0, 0.00], [5.0, -0.10],
        [6.0, 0.05], [7.0, 0.12], [8.0, 0.00],
    ], dtype=np.float32)
    layer0 = {
        node: [neighbor for neighbor in (node - 1, node + 1) if 0 <= neighbor < len(vectors)]
        for node in range(len(vectors))
    }
    layer1 = {0: [2], 2: [0, 4], 4: [2, 6], 6: [4, 8], 8: [6]}
    layer2 = {0: [4], 4: [0, 8], 8: [4]}
    layers = [layer0, layer1, layer2]

    query = np.array([7.20, 0.05], dtype=np.float32)
    k, ef_search = 3, 5
    ann, descent_trace, visited = hnsw_search(
        query, vectors, layers, entry_id=0, ef_search=ef_search, k=k
    )
    exact = sorted(
        (squared_l2(query, vector), node_id)
        for node_id, vector in enumerate(vectors)
    )[:k]
    ann_ids = [node_id for _, node_id in ann]
    exact_ids = [node_id for _, node_id in exact]
    recall = len(set(ann_ids) & set(exact_ids)) / k

    print("query:", [round(float(value), 3) for value in query], "entry: v0")
    for layer, from_id, to_id, before, after in descent_trace:
        print(f"L{layer}: v{from_id} ({before:.4f}) -> v{to_id} ({after:.4f})")
    print("layer-0 visited:", sorted(visited))
    print("ANN Top-K:", [(f"v{node_id}", round(distance, 6)) for distance, node_id in ann])
    print("Exact Top-K:", [(f"v{node_id}", round(distance, 6)) for distance, node_id in exact])
    print(f"Recall@{k}: {recall:.1%}")

if __name__ == "__main__":
    main()
9
04 · IVF coarse quantization

实验四:IVF 怎样训练 coarse centroids,再只扫描 nprobe 个倒排桶

在二维 clustered vectors 上观察 train → assign → probe → fine search:移动 query、调整 nprobe 与 Top-K,比较 selected cells、candidate count、ANN IDs 与 exact IDs。先从 IVF、centroid、cell、inverted list 与参数边界讲起,用 6 个二维点手算一轮 K-means,再通过动态计算账本逐项展开 query 到 centroids 的 coarse distance、list 合并、候选内 fine distance、Exact 对照与 Recall。使用“边界陷阱”预设验证 nprobe=1 为什么会漏掉未开桶中的真近邻。

IVF 是什么

Inverted File Index:先用 coarse centroid 划分空间,再按桶反查 vector IDs

IVF 把向量空间分成 nlist 个 coarse cells。每个 cell 由一个 centroid cⱼ 代表,并维护一条 inverted list:从 centroid ID 反向找到被分配到该 cell 的 vector IDs。query 到来时先比较少量 centroids,只打开最近的 nprobe 个 lists,再对其中 vectors 做最终距离排序。

Centroid 与 cellcⱼ 是代表向量;离它最近的空间区域叫 cell。C4 只是桶编号,不是主题或语义标签。
Inverted listlist[j] → [vector IDs];“倒排”指从桶反查属于它的向量。
Approximate误差来自没有打开全部 lists;在已打开桶内,IVF-Flat 仍用原向量算精确 metric。
普通分配关系v0 → C1
v1 → C0
v2 → C1
倒排以后list[C0] → [v1]
list[C1] → [v0, v2]

query 选中 C1 后可以直接读取 [v0,v2],不必重新扫描全部 vectors 并逐个询问“你属于哪个桶”。

离线索引与在线查询分开

train → add 只做一次,probe → fine search 为每条 query 执行

离线 Train:从代表性 vectors 学习 nlist 个 centroids;nlist 决定分多少个 coarse cells。
离线 Add:每个数据库 vector 找最近 centroid,并把自己的 ID 写入对应 inverted list。
在线 Search:query 选择最近 nprobe 个 centroids,合并这些 lists 后做 fine distance + Top-K。
参数分工:nlist 是 build-time 分区数,改变它通常要重新训练和建索引;nprobe 是 query-time 打开桶数,可以逐请求调整;K 只表示最终返回多少条,不等于扫描多少个桶或候选。
coarse centroids 到底怎样训练

K-means 反复执行“按最近中心分组 → 用组内均值更新中心”

  1. ① Initialize:先选 nlist 个初始中心 c₀…cₙₗᵢₛₜ₋₁,生产实现常用随机样本或 k-means++。
  2. ② Assign:对每个训练向量 xᵢ,计算它到全部 centroids 的距离,并分给最近的一个。
  3. ③ Update:对每个 cell 内的 vectors 求坐标均值,把 centroid 移到该均值。
  4. ④ Repeat:重复 assign/update,直到中心移动很小或达到迭代次数;随后冻结 centroids 用于 add 与 search。
J = Σᵢ minⱼ d²(xᵢ, cⱼ)
aᵢ = arg minⱼ d²(xᵢ, cⱼ)
Sⱼ = {xᵢ | aᵢ = j}
cⱼ ← (1 / |Sⱼ|) · Σ xᵢ, xᵢ∈Sⱼ

J 叫 inertia / within-cluster sum of squares,表示所有训练点到其最近中心的平方距离总和;K-means 迭代希望不断降低 J。aᵢ 是分桶编号,Sⱼ 是第 j 组训练 vectors。centroid 是组内逐坐标平均值,所以它可以落在“点与点之间”,不必是某个真实 vector。

手算一轮:6 个二维训练点,nlist=2

左侧样本(1,1)、(1,2)、(2,1)
右侧样本(7,7)、(8,7)、(7,8)
initial c0=(1,1), c1=(8,8)
for x=(2,1): d²(x,c0)=1; d²(x,c1)=85 → assign S0
new c0=((1+1+2)/3, (1+2+1)/3)=(1.33,1.33)
new c1=((7+8+7)/3, (7+7+8)/3)=(7.33,7.33)

下一轮再用新 c0、c1 重新分组。真实 embedding 有 d 个坐标,公式完全相同,只是每次距离和均值都在 d 维上计算。

为什么训练集要有代表性?若训练样本缺少某种语言、主题或新数据分布,centroids 会把这些 vectors 分进不合适或失衡的桶,query 需要更大 nprobe 才能找回近邻。
一次 IVF query 怎样计算

coarse 阶段决定“打开哪些桶”,fine 阶段才决定“返回哪些 vectors”

1 · Coarse distance计算 q 到全部 nlist 个 cⱼ 的距离并排序。
2 · Probe cells取距离最小的 nprobe 个 centroid IDs。
3 · Merge candidates求这些 inverted lists 的并集 C(q)。
4 · Fine Top-K重新计算 q 到每个候选原向量的距离,并取最近 K 个。
coarse: δⱼ = d²(q, cⱼ)
P(q) = arg top-nprobe minⱼ δⱼ
C(q) = ∪ list[j], j∈P(q)
ANN Top-K = arg top-K minᵢ∈C(q) d²(q, xᵢ)
当前二维实验使用 squared L2:
d²(q,cⱼ)=(qₓ−cⱼₓ)²+(qᵧ−cⱼᵧ)²。
省略平方根不改变排序。生产系统必须让训练、add、query 使用一致的 metric 与 normalization contract。
nlist索引共有多少个 centroids/lists;这里固定为 8。
nprobe本次 query 实际打开多少个 lists;1≤nprobe≤nlist。
C(q)所有已打开 lists 中 vector IDs 的并集,即 fine search 候选集。
K最终返回数量;K 不会自动扩大 nprobe 或候选覆盖。
工作量直觉:coarse 阶段约需 nlist·d 个坐标运算,fine 阶段约需 |C(q)|·d。只有各桶大致均衡时,才可粗略估计 |C(q)|≈N·nprobe/nlist;真实列表可能失衡,必须读取实际 candidate count。IVF-Flat 在 nprobe=nlist 时扫描全部 lists,可恢复同 metric 下的 exact Top-K。
当前 120 点实验真实执行“最近 centroid 分桶 → query 选择 nprobe 个 cells → 合并候选 → 候选内 squared-L2 Top-K → exact 对照”。为了让图稳定可复现,8 个 centroids 是预先固定的,120 个点也围绕它们确定性生成;上面的 K-means 小例子负责真实展示训练计算。生产 IVF 必须从代表性样本学习 centroids,并在数据漂移或桶严重失衡后重新训练与评估。
点击移动 query · nlist=8
C0
C1
C2
C3
C4
C5
C6
C7
q
深色 = probed cells · 黑圈 = ANN Top-K · 红圈 = exact Top-K
Coarse cells
2/8
selected C4, C6
Candidates
30/120
25.0% vectors scanned
Recall@K
100%
5/5 exact neighbors
Guided presets

先看默认成功案例,再触发边界漏召回;预设只修改 query 与 query-time 参数,不会重新训练或分桶。

Build once · search many
  1. ① Train · 原理讲解:在代表性样本上学习 nlist 个 coarse centroids;本实验用上方手算例子解释,但图中 8 个中心为固定值。
  2. ② Add · 实验真实执行:每个 vector 分配到最近 centroid 的 inverted list;生产系统可再用 PQ 压缩 residual。
  3. ③ Probe:query 先找最近 nprobe 个 centroids,只扫描这些 lists。
  4. ④ Refine:对候选算精确距离并取 Top-K;必要时回原始向量 rerank。
边界效应:真正的近邻可能落在第二、第三个 coarse cell。点击“边界陷阱”并令 nprobe=1,可以直接看到未开桶里的真近邻怎样消失;提高 nprobe 会恢复召回。本实验每桶恰好 15 个,所以候选数随 nprobe 线性增加;生产桶可能失衡,应按所选 lists 的实际长度求和。
probed = [C4, C6]
ANN = [108, 100, 116, 86, 94]
exact = [108, 100, 116, 86, 94]
Recall@5 = 5/5
当前 query 的 IVF 计算账本

先排 8 个 centroids,再合并 lists,最后只在候选中排 Top-K

移动黑色 q 或拖动 nprobe 后,下方 coarse 排名、桶大小、候选距离和漏召回原因会立即重算。所有距离均为 squared L2,数值越小越近。

q = (0.540, 0.610)
nearest = C4, d²=0.0250
Coarse work
8 centroids
16 coordinate terms in this 2D demo
Fine work
30 vectors
60 coordinate terms · exact would scan 240
Candidate reduction
75.0%
90 vectors skipped before fine scoring
A · Coarse ranking:q 到每个 centroid

这一阶段只用 centroid 距离决定开桶,不会直接返回文档。排名前 nprobe 的 cells 进入下一阶段。

rankcelld²(q,cⱼ)sizedecision
1C40.025015probe ✓
2C60.044515probe ✓
3C30.092515skip
4C70.092515skip
5C50.198915skip
6C20.209715skip
7C10.227315skip
8C00.312515skip
selected cutoff d² = 0.0445 · next skipped C3=0.0925
C4: (0.5400.630)² + (0.6100.480
= 0.0081 + 0.0169 = 0.0250
C6: (0.5400.430)² + (0.6100.790
= 0.0121 + 0.0324 = 0.0445
B · Inverted lists:把选中桶合并为 C(q)

每个数据库 vector 只属于一个 coarse cell,因此这些 lists 可以直接拼接;桶大小不一定相等,实际候选数是所选列表长度之和。

list[C4]15 vectors
IDs [4, 12, 20, 28, 36, 44, 52, 60, 68, 76, …]
list[C6]15 vectors
IDs [6, 14, 22, 30, 38, 46, 54, 62, 70, 78, …]
C(q) = list[C4] ∪ list[C6]
|C(q)| = 15 + 15 = 30
C · Fine ranking:候选内重新计算 q 到原向量的距离

下面展示候选中最近的若干项。ANN rank 只在 C(q) 内产生;Exact rank 来自扫描全部 120 个 vectors,用于判断当前候选生成是否漏掉真近邻。

ANNIDcelld²(q,xᵢ)Exact
#1v108C40.00824#1
#2v100C40.01634#2
#3v116C40.01763#3
#4v86C60.01808#4
#5v94C60.02066#5
#6v4C40.02146#6
#7v78C60.02435#7
#8v102C60.02469#8
#9v92C40.02520#9
#10v12C40.02550#10
D · 漏召回检查

当前 Exact Top-5 全部位于已打开的 lists 中;候选内精排因此复现了 exact 结果。

Recall 只评估候选是否覆盖 exact neighbors:
Recall@5 = |ANN Top-5 ∩ Exact Top-5| / 5
= 5/5 = 100.0%
为什么 nprobe 增大通常更准也更慢?打开更多 lists 会扩大 C(q),更可能包含边界另一侧的真近邻;同时 fine distance 次数、内存读取与 Top-K 工作都会增加。由于桶大小可能失衡,成本不保证严格随 nprobe 线性增长。
可直接运行:IVF 的 train → add → probe → fine search

完整脚本会生成训练集和索引语料,运行 k-means、建立 lists、执行 ANN/Exact 查询并打印 Recall;复制保存为 .py 后即可运行。上方交互图仍使用固定 centroids 保持可复现,生产系统则必须用代表性训练集并处理数据漂移。

import numpy as np

def train_ivf(training_vectors, nlist, iterations=20, seed=7):
    # A compact, runnable L2 k-means trainer for teaching.
    if training_vectors.ndim != 2 or len(training_vectors) == 0:
        raise ValueError("training_vectors must be a non-empty 2-D array")
    if not 1 <= nlist <= len(training_vectors):
        raise ValueError("nlist must be between 1 and the training-vector count")
    if iterations < 1:
        raise ValueError("iterations must be positive")
    rng = np.random.default_rng(seed)
    initial_ids = rng.choice(len(training_vectors), nlist, replace=False)
    centroids = training_vectors[initial_ids].copy()

    for _ in range(iterations):
        distance = ((training_vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
        assignments = distance.argmin(axis=1)
        updated = np.vstack([
            training_vectors[assignments == cell].mean(axis=0)
            if np.any(assignments == cell) else centroids[cell]
            for cell in range(nlist)
        ])
        converged = np.allclose(updated, centroids)
        centroids = updated
        if converged:
            break
    return centroids

def add_ivf(vectors, centroids):
    # Add is separate from training: assign the full corpus once.
    distance = ((vectors[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=2)
    assignments = distance.argmin(axis=1)
    return [np.flatnonzero(assignments == cell) for cell in range(len(centroids))]

def sorted_smallest_ids(distance, count):
    if count < 0:
        raise ValueError("count must not be negative")
    count = min(count, len(distance))
    if count == 0:
        return np.empty(0, dtype=np.int64)

    # Partial selection is O(N); explicitly resolve the cutoff tie by ID so
    # repeated runs have the same result, then sort only the selected IDs.
    cutoff = np.partition(distance, count - 1)[count - 1]
    lower_ids = np.flatnonzero(distance < cutoff)
    tied_ids = np.flatnonzero(distance == cutoff)
    ids = np.concatenate([lower_ids, tied_ids[:count - len(lower_ids)]])
    return ids[np.lexsort((ids, distance[ids]))]

def search_ivf(query, vectors, centroids, lists, nprobe, k):
    if not 1 <= nprobe <= len(centroids):
        raise ValueError("nprobe must be between 1 and nlist")
    if not 1 <= k <= len(vectors):
        raise ValueError("k must be between 1 and the vector count")

    # Coarse stage: rank centroids and open the nearest nprobe lists.
    cell_distance = ((centroids - query) ** 2).sum(axis=1)
    selected = sorted_smallest_ids(cell_distance, nprobe)
    candidate_ids = np.concatenate([lists[cell] for cell in selected])
    if len(candidate_ids) == 0:
        raise ValueError("the selected IVF lists contain no vectors")

    # IVF-Flat fine stage: exact L2 only inside the candidate union.
    candidate_distance = ((vectors[candidate_ids] - query) ** 2).sum(axis=1)
    local_topk = sorted_smallest_ids(candidate_distance, k)
    return candidate_ids[local_topk]

def main():
    rng = np.random.default_rng(11)
    true_centers = np.array([
        [-1.0, -0.8],
        [0.0, 1.2],
        [1.1, -0.3],
    ], dtype=np.float32)

    # Training samples learn the coarse centroids; the corpus is added later.
    training_vectors = np.vstack([
        rng.normal(center, 0.16, size=(80, 2)) for center in true_centers
    ]).astype(np.float32)
    vectors = np.vstack([
        rng.normal(center, 0.18, size=(40, 2)) for center in true_centers
    ]).astype(np.float32)

    nlist = 3
    centroids = train_ivf(training_vectors, nlist=nlist, seed=7)
    lists = add_ivf(vectors, centroids)

    query = np.array([1.05, -0.35], dtype=np.float32)
    nprobe, k = 2, 5
    ann_ids = search_ivf(query, vectors, centroids, lists, nprobe, k)

    cell_distance = ((centroids - query) ** 2).sum(axis=1)
    selected_cells = sorted_smallest_ids(cell_distance, nprobe)
    candidate_count = sum(len(lists[cell]) for cell in selected_cells)
    exact_distance = ((vectors - query) ** 2).sum(axis=1)
    exact_ids = sorted_smallest_ids(exact_distance, k)
    recall = len(set(ann_ids.tolist()) & set(exact_ids.tolist())) / k

    print("trained centroids:
", np.round(centroids, 3))
    print("list sizes:", [len(postings) for postings in lists])
    print("query:", [round(float(value), 3) for value in query])
    print("selected cells:", selected_cells.tolist())
    print(f"candidate vectors: {candidate_count}/{len(vectors)}")
    print("ANN Top-K:", ann_ids.tolist())
    print("Exact Top-K:", exact_ids.tolist())
    print(f"Recall@{k}: {recall:.1%}")

if __name__ == "__main__":
    main()
10

HNSW vs IVF:没有绝对赢家,只有与工作负载匹配的结构

维度HNSWIVF / IVF-Flat / IVF-PQ
候选生成在分层邻接图中导航先选 coarse cells,再扫描 inverted lists
query-time knobefSearchnprobe
build-time knobMefConstructionnlist、训练样本、PQ codebook
内存原始 vectors + graph edges,通常较高IVF-Flat 保留原向量;IVF-PQ 可显著压缩
更新适合增量插入,但删除/压缩仍需维护批量构建自然;分布漂移或桶失衡可能要 retrain/rebuild
过滤取决于实现,强 filter 可能破坏图遍历效率可结合分区/list,但仍需防止过滤后候选不足

参数分成两类

  • build-time:改变索引结构,需要重建或增量维护;影响构建时间、内存和可达到的召回上限。
  • query-time:每次请求可以调节;扩大搜索预算通常提高 Recall,也增加延迟。
11

Recall@K:ANN 找回了 Exact Top-K 中的多少个近邻

HNSW 与 IVF 都通过少访问一些向量换取更低延迟,因此必须量化它们漏掉了多少真正的 metric 近邻。对同一个 query,在语料快照、embedding、归一化、metric、metadata filter 与 K 全部相同的前提下,Exact 全量扫描产生参考答案,ANN 只访问部分候选。Recall@K 回答一个非常具体的问题:Exact 认为应该出现的 K 个向量 ID,ANN 找回了几个?

当前 query 向量;Exact 与 ANN 必须接收同一个 q 和完全相同的预处理结果。
the same query vector supplied to exact and ANN search
最终希望返回的近邻数量,也是标准评测中 Exact 参考集合需要 ANN 找回的目标数;必须满足 K≥1。
requested neighbor count and exact target-set size
对所有符合 filter 的 vectors 使用同一 metric 全量打分后得到的 K 个唯一 ID;上标 Exact 是方法标签,不是指数。
K unique IDs from exhaustive search under the same contract
ANN 在 efSearch、nprobe 等预算下只访问部分候选后返回的 K 个唯一 ID;上标 ANN 同样只是方法标签。
K IDs returned by the approximate index at a search budget
集合交集:只保留 Exact 与 ANN 两边都出现的 vector IDs;标准 Recall@K 不比较这些 ID 在 Top-K 内的先后次序。
set intersection of IDs returned by both searches
集合基数,即集合 A 中不同元素的数量;这里的竖线不是绝对值、向量长度或距离。
set cardinality, not a norm or distance
当前 query 的命中数;0≤h_K(q)≤K。一个 Exact ID 被 ANN 找回就贡献 1 次命中。
number of recovered exact neighbors for one query
单条 query 的找回比例,取值 0 到 1;乘以 100% 后得到百分比。
per-query exact-neighbor recovery ratio
未参与索引调参、并尽量覆盖真实流量分布的 held-out query 集合。
held-out, production-like evaluation query set
评测 query 的条数;宏平均时每条 query 权重相同。
number of evaluation queries
依次累计 Q 中每条 query 的 Recall@K,而不是把所有返回 ID 混成一个大集合。
sum of per-query recalls across Q
整个评测集上的宏平均 Recall@K,用于比较索引参数或版本,但仍需查看分桶和最差案例。
macro-average Recall@K over the query set

① 先固定“同一场考试”:只有搜索方法可以不同

Exact 与 ANN 必须使用同一个 q、同一份 corpus/index snapshot、同一 embedding 模型与版本、同一 normalization、同一 Cosine/IP/L2 排序契约、同一 metadata filter、同一个 K,以及相同的距离并列处理规则。Exact 扫描所有合格向量,ANN 只改变候选生成方式。若这些条件不一致,两组 Top-K 的差异可能来自数据或 metric 漂移,不能归因于 ANN。

固定不变:q + corpus snapshot + embedding/normalization + metric + filter + K Exact:访问全部 eligible vectors → 参考集合 N_exact ANN:访问索引选出的 candidates → 待评估集合 N_ann 最后比较唯一 vector IDs,而不是直接比较两边的 score 数值

② 单条 query 的五步计算逻辑

第一步,Exact 对全部合格 vectors 打分并取 Top-K;Cosine/IP 从大到小,L2 从小到大。第二步,ANN 在搜索预算内访问部分 candidates 并返回 Top-K。第三步,把两个有序列表转换为唯一 ID 集合。第四步求交集并数出命中 h_K(q)。第五步除以 Exact 目标集合大小 K。列表保留排名便于展示,但集合交集只判断“是否出现”。

1. exact_scores(all eligible vectors) → Exact Top-K 2. ann_search(search_budget) → ANN Top-K 3. hit IDs = set(Exact Top-K) ∩ set(ANN Top-K) 4. h_K(q) = number of hit IDs 5. Recall@K(q) = h_K(q) / K

③ 用实验四的真实结果手算:3 个命中怎样得到 Recall@5 = 60%

回到实验四点击“Recall 手算 · 3/5”:q=(0.03,0.40)、nprobe=1、K=5。C0 的 coarse distance 为 0.0569,略近于 C3 的 0.0625,因此 IVF 只打开 C0。可是未打开的 C3 中含有 v51、v43 两个真正的 Exact Top-5;fine search 从未见过它们,只能用 C0 中更远的 v56、v64 补位。

Exact Top-5 = [v48, v40, v51, v32, v43] ANN Top-5 = [v48, v40, v32, v56, v64] 交集 / hit = {v48, v40, v32} → 3 个 漏召回 / miss = {v51, v43} → 2 个 替代项 / extra = {v56, v64} → 不是 Exact Top-5 h_5(q) = 3 Recall@5(q) = 3 / 5 = 0.60 = 60%

④ 为什么分母是 K,而不是 ANN 返回数或语料总数 N

这里的问题是“Exact 指定的 K 个目标,ANN 找回了多少”,所以分母是 Exact 目标集合大小。若 ANN 只返回 1 条且恰好命中,仍应算 1/K;若除以 ANN 返回数就会错误地得到 100%。也不能除以整个语料数 N,因为评测目标不是找回数据库全部 vectors,而是复现 Exact Top-K。公式默认 filter 后至少有 K 个唯一结果;若只有 k_q<K 个合格结果,应明确使用 k_q=|N_K^Exact(q)| 作为有效分母,或单独报告该 query。

⑤ 60%、100% 与 0% 到底分别意味着什么

60% 表示 K 个 exact metric neighbors 中找回了 60%;100% 只表示两个集合成员完全相同,即使 ANN 把这 K 个 ID 的内部顺序全部打乱,Recall@K 仍是 100%;0% 表示两个 Top-K 集合没有共同 ID。它不是模型置信度,也不是“回答正确的概率”。K 是指标定义的一部分,Recall@10 与 Recall@100 不能当成同一个指标直接比较。

⑥ 从一条 query 到 Q:为什么要逐条计算再做宏平均

一条 query 可能恰好落在容易区域,也可能位于 HNSW 局部图或 IVF cell 边界。应对 held-out 集合 Q 中每条 query 单独计算 Recall@K,再取宏平均,使每条 query 权重相同。例如三条 query 分别为 5/5、3/5、4/5,平均 Recall@5=(1.0+0.6+0.8)/3=0.8。平均值仍会掩盖局部失败,因此还要按语言、主题、filter 选择性、热门/长尾流量切片,并查看最差 queries。

q₁: 5/5 = 1.00 q₂: 3/5 = 0.60 q₃: 4/5 = 0.80 R̄₅ = (1.00 + 0.60 + 0.80) / 3 = 0.80 = 80%

⑦ Recall@K 的作用:隔离 ANN 近似损失,并和 latency 一起选工作点

固定 embedding 与 metric 后,Recall@K 能单独衡量索引近似造成的损失:调大 HNSW 的 efSearch 或 IVF 的 nprobe,通常会访问更多 candidates、找回更多 Exact neighbors,同时增加 CPU、内存读取与延迟。它适合比较参数、索引版本和回归,例如设定“Recall@10≥95%,同时 p95≤20 ms”。下一张实验会把 Recall 与 latency 放在同一条曲线上寻找 Pareto 工作点。

⑧ Recall@K 没有衡量什么:Exact ground truth 也只是 metric ground truth

Recall@K 不判断 Exact 返回的文档是否真的能回答用户问题。即使 embedding 很差,只要 ANN 完整复现这组错误近邻,Recall@K 仍可达到 100%。它也不衡量 Top-K 内部排序、score 误差、延迟、吞吐、内存或最终 RAG 回答质量。顺序敏感时需补充 NDCG/MRR;语义相关性需要人工 relevance labels;回答正确性、完整性和引用可信度还要在 No.16 做端到端评估。距离并列时也必须固定 tie-break(本实验按 vector ID),否则 Exact Top-K 本身可能不唯一。

12
05 · Recall–latency operating point

实验五:搜索预算怎样把工作点沿 latency–recall 前沿移动

先从一次 query 的 latency 讲起,用 101 条已排序耗时手算 p50 / p95 / p99;再把同一配置的尾延迟与 mean Recall@K 组成工作点,通过支配关系理解 Pareto frontier。最后选择 corpus size、拖动教学 budget,观察 HNSW/IVF 参数、估算工作量与 Recall 如何变化。当前曲线是明确标注的 capacity projection,不是实测毫秒;生产前沿必须用真实 held-out queries、exact ground truth 与目标硬件重新测量。

先划清真实性边界:本实验用候选访问量构造 estimated distance work,再用单调函数生成示意 Recall,目的是解释参数方向,不是任何数据库的实测 latency 或性能承诺。页面中的 5% 表示估算距离工作量约为 Exact 的 5%,不表示 5 ms,也不保证真实延迟下降 95%。真实工作点必须在目标硬件和真实流量下测量。
横轴 · Latency
ℓ(q) = t_return − t_start

一条 query 从“开始搜索索引”到“返回 Top-K”的耗时,通常用 ms。越小越靠左、越快。若测的是完整 RAG,还需另计 embedding、网络、rerank 与 LLM。

纵轴 · Recall@K
mean hits / K

上一张卡定义的 Exact-neighbor 找回率。越大越靠上、越准。一个参数设置要在同一批 held-out queries 上计算平均 Recall。

一个点 · Operating point
Pθ = (p95, mean Recall)

固定索引与参数 θ,在整批 queries 上得到一个延迟分位数和一个平均 Recall,二者组成工作点。它不是某一条 query,也不是单个 score。

p50 / p95 / p99 到底是什么

一条 query 只有一个 latency;很多条 latency 排序后才有 percentile

在完全相同的配置下运行 |Q| 条 queries,得到 ℓ₁…ℓ|Q|,先从小到大排成 ℓ₍₁₎≤…≤ℓ₍|Q|₎。用于手算的 nearest-rank 定义是:

rank(pₓ) = ceil((x / 100) × |Q|)
pₓ = 排序后的第 rank(pₓ) 个 latency

ceil 表示向上取整;ℓ₍ᵣ₎ 指排序后的第 r 个值,不是原始到达顺序中的第 r 条 query。统计库可能使用插值法而得到略有差异的数值,但百分位的解释不变。

101 条查询的手算样本
排序位置 1–5151 条 · 6 ms
排序位置 52–9645 条 · 10 ms
排序位置 97–1004 条 · 40 ms
排序位置 1011 条 · 120 ms
p50 · typical
6 ms
ceil(0.50×101)=51;约一半查询不超过它
p95 · tail
10 ms
ceil(0.95×101)=96;约 5% 查询更慢
p99 · deep tail
40 ms
ceil(0.99×101)=100;约 1% 查询更慢
p99 不是最大值:这里 p99=40 ms,但最慢查询仍是 120 ms。平均值约为 (51×6+45×10+4×40+1×120)/101=10.26 ms,也无法单独表达“典型 6 ms、尾部 40–120 ms”这种形状。
Pareto frontier · 什么叫“沿前沿移动”

每个 efSearchnprobe 或其他参数组合 θ 都会产生一个点 (p95 latency, mean Recall@K)。理想方向是左上角:更快且更准

如果 A 的 latency≤B 且 Recall≥B,并且至少一项严格更好,就说 A 支配 B;B 既更慢又不更准,没有选择价值。删除所有被支配点后,剩下的左上边界才叫 Pareto frontier。

拖动预算实际上是在选择另一套参数,从一个工作点换到另一个点。预算增大通常使点右上移动:更准但更慢;严格来说,只有实测并剔除被支配点后,才能说它位于真正的前沿。

设置p95Recall@10判断
A · 低预算3 ms84%前沿
B · 中低预算5 ms91%前沿
C · 中预算8 ms96%前沿 · p95≤10 ms 时可选
D · 另一配置11 ms95%被 C 支配:更慢且更不准
E · 高预算15 ms98%前沿
Exact75 ms100%最准确,但成本最高

B→C 多付 3 ms 换 5 个百分点 Recall;C→E 多付 7 ms 只换 2 个百分点,说明越接近 100% 往往边际收益越小。

Corpus size
budget 1–10 是教学用统一档位,不是数据库 API 参数,也不能说 HNSW 的 5 等于 IVF 的 5。它分别映射到 efSearch 与 nprobe。向右拖动不会改变 embedding 或重建索引,只扩大 query-time 搜索范围。
N=100,000 · K=10 · budget=5
nlist≈round(√N)=316
HNSW: efSearch=max(K, mapped budget)=37
IVF: nprobe=14/316
Exact
all vectors
Recall@10100.0%
Estimated distance work vs exact100.00% proxy
100,000 / 100,000 distance work · 全量扫描 N 个 vectors
HNSW
efSearch=37
Recall@1083.4%
Estimated distance work vs exact0.98% proxy
983 / 100,000 distance work · 启发式估计的 distance evaluations
IVF
nprobe=14/316
Recall@1088.6%
Estimated distance work vs exact4.75% proxy
4,746 / 100,000 distance work · 316 coarse + 4,430 fine
Projected work–recall points · budget 1…10

先用工作量代理看方向;真实前沿必须把横轴替换成实测 p95 ms

● HNSW● IVF━ 未被支配的模型边界

灰淡点在当前教学模型中被另一点同时做到“工作量更低且 Recall 更高”;橙线连接未被支配点。Exact 位于 (100% work, 100% Recall),超出当前 ANN 放大视图。真实 benchmark 可能得到完全不同的曲线与前沿。

当前预算怎样产生“移动”

下面比较 budget 45。横向代价用 estimated work proxy 表示,纵向收益用模型 Recall 的百分点变化表示;拖动滑杆时当前 H/I 大圆点会选择新的配置。

HNSW · efSearch=25efSearch=37work: 0.66% → 0.98% (+0.32 pp)
Recall: 79.7% → 83.4% (+3.7 pp)
IVF · nprobe=9/316nprobe=14/316work: 3.16% → 4.75% (+1.58 pp)
Recall: 82.4% → 88.6% (+6.2 pp)
为什么预算增大通常右上移动? HNSW 保留并扩展更多图候选;IVF 打开更多 lists。更多向量获得被精算和进入 Top-K 的机会,所以 Recall 通常提高;与此同时 distance computations、随机内存访问与队列工作增加,所以真实 latency 通常也提高。由于参数取整、缓存和测量噪声,相邻档位可能出现平台甚至小幅非单调。
工作点不是“越右越好”。先写质量与体验约束,再选最低成本点:例如 Recall@10≥95% 且 p95≤20 ms。若没有点同时满足,就要改变索引结构、build 参数、硬件或产品 SLA,而不是无限增大 query-time budget。
Quality
Recall@K
ANN Top-K 与 exact Top-K 的集合重合率
Typical + tail
p50 / p95 / p99
同一配置下许多查询延迟的分布阈值;p99 仍不是最大值
Capacity
QPS / CPU / RAM
平均值帮助估算总成本,分位数约束用户体验
可直接运行:从 query-level 样本计算 mean Recall 与 p50 / p95 / p99

脚本内含 5 条 query 的 Exact/ANN IDs、101 个 latency samples、main 入口与 nearest-rank 计算。接入生产时,用真实 exact baseline 与 perf_counter 搜索耗时替换样本;同时按 query 类型、filter 选择性、语言和长尾流量分桶。

from math import ceil
from statistics import mean

def recall_at_k(exact_ids, ann_ids, k):
    truth = set(exact_ids[:k])
    returned = set(ann_ids[:k])
    return len(truth & returned) / k

def nearest_rank_percentile(values, percentile):
    """Teaching definition: sorted_values[ceil(p/100 * n) - 1]."""
    if not values:
        raise ValueError("values must not be empty")
    if not 0 < percentile <= 100:
        raise ValueError("percentile must be in (0, 100]")
    ordered = sorted(values)
    rank = ceil(percentile / 100 * len(ordered))
    return ordered[rank - 1]

def main():
    # Five held-out queries. In production, exact_results come from a full scan
    # and ann_results come from the index under one fixed search configuration.
    exact_results = [list(range(start, start + 10)) for start in range(0, 100, 20)]
    ann_results = [
        exact_results[0],
        exact_results[1][:9] + [999],
        exact_results[2][:8] + [998, 999],
        exact_results[3][:7] + [997, 998, 999],
        exact_results[4][:6] + [996, 997, 998, 999],
    ]
    recalls = [
        recall_at_k(exact_ids, ann_ids, k=10)
        for exact_ids, ann_ids in zip(exact_results, ann_results)
    ]

    # 101 measured query latencies in milliseconds. Replace these samples with
    # perf_counter timings around the ANN search call on the target machine.
    latencies_ms = [6.0] * 51 + [10.0] * 45 + [40.0] * 4 + [120.0]

    print("per-query Recall@10:", recalls)
    print(f"mean Recall@10: {mean(recalls):.1%}")
    print(f"mean latency: {mean(latencies_ms):.2f} ms")
    for percentile in (50, 95, 99):
        value = nearest_rank_percentile(latencies_ms, percentile)
        print(f"p{percentile}: {value:.2f} ms")
    print(f"max: {max(latencies_ms):.2f} ms")

if __name__ == "__main__":
    main()
13

ANN index ≠ Vector Database:图或倒排桶只是搜索内核

必须解决的问题
Vector indexbuild、add、search、ANN parameters、distance metric
Record / metadatachunk text、document_id、source、tenant、ACL、timestamps、filters
Lifecycleupsert、delete / tombstone、compaction、rebuild、schema 与 embedding migration
Reliabilitypersistence、snapshot、replication、backup、sharding、observability
Servingbatch query、concurrency、cache、rate limit、multi-tenant isolation
vector database ├── vector records + metadata ├── exact / HNSW / IVF / PQ search index ├── filters + CRUD + snapshot lifecycle └── distributed serving + observability

Embedding 版本迁移为什么危险

新旧模型产生的坐标不可直接混在同一空间。安全流程通常是:创建新 collection/index → 全量 re-embed → shadow query 对比 → 切换 alias → 保留回滚快照。

14

交给 No.16 的底层组件:一个可版本化、可评估的 search contract

OFFLINE documents → clean → chunk → embed → normalize → {vector, chunk_id, document_id, metadata} → build index snapshot ONLINE query → same model / prefix / pooling / normalize → index.search(q, k, filters, search_budget) → candidate IDs + scores → metadata/text lookup → optional rerank → context + citations → LLM

索引快照必须带上的契约

{
  "model_id": "...",
  "model_version": "...",
  "dimension": 384,
  "normalization": "l2",
  "metric": "inner_product",
  "algorithm": "hnsw",
  "build_params": { "M": 16, "efConstruction": 200 },
  "corpus_version": "2026-08-26"
}

稳定返回结构

SearchHit {
  chunk_id, document_id, text, score, rank, metadata
}
No.15 已解决No.16 继续解决
metric、exact baseline、ANN candidate generationchunk size / overlap 与语义边界
HNSW / IVF 参数与 Recall@Kmetadata filters、hybrid retrieval、rerank
latency / memory / rebuild 的索引权衡context budget、citation、answer evaluation
15
问问 LLM:用当前实验参数解释索引选择与 Recall–Latency 权衡

正在检查登录状态与模型配置…