一、搜索结果没错,错的是它属于昨天
PhotoIndex Lab的第一版已经能用文字搜索本地照片。输入“海边日落中的红色风筝”,端侧语义检索会返回相似图片,演示时很顺。真正放进相册整理流程后,一个不太显眼的问题出现了:用户删除照片、重新编辑或从其他设备同步新照片后,搜索结果仍可能指向旧缩略图。算法给出的相似度没有错,错的是索引版本落后于媒体库。
这类问题在 Demo 阶段很容易被掩盖。开发者通常准备一批固定图片,启动时完整建库,之后只测查询速度。真实相册却一直变化:新图加入、图片被删除、编辑后的资源 ID 保持不变但内容指纹改变,后台扫描还可能因为应用切换而中断。如果每次都全量重建,3000 张图片尚可接受,三四万张时耗电、发热和等待时间都会变得明显;如果只追加新图,删除和修改又无法正确反映。
本次优化没有改动语义模型,而是在模型外增加一层增量索引协议。测试数据固定为:索引版本IDX-20260930-1357-07,增量批次BATCH-071,本轮发现 37 个变化资源,其中新增或修改 33 个、删除 4 个;查询返回 12 张图片,首条资源IMG-2048,相似度0.912,端到端耗时 86 ms。
二、先给“图片变化”一个稳定定义
媒体库通知只能告诉我们“可能变了”,不能直接等价为“重新算向量”。例如用户只修改收藏标记,语义内容没有变化;相反,编辑器可能覆盖原图而保留同一个业务资源 ID。我们给每张图片建立三元版本:assetId + modifiedTime + contentFingerprint。资源 ID 用于定位,修改时间用于快速筛选,内容指纹负责最终确认。
索引记录还要保存向量模型版本。文搜图能力升级或更换预处理方式后,即使图片没变,旧向量也不能与新查询向量混用。本项目把模型版本写成text-image-v3,把预处理版本写成crop-center-2,两者任一变化都会触发有控制的重建,而不是悄悄合并两种向量空间。
这段代码解决什么问题。它把媒体资源快照与现有索引做差,生成新增、修改、删除三类确定操作,避免把一次变更通知粗暴地变成全量重建。
interfaceAssetSnapshot{assetId:stringmodifiedTime:numberfingerprint:string}interfaceIndexEntryMetaextendsAssetSnapshot{modelVersion:stringpreprocessVersion:string}interfaceIndexDelta{upserts:AssetSnapshot[]deletes:string[]}exportclassDeltaPlanner{plan(current:AssetSnapshot[],indexed:Map<string,IndexEntryMeta>):IndexDelta{constupserts:AssetSnapshot[]=[]constcurrentIds=newSet<string>()current.forEach(asset=>{currentIds.add(asset.assetId)constold=indexed.get(asset.assetId)constchanged=!old||old.modifiedTime!==asset.modifiedTime||old.fingerprint!==asset.fingerprint||old.modelVersion!=='text-image-v3'||old.preprocessVersion!=='crop-center-2'if(changed)upserts.push(asset)})constdeletes:string[]=[]indexed.forEach((_,assetId)=>{if(!currentIds.has(assetId))deletes.push(assetId)})return{upserts,deletes}}}这里没有仅靠modifiedTime。时间戳适合快速过滤,却可能因为批量导入、文件恢复或编辑器行为产生碰撞;指纹成本更高,但可以只对候选变化资源计算。实际项目应把轻量元数据检查放在前面,把像素级指纹放在后台任务中,避免每次进入页面都读取原图。
状态从SCANNING开始,得到差异后进入EMBEDDING。若模型版本变化,差异计划会把全部资源放入upserts,但仍沿用分批提交和断点恢复,不必把“全量重建”写成另一套逻辑。
三、一次增量不是 37 次独立写入
早期实现每生成一个向量就立刻写数据库。任务中途退出时,索引已经处于半新半旧状态:新图可以搜到,删除图仍然存在,版本号却被提前更新。下一次启动看见“版本一致”,就不会再补做剩余工作。
我们改成批次提交。BATCH-071有自己的暂存区,33 个向量与 4 个删除标记全部完成后,才原子切换当前索引版本。搜索线程始终读取上一个完整版本;只有批次进入COMMITTED后,新版本才对查询可见。
这段代码解决什么问题。它用暂存批次和提交指针保证查询只能看到完整索引,并让中断任务可以从最后完成的资源继续。
typeBatchState='CREATED'|'EMBEDDING'|'MERGING'|'COMMITTED'|'FAILED'interfaceIndexBatch{batchId:stringtargetVersion:stringstate:BatchState completed:numbertotal:number}exportclassIndexBatchRunner{asyncrun(batch:IndexBatch,delta:IndexDelta):Promise<void>{batch.state='EMBEDDING'for(constassetofdelta.upserts){if(awaitStagingIndex.has(batch.batchId,asset.assetId))continueconstvector=awaitSemanticEncoder.encodeImage(asset.assetId)awaitStagingIndex.put(batch.batchId,asset.assetId,vector)batch.completed++awaitBatchStore.save(batch)}batch.state='MERGING'awaitStagingIndex.markDeletes(batch.batchId,delta.deletes)awaitIndexStore.commit(batch.batchId,batch.targetVersion)batch.state='COMMITTED'awaitBatchStore.save(batch)}}completed只是进度,不是提交依据。真正决定版本切换的是暂存区完整性校验:目标条目数、删除标记数、模型版本和校验和都正确,才更新活动索引指针。若应用在第 21 个资源后进入后台,下次恢复会跳过暂存区已经存在的 21 个向量,继续完成剩余 12 个,而不是从头计算。
删除采用 tombstone,而不是立刻物理移除。这样旧查询快照仍能完成读取,新版本又不会返回被删除资源。后台压缩任务在没有读者持有旧版本时再回收向量页。4 个 tombstone 的空间很小,却换来了清晰的并发边界。
DevEco Studio 图中,左侧工程目录区分DeltaPlanner、IndexBatchRunner和SearchRepository;中间代码显示BATCH-071的提交逻辑;右侧模拟器停在MERGING 37/37;底部 HiLog 明确打印目标版本IDX-20260930-1357-07。红色标注只指向活动索引指针和 tombstone 数量。
四、查询要把“版本”带到结果页
增量索引完成后还有一个 UI 层问题。用户发起查询时活动版本是...-06,结果返回前批次切换到...-07;如果页面随后按新版本加载缩略图,可能出现列表项与资源详情不一致。解决办法不是锁住整个索引,而是让查询返回一个不可变快照 ID。
这段代码解决什么问题。它把查询文本、活动索引版本和结果集绑定成一次快照,页面翻页与打开详情时始终使用同一版本。
interfaceSearchHit{assetId:stringscore:number}interfaceSearchSnapshot{queryId:stringindexVersion:stringhits:SearchHit[]elapsedMs:number}exportclassSearchRepository{asyncsearch(text:string):Promise<SearchSnapshot>{conststarted=Date.now()constversion=awaitIndexStore.getActiveVersion()consttextVector=awaitSemanticEncoder.encodeText(text)consthits=awaitIndexStore.query(version,textVector,12)return{queryId:'Q-1357-019',indexVersion:version,hits,elapsedMs:Date.now()-started}}}页面不再自己读取“当前版本”,而是显示快照携带的IDX-20260930-1357-07。打开IMG-2048时,也把queryId=Q-1357-019和索引版本传入详情服务。即使后台已经开始BATCH-072,当前结果仍可解释、可复现。
相似度0.912只能说明在当前模型和候选集合中的相对接近程度,不应直接翻译成“91.2% 正确”。UI 用“相关度高”作为用户语言,同时在诊断页保留原始分值,既避免误导,也方便工程调试。
运行截图展示查询“海边日落中的红色风筝”,共 12 个结果,首条为IMG-2048,相关分值0.912,耗时 86 ms。状态栏、查询 ID 和索引版本都完整呈现,红色箭头强调结果属于哪个快照。
五、断点恢复比跑得快更重要
增量任务适合放在设备空闲、充电或用户明确触发时运行,但应用仍可能随时被切走。我们没有把“进入后台”视为错误,而是保存批次游标:当前批次、已完成条目、模型版本、暂存区校验和。恢复时先核对环境是否仍然兼容,再继续任务。
若模型文件在中断期间升级,旧暂存向量不能继续合并,批次会进入FAILED_MODEL_CHANGED,清理暂存区后重新规划。若媒体库再次变化,不必取消当前批次;先提交BATCH-071,随后把新变化归入BATCH-072,避免一个永远追不上变化的长任务。
本轮压力测试在第 21/37 项主动终止进程。再次启动后扫描耗时 18 ms,恢复点命中,剩余 16 项完成后进入MERGING,最终校验和CHK-7A31,没有重复编码已经完成的资源。诊断记录如下:
13:57:08.112 I PhotoIndex: batch=BATCH-071 state=EMBEDDING progress=21/37 13:57:12.450 I PhotoIndex: resume=true checkpoint=21 target=IDX-20260930-1357-07 13:57:13.806 I PhotoIndex: state=MERGING upserts=33 tombstones=4 13:57:13.892 I PhotoIndex: state=READY checksum=CHK-7A31诊断页显示批次恢复、33 个 upsert、4 个 tombstone 和最终校验和。红圈标在“21/37 恢复点”,箭头指向READY,说明这张图承担的是任务完整性解释,而不是单纯展示漂亮界面。
六、性能优化要看总账
只看单次查询,86 ms 已经足够流畅;真正影响体验的是后台索引成本。全量重建测试需要读取 18.6 GB 原图数据,设备明显发热;增量方案只读取 33 个变化资源,I/O 降到 428 MB。向量编码时间从 11 分 42 秒降到 14.8 秒,代价是增加批次表、暂存区和版本回收逻辑。
这笔复杂度值得付出,是因为它解决的不只是速度。版本快照消除了半成品查询,tombstone 解决删除一致性,检查点解决生命周期中断,模型版本字段解决升级兼容。性能只是结果之一,数据可解释性才是结构收益。
索引并非越新越好。如果用户正在连续搜索,后台频繁切版本会让相邻两次查询结果跳动。项目把合并窗口设为 30 秒:变化先聚合,用户停止交互后再切换活动版本。这个值不是平台规则,应结合图片变化频率和产品容忍度测量。
七、能力边界与上线检查
文搜图能力负责把文本语义与图像内容连接起来,应用仍要处理媒体权限、资源可见范围、缩略图生命周期和隐私提示。索引只保存必要的向量与资源引用,不保存用户查询原文的长期日志;调试日志使用查询 ID,不输出完整图片路径。
端侧检索也不等于结果永远正确。抽象词、地域性表达、截图中的小字和高度相似的连拍,都可能降低区分度。产品需要允许用户按时间、地点或相册继续过滤,并提供“结果不相关”的反馈出口,而不是把所有责任压给一个相似度分数。
最终上线前我们固定检查五件事:模型与预处理版本是否写入索引;删除资源是否通过 tombstone 立即对新查询不可见;批次中断是否能从检查点恢复;查询结果是否携带不可变索引版本;日志是否避免输出图片内容与完整路径。做到这些,文搜图才从一个会演示的 AI 功能,变成能长期维护的相册能力。
参考资料:
- Harmony Intelligence 开放能力与服务
- HarmonyOS 文搜图社区资料