第 18-19 篇用 float32 向量做 HNSW 搜索。512 维 float32 = 2KB/向量。1 万个 chunk 占 20 MB,尚可接受。但向量数增长到百万级时,内存开销达到 GB 级别——单机装不下,或者装得下但挤占了其他组件的内存预算。

本篇用量化减小向量体积,用缓存和批处理降低 embedding 推理的调用成本。

向量存储的成本账

内存占用

512 维 float32 向量的存储开销:

向量数 原始向量 含 HNSW 图 备注
10K 20 MB ~30 MB 教学语料
100K 200 MB ~300 MB 中等站点
1M 2 GB ~3 GB 大型站点
10M 20 GB ~30 GB 超出单机内存

HNSW 图的边(每节点 M 个邻居 × int32 node ID)增加约 50% 的额外开销。

推理成本

每次查询需要调用 embedding 服务编码查询文本。每次文档写入需要编码 chunk 文本。

操作 延迟(GPU) 延迟(CPU)
编码 1 条文本 ~5 ms ~50 ms
编码 100 条(批量) ~50 ms ~2 s
编码 10K 条(批量) ~5 s ~200 s

CPU 推理慢 10 倍以上。索引构建时间和查询延迟都受影响。

标量量化

原理

将每维从 float32(4 bytes)压缩为 int8(1 byte)或更少。

int8 量化将每维的浮点值映射到 [-128, 127] 整数范围:

1
2
3
4
5
6
7
8
9
byte quantize(float value, float min, float max) {
float normalized = (value - min) / (max - min); // [0, 1]
return (byte) Math.round(normalized * 255 - 128); // [-128, 127]
}

float dequantize(byte quantized, float min, float max) {
float normalized = (quantized + 128) / 255.0f;
return normalized * (max - min) + min;
}

需要保存每维的 min/max 值(2 × float32 × D = 4KB for 512 维),用于反量化。

压缩比

格式 每维字节 512 维向量 压缩比
float32 4 2048 B 1:1
float16 2 1024 B 2:1
int8 1 512 B 4:1
int4 0.5 256 B 8:1

int8 量化 1M 向量:从 2 GB 减到 512 MB。

Recall 影响

int8 量化的精度损失通常很小:

1
2
3
4
5
6
7
8
9
void measureQuantizationImpact(float[][] originalVecs, float[] queryVec, int topK) {
byte[][] quantizedVecs = quantizeAll(originalVecs);

List<Integer> exactTopK = exactSearch(queryVec, originalVecs, topK);
List<Integer> quantizedTopK = quantizedSearch(queryVec, quantizedVecs, topK);

double recall = annRecall(quantizedTopK, exactTopK);
System.out.printf("int8 量化 Recall@%d = %.4f\n", topK, recall);
}

预期:int8 量化 Recall@10 > 0.98——绝大多数查询的 Top-10 不受影响。

Lucene 的标量量化

Lucene 10.x 支持在 HNSW 索引中使用标量量化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Codec codec = new Lucene100Codec() {
@Override
public KnnVectorsFormat getKnnVectorsFormatForField(String field) {
return new Lucene99HnswScalarQuantizedVectorsFormat(
16, // M
200, // efConstruction
1, // numMergeWorkers
7, // quantization bits
false, // compress
null, // confidenceInterval
null // mergeExec
);
}
};

IndexWriterConfig config = new IndexWriterConfig(analyzer);
config.setCodec(codec);

Lucene 内部自动处理量化和反量化:搜索时用量化向量做粗筛,用原始向量对 Top-K 候选做精确重排。

乘积量化

原理

乘积量化(Product Quantization, PQ)的压缩比远高于标量量化:

  1. 将 D 维向量分为 M 个子空间(如 512 维分为 64 个 8 维子空间)
  2. 对每个子空间做 K-means 聚类,得到 K 个聚类中心(codebook)
  3. 每个子向量用最近聚类中心的 index 表示(K=256 时 index 用 1 byte)
1
2
3
原始向量 (512 维 float32) = 2048 bytes
PQ 编码 (64 子空间 × 1 byte index) = 64 bytes
压缩比: 32:1

距离计算

PQ 编码后,两个向量的距离用查表法计算:

  1. 预计算查询向量与每个 codebook 的距离表(M × K 个距离值)
  2. 对每个数据库向量,查表累加 M 个子空间的距离
1
2
3
4
5
6
7
8
9
10
11
12
13
float pqDistance(float[] queryVec, byte[] pqCode, float[][][] codebooks) {
float dist = 0;
int subDim = queryVec.length / codebooks.length;
for (int m = 0; m < codebooks.length; m++) {
int centroidIdx = pqCode[m] & 0xFF;
float[] centroid = codebooks[m][centroidIdx];
for (int d = 0; d < subDim; d++) {
float diff = queryVec[m * subDim + d] - centroid[d];
dist += diff * diff;
}
}
return dist;
}

PQ 的适用场景

