CH 18 / SOURCE EXAMPLE

ivfadc.py

ch18 向量检索系统/code/ch04_pq/ivfadc.py

这个示例讲什么

│ └── ivfadc.py §18.4.3 倒排 + 残差 PQ、n_probe / n_list 扫描

IVFADC:倒排文件 + 残差乘积量化(第18章 §18.4.3) 正文的思路:PQ 把距离计算压到 m 次查表,但还是要扫全库(O(N))。 IVFADC 再加一层粗聚类: 离线:粗 K-Means 得到 n_list 个中心;每个向量 y 归到最近的 c_i, 对它做残差编码 r = y − c_i(而不是直接编码 y), 倒排表 i 里存 (cluster id, PQ code)。 在线:1) 算查询到所有粗中心的距离; 2) 取最近的 n_probe 个倒排表; 3) 对每个表算残差查询 x − c_i 的 ADC 查找表; 4) 查表算近似距离,合并返回 Top-k。 距离恒等式(正文的推导): ‖x − (c_i + q(y − c_i))‖ =

上面来自章节 README 或源码自身的说明,不是自动验证的运行结果,也不能代替 PDF 的书中论断。讲解时请结合实际代码、输入输出与原书页码。

源码结构与阅读入口

  1. IVFADC · 第 49 行倒排 + 残差 PQ。residual=False 时退化成「倒排 + 直接 PQ」,用于对照。
  2. main · 第 100 行

完整源码 · 216 行

1"""2IVFADC:倒排文件 + 残差乘积量化(第18章 §18.4.3)34正文的思路:PQ 把距离计算压到 m 次查表,但还是要扫全库(O(N))。5IVFADC 再加一层粗聚类:67    离线:粗 K-Means 得到 n_list 个中心;每个向量 y 归到最近的 c_i,8          对它做残差编码 r = y − c_i(而不是直接编码 y),9          倒排表 i 里存 (cluster id, PQ code)。10    在线:1) 算查询到所有粗中心的距离;11          2) 取最近的 n_probe 个倒排表;12          3) 对每个表算残差查询 x − c_i 的 ADC 查找表;13          4) 查表算近似距离,合并返回 Top-k。1415距离恒等式(正文的推导):16    ‖x − (c_i + q(y − c_i))‖ = ‖(x − c_i) − q(y − c_i)‖17所以「减去粗中心」只是给查询与残差同时平移一个偏置,不改变算法结构。1819本脚本:201) 在 20000 条 64 维数据上建 IVFADC,核对倒排表规模;212) 扫 n_probe,画 Recall@10 / 候选数 / 查询耗时——正文的「调 n_probe 换22   Recall 与 Latency」;233) 对比「残差 PQ」与「直接 PQ 向量」在同一 n_probe 下的召回,验证残差编码24   确实降低了量化误差;254) 测一下 n_list 的影响。2627运行:python3 ch04_pq/ivfadc.py28"""2930from __future__ import annotations3132import pathlib33import sys34import time3536import numpy as np3738sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))39sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))4041from common import (  # noqa: E40242    knn_exact, make_queries, make_vectors, recall_at_k, sq_dists, table,43)44from product_quantization import PQ, kmeans  # noqa: E4024546SEED = 2026474849class IVFADC:50    """倒排 + 残差 PQ。residual=False 时退化成「倒排 + 直接 PQ」,用于对照。"""5152    def __init__(self, n_list: int = 64, m: int = 8, K: int = 256,53                 residual: bool = True, seed: int = SEED) -> None:54        self.n_list = n_list55        self.m = m56        self.K = K57        self.residual = residual58        self.seed = seed5960    def train(self, X: np.ndarray) -> None:61        self.centroids = kmeans(X, self.n_list, seed=self.seed)62        # sq_dists(base, queries) 返回 [len(queries), len(base)],这里 base 是粗中心63        assign = np.argmin(sq_dists(self.centroids, X), axis=1)   # [N]64        self.assign = assign65        self.pq = PQ(m=self.m, K=self.K, seed=self.seed)66        if self.residual:67            R = X - self.centroids[assign]        # 残差:粗聚类已消掉全局偏移68        else:69            R = X70        self.pq.train(R)71        codes = self.pq.encode(R)72        # 倒排表:列表 i 里是分配给中心 i 的向量编号73        self.postings = [np.where(assign == i)[0] for i in range(self.n_list)]74        self.codes = codes7576    def search(self, q: np.ndarray, k: int = 10, n_probe: int = 8):77        """返回 (Top-k 编号, 本次扫描的候选数)。"""78        d_c = sq_dists(self.centroids, q[None, :])[0]79        probes = np.argpartition(d_c, n_probe - 1)[:n_probe]80        probes = probes[np.argsort(d_c[probes])]      # 近的倒排表先查8182        cand_list, dist_list = [], []83        for i in probes:84            ids = self.postings[i]85            if len(ids) == 0:86                continue87            shift = self.centroids[i] if self.residual else np.zeros_like(q)88            L = self.pq.adc_lut(q - shift)            # 查询侧也减去同一个偏置89            cand_list.append(ids)90            dist_list.append(self.pq.adc_dists(L, self.codes[ids]))9192        cand = np.concatenate(cand_list)93        dist = np.concatenate(dist_list)94        kk = min(k, len(cand))95        part = np.argpartition(dist, kk - 1)[:kk]96        part = part[np.argsort(dist[part])]97        return cand[part], len(cand)9899100def main() -> None:101    print("IVFADC:倒排文件 + 残差乘积量化(§18.4.3)\n")102    rng = np.random.default_rng(SEED)103104    N, D = 20000, 64105    X, _ = make_vectors(n=N, dim=D, n_clusters=80, spread=0.45)106    Q = make_queries(X, n_queries=100)107    k = 10108    truth, _ = knn_exact(X, Q, k)109110    # ---------------- 1) 建索引 ----------------111    n_list, m, K = 256, 16, 256112    idx = IVFADC(n_list=n_list, m=m, K=K, residual=True, seed=SEED)113    t0 = time.perf_counter()114    idx.train(X)115    t_train = time.perf_counter() - t0116117    sizes = np.array([len(p) for p in idx.postings])118    print(f"① 建索引(N={N:,}, D={D}, n_list={n_list}, m={m}, K={K}):")119    rows = [120        ["粗聚类中心", f"{n_list}", f"{idx.centroids.nbytes / 1e3:.0f} KB",121         "k-means,O(n_list·N·D)"],122        ["PQ 码本", f"m×K×D* = {m}×{K}×{D // m}",123         f"{idx.pq.nbytes() / 1e3:.0f} KB", "训练在残差上"],124        ["倒排编码", f"{N:,} × {m} B", f"{N * m / 1e3:.0f} KB",125         "cluster id 省掉:同表内隐式已知"],126    ]127    table(["结构", "规模", "大小", "说明"], rows)128    print(f"\n  训练总耗时 {t_train:.1f} s。倒排表大小:"129          f"min {sizes.min()} / 中位 {int(np.median(sizes))} / max {sizes.max()}")130    print("  分布不均是因为合成数据的簇中心是随机高斯,粗聚类无法完全均分;")131    print("  工业实现会用 balanced k-means 或在建表后拆分超长表。\n")132133    # ---------------- 2) n_probe 扫描 ----------------134    print("② 扫 n_probe:Recall / 候选数 / 耗时的三角关系(残差 PQ):")135    rows = []136    for n_probe in (1, 2, 4, 8, 16, 32, 64, 128, 256):137        preds, cand_tot = [], 0138        t0 = time.perf_counter()139        for qi in range(len(Q)):140            p, nc = idx.search(Q[qi], k, n_probe)141            preds.append(p)142            cand_tot += nc143        dt = (time.perf_counter() - t0) / len(Q)144        rec = float(np.mean([len(set(p.tolist()) & set(t.tolist())) / k145                             for p, t in zip(preds, truth)]))146        rows.append([n_probe, f"{cand_tot / len(Q):.0f}",147                     f"{cand_tot / len(Q) / N:.2%}",148                     f"{rec:.3f}", f"{dt * 1e3:.2f} ms"])149    table(["n_probe", "平均候选数", "占全库比例", "Recall@10", "单查询耗时"], rows)150    rec_last = float(rows[-1][3])151    print("\n  正文的「n_probe 换 Recall 与 Latency」在这里能看到前半段:")152    print(f"  n_probe=1 只扫约 {rows[0][2]} 的库,召回 {rows[0][3]};")153    print("  加到 4 个表召回到 7 成,之后再加大几乎不再涨——Top-10 的近邻")154    print("  基本都在最近的几个表里,剩下的漏召回来自 PQ 量化误差")155    print(f"  (这里 n_probe 取到 {rows[-1][0]},召回上限约 {rec_last})。")156    print("  「召回随 n_probe 单调大涨」的前提是近邻分散在多个表里:")157    print("  n_list 越小、表越长,近邻越容易落在同一张表,曲线反而更平。")158    print("  下面 ④ 的 n_list 对比可以看到这一点。\n")159160    # ---------------- 3) 残差 vs 直接 PQ ----------------161    print("③ 残差编码 vs 直接编码(同 n_list、同编码长度、同 n_probe):")162    print("   (n_probe 取 4/8/16,n_list/m 与 ①② 一致)")163    idx_direct = IVFADC(n_list=n_list, m=m, K=K, residual=False, seed=SEED)164    idx_direct.train(X)165    rows = []166    for n_probe in (4, 8, 16):167        out = []168        for model in (idx_direct, idx):169            pr = []170            for qi in range(len(Q)):171                p, _ = model.search(Q[qi], k, n_probe)172                pr.append(p)173            out.append(float(np.mean([len(set(p.tolist()) & set(t.tolist())) / k174                                      for p, t in zip(pr, truth)])))175        # 两边各自的重建误差176        rows.append([n_probe, f"{out[0]:.3f}", f"{out[1]:.3f}",177                     f"{(out[1] - out[0]) / max(out[0], 1e-9):+.1%}"])178    table(["n_probe", "直接 PQ Recall", "残差 PQ Recall", "相对提升"], rows)179180    # 量化误差对比(只看被分到的那个粗中心上的残差)181    rec_direct = idx_direct.pq.reconstruct(idx_direct.codes)182    rec_res = idx.pq.reconstruct(idx.codes) + idx.centroids[idx.assign]183    err_d = float(((rec_direct - X) ** 2).sum(1).mean())184    err_r = float(((rec_res - X) ** 2).sum(1).mean())185    print(f"\n  重建 MSE:直接 PQ {err_d:.2f} → 残差 PQ {err_r:.2f}"186          f"(降低 {(1 - err_r / err_d):.0%})")187    print("  粗聚类先把「这个向量大概在哪」记下来,残差只描述局部细节,")188    print("  范数更小、分布更集中,同样的 K 个中心就能表示得更细。")189    print("  正文「残差通常范数更小」的说法在这里得到定量验证。\n")190191    # ---------------- 4) n_list 的影响 ----------------192    print("④ n_list 的影响(固定 n_probe=4、m=16,其余不变):")193    rows = []194    for nl in (16, 64, 256, 512):195        model = IVFADC(n_list=nl, m=m, K=K, residual=True, seed=SEED)196        model.train(X)197        pr, cand_tot = [], 0198        for qi in range(len(Q)):199            p, nc = model.search(Q[qi], k, 4)200            pr.append(p)201            cand_tot += nc202        rec = float(np.mean([len(set(p.tolist()) & set(t.tolist())) / k203                             for p, t in zip(pr, truth)]))204        rows.append([nl, f"{N / nl:.0f}", f"{cand_tot / len(Q):.0f}", f"{rec:.3f}"])205    table(["n_list", "平均表长", "平均候选数", "Recall@10"], rows)206    print("\n  n_list 是一个「表短 vs 跨表漏」的权衡:表越短,同样 probe 4 次")207    print("  扫的候选越少;但近邻落在未访问表里的概率也越高。本数据上召回先升后降,")208    print("  说明存在一个性价比最优点(这里是 256 附近),而不是越大越好。\n")209210    print("⑤ 一句话:IVF 负责「少扫」(粗聚类定向),PQ 负责「扫得快」")211    print("   (查表代替浮点距离),残差负责「压得准」。三者合起来才是")212    print("   工业界能扛十亿级的 IndexIVFPQ;OPQ 只是再给残差加一个旋转。")213214215if __name__ == "__main__":216    main()

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

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