智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

文搜图工程化:scope 隔离能信,但 clearData 没有 scope 参数

智绘鸿蒙·如 7 而至

前言

上一篇(《端侧文搜图:先搞清它按什么匹配,再决定搜索框怎么写》)聊的是检索质量。这一篇聊另一件事:把它做成一个能长期运行的功能时,会撞上的结构问题。

典型需求形态是这样:一个应用里同时存在多个彼此独立的图片集合。比如设计素材库和旅行照片库,或者多用户场景下每人一套图、企业场景下每个项目一套图。

用户期望很朴素:在素材库里搜到的结果,不要串到旅行库去。

textSearchImagescope 参数就是为此设计的。我实测下来,读隔离是可靠的,但写隔离在接口层面有个缺口——清空操作无法按 scope 执行。

这一点会直接影响你的功能设计,必须在架构阶段就知道,所以我把它单独写一篇。

接口回顾

init(): Promise<boolean>
insertImage(imagePath: string, scope: string): Promise<boolean>
search(query: string, scope: string, topKey?: number): Promise<ImageObject[]>
deleteImage(imagePath: string, scope: string): Promise<boolean>
clearData(): Promise<boolean>          // 注意:无 scope 参数
release(): Promise<boolean>
  • scope:图片索引的逻辑分区,类似一张表里的命名空间,用于隔离不同图库。
  • 软隔离 / 硬隔离:软隔离指查询时按条件过滤,数据仍在一起;硬隔离指数据本身分区存放。这里的 scope 属于后者。

scope 的约束是 1–32 字符、仅允许字母和数字。所以 design-assetstrip_2026 这类带连字符或下划线的命名不合法。本文用 designassetstripshots

这次要验证什么

我把问题拆成五个可验证的命题,避免凭感觉下结论:

编号 待验证命题
E1 同一查询词在不同 scope 下是否互相隔离
E2 同一路径在同一 scope 重复 insertImage 的行为
E3 deleteImage 后检索结果是否同步消失
E4 类别词与内容词在不同图库上的表现是否一致
E5 建库耗时与图片数量的关系

开发步骤

第一步:图片必须先落沙箱

和单库场景一样,insertImage 只接受应用沙箱路径,不接受媒体库 URI。

多库场景要额外注意文件名冲突——两个库里都可能有名为 cover.jpg 的图,落进同一个 filesDir 会互相覆盖,进而导致索引指向错误内容。

我的做法是把 scope 编进文件名:

async stage(ctx: Context, names: string[], scope: string): Promise<string[]> {
  const out: string[] = [];
  for (let i = 0; i < names.length; i++) {
    const bytes: Uint8Array = await ctx.resourceManager.getRawFileContent(names[i]);
    const dest: string = `${ctx.filesDir}/${scope}_${names[i]}`;   // 前缀隔离
    const f = fileIo.openSync(dest,
      fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
    fileIo.writeSync(f.fd,
      bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
    fileIo.closeSync(f);
    out.push(dest);
  }
  return out;
}

第二步:分库建索引并记录耗时

async build(paths: string[], scope: string): Promise<BuildStat> {
  await this.init();
  const start: number = Date.now();
  for (let i = 0; i < paths.length; i++) {
    await textSearchImage.insertImage(paths[i], scope);
  }
  const st: BuildStat = { scope: scope, count: paths.length, costMs: Date.now() - start };
  return st;
}

init() 全局一次即可,多个 scope 共用同一个已初始化的服务,不需要每个库单独 init。

第三步:检索时把 scope 作为查询上下文

UI 上表现为"当前在哪个库里搜",实现上就是把 scope 传进去:

const list: textSearchImage.ImageObject[] = await textSearchImage.search(query, scope, top);

返回的 ImageObject 里带 scope 字段,可以做二次校验:如果结果里出现非预期 scope,说明调用参数串了。

第四步:三个实验的实现

/** E1 跨库隔离 */
const inDesign: Hit[] = await lib.search('雪山湖泊', 'designassets', 10);
const inTrip: Hit[] = await lib.search('雪山湖泊', 'tripshots', 10);

/** E2 幂等插入 */
const a: boolean = await textSearchImage.insertImage(path, scope);
const b: boolean = await textSearchImage.insertImage(path, scope);

/** E3 删除同步:先用已知能命中的词取基线,删完再取一次 */
const before: Hit[] = await lib.search('海边日落', 'tripshots', 10);
const ok: boolean = await textSearchImage.deleteImage(path, 'tripshots');
const after: Hit[] = await lib.search('海边日落', 'tripshots', 10);

E3 的实现里有个方法论教训,我放在后面讲。

真机数据

测试机:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS 7.0.0.107,API 26。

图库构成:designassets 3 张(扁平海报、深色 UI 界面、3D 火箭图标),tripshots 2 张(海边日落、雪山湖泊)。所有图片 640×480。

建库耗时(E5)

scope 图片数 耗时 单张均摊
designassets 3 1102 ms 367 ms
tripshots 2 764 ms 382 ms
合计 5 1792 ms 358 ms

与单库场景实测的 380 ms/张吻合,说明建库耗时只与图片数量线性相关,与 scope 数量无关。多库不会带来额外索引开销。

五项实验结果

编号 命题 结果
E1 scope 读隔离 成立。「雪山湖泊」→ 设计库 0 条 / 旅行库 1 条
E2 重复插入行为 两次均返回 true,无异常、无重复条目
E3 删除同步 deleteImage 返回 true;「海边日落」1 条 → 0 条
E4 类别词无效 复现。设计库含一张海报,搜「海报」返回 0 条
E5 耗时线性 成立,见上表

三个实验按钮跑完的日志面板:

删除海边那张图之后,切到旅行库搜「海边日落」返回 0 条——与 E3 结论一致:

三条工程结论

结论一:clearData() 没有 scope 参数,这是接口层面的缺口

clearData(): Promise<boolean>

签名里没有 scope。也就是说清空是全库级别的,无法只清掉某一个 scope 的索引。

产品上的直接后果:当用户删除"某一个素材库"时,你没法只回收这个库的索引条目。可选方案只有两条:

方案 做法 代价
全清重建 clearData() 后把所有剩余库重新 insertImage 库多、图多时是几十秒级操作,必须后台 + 进度
逐张删除 应用侧记住该 scope 下所有沙箱路径,逐个 deleteImage 要自己维护"路径 → scope"映射表

我的实现选了第二条的变体:应用侧维护 Map<scope, string[]>,删库时按路径逐张 deleteImage;只有模型升级(错误码 1013100003)这种"所有索引一起作废"的场景才用 clearData()

这个选择是合理的——clearData() 的语义本来就该留给"整个索引不可信了"的情况。

这条约束在官方文档里不显眼,但会决定你的删除功能能不能做干净,建议在方案设计阶段就确认。

结论二:insertImage 幂等,可以放心做增量补索引

两次插入同一路径都返回 true,且检索结果没有出现重复条目。

这意味着你可以放心写"启动时把新增图片补进索引"的逻辑,不需要先查询某张图是否已存在(接口本身也没提供查询能力)。

但仍然建议应用侧记住已索引的路径集合。原因不是防重复插入,而是:图片被用户删除后,索引里会留下指向不存在文件的条目,检索结果会返回打不开的图。这个只能靠应用侧的路径表来清理。

结论三:类别词失效是跨图库一致的特性,不是数据问题

设计库里明明有一张海报,搜「海报」返回 0;旅行库里六张全是风景,搜「风景」返回 0。

两个完全不同的图库、两类完全不同的类别词,表现一致。这排除了"我的图不够典型"这类解释,指向模型本身的训练目标:它学的是"用一段话描述画面里有什么",不是"给图片分门别类"

所以工程上不要指望换一批图就能让类别词生效。可行的应对是在产品层做一层查询改写:把用户输入的类别词扩展成画面元素描述。例如"海报"改写成"大字标题、几何色块、扁平插画","风景"改写成"山、海、天空、树木"。改写词表可以硬编码,命中率会明显改善。

一个方法论教训

E3 的第一版实现是这样的:先删图,再用「海报」查询,看到"剩 0 条"就认为删除成功。

这个结论是无效的——因为「海报」在删除前就返回 0 条(见 E4)。用一个恒为 0 的查询去验证删除,无论删没删成功结果都一样。

修正后的做法是:先取基线(「海边日落」1 条)→ 删除 → 再取(0 条)。有变化才有证据。

这类错误在做检索、匹配类功能的验证时特别容易犯,因为"没结果"和"结果被清掉了"在界面上长得一模一样。写验证脚本时,务必先断言"操作前能拿到非空结果"。

总结

什么场景值得用它:一个应用里存在多个彼此独立的图库(多用户、多项目、素材库与相册分家),用 scope 做硬隔离,读隔离经实测可靠,多库也没有额外开销。

不适合:直接实现"删掉某一个库"——clearData() 没有 scope 参数,只能应用侧自己维护路径表、逐张 deleteImage

💡 一句话:scope 能信,但删库要自己记账;clearData() 留给模型升级那种整个索引一起作废的场景。

参考文献

  1. Core Vision Kit 开发指南 · 通过文本搜索图片:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-text-search-image
  2. Core Vision Kit API 参考 · textSearchImage(通过文本搜索图片):https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-search-image-api
  3. Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision