在这里插入图片描述

摘要

本文围绕 HarmonyOS Core Vision Kit 的文搜图(Text-to-Image Search)能力,讲透端侧语义相册搜索从建库、检索到释放的完整链路。文章先梳理 textSearchImage 的能力与规格边界(版本、区域、路径、scope、query、topKey、similarity),再给出建库与检索的核心代码骨架,最后聚焦索引生命周期的 5 个实战要点:用 scope 做图库分库、启动做增量插入、insert 放后台带进度、设相似度阈值挡误召回、release 时机与跨会话持久化实测确认。文中配有整体链路与增量索引两张流程图,以及基于 preferences 清单 + mtime 比对的完整可运行 TypeScript 增量建库实现,适合做本地语义相册、私密图库检索、素材库按内容分组的开发者参考。

目录

一、背景与痛点

上一篇我们聊了图像超分的接入坑,这一篇把镜头转到"找图"——Core Vision Kit 的文搜图(Text-to-Image Search)。它的爽点很直观:用户说一句"海边日落"“猫趴窗台”,端侧模型就把本地相册里语义匹配的照片捞出来,全程数据不出设备。

但我第一次把它做成相册搜索功能时,翻车不在"搜不准",而在索引怎么管:启动就卡 3 秒、换 scope 搜出串扰结果、release 之后索引全没、低相似度的图也混进来……这些都不是算法问题,而是索引生命周期没设计好。

举个最典型的现场:产品经理在演示机上输入"生日蛋糕",结果第一屏混进了三张 Excel 截图和一张白板照片。排查半天才发现,测试同学为了省事把所有图片都塞进了同一个 scope,而"生日"这个词在办公截图里的 OCR 文本中恰好高频出现。文搜图不是"插进去就能用",它更像一个需要你亲手维护的本地向量库。本文把端侧文搜图从建库、检索到释放的完整链路讲透,帮你少走弯路。

二、能力与规格边界

文搜图是跨模态检索:端侧模型把每张图编码成向量,把一句话也编码进同一语义空间,按相似度降序返回。先记牢约束:

维度规格
归属@kit.CoreVisionKit 下的 textSearchImage
起始版本26.0.0,仅 Stage 模型;支持 Phone / Tablet / PC·2in1
区域仅中国境内(港、澳、中国台湾除外)
输入imagePath 沙箱绝对路径(不带 file://,1–128 字符)
scope作用域仅字母/数字,长度 1–32,用于分组过滤
query查询词 1–100 字符,不支持纯数字/纯字母,支持中文
topKey返回上限 0–100,默认 100
返回ImageObject[]:imagePath / scope / similarity(范围 [-1, 1])

两个高频坑先标红:query 不能纯数字/纯字母,以及索引按 scope 隔离——这两点直接决定你后文的架构。

再补充几条容易被忽略的细节:

  • 路径必须是沙箱绝对路径。从相册选择器拿到的 file:// URI 要先转成沙箱路径,否则 insertImage 直接失败,且错误信息往往很含糊。
  • topKey 传 0 不是"不限制",而是返回空。想要"尽量多"就传 100,再在业务层做分页或截断。
  • similarity 可能为负。语义完全无关时分数会落到 0 以下,所以阈值过滤时不要只写 > 0,要按业务定一个正数下限。
  • scope 是过滤维度,不是权限隔离。它只影响检索范围,不提供任何访问控制,别拿它当多用户数据隔离方案。

三、核心代码(建库 + 检索骨架)

最关键的认知:init() 之后索引服务才就绪,索引是否跨会话持久化要看实测——稳妥做法是每次启动重新 insert 一次(增量判断见坑 2),退出时 release()。

先看整体链路,从初始化到检索再到释放,四个阶段环环相扣:

否

是

再次使用

init() 初始化索引服务

返回 true?

检查区域/版本/Stage 模型

insertImage(path, scope) 建库

search(query, scope, topKey) 检索

similarity 阈值过滤 + 降序排序

imagePath 转 PixelMap 展示

release() 释放服务

这张图里有两个容易踩的点:init() 返回 false 时不要继续往下走,先排查区域和版本;release() 之后服务不可用,回到前台必须重新走一遍 init(),所以图中把释放和初始化连成了闭环。

import { textSearchImage } from '@kit.CoreVisionKit';
import { fileIo } from '@kit.CoreFileKit';

// 1. 初始化(返回 true 才可用,失败多为设备/版本/区域不支持)
async function ready(): Promise<boolean> {
  const ok = await textSearchImage.init();
  if (!ok) {
    // 常见原因:非中国境内、系统版本低于 26.0.0、非 Stage 模型
    console.error('textSearchImage init failed, check region/version');
  }
  return ok;
}

// 2. 建库:按 scope 分组插入沙箱图片,而非一锅炖
async function buildIndex(paths: string[], scope: string): Promise<void> {
  for (const p of paths) {
    try {
      // p 必须是沙箱绝对路径,不能带 file:// 前缀
      await textSearchImage.insertImage(p, scope);
    } catch (e) {
      // 单张失败不要中断整批,记录后继续
      console.warn(`insert failed: ${p}`, e);
    }
  }
}

// 3. 检索:中文语义查询,按 scope 收窄范围
async function search(query: string, scope: string, top: number) {
  // query 不能是纯数字/纯字母,否则直接抛错
  if (!/[\u4e00-\u9fa5]/.test(query)) {
    console.warn('query 建议包含中文语义,纯数字/字母不受支持');
  }
  const hits = await textSearchImage.search(query, scope, top);
  return hits
    .filter(h => h.similarity >= 0.30) // 阈值过滤低置信误召回
    .sort((a, b) => b.similarity - a.similarity);
}

// 4. 退出释放
async function teardown(): Promise<void> {
  await textSearchImage.release();
}

几个骨架之外必须补的点:

  • insertImage 是异步串行的,几千张图务必分批 + 让出主线程,否则就是要点 3 说的卡顿。
  • 检索结果里的 imagePath 是原样返回的沙箱路径,展示前要自己转成 PixelMap 或 file:// URI 给 Image 组件。
  • release() 之后再调用 search 会失败,页面 onPageHide 里释放后,回到前台要重新 init。

四、索引生命周期的 5 个实战要点

要点 1:用 scope 做"图库分库",别把所有图塞一个作用域。
我最初把所有相册照片插进同一个 scope,结果"宠物"的 query 把工作截图也带了出来。正确姿势:相册用 album、宠物用 pet、素材用 work,同一句话在不同 scope 里分别检索,互不串扰,也大幅缩小单 scope 检索范围、提速。

要点 2:启动别全量重建,做增量插入。
首次建库几千张图,insertImage 串行跑能卡 3 秒以上。我的做法是维护一份"已索引文件清单"(路径 + 修改时间),启动只对新文件/改动文件 insertImage,老图跳过。即便索引不跨会话持久化,增量也能把启动耗时压到几百毫秒。

清单本身用 preferences 存一个 JSON 即可,结构大致是 { [path]: mtime }。每次启动扫一遍相册目录,比对 mtime 有变化的才重新插入。注意两点:删除的图片要从清单里同步移除,否则清单会越滚越大;清单和实际索引可能不一致(比如用户清了应用数据),所以首次启动或检测到清单为空时,要退化成一次全量重建。
要点 3:insert 放后台,带进度,别阻塞 UI。
大量插入建议在 taskpool 或异步任务里跑,并回调进度条;主线程直接插会掉帧、触发 ANR 风险。用户感知是"相册正在建立语义索引",比黑屏卡死友好得多。

增量判断的完整决策流程如下:

是

否

是

否

启动:loadManifest()

scanAlbum() 扫描目录快照

清单为空?

全量重建:所有文件都插入

比对 mtime

新增/改动文件 → toInsert

磁盘已删除文件 → toRemove

逐张 insertImage

插入成功?

更新清单 manifest[path] = mtime

记录日志,跳过,不写清单

清理 toRemove 的清单项

saveManifest() + flush() 落盘

关键在两条分支:清单为空直接走全量重建,避免清单与实际索引不一致;插入失败不写清单,保证下次启动还会重试这张图,不会永久漏掉。

完整实现如下(preferences 存清单 + 扫描目录比对 mtime + 增量插入 + 清理删除项 + 空清单退化全量):

import { textSearchImage } from '@kit.CoreVisionKit';
import { fileIo } from '@kit.CoreFileKit';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

// 清单结构:{ 沙箱绝对路径: 最后修改时间(ms) }
type IndexManifest = Record<string, number>;

const PREF_NAME = 'image_search_index';
const KEY_MANIFEST = 'manifest';

// 读取已索引清单;首次启动或数据被清时返回空对象
async function loadManifest(ctx: common.UIAbilityContext): Promise<IndexManifest> {
  const store = await preferences.getPreferences(ctx, PREF_NAME);
  const raw = await store.get(KEY_MANIFEST, '{}');
  try {
    return JSON.parse(raw as string) as IndexManifest;
  } catch (e) {
    // JSON 损坏时按空清单处理,触发一次全量重建
    return {};
  }
}

// 持久化清单,注意 flush 才会真正落盘
async function saveManifest(ctx: common.UIAbilityContext, manifest: IndexManifest): Promise<void> {
  const store = await preferences.getPreferences(ctx, PREF_NAME);
  await store.put(KEY_MANIFEST, JSON.stringify(manifest));
  await store.flush();
}

// 扫描相册目录,返回 { 路径: mtime } 的当前快照
async function scanAlbum(dir: string): Promise<IndexManifest> {
  const snapshot: IndexManifest = {};
  const files = await fileIo.listFile(dir); // 列出目录下所有文件名
  for (const name of files) {
    const fullPath = `${dir}/${name}`;
    const stat = await fileIo.stat(fullPath);
    // 只收图片,且必须是沙箱绝对路径(不带 file://)
    if (stat.isFile() && /\.(jpg|jpeg|png|heic|webp)$/i.test(name)) {
      snapshot[fullPath] = stat.mtime * 1000; // mtime 单位是秒,统一转毫秒
    }
  }
  return snapshot;
}

// 核心:增量建库
async function incrementalBuild(
  ctx: common.UIAbilityContext,
  albumDir: string,
  scope: string
): Promise<void> {
  const manifest = await loadManifest(ctx);
  const snapshot = await scanAlbum(albumDir);

  // 清单为空 → 退化为全量重建,避免清单与实际索引不一致
  const isFullRebuild = Object.keys(manifest).length === 0;

  // 1. 找出新增或修改过的文件(mtime 变化即视为改动)
  const toInsert = Object.keys(snapshot).filter(
    p => isFullRebuild || manifest[p] !== snapshot[p]
  );

  // 2. 找出已从磁盘删除的文件,需要从清单移除
  const toRemove = Object.keys(manifest).filter(p => !(p in snapshot));

  // 3. 逐个插入,单张失败不中断整批
  for (const p of toInsert) {
    try {
      await textSearchImage.insertImage(p, scope);
      manifest[p] = snapshot[p]; // 插入成功才更新清单
    } catch (e) {
      console.warn(`insert failed: ${p}`, e);
    }
  }

  // 4. 清理已删除文件的清单项
  for (const p of toRemove) {
    delete manifest[p];
  }

  // 5. 回写清单,下次启动即可跳过这些文件
  await saveManifest(ctx, manifest);
  console.info(`index done: insert ${toInsert.length}, remove ${toRemove.length}`);
}

几个实现上的注意点:

  • mtime 单位是秒,fileIo.stat 返回的 mtime 需要乘 1000 转成毫秒再存,否则比对时容易和别处的时间戳对不上。
  • 只有插入成功才写清单。如果先写清单再插入,中途失败会导致这张图永远被跳过,索引里缺一块。
  • 删除项只清清单,不调删除接口。当前 textSearchImage 没有按路径删除索引的公开方法,所以删除图片后最稳的做法是清空清单触发一次全量重建,或接受旧索引残留(检索时按文件是否存在再过滤一次)。
  • flush() 不能省。put 只写内存,不 flush 应用被杀就丢了。

要点 4:设相似度阈值,挡掉低置信误召回。
similarity 范围是 [-1, 1],但很多弱相关图分数在 0.1~0.2 之间。直接全量返回会让结果"看起来很准其实很杂"。我线上固定 >= 0.30 才展示,召回质量肉眼可见变干净。阈值不是越大越好——太严苛会漏掉真实命中,建议 0.25~0.35 之间按业务调。

调阈值有个笨但有效的办法:准备 20 条真实 query 和对应的人工标注结果,把阈值从 0.2 扫到 0.5,记录每档的准确率和召回率,选一个业务能接受的平衡点。相册这种"宁缺毋滥"的场景可以偏严,素材库这种"尽量找全"的场景可以偏松。另外建议结果为空时给一句兜底提示,而不是让用户对着空白页发呆。
要点 5:release 时机与跨会话持久化要实测确认。
init 后索引在内存里可用,release 会释放服务。索引是否落盘跨启动保留,官方文档未明说,必须以真机实测为准:我测的机型重启后需重新 init,但已 insert 的图是否还在要打印验证。最稳策略是不依赖持久化,按要点 2 做增量重建,行为可预期。

五、小结与适用边界

文搜图最适合本地语义相册、私密图库检索、素材库按内容分组这类"数据不能出设备"的场景——这也是它相对云端方案的核心卖点。但它有边界:仅中国境内、query 必须带语义(中文最佳)、scope 隔离需要你主动设计、相似度阈值要调。

落地记住四句话:scope 先分库、启动做增量、插入放后台、检索加阈值。把索引生命周期管顺了,一句话找图的体验才真正丝滑。


本文为原创首发实战笔记,基于 HarmonyOS 7.0.0(API 26)Core Vision Kit 文搜图能力,代码示例为可复用骨架,实际接口以华为官方文档为准。

Logo

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

更多推荐