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");
// 解析 JSON 并构造 EmbeddingVersionInfo
// ...
}

启动时校验

应用启动时检查当前模型配置与索引元数据是否匹配:

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()); // 复用第 22 篇的量化配置

try (IndexWriter writer = new IndexWriter(
FSDirectory.open(newIndexDir), config)) {
for (StoredDocument doc : docs) {
float[][] vectors = docVectors.get(doc.id());
// 为每个 chunk 创建 Lucene Document 并添加向量字段
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
// 用 current.embeddingClient 编码查询
// 用 current.searcher 执行搜索
// ...
}
}

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) {
// log error
}
});
}

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);
// 清除 query embedding 缓存——模型版本变了
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 {
// 1. 读取旧索引的所有文档
List<StoredDocument> docs = readAllDocuments(currentReader());

// 2. 用新模型批量重新编码
Map<String, float[][]> vectors = reencodeAll(
docs, newClient, BATCH_SIZE);

// 3. 构建新索引
Path newDir = buildNewIndex(vectors, docs, newVersion, tempDir);

// 4. 重放变更日志
try (IndexWriter writer = openWriter(newDir)) {
replayChanges(writer, changeLog, newClient);
}

// 5. 验证
ValidationResult result = validateNewIndex(
currentIndexDir, newDir,
currentClient(), newClient,
evalSet, 0.02);

if (!result.passed()) {
throw new RuntimeException("新索引验证未通过: " + result);
}

// 6. 原子切换
switchIndex(newDir, newClient);
queryEmbeddingCache.invalidateAll();

} finally {
changeLog.stopRecording();
}
}

当前局限

  • 重建期间双倍磁盘占用——旧索引和新索引同时存在
  • 大规模语料的重建可能需要数小时——建议在低峰期执行
  • change log 方案不保证严格一致性——极端情况下可能丢失 change log 开始记录前一瞬间的变更
  • 没有增量重建——每次模型升级都是全量重建

验收清单

场景 操作 预期
版本不匹配启动 用模型 B 启动模型 A 的索引 启动失败,报错信息明确
正常重建 全量重建并切换 新索引 nDCG 不低于旧索引
重建期间写入 重建窗口内新增文档 change log 捕获,新索引包含该文档
重建期间删除 重建窗口内删除文档 change log 捕获,新索引不含该文档
回滚 切换后发现问题 30 秒内切回旧索引
缓存清理 模型切换后查询 缓存已清空,用新模型编码

练习

  1. 实现 EmbeddingVersionInfo 的持久化和启动时校验
  2. 编写离线重建脚本,对全部文档用新模型重新编码
  3. 实现 change log 记录和重放机制
  4. 对比新旧索引的 nDCG@10,确认升级后质量不下降
  5. 模拟回滚场景:切换到新索引后立即回滚到旧索引

延伸阅读

  • Elasticsearch: Reindex API 设计
  • Facebook Faiss: Index I/O 与版本管理
  • 蓝绿部署(Blue-Green Deployment)模式在索引管理中的应用