embedding 模型会升级。Qwen3-Embedding-0.6B 今天够用,明天可能被更大或更准的版本替代。新模型产生的向量与旧模型不在同一空间——余弦相似度失去意义。
本篇解决一个工程问题:在不中断服务的前提下,用新模型重建整个向量索引,验证质量,原子切换,并在出问题时回滚。
为什么不能直接替换模型
向量空间不兼容
不同 embedding 模型(即使同一系列的不同版本)学到的向量空间不同。用模型 A 编码的文档向量和用模型 B 编码的查询向量做余弦相似度,结果无意义。
1 2 3 4
| 模型 A 空间: "搜索引擎" → [0.12, -0.34, 0.56, ...] 模型 B 空间: "搜索引擎" → [0.78, 0.11, -0.22, ...]
cosine(A_doc, B_query) = 无意义的数字
|
混用两个版本的向量等于在两套不同的坐标系之间算距离。
必须全量重建
局部替换不可行。如果只用新模型编码新文档,索引里就同时存在两个空间的向量。查询时无论用哪个模型编码查询,都只能正确匹配一半文档。
唯一正确的做法:用新模型对全部文档重新编码,构建全新索引,然后一次性切换。
Embedding 版本元数据
版本信息结构
每个向量索引绑定一组元数据,记录生成它的模型信息:
1 2 3 4 5 6 7
| record EmbeddingVersionInfo( String modelName, // "Qwen3-Embedding-0.6B" String modelVersion, // "v1.0" int dimension, // 512 String normalization, // "L2" long builtAt // Unix timestamp ) {}
|
持久化
将版本信息写入索引目录下的元数据文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| void saveVersionInfo(Path indexDir, EmbeddingVersionInfo info) throws IOException { Path metaFile = indexDir.resolve("embedding-version.json"); String json = """ { "modelName": "%s", "modelVersion": "%s", "dimension": %d, "normalization": "%s", "builtAt": %d } """.formatted(info.modelName(), info.modelVersion(), info.dimension(), info.normalization(), info.builtAt()); Files.writeString(metaFile, json); }
EmbeddingVersionInfo loadVersionInfo(Path indexDir) throws IOException { Path metaFile = indexDir.resolve("embedding-version.json"); }
|
启动时校验
应用启动时检查当前模型配置与索引元数据是否匹配:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void validateIndex(Path indexDir, EmbeddingClient currentClient) { EmbeddingVersionInfo stored = loadVersionInfo(indexDir);
if (!stored.modelName().equals(currentClient.modelName()) || !stored.modelVersion().equals(currentClient.modelVersion())) { throw new IllegalStateException( "索引 embedding 版本 (%s/%s) 与当前模型 (%s/%s) 不匹配,需要重建索引" .formatted(stored.modelName(), stored.modelVersion(), currentClient.modelName(), currentClient.modelVersion())); }
if (stored.dimension() != currentClient.dimension()) { throw new IllegalStateException( "索引维度 (%d) 与当前模型维度 (%d) 不匹配" .formatted(stored.dimension(), currentClient.dimension())); } }
|
启动失败好过静默返回错误结果。
离线重建流水线
整体流程
1 2 3
| 旧索引(模型 A)──读取全部文档──→ 批量重新编码(模型 B)──→ 构建新索引 ──→ 验证 ──→ 原子切换 ↓ 回滚(如果验证失败)
|
读取旧索引中的文档
重建不需要回到原始数据源。从当前索引中读出所有已索引文档的文本:
1 2 3 4 5 6 7 8 9 10 11 12 13
| List<StoredDocument> readAllDocuments(IndexReader reader) { List<StoredDocument> docs = new ArrayList<>(); for (int i = 0; i < reader.maxDoc(); i++) { Document doc = reader.storedFields().document(i); String id = doc.get("id"); String title = doc.get("title"); String body = doc.get("body"); if (id != null && body != null) { docs.add(new StoredDocument(id, title, body)); } } return docs; }
|
批量重新编码
用新模型对所有文档重新编码,复用第 22 篇的批处理逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| Map<String, float[][]> reencodeAll( List<StoredDocument> docs, EmbeddingClient newClient, int batchSize) { Map<String, float[][]> docVectors = new HashMap<>();
for (StoredDocument doc : docs) { List<Chunk> chunks = chunkDocument(doc, MAX_CHUNK_CHARS); List<String> texts = chunks.stream().map(Chunk::text).toList();
float[][] vectors = new float[texts.size()][]; for (int i = 0; i < texts.size(); i += batchSize) { List<String> batch = texts.subList(i, Math.min(i + batchSize, texts.size())); float[][] batchVecs = newClient.embed(batch, "document"); System.arraycopy(batchVecs, 0, vectors, i, batchVecs.length); }
docVectors.put(doc.id(), vectors); } return docVectors; }
|
构建新索引
在独立目录中构建新索引,不影响正在服务的旧索引:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| Path buildNewIndex( Map<String, float[][]> docVectors, List<StoredDocument> docs, EmbeddingVersionInfo newVersion, Path newIndexDir) throws IOException {
IndexWriterConfig config = new IndexWriterConfig(analyzer); config.setCodec(quantizedCodec());
try (IndexWriter writer = new IndexWriter( FSDirectory.open(newIndexDir), config)) { for (StoredDocument doc : docs) { float[][] vectors = docVectors.get(doc.id()); indexDocWithVectors(writer, doc, vectors); } }
saveVersionInfo(newIndexDir, newVersion); return newIndexDir; }
|
双索引验证
验证策略
新索引必须在切换前通过质量验证。用评测集对比新旧索引的指标:
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
| record ValidationResult( double oldNdcg, double newNdcg, double oldMrr, double newMrr, boolean passed ) {}
ValidationResult validateNewIndex( Path oldIndexDir, Path newIndexDir, EmbeddingClient oldClient, EmbeddingClient newClient, List<QueryJudgment> evalSet, double degradationThreshold) throws IOException {
double oldNdcg = evaluateNdcg(oldIndexDir, oldClient, evalSet, 10); double newNdcg = evaluateNdcg(newIndexDir, newClient, evalSet, 10); double oldMrr = evaluateMrr(oldIndexDir, oldClient, evalSet, 10); double newMrr = evaluateMrr(newIndexDir, newClient, evalSet, 10);
boolean passed = (newNdcg >= oldNdcg - degradationThreshold) && (newMrr >= oldMrr - degradationThreshold);
return new ValidationResult(oldNdcg, newNdcg, oldMrr, newMrr, passed); }
|
degradationThreshold 设为 0.02——允许新索引的 nDCG 最多下降 0.02。如果新模型确实更好,nDCG 应该上升。
验证报告
1 2 3 4 5 6 7 8 9 10
| === 索引升级验证报告 === 旧模型: Qwen3-Embedding-0.6B v1.0 新模型: Qwen3-Embedding-0.6B v1.1 评测集: 30 queries, 100 judgments
旧索引 新索引 变化 nDCG@10 0.6832 0.7015 +0.0183 MRR@10 0.7241 0.7389 +0.0148
结论: PASS (新索引质量不低于旧索引)
|
原子切换
volatile 引用
搜索服务持有一个指向当前活跃索引的引用。切换时替换这个引用:
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 37
| class SearchService { private volatile IndexBundle activeIndex;
record IndexBundle( IndexReader reader, IndexSearcher searcher, EmbeddingClient embeddingClient, EmbeddingVersionInfo version ) implements Closeable { @Override public void close() throws IOException { reader.close(); } }
void switchIndex(Path newIndexDir, EmbeddingClient newClient) throws IOException { EmbeddingVersionInfo newVersion = loadVersionInfo(newIndexDir); IndexReader newReader = DirectoryReader.open( FSDirectory.open(newIndexDir)); IndexSearcher newSearcher = new IndexSearcher(newReader);
IndexBundle oldBundle = this.activeIndex; this.activeIndex = new IndexBundle( newReader, newSearcher, newClient, newVersion);
scheduleClose(oldBundle, Duration.ofSeconds(30)); }
SearchResult search(String queryText) { IndexBundle current = this.activeIndex; } }
|
volatile 保证所有线程在切换后立即看到新索引。正在执行的查询继续使用旧索引(它们已经读取了旧引用),新查询使用新索引。
延迟关闭
旧索引不能立即关闭——可能有正在执行的查询仍在使用:
1 2 3 4 5 6 7 8 9 10
| void scheduleClose(IndexBundle bundle, Duration delay) { Thread.ofVirtual().start(() -> { try { Thread.sleep(delay); bundle.close(); } catch (Exception e) { } }); }
|
30 秒足以等待所有正在执行的查询完成。
回滚
保留旧索引
切换后保留旧索引目录,不立即删除:
1 2 3 4
| indices/ ├── v1.0/ ← 旧索引(保留) ├── v1.1/ ← 新索引(当前活跃) └── current -> v1.1 ← 符号链接
|
回滚操作
发现新索引有问题时,切换回旧索引:
1 2 3 4 5 6
| void rollback(Path oldIndexDir, EmbeddingClient oldClient) throws IOException { switchIndex(oldIndexDir, oldClient); queryEmbeddingCache.invalidateAll(); }
|
回滚条件:
- 线上指标(延迟、错误率)异常
- 用户反馈搜索质量下降
- 新模型服务不稳定
确认新索引稳定后(建议观察 24 小时),删除旧索引目录释放磁盘。
重建窗口内的写入和删除
问题
重建过程可能持续数分钟到数小时。期间如果有新文档写入或旧文档删除,新索引建成时就已经过期。
Change Log
记录重建窗口内的所有变更:
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
| class ChangeLog { private final List<Change> changes = Collections.synchronizedList(new ArrayList<>()); private volatile boolean recording = false;
record Change(String docId, ChangeType type, long timestamp) {} enum ChangeType { UPSERT, DELETE }
void startRecording() { this.recording = true; } void stopRecording() { this.recording = false; }
void recordUpsert(String docId) { if (recording) { changes.add(new Change(docId, ChangeType.UPSERT, System.currentTimeMillis())); } }
void recordDelete(String docId) { if (recording) { changes.add(new Change(docId, ChangeType.DELETE, System.currentTimeMillis())); } }
List<Change> drain() { List<Change> snapshot = new ArrayList<>(changes); changes.clear(); return snapshot; } }
|
重放变更
新索引构建完成后,在切换前重放变更日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| void replayChanges(IndexWriter newWriter, ChangeLog changeLog, EmbeddingClient newClient) throws IOException { List<ChangeLog.Change> changes = changeLog.drain();
for (ChangeLog.Change change : changes) { switch (change.type()) { case UPSERT -> { StoredDocument doc = fetchDocument(change.docId()); if (doc != null) { float[][] vectors = encodeDocument(doc, newClient); newWriter.updateDocument( new Term("id", change.docId()), buildLuceneDoc(doc, vectors)); } } case DELETE -> { newWriter.deleteDocuments( new Term("id", change.docId())); } } } newWriter.commit(); }
|
完整重建流程
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 37
| void rebuildIndex(EmbeddingClient newClient) throws IOException { changeLog.startRecording();
try { List<StoredDocument> docs = readAllDocuments(currentReader());
Map<String, float[][]> vectors = reencodeAll( docs, newClient, BATCH_SIZE);
Path newDir = buildNewIndex(vectors, docs, newVersion, tempDir);
try (IndexWriter writer = openWriter(newDir)) { replayChanges(writer, changeLog, newClient); }
ValidationResult result = validateNewIndex( currentIndexDir, newDir, currentClient(), newClient, evalSet, 0.02);
if (!result.passed()) { throw new RuntimeException("新索引验证未通过: " + result); }
switchIndex(newDir, newClient); queryEmbeddingCache.invalidateAll();
} finally { changeLog.stopRecording(); } }
|
当前局限
- 重建期间双倍磁盘占用——旧索引和新索引同时存在
- 大规模语料的重建可能需要数小时——建议在低峰期执行
- change log 方案不保证严格一致性——极端情况下可能丢失 change log 开始记录前一瞬间的变更
- 没有增量重建——每次模型升级都是全量重建
验收清单
| 场景 |
操作 |
预期 |
| 版本不匹配启动 |
用模型 B 启动模型 A 的索引 |
启动失败,报错信息明确 |
| 正常重建 |
全量重建并切换 |
新索引 nDCG 不低于旧索引 |
| 重建期间写入 |
重建窗口内新增文档 |
change log 捕获,新索引包含该文档 |
| 重建期间删除 |
重建窗口内删除文档 |
change log 捕获,新索引不含该文档 |
| 回滚 |
切换后发现问题 |
30 秒内切回旧索引 |
| 缓存清理 |
模型切换后查询 |
缓存已清空,用新模型编码 |
练习
- 实现 EmbeddingVersionInfo 的持久化和启动时校验
- 编写离线重建脚本,对全部文档用新模型重新编码
- 实现 change log 记录和重放机制
- 对比新旧索引的 nDCG@10,确认升级后质量不下降
- 模拟回滚场景:切换到新索引后立即回滚到旧索引
延伸阅读
- Elasticsearch: Reindex API 设计
- Facebook Faiss: Index I/O 与版本管理
- 蓝绿部署(Blue-Green Deployment)模式在索引管理中的应用