
新模型上线以后“红色跑车”的搜索结果确实更准了但用户新增的照片能搜到半年前的照片却突然沉到了结果底部。问题不在检索语句而在新旧嵌入向量已经不能放在同一个距离空间里比较。我给这次迁移做了一个独立工程VectorEpoch Lab。迁移任务编号是embed_20261001_18旧模型clip_v3输出 512 维向量新模型clip_v4输出 768 维向量。本地共有 12,840 张图片17:08 时已完成 10,272 张进度 80%剩余 2,568 张。运行状态不是简单的RUNNING而是DUAL_READ。新查询使用clip_v4生成文本向量同时在 v4 影子索引和 v3 正式索引中检索然后按版本内部分数归一化合并。当前查询 p95 为 42 ms回滚状态READY只有进度、数量和校验和全部通过才会把活跃 epoch 从 v3 切到 v4。一、模型换了向量就不再是同一种数据一开始我只在表里增加了modelVersion字段然后让后台任务逐条覆盖向量。这个做法有两个问题。第一512 维和 768 维根本无法在同一索引配置中并存第二即使新旧模型维度相同它们的嵌入空间也不能直接比距离。这和普通字段迁移不同。把用户名从 64 个字符改成 128 个字符旧值仍然可读但嵌入模型换代以后向量的每个分量都只在当前模型的语义空间里有意义。所以我不再把 v4 当作 v3 的一次就地升级而是把它当成一个全新索引 epoch。当前数据目录中有media_catalog、embedding_manifest和两个物理索引。media_catalog保留稳定的assetId、修改时间和可见状态manifest 记录 epoch、模型摘要、维度、已处理数和校验和向量数据则放在vec_clip_v3与vec_clip_v4_shadow。二、先写影子索引再决定是否切换这段代码解决的是“迁移到一半应用被杀下次启动如何继续又不会污染当前正式索引”。每个批次先在 TaskPool 生成嵌入再用一个 RDB 事务写入影子索引和迁移游标。exportclassEpochMigrator{asyncmigrateBatch(manifest:EpochManifest,assets:MediaAsset[]):Promisevoid{consttasknewtaskpool.Task(buildEmbeddings,{model:clip_v4,dimension:768,items:assets.map(item({id:item.assetId,uri:item.uri}))})constvectorsawaittaskpool.execute(task)asEmbeddingResult[]consttxawaitthis.rdb.createTransaction()try{awaitthis.vectorStore.upsert(vec_clip_v4_shadow,vectors,tx)awaitthis.manifestDao.advance(tx,manifest.epoch,vectors.length,checksumOf(vectors))awaittx.commit()}catch(error){awaittx.rollback()throwerror}}}这里不把向量以 ArkTS 对象长期留在主线程。TaskPool 返回一个批次后就交给数据层事务成功后释放临时结果。如果应用在写向量后、更新游标前中断事务会回滚下次仍从原游标重做这批不会出现“数据已经存在进度却不知道”的半成功状态。批次大小定为 64不是因为 64 是通用最优值而是在这台测试设备上它让单次峰值内存、事务时间和取消响应比较均衡。正式项目应根据图像尺寸、模型耗时和设备压力调整不要把 Demo 批量直接带到所有机型。任务的恢复点也没有只存一个自增行号。相册资产会增删行号在下一次查询时并不稳定游标实际由modifiedAt assetId组成并在 manifest 中记录本批次输入摘要。恢复时先确认模型文件摘要仍是clip_v4再从复合游标继续。如果应用更新替换了同名模型却沿用旧游标前后批次会来自两个不同权重版本最终数量完全正确语义空间却已经裂开。我还给迁移调度加了温度、电量和前后台限制。页面离开并不等于立刻销毁迁移任务但设备进入低电量或温控等级升高时当前批次完成后会暂停不再领取下一批。选择批次边界暂停是为了避免把一个事务停在半路。再次恢复时先读 manifest而不是相信内存里的processed所以系统回收进程也不会让进度倒退或虚增。三、双读不是把两组距离混在一起排序迁移进度只有 80% 时新索引不能单独服务否则未迁移的 2,568 张图会像被删除一样从搜索结果中消失。但 v3 和 v4 的 cosine 分数分布不同不能直接把 0.83 和 0.79 放在一起比。下面这段代码解决的是“迁移期间既要优先使用新索引又不能让旧照片消失”。查询先在两个 epoch 内各自取 TopK再使用对应版本的分位映射做归一化相同assetId只保留 v4 结果。exportasyncfunctiondualSearch(query:string):PromiseSearchHit[]{const[q3,q4]awaitPromise.all([embedText(clip_v3,query),embedText(clip_v4,query)])const[oldHits,newHits]awaitPromise.all([vectorStore.search(vec_clip_v3,q3,40),vectorStore.search(vec_clip_v4_shadow,q4,40)])constmergednewMapstring,SearchHit()for(consthitofoldHits)merged.set(hit.assetId,normalize(hit,v3))for(consthitofnewHits)merged.set(hit.assetId,normalize(hit,v4))return[...merged.values()].sort((a,b)b.rankScore-a.rankScore).slice(0,40)}双读是过渡状态不是长期架构。它会多做两次文本嵌入与两次检索所以 p95 从单索引的 27 ms 增加到 42 ms。这个代价可以接受但不应在迁移完成后仍保留。如果页面退到后台当前查询的两个子任务都要取消不能只取消 UI 合并阶段。归一化曲线来自固定验证集而不是从当前四十条结果临时计算。临时做 min-max 会被极端分数拉伸同一个查询在相册新增一张照片后就可能发生大幅跳变。v3 和 v4 各自保存一份分位点把原始相似度映射到版本内百分位再进入融合排序。这个分数只用于迁移阶段切换完成以后直接返回 v4 的原始排序避免长期维护两套不可解释的权重。重复资产的处理也必须明确。同一张照片如果已经进入 v4融合时不能因为 v3 也命中就获得两次加分。我用assetId去重并明确让 v4 覆盖 v3还没迁移的资产只有 v3 结果。这样随着迁移进度增长一张资产只会从旧版本结果平滑替换为新版本结果不会突然在列表里出现两份。四、原子切换只改指针不在切换时搬数据最早的切换脚本会把vec_clip_v4_shadow重命名为正式表同时删除 v3。数据量一大这个操作很难保证短时完成失败后也不容易判断当前名字指向哪份数据。现在的做法是物理索引名不变只在 manifest 中修改activeEpoch。这段代码解决的是“所有验收条件通过时一次切换任何一项不满足都继续留在 v3”。exportasyncfunctionpromoteEpoch(expected:PromotionGuard):Promisevoid{consttxawaitrdb.createTransaction()try{constmanifestawaitmanifestDao.lockForUpdate(tx,clip_v4)if(manifest.processed!expected.total||manifest.dimension!768||manifest.checksum!expected.checksum||manifest.state!VERIFIED){thrownewError(EPOCH_NOT_READY)}awaitmanifestDao.setActiveEpoch(tx,clip_v4)awaitmanifestDao.setRollbackEpoch(tx,clip_v3)awaittx.commit()}catch(error){awaittx.rollback()throwerror}}切换之后 v3 不立即删除而是进入只读回滚期。如果 v4 的无结果率、重复率或查询耗时超出阈值只需在新事务中把activeEpoch指回 v3。数据清理是另一个低优先级任务要等回滚观察窗结束后再执行。lockForUpdate的目的不是单纯防止两个按钮同时被点。后台迁移、定时校验和开发调试页都可能尝试更新 manifest如果不串行化校验任务读到VERIFIED后准备切换的几毫秒内另一个失败批次可能已经把状态改成PAUSED。事务中的二次检查把“看到可以发布”和“真正发布”变成同一个原子判断。切换操作本身不触碰 12,840 条向量因此耗时与图库规模无关。用户正在搜索时当前请求会持有它开始时读到的 epoch新请求读取新的 active 指针不会出现一个请求前半段查 v3、后半段查 v4 的情况。正式服务还应把 epoch 写进查询日志出现排序投诉时才能还原当时使用的模型和索引。DevEco Studio 图中左侧目录包含epoch / search / data / workers中间停在EpochMigrator.ets右侧模拟器显示embed_20261001_18 / DUAL_READ / 80%。底部日志把clip_v3 512d、clip_v4 768d、10272/12840、p9542ms和rollbackREADY放在同一个视野里用来证明当前是可回滚的双读而不是两份向量被混到一张表中。五、数量对上了还不等于索引可以接管迁移 12,840 张并得到 12,840 条向量只能说没有少行。我还做了三层校验。第一层检查assetId、模型版本和维度第二层对向量做非法值、范数与摘要检查第三层使用一组固定文本查询做回归观察 Top10 中的基准图片是否仍在合理位置。查询回归不要硬性要求 v4 与 v3 顺序完全相同否则模型升级永远无法带来改善。比较的是人工标注集的命中率、无结果率和明显错误样本。当前 Demo 的红色跑车查询在 v4 中提升了前三位命中而“黑板上的手写公式”没有出现退化。我另外随机抽取了 200 条向量重新计算检查存储值与模型输出的余弦差这一步能抓住字节序、浮点精度和错误模型文件等“行数正确、内容错误”的问题。校验任务读取影子索引时只拿必要列并限制并发不能为了证明索引正确把迁移本身挤到后台。校验失败会把状态置为VERIFY_FAILED但不会删除影子索引开发者可以保留失败样本继续定位。还有一个容易漏的边界迁移期间用户可能删除照片。生成向量前和写入前都要校验modifiedAt与可见状态否则影子索引会复活已删除资产。新增照片则同时写入当前活跃 epoch 和迁移目标 epoch避免迁移进度追不上活数据增长。六、手机页面要告诉我们“为什么还没切换”迁移页没有只放一个 80% 进度条。它展示 v3 和 v4 的维度、已完成与待处理数量、双读耗时、校验状态以及回滚点。这样即使任务停在 80%也能区分是设备在后台暂停还是某批次校验失败。进度来自已提交事务的 manifest不使用“TaskPool 已经返回多少条”作为完成数。后者在事务回滚时会产生假进度UI 显示 80%数据库可能只有 79%。页面订阅 manifest 快照每个批次提交后才刷新一次既减少频繁重绘也保证截图上的 10,272 真的是已经可检索的数据。17:08 的手机页数据与正文保持一致任务embed_20261001_18状态DUAL_READv3 为 512dv4 为 768d进度 10,272 / 12,840剩余 2,568p95 42 ms回滚READY。红色批注只标出“影子索引未接管”和“回滚指针仍保留”两个判断比一个漂亮的进度数字更有用。七、模型升级应该像数据发布不像文件替换这次工作最大的改变是不再把嵌入模型看成一个替换后立即生效的资源文件。它实际上改变了整个向量语义空间因此需要独立版本、影子数据、过渡查询、验收门禁和回滚指针。embed_20261001_18在 80% 时没有急着把 v4 变成正式索引。它继续让 v3 承担完整性v4 提供已迁移数据的新模型结果直到 12,840 条数据、维度、摘要和回归样本都对上。对文搜图来说新模型的准确率很重要但比“更准”更基础的是升级过程中旧照片仍然可被找到。上线后的观察也沿用同一套 epoch 视角。我把无结果率、点击后返回率、重复资产率和 p95 都按activeEpoch分开统计避免把 v3 的稳定数据和 v4 的异常平均在一起。只要回滚窗口还在告警就携带当前与备用 epoch值班人员看到问题时可以先切回 v3再保留 v4 现场排查而不是一边承受错误结果一边重新生成全库向量。影子索引最终删除前还要过两个条件回滚观察期结束且没有任何活跃查询仍持有 v3 快照。清理任务先把 v3 标记为RETIRED等待引用计数归零后再分批释放文件。这样资源回收不会和搜索线程争用句柄也不会因为一次删除失败把 active 指针弄乱。真正稳定的升级不是切换按钮按下的那一刻而是旧资源退出后系统仍能解释每一次状态变化。参考资料HarmonyOS 数据库调试与 Vector Store 检查HarmonyOS RDB Transaction APIHarmonyOS 使用 TaskPool 执行耗时任务HarmonyOS 文搜图社区实践