ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS 7 VisionKit:文搜图Golden Query回归与NDCG漂移

HarmonyOS 7 VisionKit:文搜图Golden Query回归与NDCG漂移 RankGuard最近换了一版文搜图索引人工体验看起来更“聪明”搜“雨天红伞”第一张确实是红伞搜“窗边的猫”相似度也比旧版高。可灰度当天测试同学发来一句很难回答的话“有些词结果更准有些词只是第一张换了我们怎么证明整体没有退步”只盯相似度不够。模型升级后分数分布可能整体漂移0.82 和旧版的 0.82 甚至不再等价只看第一张也不够用户经常会翻到前十张。最后我没有继续截图对比而是整理了 48 条 Golden Query把相关图片分成 0、1、2、3 四级固定数据集版本再用 Recall10 和 NDCG10 做发布门禁。一、一次“结果看着不错”的升级差点混过发布旧索引基线记为vision-text-7.1候选版本为vision-text-7.2。最初的抽查只有六个词五个词的首图更符合直觉于是大家倾向于直接上线。问题出在“雨夜公交站”候选版本把一张白天公交站排到第二位而真正的雨夜照片掉到了第八位。如果只算 Top1这条查询会被判失败如果只看“前十是否找到”它又算成功。两种判断都不完整。Recall10 回答的是相关图片有没有被召回NDCG10 还会考虑相关程度和排序位置高相关照片越靠前得分越高。本轮批次固定为RG-1002-0512数据集版本gallery-golden-2026.10.02-r3共 48 个查询每条最多返回 10 张。发布阈值不是“候选必须每项都更高”而是 Recall10 不能下降超过 0.010NDCG10 不能下降超过 0.015且不能出现未解释的结果抖动。二、Golden Query 不是一份关键词列表最开始我把查询和“期望文件名”写在 JSON 里很快就发现这会把测试做死。一张海边日落同时适合“橙色天空”“海边散步”“落日背影”相关程度也不一样。Golden 集最终改为查询、scope、相关性分级、场景标签和审阅版本五部分。下面的结构解决的是“同一张图对不同查询的相关性不相同”。grade取 033 是核心命中2 是明显相关1 是弱相关0 是负样本。数据只引用稳定的assetId不把沙箱路径当主键。exportinterfaceGoldenJudgement{assetId:stringgrade:0|1|2|3}exportinterfaceGoldenQuery{queryId:stringtext:stringscope:stringtags:string[]reviewedAt:stringjudgements:GoldenJudgement[]}exportconstGOLDEN_SET_VERSIONgallery-golden-2026.10.02-r3exportconstTOP_K10exportconstRECALL_DROP_LIMIT0.010exportconstNDCG_DROP_LIMIT0.015审阅人看到的是缩略图和查询不看到模型版本避免“新模型应该更好”的心理暗示。新增照片不会直接进入 Golden 集先进入候选池完成双人标注后才提升数据集版本。否则同一批次的基线与候选实际上在跑不同题目指标差异就失去解释力。删除资源也有规则。如果产品库里删掉一张 Golden 图片门禁不悄悄忽略而是把查询标记为DATASET_INVALID。数据缺口要先修复再比较模型不能把坏题目当成模型退步。三、搜索适配层只负责得到稳定的 TopKCore Vision Kit 的textSearchImage.search(query, scope, topKey)会返回图片路径、scope 和相似度。回归工具不直接依赖页面状态而是通过一个适配层完成初始化、查询、路径到 assetId 的映射和资源释放。这段代码解决的是“页面切走或批次取消后迟到结果仍写入当前报告”。每次运行生成generation只有同代结果才能进入指标计算aboutToDisappear()里递增代际并释放服务。import{textSearchImage}fromkit.CoreVisionKitexportclassSearchProbe{privategeneration:number0privateready:booleanfalseasyncopen():Promisevoid{this.generationthis.readyawaittextSearchImage.init()if(!this.ready)thrownewError(TEXT_SEARCH_INIT_FAILED)}asyncsearch(query:GoldenQuery):Promisestring[]{if(!this.ready)thrownewError(PROBE_NOT_READY)construnGenerationthis.generationconstrowsawaittextSearchImage.search(query.text,query.scope,TOP_K)if(runGeneration!this.generation)return[]returnrows.map((row)assetCatalog.idOf(row.imagePath))}asyncclose():Promisevoid{this.generationthis.readyfalseawaittextSearchImage.release()}}这里故意不把similarity放进质量指标。它仍会写入诊断快照帮助判断分数分布是否变化但 Golden 判定依赖人工相关性等级。候选模型可以把所有分数都抬高却不代表排序更好。资源释放也不能省。48 条查询会连续调用服务页面退后台时停止当前批次已完成的查询保存为临时报告未完成部分下次从查询 ID 续跑。重复调用init()前必须确认上一次已经release()否则测试工具本身会制造错误状态。四、NDCG 的价值在于它知道“相关也分轻重”Recall10 的分子是前十中命中的相关资源数分母是 Golden 集里所有 grade 大于 0 的资源数。NDCG 则先计算 DCG位置越靠后折扣越大grade 越高收益越高再除以理想排序的 DCG得到 01 的归一化值。下面的实现解决的是指标口径不透明的问题。每条查询都输出recallAt10、ndcgAt10和前十 assetId报告能够追溯到具体排序而不是只剩一个总分。functiondcg(grades:number[]):number{returngrades.reduce((sum,grade,index){constgainMath.pow(2,grade)-1returnsumgain/Math.log2(index2)},0)}exportfunctionscoreQuery(query:GoldenQuery,rankedIds:string[]):QueryScore{constgradeMapnewMap(query.judgements.map((item)[item.assetId,item.grade]))consttopGradesrankedIds.slice(0,TOP_K).map((id)gradeMap.get(id)??0)constrelevantTotalquery.judgements.filter((item)item.grade0).lengthconsthitCounttopGrades.filter((grade)grade0).lengthconstidealGradesquery.judgements.map((item)item.grade).sort((a,b)b-a).slice(0,TOP_K)constidealdcg(idealGrades)return{queryId:query.queryId,recallAt10:relevantTotal0?1:hitCount/relevantTotal,ndcgAt10:ideal0?1:dcg(topGrades)/ideal,rankedIds:rankedIds.slice(0,TOP_K)}}零相关查询单独放在负样本组不参与这里的平均分避免用大量“什么都搜不到”的查询把总分冲高。负样本关注的是误召回数量和最高相似度属于另一条门禁。指标计算必须固定排序。相似度相同的结果用 assetId 做二级排序避免底层返回顺序不稳定导致快照反复变化。这个细节不影响用户体验却会直接决定 CI 报告能否重复。五、门禁不只看平均值还要找局部坍塌候选版最后的汇总看起来不错Recall10 从 0.938 升到 0.944变化0.006NDCG10 从 0.912 降到 0.908变化-0.004仍在 0.015 阈值内。如果只看平均值已经可以放行。我又加了单查询规则任何 query 的 NDCG 下降超过 0.12 都算LOCAL_COLLAPSE。因为平均值会把一条严重退步藏在几十条轻微提升里。第一次跑时“雨夜公交站”下降 0.19因此状态从EVALUATING进入DRIFT_BLOCKED。排查后发现新索引少插入两张夜景图问题不在模型而在构建候选 scope 时发生了资源遗漏。修复索引清单后再次运行局部坍塌为 0未解释抖动为 048 条全部完成。六、Hvigor 只消费报告不重跑模型文搜图依赖设备能力构建机不一定能执行。我的处理是让真机/模拟器测试页生成签名报告Hvigor 在 release 前验证报告版本、数据集哈希、模型标识、完成时间和阈值结论。报告过期、批次未完成或存在局部坍塌都直接阻断。这段任务解决的是“拿上一次绿灯给这一次构建背书”。候选版本必须与报告里的candidateModel完全一致报告时间不能早于当前索引产物。node.registerTask({name:rankQualityGate,run:(){constreportreadReport(build/reports/rank-RG-1002-0512.json)assertEqual(report.datasetVersion,gallery-golden-2026.10.02-r3)assertEqual(report.candidateModel,vision-text-7.2)assertEqual(report.completedQueries,48)assertLessOrEqual(report.recallDrop,0.010)assertLessOrEqual(report.ndcgDrop,0.015)assertEqual(report.localCollapseCount,0)assertEqual(report.unexplainedChanges,0)console.info(RELEASE_READY batchRG-1002-0512 recall0.944 ndcg0.908 queries48/48)}})门禁中的recallDrop和ndcgDrop取下降幅度提升时按 0 处理避免正负号判断写反。报告原始 delta 仍保留0.006与-0.004页面展示更直观。如果 Core Vision Kit 返回能力更新错误测试页不会自动清库后继续因为那会改变本轮数据集。它把批次标记为CAPABILITY_UPDATED由操作者清理、重建索引并生成新报告。发布门禁最怕“失败后偷偷换环境再跑”。七、最终页面不是排行榜而是一张发布证据最终运行页固定显示批次RG-1002-0512、数据集gallery-golden-2026.10.02-r3、模型vision-text-7.2、查询48 / 48、TopK10。Recall10 为0.944NDCG10 为0.908变化分别为0.006和-0.004局部坍塌与未解释变化都为 0。状态机走完BASELINE → EVALUATING → RELEASE_READY构建退出码为 0。截图时间统一为 05:12HiLog 同时记录数据集哈希和报告文件名避免只凭 UI 上的一枚绿色标签做判断。八、这套方法真正约束的是发布判断Golden Query 不是越多越好。48 条能覆盖雨天、夜景、人物、宠物、票据、食物和旅行等主场景先把标注质量做扎实比堆 500 条含糊查询更可靠。每次产品新增一类真实搜索失败再把它沉淀为回归样本。Recall 与 NDCG 也不是通用真理。若产品只展示一张图应提高 Top1 指标权重若用户会浏览长列表前十排序更有意义。阈值要结合业务损失设定不能从别的项目抄一个数字。这次改造最大的变化是把“结果看起来还行”变成了可追溯的发布结论。模型可以升级索引可以重建分数分布也可以变化但数据集版本、相关性判断、局部退步和生命周期证据都必须留下。文搜图真正进入产品后稳定发布比一次漂亮 Demo 更难也更值得工程化。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进