# 文搜图工程化：scope 隔离能信，但 clearData 没有 scope 参数 # 智绘鸿蒙·如 7 而至#

## 前言

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

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

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

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

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

## 接口回顾

```typescript
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 编进文件名：

```typescript
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;
}
```

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

```typescript
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 传进去：

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

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

### 第四步：三个实验的实现

```typescript
/** 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 | 耗时线性 | 成立，见上表 |

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

![](assets/01-experiments.png)

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

![](assets/02-trip-scope.png)

## 三条工程结论

### 结论一：`clearData()` 没有 scope 参数，这是接口层面的缺口

```typescript
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
