一、图片没重复,结果却重复了

文搜图页面准备接入两个来源时,最容易被忽略的不是搜索词解析,而是同一张照片在不同索引中可能拥有完全不同的编号。相册索引把它叫作 local-001,云端索引又称为 remote-A;如果直接按照接口返回的字符串去重,页面会把它当成两张图。再叠加相关性分数排序,列表首屏的位置就会在不同返回顺序之间反复变化,用户看到的既不稳定,也不便于定位。

本文不讨论向量模型的识别准确率,也不假设某个华为接口会替我们做业务合并。我将问题明确收缩为:在已经拿到业务候选结果的前提下,如何按可信别名表归一化身份,处理异常分数,给出可重复的结果顺序。最终的 ArkUI Search 只是输入及展示入口,真正接受离线校验的是 Node.js 脚本。这样的分工让算法合同能够先于设备集成接受检查。

我采用的演示项目名为 ResultMergeGuard,任务编号 RM-409。它保存七条构造候选、六条已知别名和一份报告。目标并非强求所有来源评分可比较,而是假定这些候选的评分已由业务上游校准为同一范围。若没有校准步骤,直接把不同模型的置信度排列到同一排行榜是没有解释力的。本文仅验证排序器接受既定合同后的行为。

二、身份必须先于排名被确定

第一项约束是“同一资产”的定义。不建议用缩略图路径、相同文件名或颜色直方图作为绝对身份,因为重编码、裁剪与路径迁移都可能改变它们。这里使用显式的业务别名表:local-001 与 remote-A 都映射到 PHOTO-21;cloud-22 与 local-22 都映射到 PHOTO-22。映射的来源必须是可信的资产记录,不能任由客户端自己猜测、合并两个看起来相似的文件。

第二项约束是分数。数值零可以是有效低分,字符串形式的“NaN”却不能在 JS 排序中悄悄参与比较。同样,Infinity、负分、超出业务合同上限的数字也应在合并前拦截。若先排序再检查,比较器可能退化为返回 NaN,导致结果顺序依赖原始输入,问题会表现得非常隐蔽。

开始时先读入 fixtures/candidates.json,并为每条候选保存源数组中的位置。这里的 index 不是 UI 行号,而是在分数和来源优先级相同的情况下使用的最终排序键。拒绝项保留原始 id 和原因,便于对照上游数据;被拒项不会进入选择最终照片的 Map。

const valid = [];
const results = [];
for (const [index, item] of data.candidates.entries()) {
  const canonicalId = data.aliases[item.lookupKey];
  if (!canonicalId) {
    results.push({ id: item.id, status: 'HOLD_KEY' });
    continue;
  }
  if (typeof item.score !== 'number' || !Number.isFinite(item.score)
      || item.score < 0 || item.score > 1) {
    results.push({ id: item.id, status: 'HOLD_SCORE' });
    continue;
  }
  valid.push({ ...item, canonicalId, index });
}

这里的 HOLD_KEY 不表示照片一定损坏,只说明系统没有足够证据将该别名绑定到统一的资产身份。正常做法是补齐映射来源或交人工复核,而不是以原有 lookupKey 偷偷插回列表。HOLD_SCORE 则需要排查模型或序列化阶段,尤其关注数字被转换成字符串的情况。两种拦截理由分开保存,才能对不同上游负责人形成可操作的反馈。

三、稳定排序不是只写一个降序比较器

有效候选需要按照三个层次排序。先比较校准后的 score,分数高者优先;再看来源优先级,本例 local、cloud、backup 依次排列;最后按照原始索引排序。只有在这些键都确定之后,才按 canonicalId 判重,保留排序结果里第一个出现的候选。若先按到达顺序去重,再按分数排序,PHOTO-22 可能会错误保留得分 0.88 的云端版本,而丢掉 0.90 的本地版本。

const sourcePriority = { local: 0, cloud: 1, backup: 2 };
valid.sort((a, b) => b.score - a.score
  || (sourcePriority[a.source] ?? 99) - (sourcePriority[b.source] ?? 99)
  || a.index - b.index);
const accepted = new Map();
for (const item of valid) {
  const status = accepted.has(item.canonicalId) ? 'DUP' : 'KEEP';
  if (status === 'KEEP') accepted.set(item.canonicalId, item);
  results.push({ id: item.id, canonicalId: item.canonicalId, status });
}

这段代码使用的 Map 是 Node.js 标准 JavaScript 集合,并非 ArkUI 的系统能力。sourcePriority 只是本项目决定的平局优先级,不代表本地搜索结果天然优于云端结果。若产品改为可信度不同的多模型混排,应当重新校准分数、制定源优先规则,再更新测试案例,而不是只换个数字声称提升了搜索效果。

顺序一致还有一个前提:输入候选集合与别名表保持不变。若同一批候选每次都携带不同的到达索引,那么完全同分的结果仍可能换位。生产环境应当替换成稳定的上游记录 ID 作末级排序键,并约定大小写与 Unicode 归一化策略。本例故意保留输入位置,是为了展示最小可执行版,并在文章里写清这个限制。

四、用七条数据把四类结果跑出来

七条样本中,C-901 和 C-903 分别指向 PHOTO-21,0.94 分先保留,0.91 分标成 DUP;C-902 与 C-907 都指向 PHOTO-22,0.90 分的 C-907 保留,0.88 分的 C-902 判重。C-904 指向 PHOTO-23,可独立保留。C-905 的分数字段故意放入字符串 NaN,C-906 使用未登记的 unknown-xyz。这样既有正常排序路径,也有必须拒绝的不可信输入。

脚本输出每条原始候选的处理状态,还输出最终保留列表与统计。命令行可先普通运行观察结果;加上 --ci 时,只要存在 HOLD 项就返回非零退出码,方便流水线阻止问题样本进入发布环节。示例代码是本地 Node.js 运行数据,不能被描述为手机搜索实测耗时或真实语义检索准确率。

const counts = { KEEP: 0, DUP: 0, HOLD_SCORE: 0, HOLD_KEY: 0 };
for (const item of results) counts[item.status] += 1;
const final = [...accepted.values()].map(({ id, canonicalId, score }) =>
  ({ id, canonicalId, score }));
console.log('RM-409 summary', counts);
console.log('final:', final);
if (process.argv.includes('--ci') && (counts.HOLD_SCORE || counts.HOLD_KEY)) {
  process.exitCode = 2;
}

实际执行构造数据得到 KEEP 3 / DUP 2 / HOLD_SCORE 1 / HOLD_KEY 1,最终显示 PHOTO-21、PHOTO-22、PHOTO-23,且各自的展示分数依次为 0.94、0.90、0.88。这个结果只确认离线排序与去重合同。正常执行的退出码为零;因当前测试集含有两个特意构造的 HOLD 项,CI 模式按预期返回退出码二。后者不是 Node.js 崩溃,而是我们的业务门禁定义。

五、ArkUI 接入要考虑加载态与资源释放

在 HarmonyOS 页面中,可以让 Search 组件只负责读取用户输入、触发查询,让业务层收到候选数组后调用同样的规范化逻辑。建议把 queryId 与候选集合一起传入结果模型,避免用户切换关键词后旧批次覆盖新的排序结果。此前批次文章讨论过请求乱序,这里只处理同一批候选内部的身份合并,不再重复把旧响应门禁写成文章核心。UI 列表的每一项应以 canonicalId 作为逻辑身份,而不是以数组下标作为持久选中依据。

如果某个来源返回大量记录,不宜在 UI 线程同步做复杂图像相似度比较。本示例只有一次普通数组排序和 Map 去重,对七条测试数据足够;放大到长列表时要关注候选获取、排序内存、分批合并与主线程占用。这里没有设计 Worker 或 TaskPool 实现,所以不声称已解决大规模排序卡顿。对于图片预览对象,列表移除后是否释放由实际 Image/PixelMap 生命周期决定,Node.js 工具并未创建系统 PixelMap。

异常恢复时建议区分暂态与永久性错误:网络重试不会自动修复未知别名;服务端校准器异常时,简单重新获取同一份不合法分数也不能改善结果。最终报告应记录采样时间、模型评分合同版本、别名表版本及本地脚本版本。若需要追责,还应保存候选 id 与来源,但不建议默认把用户真实搜索词、私有照片路径或身份信息写入外发日志。

六、界面结果、验证范围和可复现步骤

项目结构保持简单:tools/merge-results.mjs 是可执行脚本,fixtures/candidates.json 是七条构造数据,reports/merge-report.json 是机器可读报告,reports/run.log 与 reports/ci.log 分别保存普通模式和 CI 模式输出。复核时从 ZIP 解压后的 ResultMergeGuard 目录运行 node tools/merge-results.mjs,对照三个最终 canonicalId;再运行 node tools/merge-results.mjs --ci 检查退出码二。无需联网。

文中的三幅图片按封面、DevEco Studio 风格代码演示、9:16 应用屏幕分别绘制。第二幅显示的是项目的源码与示例 HiLog 排版,第三幅显示构造数据对应的 KEEP、DUP、HOLD 状态,若以后迁移到真机,应另外记录 DevEco 构建版本、目标 API 版本、机型、真实输入样本与可复现步骤。

官方参考:华为 ArkUI 官方能力概览 提供声明式界面及组件体系的入口;ArkUI 资源与开发工具 可用于核对开发环境和文档导航。本文 Node.js 的 Map、Array.sort、Number.isFinite 来自标准 JavaScript。正式产品仍需到当前组件参考中按所用 SDK 版本核实 Search 的具体回调签名。

这套方法留下的工程结论很明确:照片身份必须可靠归一化,分数必须先验证再排序,平局规则必须固定,拒绝项必须留下可解释的原因。它把经常被误判为“页面随机跳动”的业务问题变成容易复现和回归的确定性规则。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