前言
上一篇(《端侧文搜图:先搞清它按什么匹配,再决定搜索框怎么写》)聊的是检索质量。这一篇聊另一件事:把它做成一个能长期运行的功能时,会撞上的结构问题。
典型需求形态是这样:一个应用里同时存在多个彼此独立的图片集合。比如设计素材库和旅行照片库,或者多用户场景下每人一套图、企业场景下每个项目一套图。
用户期望很朴素:在素材库里搜到的结果,不要串到旅行库去。
textSearchImage 的 scope 参数就是为此设计的。我实测下来,读隔离是可靠的,但写隔离在接口层面有个缺口——清空操作无法按 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-assets、trip_2026 这类带连字符或下划线的命名不合法。本文用 designassets 与 tripshots。
这次要验证什么
我把问题拆成五个可验证的命题,避免凭感觉下结论:
| 编号 | 待验证命题 |
|---|---|
| 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()留给模型升级那种整个索引一起作废的场景。
参考文献
- Core Vision Kit 开发指南 · 通过文本搜索图片:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-text-search-image
- Core Vision Kit API 参考 · textSearchImage(通过文本搜索图片):https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-search-image-api
- Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision