算法可视化与交互学习平台
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。
No.14 交来的不是答案,而是一张 N×d 向量表
No.14 已经把文本编码成同一语义空间中的向量:query 是 **q∈ℝᵈ**,每个 document chunk 是 **dᵢ∈ℝᵈ**。现在问题从“怎样得到坐标”变成了“怎样从 N 个坐标中找到最接近 q 的 K 个”。
本模块先保留一条绝对可靠的 exact / brute-force baseline,再一步步减少 query 真正访问的候选数。**ANN 通常不是把距离公式算得更粗糙,而是聪明地避免查看全部向量。**
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 与回答评估 |
检索目标:返回索引与向量,而不是把 query 变成另一个 query
query q 保持不变;argmax 返回最佳 document/chunk 的索引 i*,再由索引取回 d* 与 metadata。实际 RAG 通常返回有序的 K 个 chunk IDs,而不是只返回一个向量。
打分和取回是两步
index 内部只需要 vector ID 与搜索结构;返回 ID 后再读取 text、document_id、source、ACL 等 metadata。
Top-K 不等于 ANN
Top-K 只是从分数中选择结果;exact scan 与 HNSW、IVF 都必须完成 Top-K。ANN 解决的是候选从哪里来。
过滤会改变搜索问题
language、tenant、time range、ACL 等 filter 可能在搜索前、搜索中或搜索后执行;过晚过滤可能让返回数量不足。
实验一:同一 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 或向量数据库。
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 从小到大。
只保留排序前 K 个;K 不会改变原始分数。
同一批当前向量,三把尺子的实时排行榜
注意有序结果和集合的区别:Vector Search 返回的是带 rank 的有序 Top-K。两种 metric 即使选中了相同 IDs,也可能顺序不同;K=1 时最容易直接看出三种规则分别选择 A、B、C。
把向量看成从原点射出的箭头,只比较夹角方向。范围是 −1 到 1,越接近 1 表示方向越一致;向量 B 只改变长度时,cosine 不变。
qᵀ(s·B) = s·(qᵀB)
L2(q, s·B) 没有缩放不变性
A 与 q 的夹角方向最接近,cosine=1.000。
Top-1 = [A]
| Rank | Chunk | cos(q,d) | qᵀd | L2 | Selected |
|---|---|---|---|---|---|
| #1 | 1.000 | 0.864 | 0.309 | ✓ Top-K | |
| #2 | 0.999 | 1.224 | 0.057 | 未入选 | |
| #3 | 0.988 | 1.619 | 0.447 | 未入选 | |
| #4 | 0.827 | 0.831 | 0.616 | 未入选 | |
| #5 | 0.294 | 0.312 | 1.231 | 未入选 |
逐项检查文档 A 为什么排在第 1 名
默认跟随当前第一名;也可以固定一个文档,再切换 metric 比较同一组数字如何得到不同名次。
qᵀd_A = (1.00 × 0.72) + (0.45 × 0.32) = 0.864
没有除以向量长度,所以把同方向的 d 拉长,乘积和通常也会增大。
‖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(q,d_A) = √((1.00 − 0.72)² + (0.45 − 0.32)²) = 0.309
自然距离是越小越好。代码为了复用“取最大值”的 heap,会暂时使用 −L2;表格仍显示直观的正距离。
建议按这个顺序亲手验证
拖动 B:它沿射线移动,但 cosine 列应保持不变。
继续放大 B:长度进入分数,B 更容易升到第一名。
观察 C:它的终点最靠近 q,因此通常成为第一名。
分数和名次都不动,只有“入选/未入选”的截止线移动。
可直接运行:同一个 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()
单位向量上的关键等价:Cosine = IP,L2 排序也相同
当 query 与 documents 都 L2-normalize 后,最大化 cosine、最大化 inner product、最小化 squared L2 会得到同一排序。raw vectors 不具备这个保证,因此 embedding 生成、索引 metric 与在线 query normalization 必须作为一个版本化契约。
为什么数据库配置不能随便改
用 cosine 构建的语义与 raw inner product 的排序目标可能不同。变更 normalization 或 metric 后要重新验证,通常也要重建索引。
平方根可以省略
L2 与 squared L2 单调同序;只为排序时无需开平方。
Exact Search:Top-K heap 省掉全排序,却省不掉全扫描
一次 exact Top-K 查询分成两段:先让 query 与搜索范围内的全部 N 个 vectors 逐一算分,再用容量为 K 的 min-heap 只保留当前最好的 K 个结果。heap 把“对 N 个分数全部排序”改成“维护 K 个候选”,却不会让任何 vector 跳过算分;因此通常占主导的全扫描 Θ(Nd) 仍然存在。右侧公式单独计算原始向量数据需要的字节数。
先算分,再选 Top-K:加号连接的是两段顺序工作
第一项回答“所有候选的分数怎样得到”,第二项回答“已有 N 个分数后怎样只留下最好的 K 个”。heap 只优化第二段,所以不能把 O(N log K) 误当成整个 exact search 的唯一成本。
heap 到底省掉了什么
全排序会保存并排序全部 N 个分数;size-K heap 始终只维护 K 个最好候选。两种方法都已为 N 个 vectors 算过分,差别只发生在结果选择阶段。
代入 100K × 384D、K=10、float32
一次 query 仍要处理 3,840 万个坐标;heap 的高度只有约 log₂10≈3.32,但它是在全量算分之后维护结果。仅原始向量就约 153.6 MB,每次冷扫描都可能受内存带宽和缓存命中影响。
渐进式不是秒表公式
Θ 与 O 隐去了常数:SIMD / BLAS、CPU 或 GPU、内存布局、batching、缓存、并发都会改变毫秒数。因此下一张实验卡会在当前设备上真实测量延迟,而这里负责解释为什么规模增长后工作量必然上升。
ANN 的真正切入点
把全体集合 D 换成更小的候选集合 C(q),只对候选做最终 metric 排序。
实验二:100 → 1,000 → 10,000 → 100,000 chunks,亲自撞上规模墙
用固定 seed 的 synthetic normalized vectors 隔离搜索算法本身,不伪装成编码了 100,000 篇真实文本。一次点击依次生成同维度语料,并真实执行 brute-force inner product + Top-K heap;观察当前设备上的 p50 / p95 延迟、坐标运算量与向量内存如何增长。
计时会受设备、浏览器、电源模式与后台负载影响;请比较增长趋势,不把单次毫秒数当成生产 SLA。
Exact scan latency
median · current device暴力搜索的候选访问数随 chunk 数 N 线性增长;Top-K heap 不会消除这次全量扫描。
把所有分数全排序是 O(N log N);维护 K 个候选只需小根堆,但距离计算仍是主成本。
向量索引检索的是 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])
ANN 的核心承诺:用更小的候选集换速度,而不是偷换 metric
| 不会自动改变 | 会改变 |
|---|---|
| embedding model、dimension、normalization | 候选生成方式与访问顺序 |
| 最终使用的 cosine / IP / L2 契约 | 可能漏掉的 exact neighbors |
| Top-K 返回接口 | build time、index memory、query latency |
实验三:HNSW 怎样从稀疏高层长跳,再在第 0 层扩大候选
点击图中任意位置移动 query,切换 layer 并拖动 efSearch。实验真实执行分层 greedy descent 与底层 best-first 扩展,同时对照 exact Top-K,直接看到访问节点数、路径与漏召回怎样变化。先从 layer、节点最高层 hᵢ、生产随机抽层公式与当前实验的确定性分层规则讲起,再进入 squared L2 原子计算,并用动态计算账本逐轮观察高层距离比较、Layer 0 candidate queue、Wef 结果集和停止条件。
Hierarchical Navigable Small World:可导航的小世界分层图
每个已索引 vector 是一个节点,相近 vectors 之间建立边。多数节点只在 layer 0,少量节点还会出现在更高层;高层节点少、边跨度长,负责快速跨越全局,底层节点密、负责局部精查。查询不是扫描全部 vectors,而是沿图只给遇到的候选计算距离,因此得到的是近似近邻。
索引不是 query 到来后临时生成
- ① 为节点抽取最高层 hᵢ:每个 vector 插入时只抽一次随机数。hᵢ=0 表示只在 L0;hᵢ=2 表示同一节点参与 L0、L1、L2。它不是由坐标大小或离 query 的距离计算出来的。
- ② 寻找候选邻居:从旧图最高层入口出发,逐层搜索新节点附近的候选;
efConstruction越大,建图看得越宽。 - ③ 连边并剪枝:每层最多保留约
M条兼顾距离与方向多样性的连接,通常还建立反向边。 - ④ 查询复用图:在线 query 只调整
efSearch,不会重新分层或重建边。
M、efConstruction 是 build-time,影响图质量、构建成本与内存;efSearch 是 query-time,控制一次搜索愿意保留多少候选,且应满足 efSearch ≥ K。层是同一批向量的稀疏导航图;层高是单个节点能出现到哪一层
HNSW 里的 layer 不是 embedding 的某个维度,也不是按距离切出的区间,更不是一个聚类桶。它是一张只保留部分节点及其连接的邻接图。每个 vector 都属于最底层 L0;节点的最高层 hᵢ 决定它还会不会继续出现在 L1、L2……中。
1 · 先看一个只有 6 个节点的例子
假设各节点最高层为:h₀=0、h₁=2、h₂=0、h₃=1、h₄=0、h₅=1。
V2 ⊆ V1 ⊆ V0
例如 h₁=2,所以 v1 同时参与 L0、L1、L2;它仍是同一个 vector,只是在三层分别拥有邻接边。h₀=0 的 v0 则只在 L0。相邻两层的节点集通常会变小,但随机情况下也可能暂时相同,所以集合关系写成 ⊆。查询从最高非空层 Lmax 的入口开始。
2 · 生产 HNSW 怎样抽出 hᵢ
插入第 i 个节点时抽一次均匀随机数 U∈(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 会不同,但“每个节点只抽一次、越高概率指数下降”这一结构不变。
3 · 当前 48 点实验为了可复现,没有使用随机抽层
hᵢ = 1, else if id mod 4 = 0
hᵢ = 0, otherwise
这个规则形成 48→12→3,即每升一层保留 1/4,目的是让小图仍有足够高层节点可观察;它不模拟上方 M=16 时每层约保留 1/16 的生产采样。若 48 点真的按 M=16 抽样,L1 期望仅 3 个,L2 期望仅 0.1875 个,常常根本没有可展示的 L2。实验因此忠实演示“嵌套层 + 查询流程”,但没有伪装成生产层高分布。层高在建索引时决定一次,移动 query 或拖动 efSearch 都不会重新计算它。
= (qₓ − xᵢ)² + (qᵧ − yᵢ)²
smaller distance → nearer node
二维教学图使用 squared L2,省掉不影响排序的平方根。生产 HNSW 会在 d 维 embedding 上调用索引约定的 cosine、inner product 或 L2;核心没有变:每触达一个节点,就计算一次同一 metric,再决定是否沿它的边继续走。
layer 0 stop: min distance(candidate queue) > max distance(Wef), when |Wef| = efSearch
- 1. Entry:从稀疏最高层的固定入口开始。
- 2. Greedy descent:只要邻居更靠近 q 就移动,并逐层下沉。
- 3. Layer 0:按距离扩展候选,efSearch 越大越不易陷入局部区域。
- 4. Return:从访问集合中取最近 K 个;这一步可能漏掉 exact neighbors。
ANN = [2, 36, 20]
exact = [2, 36, 20]
efSearch = 12
把图上的一条路径还原成每次距离比较与队列变化
移动红色 q 或调整 efSearch 后,下方数字会重新计算。距离统一显示 squared L2;✓ 表示新邻居进入候选队列和 Wef,× 表示它比当前结果集边界更差而被剪掉。
d²(q, v0) = 0.0831
每轮比较“当前节点 + 当前层邻居”。只有找到严格更近的邻居才移动;没有更近邻居并不代表已经找到全局最近点,只表示可以从同一节点下降到更密的一层继续找。
candidate min-queue 决定下一步展开谁;Wef 保存目前距离最小的至多 efSearch 个节点,并用其中最远项作为剪枝边界。二者用途不同,不能把 efSearch 理解成最终返回数量。
可直接运行: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()
实验四: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 为什么会漏掉未开桶中的真近邻。
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 做最终距离排序。
v1 → C0
v2 → C1
list[C1] → [v0, v2]
query 选中 C1 后可以直接读取 [v0,v2],不必重新扫描全部 vectors 并逐个询问“你属于哪个桶”。
train → add 只做一次,probe → fine search 为每条 query 执行
nlist 是 build-time 分区数,改变它通常要重新训练和建索引;nprobe 是 query-time 打开桶数,可以逐请求调整;K 只表示最终返回多少条,不等于扫描多少个桶或候选。K-means 反复执行“按最近中心分组 → 用组内均值更新中心”
- ① Initialize:先选 nlist 个初始中心 c₀…cₙₗᵢₛₜ₋₁,生产实现常用随机样本或 k-means++。
- ② Assign:对每个训练向量 xᵢ,计算它到全部 centroids 的距离,并分给最近的一个。
- ③ Update:对每个 cell 内的 vectors 求坐标均值,把 centroid 移到该均值。
- ④ Repeat:重复 assign/update,直到中心移动很小或达到迭代次数;随后冻结 centroids 用于 add 与 search。
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
下一轮再用新 c0、c1 重新分组。真实 embedding 有 d 个坐标,公式完全相同,只是每次距离和均值都在 d 维上计算。
coarse 阶段决定“打开哪些桶”,fine 阶段才决定“返回哪些 vectors”
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ᵢ)
d²(q,cⱼ)=(qₓ−cⱼₓ)²+(qᵧ−cⱼᵧ)²。
省略平方根不改变排序。生产系统必须让训练、add、query 使用一致的 metric 与 normalization contract。
先看默认成功案例,再触发边界漏召回;预设只修改 query 与 query-time 参数,不会重新训练或分桶。
- ① Train · 原理讲解:在代表性样本上学习 nlist 个 coarse centroids;本实验用上方手算例子解释,但图中 8 个中心为固定值。
- ② Add · 实验真实执行:每个 vector 分配到最近 centroid 的 inverted list;生产系统可再用 PQ 压缩 residual。
- ③ Probe:query 先找最近 nprobe 个 centroids,只扫描这些 lists。
- ④ Refine:对候选算精确距离并取 Top-K;必要时回原始向量 rerank。
ANN = [108, 100, 116, 86, 94]
exact = [108, 100, 116, 86, 94]
Recall@5 = 5/5
先排 8 个 centroids,再合并 lists,最后只在候选中排 Top-K
移动黑色 q 或拖动 nprobe 后,下方 coarse 排名、桶大小、候选距离和漏召回原因会立即重算。所有距离均为 squared L2,数值越小越近。
nearest = C4, d²=0.0250
这一阶段只用 centroid 距离决定开桶,不会直接返回文档。排名前 nprobe 的 cells 进入下一阶段。
= 0.0081 + 0.0169 = 0.0250
= 0.0121 + 0.0324 = 0.0445
每个数据库 vector 只属于一个 coarse cell,因此这些 lists 可以直接拼接;桶大小不一定相等,实际候选数是所选列表长度之和。
|C(q)| = 15 + 15 = 30
下面展示候选中最近的若干项。ANN rank 只在 C(q) 内产生;Exact rank 来自扫描全部 120 个 vectors,用于判断当前候选生成是否漏掉真近邻。
当前 Exact Top-5 全部位于已打开的 lists 中;候选内精排因此复现了 exact 结果。
= 5/5 = 100.0%
可直接运行: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()
HNSW vs IVF:没有绝对赢家,只有与工作负载匹配的结构
| 维度 | HNSW | IVF / IVF-Flat / IVF-PQ |
|---|---|---|
| 候选生成 | 在分层邻接图中导航 | 先选 coarse cells,再扫描 inverted lists |
| query-time knob | efSearch | nprobe |
| build-time knob | M、efConstruction | nlist、训练样本、PQ codebook |
| 内存 | 原始 vectors + graph edges,通常较高 | IVF-Flat 保留原向量;IVF-PQ 可显著压缩 |
| 更新 | 适合增量插入,但删除/压缩仍需维护 | 批量构建自然;分布漂移或桶失衡可能要 retrain/rebuild |
| 过滤 | 取决于实现,强 filter 可能破坏图遍历效率 | 可结合分区/list,但仍需防止过滤后候选不足 |
参数分成两类
- build-time:改变索引结构,需要重建或增量维护;影响构建时间、内存和可达到的召回上限。
- query-time:每次请求可以调节;扩大搜索预算通常提高 Recall,也增加延迟。
Recall@K:ANN 找回了 Exact Top-K 中的多少个近邻
HNSW 与 IVF 都通过少访问一些向量换取更低延迟,因此必须量化它们漏掉了多少真正的 metric 近邻。对同一个 query,在语料快照、embedding、归一化、metric、metadata filter 与 K 全部相同的前提下,Exact 全量扫描产生参考答案,ANN 只访问部分候选。Recall@K 回答一个非常具体的问题:Exact 认为应该出现的 K 个向量 ID,ANN 找回了几个?
① 先固定“同一场考试”:只有搜索方法可以不同
Exact 与 ANN 必须使用同一个 q、同一份 corpus/index snapshot、同一 embedding 模型与版本、同一 normalization、同一 Cosine/IP/L2 排序契约、同一 metadata filter、同一个 K,以及相同的距离并列处理规则。Exact 扫描所有合格向量,ANN 只改变候选生成方式。若这些条件不一致,两组 Top-K 的差异可能来自数据或 metric 漂移,不能归因于 ANN。
② 单条 query 的五步计算逻辑
第一步,Exact 对全部合格 vectors 打分并取 Top-K;Cosine/IP 从大到小,L2 从小到大。第二步,ANN 在搜索预算内访问部分 candidates 并返回 Top-K。第三步,把两个有序列表转换为唯一 ID 集合。第四步求交集并数出命中 h_K(q)。第五步除以 Exact 目标集合大小 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 补位。
④ 为什么分母是 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。
⑦ 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 本身可能不唯一。
实验五:搜索预算怎样把工作点沿 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 与目标硬件重新测量。
一条 query 从“开始搜索索引”到“返回 Top-K”的耗时,通常用 ms。越小越靠左、越快。若测的是完整 RAG,还需另计 embedding、网络、rerank 与 LLM。
上一张卡定义的 Exact-neighbor 找回率。越大越靠上、越准。一个参数设置要在同一批 held-out queries 上计算平均 Recall。
固定索引与参数 θ,在整批 queries 上得到一个延迟分位数和一个平均 Recall,二者组成工作点。它不是某一条 query,也不是单个 score。
一条 query 只有一个 latency;很多条 latency 排序后才有 percentile
在完全相同的配置下运行 |Q| 条 queries,得到 ℓ₁…ℓ|Q|,先从小到大排成 ℓ₍₁₎≤…≤ℓ₍|Q|₎。用于手算的 nearest-rank 定义是:
pₓ = 排序后的第 rank(pₓ) 个 latency
ceil 表示向上取整;ℓ₍ᵣ₎ 指排序后的第 r 个值,不是原始到达顺序中的第 r 条 query。统计库可能使用插值法而得到略有差异的数值,但百分位的解释不变。
每个 efSearch、nprobe 或其他参数组合 θ 都会产生一个点 (p95 latency, mean Recall@K)。理想方向是左上角:更快且更准。
如果 A 的 latency≤B 且 Recall≥B,并且至少一项严格更好,就说 A 支配 B;B 既更慢又不更准,没有选择价值。删除所有被支配点后,剩下的左上边界才叫 Pareto frontier。
拖动预算实际上是在选择另一套参数,从一个工作点换到另一个点。预算增大通常使点右上移动:更准但更慢;严格来说,只有实测并剔除被支配点后,才能说它位于真正的前沿。
| 设置 | p95 | Recall@10 | 判断 |
|---|---|---|---|
| A · 低预算 | 3 ms | 84% | 前沿 |
| B · 中低预算 | 5 ms | 91% | 前沿 |
| C · 中预算 | 8 ms | 96% | 前沿 · p95≤10 ms 时可选 |
| D · 另一配置 | 11 ms | 95% | 被 C 支配:更慢且更不准 |
| E · 高预算 | 15 ms | 98% | 前沿 |
| Exact | 75 ms | 100% | 最准确,但成本最高 |
B→C 多付 3 ms 换 5 个百分点 Recall;C→E 多付 7 ms 只换 2 个百分点,说明越接近 100% 往往边际收益越小。
nlist≈round(√N)=316
HNSW: efSearch=max(K, mapped budget)=37
IVF: nprobe=14/316
先用工作量代理看方向;真实前沿必须把横轴替换成实测 p95 ms
灰淡点在当前教学模型中被另一点同时做到“工作量更低且 Recall 更高”;橙线连接未被支配点。Exact 位于 (100% work, 100% Recall),超出当前 ANN 放大视图。真实 benchmark 可能得到完全不同的曲线与前沿。
下面比较 budget 4→5。横向代价用 estimated work proxy 表示,纵向收益用模型 Recall 的百分点变化表示;拖动滑杆时当前 H/I 大圆点会选择新的配置。
Recall: 79.7% → 83.4% (+3.7 pp)
Recall: 82.4% → 88.6% (+6.2 pp)
可直接运行:从 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()
ANN index ≠ Vector Database:图或倒排桶只是搜索内核
| 层 | 必须解决的问题 |
|---|---|
| Vector index | build、add、search、ANN parameters、distance metric |
| Record / metadata | chunk text、document_id、source、tenant、ACL、timestamps、filters |
| Lifecycle | upsert、delete / tombstone、compaction、rebuild、schema 与 embedding migration |
| Reliability | persistence、snapshot、replication、backup、sharding、observability |
| Serving | batch query、concurrency、cache、rate limit、multi-tenant isolation |
Embedding 版本迁移为什么危险
新旧模型产生的坐标不可直接混在同一空间。安全流程通常是:创建新 collection/index → 全量 re-embed → shadow query 对比 → 切换 alias → 保留回滚快照。
交给 No.16 的底层组件:一个可版本化、可评估的 search contract
索引快照必须带上的契约
{
"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 generation | chunk size / overlap 与语义边界 |
| HNSW / IVF 参数与 Recall@K | metadata filters、hybrid retrieval、rerank |
| latency / memory / rebuild 的索引权衡 | context budget、citation、answer evaluation |
正在检查登录状态与模型配置…