# 端侧文搜图：先搞清它按什么匹配，再决定搜索框怎么写 # 智绘鸿蒙·如 7 而至#

## 前言

假设你在做一个旅行相册应用。用户拍了几千张照片，文件名全是 `IMG_20260924_183207.jpg`，按时间排序帮不上忙，标签系统又没人愿意手动打。

用户实际会怎么找照片？他会说：**"把海边日落那张给我看看。"**

传统两条路都不划算：云端多模态检索要上传用户全部相册，隐私和带宽都是问题；本地自建向量库要引模型、做推理、管索引，工程量劝退。

HarmonyOS 7（API 26）在 `Core Vision Kit` 里新增了这个能力：**文搜图**（`textSearchImage`）。把图片特征存进本地索引，然后用一句自然语言把匹配的图片捞回来，全程不出设备。

这篇先讲怎么用，再讲一个更重要的事：**它到底按什么在匹配**——这个直接决定你的产品文案怎么写。

## 能用在哪些场景

- 旅行、家庭相册的自然语言检索（最贴合）
- 自媒体、设计类的本地素材库
- 工程、家装类的参考图归档
- 任何"图片多、命名烂、标签没人打"的本地相册

需要注意的是，它**不是**通用图片搜索引擎。第二节末尾会看到它明确的脾气边界。

## 接口与约束

调用面六个方法，都是 `textSearchImage` 命名空间下的静态函数，不需要 create/destroy 分析器：

```typescript
import { textSearchImage } from '@kit.CoreVisionKit';

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>
release(): Promise<boolean>
```

返回的 `ImageObject` 有三个字段：`imagePath`、`scope`、`similarity`（取值范围 [-1, 1]，越大越相似）。

> - **文搜图（Text-to-Image Search）**：用文本作为查询条件，从已建立索引的图片集合中检索匹配图像。
> - **scope**：图片索引的逻辑分区，用来隔离不同图库，类似一张表里的命名空间。
> - **端侧索引**：图片特征提取与检索都在设备本地完成，不上传原图。

参数约束（来自 SDK 声明，实测有效）：

| 参数 | 约束 |
| --- | --- |
| `imagePath` | **应用沙箱路径**，1–128 字符 |
| `scope` | 1–32 字符，**只允许字母或数字** |
| `query` | 1–100 字符，允许中文 |
| `topKey` | 整数 0–100，默认 100 |

三个错误码值得提前处理：

- `1013100001` Invalid image path —— 传了媒体库 URI 而不是沙箱路径
- `1013100002` Service abnormal —— 服务异常，需要降级
- `1013100003` The capability has been updated —— **模型能力升级后旧索引失效**，官方要求先 `clearData()` 再重新 `insertImage()`

最后这个坑比较隐蔽：用户升级系统后你的索引可能整体作废。不处理 `1013100003`，表现就是"搜索突然全空"，而且界面上没有任何提示。

## 开发步骤

### 第一步：把图片落到沙箱

这是最容易卡住的地方。`insertImage` 只吃沙箱路径，你从相册选图拿到的 `file://media/Photo/...` URI 直接传进去就是 `1013100001`。

所以流程必须是：**取图 → 拷进 `context.filesDir` → 用沙箱路径建索引**。

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

从相册导入同理：`fileIo.open(uri)` 读出内容，写进 `filesDir`，再拿这个路径建索引。

还有一点：**`scope` 只能用字母和数字**。我一开始想用 `trip_2026`，下划线不在允许字符集里，最后用了 `triplens2026`。scope 是索引的逻辑分区，多用户、多图库都靠它隔离，别浪费它。

### 第二步：建索引，并把耗时暴露出来

```typescript
async build(paths: string[], scope: string): Promise<number> {
  await this.init();
  const start: number = Date.now();
  for (let i = 0; i < paths.length; i++) {
    try {
      await textSearchImage.insertImage(paths[i], scope);
    } catch (err) {
      const e = err as BusinessError;
      if (e.code === 1013100003) {
        await this.reset();                 // 模型升级，旧索引作废
        return this.build(paths, scope);    // 重建一次
      }
      throw new TsError(e.code, `建索引失败（${e.code}）：${e.message}`);
    }
  }
  return Date.now() - start;
}
```

实测单张 640×480 图片的 `insertImage` 约 **380 ms**，6 张约 2.3 秒。

这个数字决定了产品形态：**建索引必须是可后台执行、可中断、有进度的任务**，不能放在首屏等待里。

### 第三步：检索

```typescript
async search(query: string, scope: string, top: number): Promise<Hit[]> {
  await this.init();
  const list: textSearchImage.ImageObject[] = await textSearchImage.search(query, scope, top);
  // 转成 UI 需要的结构：文件名 + similarity
}
```

检索很快，实测 13–36 ms，用户体感是即时的。

### 第四步：生命周期

`init()` 自己保证幂等；`release()` 放 `aboutToDisappear`。因为 `insertImage` 是同步等返回的，索引期间不要并发调用 `search`。

## 真机运行效果

测试机：HUAWEI MatePad Pro（MRDI-W00），HarmonyOS 7.0.0.107，API 26。图库是 6 张旅行照：海边日落、雪山湖泊、都市夜景、古镇小巷、森林溪流、沙漠星空。

精确命中一张：

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

搜「夜景」命中两张（都市夜景 + 沙漠星空）：

![](assets/02-multi-hit.png)

搜「风景」返回空：

![](assets/03-zero-hit.png)

单特征词「城市夜景」：

![](assets/04-city.png)

## 它到底按什么匹配

我用 9 个查询词做了完整对照，结果如下：

| 查询词 | 命中 | 相似度 | 检索耗时 | 解读 |
| --- | --- | --- | --- | --- |
| 海边日落 | 1 张（海边） | 33.7% | 36 ms | 精确 |
| 雪山湖泊 | 1 张（雪山） | 35.9% | 21 ms | 精确 |
| 城市夜景 | 1 张（都市夜景） | 35.3% | 17 ms | 精确 |
| 沙漠星空 | 1 张（沙漠星空） | 34.8% | 19 ms | 精确 |
| **夜景** | **2 张**（都市夜景 32.9% / 沙漠星空 31.7%） | — | 17 ms | 跨图共同特征 |
| 雪 | 1 张（雪山） | 31.2% | — | 单字也能命中 |
| 一张猫 | 0 张 | — | 13 ms | 图库无猫，正确拒绝 |
| **风景** | **0 张** | — | 17 ms | **6 张全是风景，却返回 0** |
| 海边的雪山 | 0 张 | — | — | 跨图元素拼接，正确拒绝 |

两个结论，直接决定产品怎么写。

**结论一：它匹配"画面里有什么"，不匹配类别。**

「风景」返回 0 是最有信息量的一条——6 张图全是风景，但"风景"不描述任何画面内容，模型无从对齐。而「夜景」返回 2 张，因为都市夜景和沙漠星空**在画面上共享"夜"这个可视特征**。

所以搜索框的引导文案不能写"输入图片类别"，要写**"描述照片里的东西"**。示例词要具体到画面元素：`海边日落`、`下雪的山`、`夜晚的霓虹灯`，而不是 `风景`、`旅行`、`好看`。

**结论二：相似度绝对值不能直接给用户看。**

精确命中的分数也只有 31%–36%，因为返回值落在 [-1,1] 全区间，实际分布被压缩在 0.3 附近。如果 UI 上显示"匹配度 33.7%"，用户会认为结果很差。

建议做法：**只做相对排序，不做绝对展示**；一定要显示，就按本次结果集归一化（最高分记 100），或者干脆显示名次而不是百分比。

## 上生产前还要补的三件事

1. **索引持久化与增量。** `insertImage` 是往系统数据库里写，但你需要自己记住"哪些文件已经建过"，否则每次冷启重建，2.3 秒/6 张会放大成几百秒。
2. **删除同步。** 用户从你的应用里删照片时，必须同步 `deleteImage`，否则结果里会返回已经不存在的文件。
3. **`1013100003` 的自动恢复。** 别把它当普通错误抛给用户，检测到就静默 `clearData()` + 重建，并在 UI 上提示"正在重新整理相册索引"。

## 总结

什么场景值得用它：图片多、命名烂、标签没人打的本地相册——旅行、家庭、自媒体素材、工程参考图都算，全程端侧跑完，原图不出设备。

不适合：需要按"类别、事件"检索的场景。它认的是画面里有什么，不认"风景""旅行"这种抽象标签，硬套只会得到一片空白。

> 💡 **一句话**：搜索框要引导用户"描述照片里的东西"，别让他选分类；相似度只做相对排序。

## 参考文献

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
4. HarmonyOS 7(API 26) 官方新能力解读 & 开发者实战开发案例合集：https://developer.huawei.com/consumer/cn/forum/topic/0208224160644765091
