第 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); return (byte) Math.round(normalized * 255 - 128); }
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, 200, 1, 7, false, null, null ); } };
IndexWriterConfig config = new IndexWriterConfig(analyzer); config.setCodec(codec);
|
Lucene 内部自动处理量化和反量化:搜索时用量化向量做粗筛,用原始向量对 Top-K 候选做精确重排。
乘积量化
原理
乘积量化(Product Quantization, PQ)的压缩比远高于标量量化:
- 将 D 维向量分为 M 个子空间(如 512 维分为 64 个 8 维子空间)
- 对每个子空间做 K-means 聚类,得到 K 个聚类中心(codebook)
- 每个子向量用最近聚类中心的 index 表示(K=256 时 index 用 1 byte)
1 2 3
| 原始向量 (512 维 float32) = 2048 bytes PQ 编码 (64 子空间 × 1 byte index) = 64 bytes 压缩比: 32:1
|
距离计算
PQ 编码后,两个向量的距离用查表法计算:
- 预计算查询向量与每个 codebook 的距离表(M × K 个距离值)
- 对每个数据库向量,查表累加 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 的压缩比高但精度损失大——适合粗筛场景:
- 用 PQ 向量做粗筛,选出 Top-1000 候选
- 用原始 float32 向量对 Top-1000 重新计算精确距离
- 取精确距离的 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) { 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 内存调整
练习
- 对所有 chunk 向量做 int8 量化,比较量化前后的 Recall@10
- 测量量化后的索引文件大小,计算实际压缩比
- 实现 query embedding 缓存,测量缓存命中率
- 将 batch size 从 1 调到 128,记录编码 1000 个 chunk 的总时间
- 在评测集上比较 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