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 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. HNSW · 第 54 行极简 HNSW:每层的邻接表用 dict[int, np.ndarray] 存(只对进入该层的节点建表)。
  2. recall_of · 第 238 行返回 (Recall@k, 平均距离计算次数, 单查询毫秒)。
  3. main · 第 253 行

完整源码 · 380 行

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()

如何用于学习或编写新代码

  1. 先看章节 README 的依赖和运行成本,定位这个例子的输入、输出及关键函数。
  2. 在原书 PDF 中找到对应概念的具体页;代码演示与书中论断分开标注。
  3. 只在依赖和耗时可控时运行单个示例,修改一个参数并记录真实输出与失败情形。
  4. 参考实现新代码时说明复用、改动、测试与尚未完成的生产依赖;教学示例并非生产系统。