当搜索和推荐系统从百万级向海量向量数据扩展时,真正先撞上的瓶颈往往不是算法复杂度,而是内存、存储和成本。Pinterest 对 Manas 搜索平台的持续改造,展示了一条很有代表性的路径:从内存占用较高的 HNSW,逐步引入 Scalar Quantization、Product Quantization 和基于 SSD 的 SPANN 架构,在降低资源消耗的同时尽量保持召回率,并进一步为多向量模型准备基础设施。
这类演进的核心并不是简单地“换一个索引”,而是重新分配不同硬件的职责:内存保存更紧凑的路由信息,SSD 承载规模更大的向量数据,量化负责压缩表示,精排阶段再用更准确的向量恢复相关性。
为什么单纯扩大 HNSW 内存并不理想
HNSW 的优势很明确:查询路径短、延迟低、工程实现成熟,适合把大量向量放在内存中进行近似最近邻搜索。但随着向量数量、维度和副本数量增长,成本会变得明显:
- 图结构本身需要额外内存;
- 每个节点通常还要保存完整的浮点向量;
- 多分片、多副本和在线更新会放大内存开销;
- 当数据规模超过内存容量后,依赖随机磁盘访问会带来严重的尾延迟。
因此,扩大机器内存只能延后问题,而不能改变成本曲线。对于搜索和发现系统,还需要同时考虑召回率、P99 延迟、索引构建时间、SSD 读放大以及更新策略。
量化:把内存中的每一位都用在刀刃上
量化的基本思路是用更少的比特表示原本的浮点向量。
Scalar Quantization
Scalar Quantization(标量量化)通常独立处理每个维度,把浮点数映射为较低精度的整数,例如 INT8。它实现简单、压缩效果直接,也比较容易利用 SIMD 指令加速。代价是每个维度的精度都会下降,量化范围和校准数据的选择会直接影响召回率。
下面是一个可运行的简化示例。它使用固定范围把向量压缩为 8 位无符号整数,再解码并进行近似打分。这个例子不是生产级 ANN 索引,但可以帮助理解量化误差从哪里产生。
运行方式:将代码保存为 scalar_quantization_demo.py,执行 python scalar_quantization_demo.py。生产环境中应使用真实数据分布校准范围,并配合候选集精排。
from math import sqrt
def normalize(vector):
norm = sqrt(sum(value * value for value in vector))
return [value / norm for value in vector] if norm else vector[:]
def quantize_int8(vector, min_value=-1.0, max_value=1.0):
"""将每个浮点维度映射到 0~255。"""
scale = 255.0 / (max_value - min_value)
return [
max(0, min(255, round((value - min_value) * scale)))
for value in vector
]
def dequantize_int8(vector, min_value=-1.0, max_value=1.0):
scale = (max_value - min_value) / 255.0
return [value * scale + min_value for value in vector]
def dot(left, right):
return sum(a * b for a, b in zip(left, right))
# 假设这些向量已经经过模型生成并归一化。
documents = {
"pin_1": normalize([0.82, 0.12, 0.35, 0.08]),
"pin_2": normalize([0.10, 0.91, 0.05, 0.25]),
"pin_3": normalize([0.71, 0.20, 0.38, 0.11]),
}
query = normalize([0.80, 0.15, 0.30, 0.10])
compressed = {
doc_id: quantize_int8(vector)
for doc_id, vector in documents.items()
}
approximate_scores = []
for doc_id, encoded in compressed.items():
decoded = dequantize_int8(encoded)
approximate_scores.append((dot(query, decoded), doc_id))
for score, doc_id in sorted(approximate_scores, reverse=True):
print(f"{doc_id}: approximate_score={score:.6f}")
在真实系统中,通常不会只依赖解码后的量化向量完成全部排序。更常见的做法是先用压缩表示快速找出较大的候选集,再使用原始向量或更高精度表示重新计算分数。这种“近似召回 + 精确重排”可以在成本和相关性之间取得更稳定的平衡。
Product Quantization
Product Quantization(乘积量化)会把一个高维向量拆成多个子空间,分别对每个子空间学习码本,再用较短的编码表示完整向量。相比独立的标量量化,PQ 能更充分地利用维度之间的局部结构,在极高压缩比下仍有机会保留更好的距离信息。
但 PQ 的工程复杂度也更高:需要训练码本,在线查询要构造查找表,并且要根据数据分布定期评估码本是否失效。数据分布变化、模型升级和不同业务场景混合,都可能让原来的量化参数不再合适。
从全内存图索引到 SSD 友好的 SPANN
量化解决了“一个向量占多少内存”的问题,但海量数据仍然可能无法完全放入内存。SPANN 类架构的关键方向,是把内存和 SSD 组合成分层存储:
- 内存保存聚类中心、路由结构或其他较小的导航信息;
- 查询先在内存中定位可能相关的分区;
- 再从 SSD 读取这些分区中的压缩向量或候选数据;
- 最后进行距离计算和精排。
这使系统可以利用 SSD 的容量,同时尽量减少随机读取次数。设计时需要重点关注几个问题:
- 分区大小:分区过小会增加元数据和读取次数,过大则会带来更多无关候选;
- 探测数量:探测更多分区通常能提升召回,但会增加 SSD I/O 和 CPU 计算;
- 数据布局:相关向量应尽量连续存储,降低读放大;
- 缓存策略:高频分区可以放入内存或本地缓存;
- 尾延迟:平均延迟不够,必须单独监控 P95、P99 和 SSD 队列深度。
换句话说,SPANN 的性能不只由 ANN 算法决定,还取决于索引布局、批量读取、缓存命中率和硬件调度。把一个内存算法直接搬到 SSD 上,通常不会自动得到理想结果。
多向量模型带来的下一轮变化
单向量表示便于索引,但它可能把复杂对象压缩得过于激进。例如,一张内容丰富的图片、一篇长文本或一个包含多个兴趣主题的用户画像,使用一个向量表达时,局部语义容易被平均掉。
多向量模型会为同一对象生成多个向量,让查询可以与对象的不同局部表示分别匹配。这有机会改善细粒度相关性,但也会放大基础设施压力:
- 同一个对象对应更多索引条目;
- 候选合并和去重逻辑更复杂;
- 内存、SSD 容量和 I/O 数量随向量数增长;
- 需要定义对象级别的最终打分,而不是只排序单个向量。
因此,从单向量到多向量不能只修改模型服务。索引分片、量化参数、缓存、过滤、候选合并和精排都需要一起设计。量化与 SSD 分层在这里变得更重要,因为多向量会进一步放大存储规模。
落地时如何做取舍
可以把一次迁移拆成几个可验证的阶段,而不是直接替换线上索引:
- 建立基线:记录当前 HNSW 的 Recall@K、QPS、P50/P95/P99 延迟、内存和索引构建时间;
- 单独评估量化误差:在相同候选集上比较 FP32、Scalar Quantization 和 PQ 的召回差异;
- 引入候选精排:为量化索引保留一个更高精度的重排路径;
- 模拟 SSD 行为:测量不同分区大小、探测数量和缓存策略下的尾延迟;
- 灰度多向量:先在离线评估和小流量场景验证对象级合并逻辑;
- 设置回滚条件:当召回、P99 延迟或 SSD 错误率超过阈值时,自动回到旧索引。
一个实用的评估表至少应包含以下指标:
| 维度 | 需要观察的指标 |
|---|---|
| 相关性 | Recall@K、NDCG、人工评估或业务转化指标 |
| 延迟 | P50、P95、P99、最慢分片 |
| 资源 | 内存、SSD 容量、读带宽、CPU 利用率 |
| 运维 | 构建耗时、更新延迟、失败重试、回滚时间 |
| 成本 | 每百万向量的存储与计算成本 |
结语:让索引适配数据规模,而不是反过来
Manas 的技术演进反映了大规模向量搜索的一个普遍规律:当数据量持续增长时,单一索引和单一存储介质很难同时满足成本、延迟与召回率要求。量化降低表示成本,SPANN 利用 SSD 扩展容量,候选精排修正压缩误差,多向量模型则提升匹配的细粒度。
对工程团队而言,最稳妥的路线不是追求某个算法名词,而是围绕业务指标做分层设计:先测量,再压缩;先验证召回,再优化 I/O;最后才把多向量模型引入线上。只要每一步都有可比较的基线和明确的回滚条件,索引架构就能随着数据规模演进,而不必被内存容量牵着走。