谱哈希实战:从谱方法到二进制编码的ANN召回方案 先说一个我实际碰到过的场景一批短文本向量维度不算高128维左右但要支持五百万条数据的近邻召回。暴力算距离不可能单条查询全表扫描要算上亿次浮点乘加就算优化到极致也扛不住高并发倒排索引在稀疏文本上很好用换成稠密向量后基本失效。团队当时的候选方案里就有谱哈希Spectral Hashing——一种基于谱方法的二进制编码学习技术。它和大多数工程人员更熟悉的随机投影LSH局部敏感哈希不同不是靠随机映射碰运气而是从数据分布里学出一族编码函数让相似样本天然被编码成相近的比特串。我把完整流程复现、调参、部署后发现这套老方法在很多场景下依然非常能打。这篇文章会从问题背景、核心数学原理、工程实现、部署细节和踩坑记录五个方面完整过一遍。如果你正在做向量检索、近似最近邻搜索或者想理解后来各种深度哈希方法到底在解决什么问题这份笔记应该对你有用。1. 为什么还需要谱哈希从精确近邻到数据依赖的二进制编码1.1 精确近邻在高维空间里的困境高维空间里的近邻检索第一反应通常是暴力扫描。以128维float向量、五百万条数据为例一次查询要做约6.4亿次乘加。就算用上SIMD指令单机单核也很难在几十毫秒内返回结果。换个思路KD-Tree、Ball Tree这类树形索引在二维三维表现很好特征维度一高就退化得接近线性扫描。这不是工程能力问题是高维空间的几何特性决定的大多数样本之间的距离都趋向于某个狭窄区间所谓近邻变得很不稳定。这时候与其追求精确距离不如先做一轮快速召回把候选集从五百万缩小到几百个再进行精确重排。这个思路就是近似最近邻检索ANN。1.2 LSH 为什么不够用数据无关的局限提到ANN很多人会想到LSH。LSH的做法是用一组随机投影把相近的点以较大概率映射到同一个桶里。设计好合适的投影函数后LSH有非常漂亮的理论保证距离近的点碰撞概率高距离远的点碰撞概率低。它的优点是数据无关离线没有任何训练成本实现简单分布式也好做。但问题恰恰出在数据无关上。投影方向随机生成完全没有利用数据本身的分布结构。如果数据在某个方向上几乎没有方差随机投影落在那个方向的分量基本是纯噪声如果数据存在明显簇结构随机超平面又往往切在密度最高的地方把同一簇样本大量分裂。结果就是哈希桶利用率很低需要很长的码长才能获得理想召回率。码长一长存储和汉明距离计算不是大问题编码空间里有效区域却依然是稀疏的。1.3 谱哈希的基本思路与适用场景谱哈希想解决的是能不能让算法从训练数据中学习出一组哈希函数而不是靠随机生成。属于>import numpy as np def train_spectral_hash(X, num_bits64, pca_dim24, max_freq80, percentile2): # 1. PCA降维到 pca_dim mean X.mean(axis0) Xc X - mean cov np.cov(Xc, rowvarFalse) eigvals, eigvecs np.linalg.eigh(cov) order np.argsort(eigvals)[::-1][:pca_dim] proj eigvecs[:, order] Y Xc proj # 2. 估计每个主成分的取值区间用分位数而不是min/max low np.percentile(Y, percentile, axis0) high np.percentile(Y, 100 - percentile, axis0) ranges np.maximum(high - low, 1e-8) # 3. 每个维度生成低频候选特征函数按特征值排序 candidates [] for j in range(pca_dim): for m in range(1, max_freq 1): lam (np.pi * m / ranges[j]) ** 2 candidates.append((lam, j, m)) candidates.sort(keylambda t: t[0]) # 4. 简化版按维度均分保证所有主成分都有机会进入编码 per_dim max(1, num_bits // pca_dim) funcs [] for d in range(pca_dim): dim_cands [c for c in candidates if c[1] d][:per_dim] funcs.extend(dim_cands) if len(funcs) num_bits: used set(funcs) extra [c for c in candidates if c not in used][:num_bits - len(funcs)] funcs.extend(extra) funcs funcs[:num_bits] return mean, proj, low, ranges, funcs def encode_spectral_hash(X, model): mean, proj, low, ranges, funcs model Y (X - mean) proj bits [] for lam, j, m in funcs: val np.cos(np.pi * m * (Y[:, j] - low[j]) / ranges[j]) bits.append((val 0).astype(np.uint8)) B np.vstack(bits).T return np.packbits(B, axis1)注意这套代码里的特征函数是一维的没有做多维乘积组合工程上更接近按维度分配的近似做法。真要严格按原论文来需要枚举多维特征函数乘积后文会细说。3.2 PCA为什么必须做主方向近似独立性有些初学者会直接拿原始特征做区间估计和谱函数选择效果往往很差。原因是谱特征函数的可分离性质依赖各维度近似独立。PCA去相关之后各主成分之间不再有线性相关用独立的余弦波去拟合才合理。没有PCA各维度的波会产生复杂相关性后面比特不相关的目标就很难满足。主成分数量 $r$ 不需要很大。经验上16到32就够用具体取决于原始特征维度和数据规模。r取得太小信息丢得多取得太大每个主成分方向上的有效样本量变少区间估计会不稳定候选函数数量还会爆炸。3.3 一维特征函数族的选择频率越大特征值越大对某个主成分方向假设取值范围已经平移缩放到 $[0,1]$最自然的一维谱特征函数是$$ \phi_m(x) \cos(m\pi x) $$对应的特征值是 $(m\pi)^2$。这里取余弦而不是正弦是因为余弦波关于区间中心对称阈值化之后更容易满足比特平衡。这里有一个可以体会的直觉m越大函数在区间内震荡次数越多能区分的位置更细但m越大特征值越大在谱意义下越不优先——因为谱方法希望用尽可能低的频率表达尽可能多的结构。按特征值从小到大取函数等于在做一次自适应频率截断先用粗粒度低频波保证相似性保持再逐步加入中频波增加区分度。3.4 多维组合与按特征值取候选两种工程做法严格按原论文的做法是连乘组合。以二维为例特征函数形如$$ \psi(x, y) \cos(m\pi x) \cdot \cos(n\pi y) $$特征值是两部分特征值之和。这样低频组合会让不同维度都有机会进入编码不会出现所有bit都被第一个主成分占光的极端情况。工程上有两种落地方式第一种是上面代码里的按维度均分。简单、可控、不容易出现维度饥饿缺点是不是严格的特征值最优。第二种是真正枚举组合。可以用最小堆做低特征值优先扩展初始把每个维度的最低频函数放进堆取出当前特征值最小的组合后再把该组合在某一个维度上频率递增得到新组合压回堆重复直到取满k个函数。这个思路可以做到接近论文原版但实现复杂度高一些调参也要更小心。第一次复现时我更推荐先跑简化版。3.5 新样本编码只保存一份参数表训练完成后需要保存的东西非常少PCA均值、投影矩阵、每个主成分方向的区间起止值以及选中的函数列表。线上编码就是一次矩阵乘法加若干次余弦计算单条查询的开销几乎可以忽略。这套参数甚至可以序列化成一个配置文件几十KB的大小。相比深度哈希动辄上百MB的模型文件谱哈希在轻量级部署上的优势非常明显。4. 部署到近邻检索索引存储、查询流水线与超参数4.1 比特位存储与汉明距离计算五百万条64bit编码用uint64数组存储只需要4MB。相比之下128维float原始向量要占2.5GB左右。这是谱哈希让人无法拒绝的地方内存占用比原始向量小两个数量级同时还能支持快速的汉明距离扫描。查询时先把query编码成uint64再用CPU的popcount指令计算汉明距离。现代CPU对位运算的优化非常激进五百万条编码的暴力扫描可以在几十毫秒内跑完。如果还嫌慢可以把64bit拆成4段16bit每段建一个桶索引查询时只扫描落在相近桶里的候选速度能再提升一截。4.2 查询流水线召回、重排、再过滤哈希编码适合做召回层不适合做最终排序。我常用的流水线是query编码成64bit哈希码。做汉明距离topN召回N通常取50到100。在候选集上用原始向量计算真实余弦距离或内积。按真实距离排序再过滤掉明显不相关的样本。为什么必须重排因为谱哈希的目标是保持大致相似性不是精确距离。编码空间里汉明距离相近的点原始空间里大概率也相近但总会有人被挤压到错误的位置。加一层轻量重排性价比非常高。4.3 关键超参数的经验区间下面是我在多次复现中比较稳定的经验范围可以当初始值用参数含义经验范围备注num_bits哈希码位数32~128数据量越大需要的位数越多pca_dimPCA截断维度16~32不是越大越好max_freq每维最大频率40~100超过这个范围通常是纯噪声percentile区间分位数2~5用于抵抗离群点num_bits和pca_dim的比值也值得关注。我的经验是3到8左右比较合适。如果64个bit却只有8个主成分每个维度会分到8个bit意味着同一方向上要做8个不同频率的细分信息重复度高如果64个bit却有64个主成分每个维度只够做一次简单二分很多维度被浪费。4.4 我在一个内部场景中观察到的效果我处理过的某个内部短文本去重场景里输入是预训练embedding降到128维后的向量。用64bit谱哈希做召回汉明距离top100的精确率大约在0.5上下比随机LSH明显高一个台阶。部署后单次查询的P999从暴力方案的几十毫秒降到十几毫秒内存占用更是小得可以忽略。这种数字不具备通用参考价值毕竟不同数据集差异很大。但趋势是一致的谱哈希在embedding已经比较规整的场景下完全可以作为深度哈希的廉价替代品。5. 复现路上最值得记录的坑与调参经验5.1 min/max 区间估计被离群点毁掉的频谱最隐蔽的坑就是直接拿主成分方向上的min和max做区间估计。某个方向只要有少数几个离群样本区间会被拉得很宽。区间变宽之后所有余弦波的特征值整体变小看起来震荡次数不少实际区分度却严重下降因为大量样本被压在一小段区间内。我后来改用2%和98%分位数代替min/max召回率立刻回升。但要注意分位数截断之后区间外的样本仍然存在后面的边界问题也要一并处理。5.2 PCA截断维度与比特分配PCA截断维度调得太低原始信息大量丢失调得太高每个主成分方向上的样本量变少区间估计噪声变大。我见过有人把pca_dim设成和原始维度一样高结果编码质量反而下降。更隐蔽的问题是比特分配。如果不做按维度均分而是纯按特征值排序选函数很可能前64个函数全部落在第一个主成分上因为第一主成分方差大、区间宽、特征值天然偏小。这样后面的主成分完全没有参与编码码的冗余度很低。按维度均分虽然是近似方案实践上反而更稳。5.3 边界样本与周期延拓问题余弦波是周期函数对区间外的样本会做周期延拓。一个离群样本可能被折叠到某个低频区域和完全不相关的其他样本共享同样的编码。这是谱哈希的理论缺陷之一。工程上我的做法是编码前对主成分值做一次soft clip把超出区间一定比例的值拉到边界附近。这么做会损失一部分真实差异但能显著减少伪相似。最终的详细重排层会兜底召回阶段更看重稳定性而不是极致精度。5.4 比特不平衡自动丢弃与替换数据分布不均匀时有些余弦波阈值化后出现严重偏置比如某个bit在全库里有80%都是1。这种bit的信息量趋近于0还会让汉明距离被几个坏bit主导。我会在建索引后统计每个bit的均值如果绝对值超过0.15就从候选函数列表里取下一位替换。这是很简单但有效的校准手段。5.5 与 ITQ、随机LSH 的对比观察在本地复现时我在三类数据上做了相对对比预训练文本embedding、一些图像风格特征、纯随机高斯数据。统一的评价指标是64bit编码下的mAP100。结果量级如下不代表严格Benchmark结论方法文本embedding图像特征高斯随机数据随机LSH0.330.280.36谱哈希0.520.470.44PCAITQ0.560.530.58谱哈希在文本和图像上跟ITQ差距不大训练还快得多但在高斯随机数据上明显落后。这也符合预期高斯数据没有稳定的低维结构固定频率的余弦波很难表达复杂分布ITQ这类基于迭代量化误差最小化的方法会更稳。如果你的数据本身没有明显结构谱哈希不是最优选择。6. 从谱哈希到深度哈希这套思路为什么还没过时6.1 谱哈希给深度哈希留下的问题和资产后来大量深度哈希方法做的工作其实是在谱哈希的骨架上换一个更强的编码函数用神经网络替代固定余弦波。训练损失依然是相似样本嵌入距离小、不相似样本嵌入距离大额外加平衡正则、正交正则、量化误差正则。谱哈希里定义相似性目标—谱松弛—阈值化—可扩展编码的四个步骤至今是理解深度哈希论文最顺手的框架。但谱哈希也留了一个重要问题固定频率的解析函数表达能力有限数据分布一复杂低频余弦波就不够用了。这正是深度学习切入的点。神经网络端到端训练出的编码函数表达能力远超任何固定函数族。6.2 什么情况优先选谱哈希如果手上有GPU、数据量大、语义复杂深度哈希更值得投入。但也别急着什么都上深度方案。只是给向量检索加一层粗排或者数据是中文短文本、商品标题这类相对规整的embedding谱哈希依然非常划算。它不需要训练GPU模型不需要调学习率一个CPU加几行代码就能跑完整个索引流程。6.3 进一步扩展的方向这个方向还能继续往下挖。比如把固定余弦波换成更通用的核特征函数把谱哈希的编码当成深度模型的初始化或者在谱哈希编码后面接一个可学习的线性映射让编码更好地适配下游排序。我自己最近在尝试的方向是把谱哈希的输出用于乘积量化的比特分配让每个码字的位数由谱特征值自适应决定。最后分享一点个人体会。复现谱哈希最大的困难不在数学推导而在工程细节区间怎么估计、PCA截断取多少、比特不平衡怎么办这些论文里往往不会展开只有实际跑过一遍才会真正意识到它们的杀伤力。不要一上来就追求完整复现论文里的多维乘积枚举先用简化版把召回链路跑通再逐步加入频率组合、边界处理、比特校准这些细节。这套方法从提出到现在已经过去了很多年仍然被反复拿出来讨论本身就说明先用谱找方向再让数据定义编码这个思路远比我们想象的要耐嚼。