CH 18 / SOURCE EXAMPLE
hnsw.py
ch18 向量检索系统/code/ch05_hnsw/hnsw.py
这个示例讲什么
│ └── hnsw.py 层数采样、构图、efSearch / M / efC、选边启发式
HNSW:分层可导航小世界图的构建与检索(第18章 §18.5) 正文的结构定义: 每个节点随机取最高层 l_i = ⌊−m_L · ln(u)⌋, u ~ U(0,1) 于是 P(L ≥ l) = e^(−l/m_L),层数越高节点越稀疏;第 0 层包含全部节点。 插入一个节点 x: 1) 从最高层入口做贪心下降,到第 l 层; 2) 在 l … 0 每层用 efConstruction 的 Best-First 找候选; 3) Neighbor Selection Heuristic 选边:候选按距离升序,只有当 d(x, v_i) ≤ d(v_i, s) ∀ s ∈ S(已选邻居) 才保留——保证边朝向不同方向,而不是全扎堆在同一个簇; 4) 与选中的邻居建立双向连接,邻居超度数时再用同一
上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。
源码结构与阅读入口
HNSW· 第 54 行极简 HNSW:每层的邻接表用 dict[int, np.ndarray] 存(只对进入该层的节点建表)。recall_of· 第 238 行返回 (Recall@k, 平均距离计算次数, 单查询毫秒)。main· 第 253 行
1"""2HNSW:分层可导航小世界图的构建与检索(第18章 §18.5)34正文的结构定义:5 每个节点随机取最高层 l_i = ⌊−m_L · ln(u)⌋, u ~ U(0,1)6 于是 P(L ≥ l) = e^(−l/m_L),层数越高节点越稀疏;第 0 层包含全部节点。78插入一个节点 x:9 1) 从最高层入口做贪心下降,到第 l 层;10 2) 在 l … 0 每层用 efConstruction 的 Best-First 找候选;11 3) Neighbor Selection Heuristic 选边:候选按距离升序,只有当12 d(x, v_i) ≤ d(v_i, s) ∀ s ∈ S(已选邻居)13 才保留——保证边朝向不同方向,而不是全扎堆在同一个簇;14 4) 与选中的邻居建立双向连接,邻居超度数时再用同一启发式裁剪。1516查询:17 高层贪心下降(每层只留 1 个当前最优点);18 第 0 层用 efSearch 的 Best-First,两个优先队列(候选 / 结果),19 候选队列最优点已劣于结果队列最差点时停止。2021数据的选取(很重要):22 一开始用 30 个紧簇、16 维的数据,M=16 时召回在任何 efSearch 下都是23 1.000——图几乎全连通,看不出任何权衡。换成 64 维近均匀分布后,24 近邻不再集中在一小片区域,图导航的难度才接近真实 Embedding 检索的25 场景(工业上 128~960 维、百万级库正是这种几何)。2627本脚本:281) 验证层数分布 P(L ≥ l) = e^(−l/m_L);292) 在 6000 条 64 维数据上构图,统计度数、层节点数、构图耗时;303) 扫 efSearch,画 Recall@10 / 访问距离次数 / 延迟,复现正文「efSearch 换召回」;314) 对比 M 与 efConstruction 的影响;325) 对比 Neighbor Selection Heuristic 与「直接取最近 M 个」。3334运行:python3 ch05_hnsw/hnsw.py35"""3637from __future__ import annotations3839import heapq40import math41import pathlib42import sys43import time4445import numpy as np4647sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))4849from common import knn_exact, make_queries, make_vectors, table # noqa: E4025051SEED = 2026525354class HNSW:55 """56 极简 HNSW:每层的邻接表用 dict[int, np.ndarray] 存(只对进入该层的节点建表)。5758 刻意不引入删除、不引入多线程/加锁,因为正文这一节讲的是结构与搜索过程,59 这些工程细节会掩盖算法本身。60 """6162 def __init__(self, m: int = 16, ef_construction: int = 100,63 m_L: float | None = None, seed: int = SEED) -> None:64 self.M = m65 self.M0 = 2 * m # 第 0 层允许 2M 条边(hnswlib 的约定)66 self.efC = ef_construction67 self.m_L = m_L if m_L is not None else 1.0 / math.log(m)68 self.rng = np.random.default_rng(seed)69 self.vectors: list[np.ndarray] = []70 self.nbrs: list[dict[int, np.ndarray]] = [] # nbrs[id][level] = 邻居数组71 self.level: list[int] = []72 self.entry = -173 self.max_level = -174 self.dist_calls = 0 # 统计:累计距离计算次数7576 # ---------------------------------------------------------------- 建图77 def _sample_level(self) -> int:78 """l = ⌊−m_L ln u⌋;u 取 (0,1],避免 log(0)。"""79 u = 1.0 - self.rng.random()80 return int(-self.m_L * math.log(u))8182 def _d(self, a: int, b: int) -> float:83 self.dist_calls += 184 d = self.vectors[a] - self.vectors[b]85 return float(d @ d)8687 def _dvec(self, q: np.ndarray, a: int) -> float:88 self.dist_calls += 189 d = self.vectors[a] - q90 return float(d @ d)9192 def _greedy(self, q: np.ndarray, start: int, level: int) -> int:93 """高层贪心:沿着更近的邻居一路走,直到本层没有更近的邻居。"""94 cur = start95 cur_d = self._dvec(q, cur)96 improved = True97 while improved:98 improved = False99 for nb in self.nbrs[cur].get(level, ()):100 dn = self._dvec(q, int(nb))101 if dn < cur_d:102 cur, cur_d = int(nb), dn103 improved = True104 return cur105106 def _search_layer(self, q: np.ndarray, entries: list[int], ef: int,107 level: int) -> list[tuple[float, int]]:108 """109 单层 Best-First,返回按距离升序的 (距离, 编号),长度 ≤ ef。110111 两个堆:112 cand —— 极小堆,下一步要扩展的候选;113 result—— 极大堆(存负距离),当前最好的 ef 个节点。114 终止条件:候选堆的最小距离 > 结果堆的最大距离。115 """116 visited = set(entries)117 cand = [(self._dvec(q, e), e) for e in entries]118 heapq.heapify(cand)119 result = [(-d, e) for d, e in cand]120 heapq.heapify(result)121 while len(result) > ef:122 heapq.heappop(result)123124 while cand:125 d_c, c = heapq.heappop(cand)126 if len(result) >= ef and d_c > -result[0][0]:127 break # 最近的候选都比结果堆最差还远 → 停128 for nb in self.nbrs[c].get(level, ()):129 nb = int(nb)130 if nb in visited:131 continue132 visited.add(nb)133 d_n = self._dvec(q, nb)134 if len(result) < ef or d_n < -result[0][0]:135 heapq.heappush(cand, (d_n, nb))136 heapq.heappush(result, (-d_n, nb))137 if len(result) > ef:138 heapq.heappop(result)139 out = [(-d, i) for d, i in result]140 out.sort()141 return out142143 def _select(self, cands: list[tuple[float, int]], M: int) -> list[int]:144 """145 正文的 Neighbor Selection Heuristic:146147 候选按 d(base, v) 升序;若 v 到每个已选邻居 s 的距离都 ≥ d(base, v),148 说明 v 提供了新的方向,保留;否则丢弃。149150 直觉:若 d(v, s) < d(base, v),则 base→s→v 比直达更短,这条直达边151 对导航的边际价值小;删掉它,度数就能留给真正朝向新方向的邻居。152153 代价是每次筛选都要在候选之间算距离,所以构图比「取最近 M 个」慢。154 """155 chosen: list[int] = []156 for dv, v in cands:157 ok = True158 for s in chosen:159 if self._d(v, s) < dv:160 ok = False161 break162 if ok:163 chosen.append(v)164 if len(chosen) >= M:165 break166 return chosen167168 def _connect(self, node: int, chosen: list[int], level: int) -> None:169 """双向连接 + 超度数裁剪。"""170 limit = self.M0 if level == 0 else self.M171 self.nbrs[node][level] = np.array(chosen, dtype=np.int32)172 for nb in chosen:173 arr = self.nbrs[nb][level]174 if node in arr:175 continue176 arr = np.append(arr, node)177 if len(arr) > limit:178 # 超度数:对现有邻居再跑一次启发式,保留 limit 条179 cands = sorted((self._d(nb, int(x)), int(x)) for x in arr)180 arr = np.array(self._select(cands, limit), dtype=np.int32)181 self.nbrs[nb][level] = arr182183 def add(self, x: np.ndarray) -> None:184 idx = len(self.vectors)185 self.vectors.append(x)186 lvl = self._sample_level()187 self.level.append(lvl)188 self.nbrs.append({lv: np.zeros(0, dtype=np.int32) for lv in range(lvl + 1)})189190 if self.entry < 0: # 第一个点直接做入口191 self.entry, self.max_level = idx, lvl192 return193194 ep = self.entry195 # 1) 从最高层贪心下降到 lvl+1196 for lv in range(self.max_level, lvl, -1):197 if ep in self.nbrs and lv in self.nbrs[ep]:198 ep = self._greedy(x, ep, lv)199 # 2) 从 min(lvl, max_level) 到 0 逐层搜索并连边200 for lv in range(min(lvl, self.max_level), -1, -1):201 found = self._search_layer(x, [ep], self.efC, lv)202 cands = [(self._dvec(x, i), i) for _, i in found]203 chosen = self._select(cands, self.M0 if lv == 0 else self.M)204 self._connect(idx, chosen, lv)205 ep = found[0][1] if found else ep206 if lvl > self.max_level:207 self.entry, self.max_level = idx, lvl208209 def build(self, X: np.ndarray) -> None:210 for x in X:211 self.add(x)212213 # ---------------------------------------------------------------- 查询214 def search(self, q: np.ndarray, k: int = 10, ef_search: int = 50215 ) -> tuple[np.ndarray, int]:216 """返回 (Top-k 编号, 本次查询的距离计算次数)。"""217 start_calls = self.dist_calls218 ep = self.entry219 for lv in range(self.max_level, 0, -1):220 ep = self._greedy(q, ep, lv)221 found = self._search_layer(q, [ep], max(ef_search, k), 0)222 ids = np.array([i for _, i in found[:k]], dtype=np.int64)223 return ids, self.dist_calls - start_calls224225 # ---------------------------------------------------------------- 统计226 def level_counts(self) -> dict[int, int]:227 out: dict[int, int] = {}228 for lv in self.level:229 for l in range(lv + 1):230 out[l] = out.get(l, 0) + 1231 return dict(sorted(out.items()))232233 def degrees(self) -> tuple[float, int]:234 degs = [len(v) for lv in self.nbrs for v in lv.values()] # 各层汇总235 return float(np.mean(degs)), int(np.max(degs))236237238def recall_of(model: HNSW, Q: np.ndarray, truth: np.ndarray,239 k: int, ef: int) -> tuple[float, float, float]:240 """返回 (Recall@k, 平均距离计算次数, 单查询毫秒)。"""241 preds, calls, t = [], 0, 0.0242 for q in Q:243 t0 = time.perf_counter()244 ids, c = model.search(q, k, ef)245 t += time.perf_counter() - t0246 preds.append(ids)247 calls += c248 rec = float(np.mean([len(set(p.tolist()) & set(t_.tolist())) / k249 for p, t_ in zip(preds, truth)]))250 return rec, calls / len(Q), t / len(Q) * 1e3251252253def main() -> None:254 print("HNSW:分层可导航小世界图的构建与检索(§18.5)\n")255256 # ---------------- 1) 层数分布 ----------------257 print("① 层数采样 l = ⌊−m_L ln u⌋,m_L = 1/ln(M):")258 rows = []259 for M in (8, 16, 32):260 m_L = 1.0 / math.log(M)261 h = HNSW(m=M, m_L=m_L, seed=SEED)262 n_samp = 200_000263 lv = np.array([h._sample_level() for _ in range(n_samp)])264 for l in range(1, 5):265 emp = float((lv >= l).mean())266 theo = math.exp(-l / m_L)267 rows.append([M, f"{m_L:.3f}", l, f"{emp:.4f}", f"{theo:.4f}"])268 table(["M", "m_L", "l", "实测 P(L≥l)", "理论 e^(−l/m_L)"], rows)269 print("\n 实测与指数分布吻合。m_L 越小,高层越稀:M=8 时第 2 层只有 1.5%,")270 print(" M=32 时只有 0.1%。M 大 → 每层节点多、图更密,导航路径更短但内存更大。\n")271272 # ---------------- 2) 构图 ----------------273 N, D = 6000, 64274 # n_clusters=1 即近均匀的 64 维球状分布:近邻不像紧簇数据那样扎堆,275 # 这是让「efSearch 换召回」显形的关键(见文件头的说明)。276 X, _ = make_vectors(n=N, dim=D, n_clusters=1, spread=1.0)277 Q = make_queries(X, n_queries=100, jitter=0.5)278 k = 10279 truth, _ = knn_exact(X, Q, k)280281 M, efC = 8, 40282 g = HNSW(m=M, ef_construction=efC, seed=SEED)283 t0 = time.perf_counter()284 g.build(X)285 t_build = time.perf_counter() - t0286 calls_build = g.dist_calls287288 counts = g.level_counts()289 avg_deg, max_deg = g.degrees()290 print(f"② 构图(N={N}, D={D}, M={M}, efConstruction={efC}):")291 print(f" 耗时 {t_build:.1f} s,距离计算 {calls_build:,} 次"292 f"(平均每点 {calls_build / N:,.0f} 次,暴力两两要 {N * (N - 1) // 2:,} 次)")293 print(f" 层节点数:{counts}")294 print(f" 平均度数 {avg_deg:.1f},最大度数 {max_deg}"295 f"(第 0 层上限 M0 = 2M = {2 * M},高层上限 M = {M})")296 print(f" 层节点数按约 1/M 的几何比例递减:{counts[0]} → {counts.get(1, 0)}"297 f" → {counts.get(2, 0)} → {counts.get(3, 0)} → {counts.get(4, 0)},")298 print(" 与正文「越高越稀」的描述一致。构图的总距离计算量是")299 print(f" {calls_build / N:,.0f} 次/点,约为暴力两两的 "300 f"{calls_build / (N * (N - 1) / 2):.0%}——每层只扫邻域,所以是 O(N log N),")301 print(" 不是 O(N²)。插入顺序会影响图质量:这里按数据顺序插入,")302 print(" 工业实现通常先打乱或按随机顺序插入。\n")303304 # ---------------- 3) efSearch 扫描 ----------------305 print("③ 扫 efSearch:召回 / 距离计算次数 / 延迟(M=8, efC=40):")306 rows, vals = [], []307 for ef in (10, 20, 40, 80, 160):308 rec, calls, ms = recall_of(g, Q, truth, k, ef)309 vals.append((ef, rec, calls, ms))310 rows.append([ef, f"{rec:.3f}", f"{calls:.0f}", f"{ms:.2f} ms"])311 table(["efSearch", "Recall@10", "平均距离计算", "单查询耗时"], rows)312 print("\n efSearch 从 10 加到 160,召回几乎单调升、距离计算次数同步涨——")313 print(" 这就是正文「用 efSearch 在线平衡召回与延迟」。注意 efSearch<k 会被")314 print(" 自动抬到 k,否则结果队列装不下 Top-k;还有一点是实现细节:")315 print(" Python 每次浮点运算的开销远大于 C++,所以这里的绝对毫秒只能看相对趋势。")316 print(f" 对照:全库暴力每次要算 {N} 次距离,efSearch=80 时只有 "317 f"{vals[3][2]:.0f} 次,约为全库的 {vals[3][2] / N:.1%}。\n")318319 # ---------------- 4) M / efConstruction ----------------320 print("④ M 与 efConstruction 的影响(efSearch=80):")321 rows, note = [], {}322 for M_ in (8, 16, 32):323 for efC_ in (40, 100):324 gg = HNSW(m=M_, ef_construction=efC_, seed=SEED)325 t0 = time.perf_counter()326 gg.build(X)327 tb = time.perf_counter() - t0328 rec, _, _ = recall_of(gg, Q, truth, k, 80)329 d, _ = gg.degrees()330 rows.append([M_, efC_, f"{rec:.3f}", f"{d:.1f}", f"{tb:.1f} s"])331 note[(M_, efC_)] = rec332 table(["M", "efConstruction", "Recall@10", "平均度数", "构图耗时"], rows)333 print("\n 两列分开读:M 决定图的容量上限(度数、内存、可达到的召回),")334 print(" efConstruction 决定建图时「找得够不够仔细」,主要影响构图时间。")335 print(f" M=8→16(efC=40)召回 {note[(8, 40)]:.3f}→{note[(16, 40)]:.3f},")336 print(f" 提升明显;efC 40→100 在同一 M 下只多 "337 f"{note[(16, 100)] - note[(16, 40)]:+.3f},却让构图时间接近翻倍。")338 print(" 这与正文的调参顺序一致:先定 M,再调 efConstruction,")339 print(" 线上最后用 efSearch 做召回/延迟的细调。\n")340341 # ---------------- 5) 启发式 vs 最近 M 个 ----------------342 print("⑤ Neighbor Selection Heuristic 的作用:对比「取最近 M 个」建图")343 print(" (同样 M=8、efC=40,只换选边规则,两档 efSearch 都看)")344345 class NearestM(HNSW):346 def _select(self, cands, M):347 return [v for _, v in cands[:M]]348349 built = {}350 for name, cls in (("启发式选边", HNSW), ("最近 M 个", NearestM)):351 gg = cls(m=M, ef_construction=efC, seed=SEED)352 t0 = time.perf_counter()353 gg.build(X)354 built[name] = (gg, time.perf_counter() - t0)355356 rows = []357 for ef in (10, 40, 80):358 rec_h, _, _ = recall_of(built["启发式选边"][0], Q, truth, k, ef)359 rec_n, _, _ = recall_of(built["最近 M 个"][0], Q, truth, k, ef)360 rows.append([ef, f"{rec_h:.3f}", f"{rec_n:.3f}",361 f"{(rec_h - rec_n) / max(rec_n, 1e-9):+.0%}"])362 table(["efSearch", "启发式 Recall@10", "最近 M 个 Recall@10", "相对提升"], rows)363364 th, tn = built["启发式选边"][1], built["最近 M 个"][1]365 print(f"\n 构图耗时:{th:.1f} s vs {tn:.1f} s(+{th / tn - 1:.0%})——")366 print(" 启发式每次选边要在候选之间算距离,慢是必然的,这是用建图时间买召回。")367 print(" 差距在每一档 efSearch 都存在(9%~16% 的相对提升),说明它不是")368 print(" 「搜索参数调一调就能补上」的问题,而是图本身的结构差异:")369 print(" 最近 M 个邻居必然落在同一片局部区域,图会被切成一个个「团」,")370 print(" 查询从入口下降后一旦落错团就再也走不出去;启发式拒绝「已能被中间点")371 print(" 到达」的候选,反而保留了指向远处的边,让图保持连通。")372 print(" 大库、高维、簇结构不明显的数据上,这个结构差异通常比这里更显著。\n")373374 print("⑥ 一句话:HNSW = 跳表的层 + NSW 的贪心 + 启发式选边。")375 print(" 高层「少而远」负责导航,第 0 层「多而近」负责精搜,")376 print(" efSearch 是线上唯一要动的旋钮。")377378379if __name__ == "__main__":380 main()如何用于学习或编写新代码
- 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
- 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
- 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
- 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。