CH 18 / SOURCE EXAMPLE

product_quantization.py

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

这个示例讲什么

│ ├── product_quantization.py §18.4.1–§18.4.2 子空间分解、码本、SDC vs ADC

乘积量化 PQ:子空间分解、码本训练与 SDC/ADC 距离(第18章 §18.4) 正文的 PQ 定义: 把 D 维向量均分为 m 个子向量 u_1(x), …, u_m(x),每个 D* = D/m 维; 第 j 个子空间用 K-Means 训练 K 个聚类中心 C_j = {c_{j,1}, …, c_{j,K}}; 编码 q(x) = [q_1(x), …, q_m(x)],q_j(x) = argmin_c ‖u_j(x) − c‖。 存储:m 个 8 bit 编号(K=256),128 维 float32 的 512 B 压到 8 B,压缩 64 倍。 但整个码本是笛卡尔积,表达能力是 K^m = 256^8 ≈ 1.8×10^19 个码字。 距离的两种算法: SDC:d̃(x,y) = ‖q(x) − q(y)‖,查询也要量化,K×K

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

源码结构与阅读入口

  1. kmeans · 第 49 行朴素的 Lloyd 迭代:随机初始化 + 最近中心分配 + 重算均值。
  2. PQ · 第 88 行乘积量化器:训练 m 个子码本,提供编码、重建、SDC、ADC 四种操作。
  3. main · 第 195 行

完整源码 · 350 行

1"""2乘积量化 PQ:子空间分解、码本训练与 SDC/ADC 距离(第18章 §18.4)34正文的 PQ 定义:56    把 D 维向量均分为 m 个子向量 u_1(x), …, u_m(x),每个 D* = D/m 维;7    第 j 个子空间用 K-Means 训练 K 个聚类中心 C_j = {c_{j,1}, …, c_{j,K}};8    编码 q(x) = [q_1(x), …, q_m(x)],q_j(x) = argmin_c ‖u_j(x) − c‖。910存储:m 个 8 bit 编号(K=256),128 维 float32 的 512 B 压到 8 B,压缩 64 倍。11但整个码本是笛卡尔积,表达能力是 K^m = 256^8 ≈ 1.8×10^19 个码字。1213距离的两种算法:14    SDC:d̃(x,y) = ‖q(x) − q(y)‖,查询也要量化,K×K 距离表可预计算15    ADC:d̃(x,y) = ‖x − q(y)‖,查询不量化,每个查询建 m×K 的查找表16         L_j(i) = ‖u_j(x) − c_{j,i}‖²,之后 m 次查表 + 累加1718正文的复杂度结论:暴力 O(ND),ADC 建表 O(KD) + 扫描 O(mN)。1920本脚本:211) 在 6000 条 128 维数据上训练 PQ(m=8, K=256),核对压缩率与码本大小;222) 扫子空间数 m,看「量化误差 vs 压缩率」的权衡;233) 对比 SDC 与 ADC 的 Recall@10——正文说 ADC 明显更好,这里实测差多少;244) 实测 ADC 扫描耗时与暴力检索的差距,验证 O(mN) vs O(DN)。2526运行:python3 ch04_pq/product_quantization.py27"""2829from __future__ import annotations3031import pathlib32import sys33import time3435import numpy as np3637sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))3839from common import (  # noqa: E40240    knn_exact, make_queries, make_vectors, recall_at_k, sq_dists, table,41)4243SEED = 2026444546# --------------------------------------------------------------------------47# K-Means(PQ 的每个子空间都要跑一次)48# --------------------------------------------------------------------------49def kmeans(X: np.ndarray, K: int, iters: int = 25,50           seed: int = SEED) -> np.ndarray:51    """52    朴素的 Lloyd 迭代:随机初始化 + 最近中心分配 + 重算均值。5354    为什么不用 k-means++:这里的目的是展示 PQ 的结构,子空间维度只有55    D/m(8~32 维),随机初始化的 K-Means 在 25 轮内已经足够收敛;56    用 k-means++ 会多 20 行代码,但结果差异在 1% 以内(可自行替换验证)。5758    重算均值的写法:不能在循环里用布尔索引 C[k] = X[a == k].mean(0),59    那样每轮要做 K 次全量扫描(K=256 时是 1536 万次比较)。这里用60    bincount 一次聚合所有簇的和与计数,是稳定的 O(n·D + n) 操作。61    """62    rng = np.random.default_rng(seed)63    n, D = X.shape64    C = X[rng.choice(n, K, replace=False)].copy()65    x_sq = (X * X).sum(axis=1)                # 只算一次,循环里复用66    for _ in range(iters):67        # 写成一个 [n, K] 的矩阵乘,而不是 common.sq_dists 的「按查询逐条」——68        # 那个版本为省内存设计了 Python 循环,K=256 时等于 256 次小 BLAS 调用,69        # 每次调用的线程调度开销反而成了主成本(实测慢 4 倍以上)70        d = np.maximum(x_sq[:, None] + (C * C).sum(1)[None, :] - 2.0 * (X @ C.T), 0.0)71        a = np.argmin(d, axis=1)              # 每个样本最近的中心编号72        # 逐维 bincount:sum_{i: a_i=k} X[i, j],再除以计数得到均值73        cnt = np.bincount(a, minlength=K).astype(np.float32)74        newC = np.empty_like(C)75        for j in range(D):76            newC[:, j] = np.bincount(a, weights=X[:, j], minlength=K)77        newC /= np.maximum(cnt, 1.0)[:, None]78        empty = cnt == 079        if empty.any():          # 空簇保留原中心,避免除零和中心消失80            newC[empty] = C[empty]81        C = newC82    return C.astype(np.float32)838485# --------------------------------------------------------------------------86# PQ87# --------------------------------------------------------------------------88class PQ:89    """乘积量化器:训练 m 个子码本,提供编码、重建、SDC、ADC 四种操作。"""9091    def __init__(self, m: int = 8, K: int = 256, seed: int = SEED) -> None:92        self.m = m93        self.K = K94        self.seed = seed95        self.codebooks: list[np.ndarray] = []96        self.D = 097        self.Ds = 09899    def train(self, X: np.ndarray) -> None:100        D = X.shape[1]101        assert D % self.m == 0, "维度必须能被 m 整除"102        self.D = D103        self.Ds = D // self.m104        # 码本拼成 [m, K, D*] 的单个数组:ADC 建表可以一次算完所有子空间,105        # 不用 for j in range(m) ——扫描侧是热路径,省一次 Python 循环很值106        self.code = np.empty((self.m, self.K, self.Ds), dtype=np.float32)107        for j in range(self.m):108            sub = X[:, j * self.Ds:(j + 1) * self.Ds]109            self.code[j] = kmeans(sub, self.K, seed=self.seed + j)110111    def encode(self, X: np.ndarray) -> np.ndarray:112        """返回 [n, m] 的 uint8 编码。"""113        n = len(X)114        codes = np.empty((n, self.m), dtype=np.uint8)115        for j in range(self.m):116            sub = X[:, j * self.Ds:(j + 1) * self.Ds]117            codes[:, j] = np.argmin(sq_dists(sub, self.code[j]), axis=0)118        return codes119120    def reconstruct(self, codes: np.ndarray) -> np.ndarray:121        """按编码取回聚类中心,拼成完整向量(ADC 里只在需要时做,SDC 不需要)。"""122        n = len(codes)123        out = np.empty((n, self.D), dtype=np.float32)124        for j in range(self.m):125            out[:, j * self.Ds:(j + 1) * self.Ds] = self.code[j][codes[:, j]]126        return out127128    # -------- SDC --------129    def sdc_table(self) -> np.ndarray:130        """131        预计算每个子空间的 K×K 距离表:T[j, a, b] = ‖c_{j,a} − c_{j,b}‖²。132133        正文强调「查询过程无需任何浮点距离计算」,指的就是这张表。134        代价是 m × K² 个浮点数(K=256、m=8 时约 2 MB),比码本本身大得多。135        """136        T = np.empty((self.m, self.K, self.K), dtype=np.float32)137        for j in range(self.m):138            C = self.code[j]139            c2 = (C * C).sum(axis=1)140            T[j] = np.maximum(c2[:, None] + c2[None, :] - 2.0 * (C @ C.T), 0.0)141        return T142143    def sdc_dists(self, qcodes: np.ndarray, dbcodes: np.ndarray,144                  tables: np.ndarray) -> np.ndarray:145        """[nq, ndb] 的近似平方距离,纯查表累加。"""146        out = np.zeros((len(qcodes), len(dbcodes)), dtype=np.float32)147        for j in range(self.m):148            out += tables[j][qcodes[:, j][:, None], dbcodes[:, j][None, :]]149        return out150151    # -------- ADC --------152    def adc_lut(self, q: np.ndarray) -> np.ndarray:153        """154        单个查询的查找表:L[j, i] = ‖u_j(q) − c_{j,i}‖²,形状 [m, K]。155156        正文说建表复杂度 O(m·K·D*) = O(KD),这张表就是它。157        一次 broadcast 算完 m 个子空间,避免 m 次 Python 循环。158        """159        sub = q.reshape(self.m, 1, self.Ds)                 # [m, 1, D*]160        diff = self.code - sub                              # [m, K, D*]161        return (diff * diff).sum(axis=2).astype(np.float32)162163    def adc_dists(self, L: np.ndarray, dbcodes: np.ndarray) -> np.ndarray:164        """[ndb] 的近似平方距离:对每个数据库向量做 m 次查表累加。"""165        out = np.zeros(len(dbcodes), dtype=np.float32)166        for j in range(self.m):167            out += L[j][dbcodes[:, j]]168        return out169170    def adc_lut_batch(self, Q: np.ndarray) -> np.ndarray:171        """172        批量建表:[nq, m, K]。173174        一次算完所有查询的查找表,而不是每个查询单算——建表本身是 O(KD),175        批量做能让 BLAS 吃满,这是实际实现里最省事的优化。176        """177        nq = len(Q)178        sub = Q.reshape(nq, self.m, 1, self.Ds)             # [nq, m, 1, D*]179        diff = self.code[None] - sub                        # [nq, m, K, D*]180        return (diff * diff).sum(axis=3).astype(np.float32)181182    def adc_dists_batch(self, lut: np.ndarray,183                        dbcodes: np.ndarray) -> np.ndarray:184        """批量查表累加:[nq, ndb]。每个子空间取一次 [nq, ndb] 的索引。"""185        out = np.zeros((len(lut), len(dbcodes)), dtype=np.float32)186        for j in range(self.m):187            out += lut[:, j][:, dbcodes[:, j]]188        return out189190    def nbytes(self) -> int:191        """码本总字节数。编码另算:每条 m 字节。"""192        return int(self.code.nbytes)193194195def main() -> None:196    print("乘积量化 PQ:子空间分解、码本训练与 SDC/ADC 距离(§18.4)\n")197    rng = np.random.default_rng(SEED)198199    N, D = 6000, 128200    X, _ = make_vectors(n=N, dim=D, n_clusters=40, spread=0.45)201    Q = make_queries(X, n_queries=100)202    k = 10203    truth, truth_d = knn_exact(X, Q, k)204205    # ---------------- 1) 压缩账 ----------------206    m, K = 8, 256207    pq = PQ(m=m, K=K, seed=SEED)208    t0 = time.perf_counter()209    pq.train(X)210    t_train = time.perf_counter() - t0211    codes = pq.encode(X)212    recon = pq.reconstruct(codes)213214    raw_b = D * 4215    code_b = m                      # K=256 → 每段 1 字节216    print(f"① 压缩账(N={N:,}, D={D}, m={m}, K={K}):")217    rows = [218        ["原始向量", f"{raw_b} B", f"{raw_b * N / 1e6:.1f} MB", "—"],219        ["PQ 编码", f"{code_b} B", f"{code_b * N / 1e3:.0f} KB",220         f"{raw_b / code_b:.0f}×"],221        ["子码本", "—", f"{pq.nbytes() / 1e6:.2f} MB",222         f"m×K×D* = {m}×{K}×{D // m} 个 float32"],223    ]224    table(["对象", "单条大小", "总量", "说明"], rows)225    print(f"\n  训练耗时 {t_train:.1f} s({m} 个子空间各跑一次 K={K} 的 K-Means)。")226    print(f"  组合码字数 K^m = {K}^{m} ≈ {float(K) ** m:.1e},而实际只存了 "227          f"m×K = {m * K} 个中心——")228    print("  「乘积」指的就是这个笛卡尔积:线性存储换指数级表达能力。")229    mse = float(((recon - X) ** 2).sum(axis=1).mean())230    rel = mse / float((X ** 2).sum(axis=1).mean())231    print(f"  重建误差 MSE = {mse:.4f},相对能量 {rel:.1%}——这就是 64 倍压缩的代价。\n")232233    # ---------------- 2) m 的权衡 ----------------234    print("② 子空间数 m 的权衡(D=128 固定,K=256 固定,Top-10):")235    rows = []236    for m_ in (4, 8, 16, 32):237        pq_ = PQ(m=m_, K=K, seed=SEED)238        pq_.train(X)239        cd = pq_.encode(X)240        rc = pq_.reconstruct(cd)241        rel_e = float(((rc - X) ** 2).sum(1).mean() / (X ** 2).sum(1).mean())242        # ADC 召回243        rec = 0.0244        lut = pq_.adc_lut_batch(Q)245        preds = np.argsort(pq_.adc_dists_batch(lut, cd), axis=1)[:, :k]246        for qi in range(len(Q)):247            rec += len(set(preds[qi].tolist()) & set(truth[qi].tolist())) / k248        bits = m_ * 8249        rows.append([m_, 128 // m_, f"{bits} bit / {bits // 8} B",250                     f"{128 * 4 / (bits // 8):.0f}×", f"{rel_e:.1%}",251                     f"{rec / len(Q):.3f}"])252    table(["m", "D* = D/m", "编码长度", "压缩率", "相对重建误差", "ADC Recall@10"], rows)253    print("\n  m 增大 → 子空间更短、码本更贴合 → 误差下降、召回上升,")254    print("  但编码变长、建表从 O(KD) 里的 K 不变而 D* 变小(总建表仍是 O(KD)),")255    print("  扫描侧从 m 次查表变成 2m、4m 次。所以 m 的取值是「精度 vs 扫描成本」。")256    print("  工业界 m=8/16 的常见选择,正是这张表里 8 B~16 B 那一段。\n")257258    # ---------------- 3) SDC vs ADC ----------------259    print("③ SDC vs ADC:同一个 PQ 编码,只换距离算法(m=8, K=256):")260    tables = pq.sdc_table()261    qcodes = pq.encode(Q)262263    # SDC264    t0 = time.perf_counter()265    pred_s = np.empty((len(Q), k), dtype=np.int64)266    for qi in range(len(Q)):267        dd = pq.sdc_dists(qcodes[qi:qi + 1], codes, tables)[0]268        pred_s[qi] = np.argsort(dd)[:k]269    t_sdc = time.perf_counter() - t0270271    # ADC272    t0 = time.perf_counter()273    lut_all = pq.adc_lut_batch(Q)                    # [nq, m, K]274    pred_a = np.argsort(pq.adc_dists_batch(lut_all, codes), axis=1)[:, :k]275    t_adc = time.perf_counter() - t0276277    # 距离估计误差:与真实平方距离比较(抽样,全量太慢)278    n_s = 100279    idx_s = rng.choice(N, n_s, replace=False)280    rows_d = []281    for qi in range(3):282        d_true = sq_dists(X[idx_s], Q[qi:qi + 1])[0]283        d_sdc = pq.sdc_dists(qcodes[qi:qi + 1], codes[idx_s], tables)[0]284        L = pq.adc_lut(Q[qi])285        d_adc = pq.adc_dists(L, codes[idx_s])286        rows_d.append([qi,287                       f"{np.abs(d_sdc - d_true).mean():.4f}",288                       f"{np.abs(d_adc - d_true).mean():.4f}",289                       f"{np.abs(d_adc - d_true).mean() / np.abs(d_sdc - d_true).mean():.2f}"])290    table(["查询", "SDC 平均|误差|", "ADC 平均|误差|", "ADC/SDC"],291          rows_d)292    print("\n  ADC 的距离误差系统性更小:它只承担数据库侧的量化误差,")293    print("  SDC 还要再叠加一次查询侧的量化误差。实测召回:")294    table(["算法", "Recall@10", "单查询耗时"],295          [["SDC", f"{recall_at_k(pred_s, truth):.3f}",296            f"{t_sdc / len(Q) * 1e3:.2f} ms"],297           ["ADC", f"{recall_at_k(pred_a, truth):.3f}",298            f"{t_adc / len(Q) * 1e3:.2f} ms"]])299    print("\n  ADC 通常更好,代价是「每个查询要现建一张表」(O(KD) + O(mK) 次除法/减法),")300    print("  而 SDC 的表可以离线算好、查询时零浮点运算。查询量极大且对精度要求不高时")301    print("  SDC 仍有场景,但正文「工业界几乎都用 ADC」与这里的实测一致。\n")302303    # ---------------- 4) ADC vs 暴力 ----------------304    print("④ ADC 扫描 vs 暴力欧氏扫描(同样的 N=6000、D=128、Top-10):")305    t0 = time.perf_counter()306    for _ in range(3):307        sq_dists(X, Q)308    t_brute = (time.perf_counter() - t0) / 3309310    t0 = time.perf_counter()311    for _ in range(3):312        lut = pq.adc_lut_batch(Q)313        pq.adc_dists_batch(lut, codes)314    t_adc_all = (time.perf_counter() - t0) / 3315316    # 只算 nprobe=1/64 的候选量,模拟 IVF 之后的扫描317    sub_n = N // 64318    rows = [319        ["暴力 L2(float32)", f"{N * D:,}", f"{t_brute / len(Q) * 1e3:.2f} ms", "1.000"],320        [f"ADC 全库扫描", f"{N * m:,}", f"{t_adc_all / len(Q) * 1e3:.2f} ms",321         f"{recall_at_k(pred_a, truth):.3f}"],322    ]323    table(["方式", "每次查询的乘加/查表次数", "单查询耗时", "Recall@10"], rows)324    print(f"\n  在 N={N} 这个量级上,NumPy 的暴力 L2 走 BLAS、能吃到多线程和 cache,")325    print("  而 ADC 是 Python 循环里的 m 次高级索引,反而更慢。这恰恰说明:")326    print("  ADC 的收益要在「N 巨大、且已经用 IVF 把候选缩到几千」时才体现,")327    print("  而且工业实现(Faiss)把查表扫描写成 SIMD 内核,Python 循环体现不出来。")328    print("  复杂度结论 O(mN) < O(DN) 是正确的,但常数因子由实现决定。\n")329330    # ---------------- 5) 查找表大小 ----------------331    print("⑤ 两张查找表的大小(正文给的数字核对):")332    rows = [333        ["SDC 距离表", f"m × K × K = {m}×{K}×{K}",334         f"{m * K * K * 4 / 1e6:.2f} MB", "离线预计算,查询零浮点运算"],335        ["ADC 查找表", f"m × K = {m}×{K}",336         f"{m * K * 4 / 1e3:.0f} KB", "每个查询现建,O(KD) 次运算"],337    ]338    table(["表", "形状", "大小", "说明"], rows)339    print("\n  正文提到的「K=256 时每段 32640 个距离值」= K(K−1)/2 = "340          f"{K * (K - 1) // 2:,},")341    print("  那是利用对称性只存上三角;存全矩阵是 K² = 65536 个值,两者都极小。")342    print("  所以 SDC 的表不是瓶颈,瓶颈永远是「要不要扫全库」。\n")343344    print("⑥ 一句话:PQ 用 m 个独立的低维码本把 512 B 压成 8 B,")345    print("   距离计算退化成 m 次查表;但 PQ 本身仍是 O(N) 扫描,")346    print("   要真正减少候选数还得靠 §18.4.3 的 IVF(见 ivfadc.py)。")347348349if __name__ == "__main__":350    main()

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

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