PQ 的压缩比高但精度损失大——适合粗筛场景:

  1. 用 PQ 向量做粗筛,选出 Top-1000 候选
  2. 用原始 float32 向量对 Top-1000 重新计算精确距离
  3. 取精确距离的 Top-10

这要求原始向量仍然可访问(存在磁盘上),只是不全部放内存。

保留原始向量精排

两阶段向量搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
List<DocScore> twoStageVectorSearch(float[] queryVec, int topK) {
// 第一阶段:用量化向量 + HNSW 粗筛
int candidateK = topK * 10; // 多召回一些
List<Integer> candidates = hnswSearchQuantized(queryVec, candidateK);

// 第二阶段:加载原始向量,精确重排
PriorityQueue<DocScore> heap = new PriorityQueue<>(
Comparator.comparingDouble(DocScore::score));
for (int docId : candidates) {
float[] originalVec = loadOriginalVector(docId);
float score = dotProduct(queryVec, originalVec);
heap.offer(new DocScore(String.valueOf(docId), score));
if (heap.size() > topK) heap.poll();
}

return heap.stream()
.sorted(Comparator.comparingDouble(DocScore::score).reversed())
.toList();
}

Lucene 的量化 HNSW 实现已经内置了这个两阶段流程。

缓存

Query Embedding 缓存

相同查询文本不需要重复编码。用 LRU 缓存保存最近的查询向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class QueryEmbeddingCache {
private final LinkedHashMap<String, float[]> cache;
private final int maxSize;
private final String modelVersion;

QueryEmbeddingCache(int maxSize, String modelVersion) {
this.maxSize = maxSize;
this.modelVersion = modelVersion;
this.cache = new LinkedHashMap<>(maxSize, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, float[]> eldest) {
return size() > QueryEmbeddingCache.this.maxSize;
}
};
}

float[] getOrEmbed(String queryText, EmbeddingClient client) {
String key = queryText.strip().toLowerCase();
synchronized (cache) {
float[] cached = cache.get(key);
if (cached != null) return cached;
}

float[] vec = client.embed(List.of(queryText), "query")[0];
synchronized (cache) {
cache.put(key, vec);
}
return vec;
}

void invalidateAll() {
synchronized (cache) {
cache.clear();
}
}
}

缓存失效条件:

  • 模型版本变更——旧向量在新空间中无效
  • 缓存满时 LRU 淘汰

缓存不跨版本

1
2
3
4
5
6
7
float[] getOrEmbed(String queryText, EmbeddingClient client) {
if (!client.modelVersion().equals(this.modelVersion)) {
invalidateAll();
this.modelVersion = client.modelVersion();
}
// ...
}

模型升级后缓存中的旧向量必须清空——新旧模型的向量空间不兼容。

批处理

索引时批量编码

逐条调用 embedding 服务效率低。批量发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void indexDocumentsWithBatching(List<SearchDocument> docs, int batchSize) {
List<Chunk> allChunks = new ArrayList<>();
for (SearchDocument doc : docs) {
allChunks.addAll(chunkDocument(doc, MAX_CHUNK_CHARS));
}

for (int i = 0; i < allChunks.size(); i += batchSize) {
List<Chunk> batch = allChunks.subList(i,
Math.min(i + batchSize, allChunks.size()));
List<String> texts = batch.stream().map(Chunk::text).toList();

float[][] vectors = embeddingClient.embed(texts, "document");

for (int j = 0; j < batch.size(); j++) {
indexChunkWithVector(batch.get(j), normalize(vectors[j]));
}
}
}

batchSize = 64 或 128 是常见选择。过大的 batch 可能超出模型服务的内存限制。

验证

场景 操作 预期
int8 量化 recall 对比精确扫描 Recall@10 > 0.98
磁盘/内存节省 比较索引大小 int8 约为 float32 的 1/4
质量影响 评测集 nDCG@10 下降 < 0.02
缓存命中 重复相同查询 第二次无 embedding 调用
缓存失效 切换模型版本 缓存清空,重新编码
批处理效率 1000 chunk 编码 批量比逐条快 5-10 倍

当前局限

  • PQ 需要训练 codebook——小数据量下聚类质量差
  • Lucene 10.x 的量化选项有限——不支持 PQ,只支持标量量化
  • 缓存只在单进程内——分布式场景需要 Redis 等外部缓存
  • 批处理的 batch size 需要根据 GPU 内存调整

练习

  1. 对所有 chunk 向量做 int8 量化,比较量化前后的 Recall@10
  2. 测量量化后的索引文件大小,计算实际压缩比
  3. 实现 query embedding 缓存,测量缓存命中率
  4. 将 batch size 从 1 调到 128,记录编码 1000 个 chunk 的总时间
  5. 在评测集上比较 float32 索引和 int8 索引的 nDCG@10

延伸阅读

  • Jégou, Douze & Schmid, “Product Quantization for Nearest Neighbor Search”, IEEE TPAMI 2011
  • Apache Lucene: Lucene99HnswScalarQuantizedVectorsFormat 源码
  • Google ScaNN: Efficient Vector Similarity Search