欢迎光临
我们一直在努力

HarmonyOS 7 Core Vision Kit + RDB:文搜图索引的 scope 隔离、相册撤权与孤儿向量回收【鸿蒙心迹】

相册撤权后,页面已经看不到那十八张照片,输入“夜景中的古建筑”却还能搜到其中一张。图片文件打不开,缩略图变成灰块,向量结果仍然参与排序。这不是搜索精度问题,而是文件权限、业务目录和端侧索引之间没有共享同一份数据边界。

一、页面删干净了,搜索索引却还记得

Demo 叫 ScopeLens Lab,清理批次 scope_20261001_07。图库共 236 张图片,业务上分成 personal 与 shared 两个 scope。测试时撤销一个包含 18 张图片的相册授权,再搜索“夜景中的古建筑”,旧版本仍返回六项,其中一项来自已撤权目录。

文搜图接口本身提供 scope,用来区分插入、查询和删除的数据范围。问题出在应用把 scope 当作字符串标签,却没有把它和相册授权、文件路径、RDB 行状态绑在一起。相册侧失去访问权以后,索引侧没有自动知道这件事。

第一版清理逻辑只从页面列表删除照片 URI。第二版补了 deleteImage(path, scope),又遇到进程中断:前九张删除成功,RDB 事务还没提交,重启后十八张都重新进入清理;有些返回 false 的条目被不断重试,最终形成无法解释的孤儿计数。

这次解决的工程问题不是增量游标,也不是模型双索引迁移,而是授权边界变化时的数据收口。验收数据固定为:图库 236 张,撤权 18 张,清除向量 18 条,孤儿向量 0,授权 generation 12,查询返回 6 条有效结果,最终状态 CONSISTENT。

二、scope 不是随手拼出来的查询参数

我给 scope 建了注册表。每个可搜索文件都要有 RDB 记录,至少包含 imagePath、scope、albumId、authGeneration、indexState 和内容摘要。只有记录写入成功以后,才允许调用索引插入;只有索引确认成功,状态才从 PENDING 变为 ACTIVE。

entry/src/main/ets
├── pages/ScopeLensPage.ets
├── vision/TextSearchAdapter.ets
├── registry/ScopeRegistry.ets
├── cleanup/RevokeCleanupJob.ets
├── search/ScopedSearchService.ets
├── database/IndexRecordStore.ets
├── model/IndexRecord.ets
└── diagnostics/ConsistencyReport.ets

scope 名称由注册表分配,页面不能传任意字符串。personal 与 shared 的路径即使相同,也视为两个索引关系;清理其中一个 scope 时不能误删另一个。正式项目可以把账户、空间或租户编码进内部 ID,但日志只打印脱敏别名。

注册表还保存 scope 的生命周期:CREATING、ACTIVE、REVOKING、CLOSED。处于 REVOKING 的 scope 不接受新插入,避免清理任务一边删除、相册监听又把同一图片加回来。状态变为 CLOSED 后仍保留统计摘要,但不保存私人路径,用于解释本次撤权清理了多少条。

我没有把相册名直接用作 scope。名称可以被用户修改,也可能包含不适合日志或存储的字符;稳定内部 ID 与展示名称分离更安全。页面显示“个人相册”,底层仍使用注册表生成的 scope,改名不会触发整库重建,也不会产生同名空间冲突。

同一路径跨 scope 出现时,账本主键必须包含 scope。若只用 path 做唯一键,把照片共享到团队空间会覆盖个人记录,随后撤销共享又会误删个人索引。这个问题在普通列表里不明显,却会直接破坏搜索边界。

三、插入先落登记,再调用向量索引

下面的代码解决“能力调用成功了,应用却没有可追踪记录”的问题。登记行先以 PENDING 写入,随后调用 textSearchImage.insertImage();成功后改为 ACTIVE,失败则保留错误状态,后续诊断工具可以重新处理。

import { textSearchImage } from '@kit.CoreVisionKit'

async registerImage(path: string, scope: string, albumId: string): Promise<void> {
const generation = await this.auth.currentGeneration(albumId)
const recordId = await this.records.insert({
imagePath: path,
scope,
albumId,
authGeneration: generation,
indexState: 'PENDING'
})

try {
const inserted = await textSearchImage.insertImage(path, scope)
await this.records.updateState(recordId, inserted ? 'ACTIVE' : 'FAILED')
} catch (error) {
await this.records.updateState(recordId, 'FAILED', this.mapError(error))
throw error
}
}

RDB 记录不是向量索引的副本,它是业务可解释性的账本。能力内部怎样存储向量不需要页面知道,但应用必须回答一条结果属于哪个授权、何时插入、是否仍可访问。没有这层记录,撤权时只能全量 clearData(),会把仍合法的另一个 scope 一起清空。

PENDING 行不能参与查询结果展示。若进程在 insert 成功后、RDB 更新前被回收,启动时一致性扫描会检查这类悬挂记录:能够确认文件与授权仍有效就重放插入并幂等标记,无法确认则进入清理。当前 Demo 不假设底层 insert 一定幂等,因此用内容摘要和 scope 先做业务去重。

生命周期上,textSearchImage.init() 在服务启动时调用一次,页面消失不 release;所有依赖页面都退出且后台任务结束后,服务再统一 release()。页面级重复 init/release 容易让清理任务和搜索任务互相打断。

初始化也不是一个全局布尔值就够了。服务用引用计数区分搜索页、索引任务和清理任务,最后一个持有者退出才进入 release;release 期间拒绝新任务,并等待正在执行的外部调用收口。若新页面恰好打开,则取消释放计划并复用现有实例,避免频繁加载模型。

图片路径变化是另一个边界。用户移动或重命名沙箱文件时,内容摘要可能相同,旧 path 却已经失效。当前 Demo 把它处理成“旧记录撤销 + 新记录登记”,不尝试在底层索引里原地改名。步骤多一些,却能沿用同一套可恢复状态机。

插入失败不会立即重试十次。错误映射区分模型未就绪、资源紧张、文件不可读和参数不合法;前两类指数退避,文件不可读等待相册事件,参数错误转永久失败。没有分类的重试只会把一条坏数据变成持续耗电任务。

四、撤权清理要允许被中断,也要能继续

相册权限变化后,先把对应 generation 从 11 提升到 12。这个动作很小,却立即让旧记录失去展示资格;随后后台清理十八条向量。即使清理需要几百毫秒,搜索页也不会继续展示已撤权结果。

下面这段代码解决“删除一半进程退出,重启后不知道从哪里继续”。每条记录单独从 REVOKED 推进到 PURGING 和 PURGED,失败回到 RETRYABLE;批次状态由明细聚合,不拿一个大事务包住外部能力调用。

async purgeRevokedAlbum(albumId: string, generation: number): Promise<number> {
const rows = await this.records.listRevoked(albumId, generation)
let purged = 0
for (const row of rows) {
if (!await this.records.claim(row.id, 'PURGING')) continue
try {
const removed = await textSearchImage.deleteImage(row.imagePath, row.scope)
await this.records.markPurged(row.id, removed ? 'DELETED' : 'NOT_FOUND')
purged++
} catch (error) {
await this.records.markRetryable(row.id, this.mapError(error))
}
}
return purged
}

NOT_FOUND 在这里可以视为收口成功,因为目标是“索引中不存在”。但它不能悄悄消失,报告会保留这个结果,用来判断是否曾经出现账本与索引不一致。网络或系统繁忙属于可重试,参数错误和非法 scope 则进入 FAILED_PERMANENT,不能无限循环。

外部能力调用不能放进 RDB 长事务。删除可能耗时,持锁会阻塞搜索和新图片登记。claim() 用条件更新抢占一条记录,只有从 REVOKED 成功改为 PURGING 的 worker 才执行;重复任务看到状态已变就跳过。这相当于用小状态机实现可恢复作业。

清理完成后,还要核对文件授权。路径仍存在不代表应用仍被允许读取;反过来文件暂时不可达也不一定代表撤权。权威信号来自授权 generation 与业务相册关系,文件探测只作为诊断证据。

claim() 还带有 workerId 和租约到期时间。如果进程在 PURGING 状态退出,下一次启动不会永久认为“有人正在处理”,而是在租约过期后重新认领。删除操作按目标不存在即成功的原则收口,因此重复执行不会恢复已清除向量。

每完成五条就写一次批次进度,页面显示 5/18、10/18,而不是等全部结束才刷新。进度来自 RDB 聚合,不是内存循环变量;进程重启后依然准确。最后一条完成时先生成一致性报告,再把批次改为 COMPLETED,避免页面提前显示成功。

用户重新授权发生在清理中途时,不会撤销当前批次。新的授权 generation 为 13,旧 generation 12 的清理继续完成;随后重新扫描当前文件并建立新记录。试图“暂停删除并把旧索引救回来”会让状态更复杂,也无法证明旧向量与新文件仍相同。

五、查询结果要再过一次授权闸门

即使删除已经启动,旧向量也可能在短窗口内被搜到。下面的代码解决“索引结果先回来,授权状态随后变化”的竞态。查询开始记录 generation,结果返回后逐条与 RDB 活跃记录交叉校验;generation 已改变则整批作废并重查。

async searchScoped(query: string, scope: string): Promise<SearchItem[]> {
const generation = await this.auth.scopeGeneration(scope)
const raw = await textSearchImage.search(query, scope, 12)
if (generation !== await this.auth.scopeGeneration(scope)) return []

const active = await this.records.findActive(
raw.map(item => item.imagePath), scope, generation)
const allowed = new Set(active.map(item => item.imagePath))
return raw
.filter(item => allowed.has(item.imagePath))
.slice(0, 6)
.map(item => ({ path: item.imagePath, similarity: item.similarity }))
}

这里向底层请求 12 条,授权过滤后最多展示 6 条,是为了避免一个失效结果让首屏出现空洞。它不是无限扩大 topK;候选过少时页面明确显示“部分内容因权限变化已更新”,不能为了凑满六张跨 scope 搜索。

结果过滤发生在展示前,也发生在用户点开原图前。列表停留期间又发生撤权,点击处理仍要重新核验 generation。只在 search 返回时验证一次,会留下从旧页面进入已撤权文件的时间窗。

搜索取消和页面销毁不 release 全局索引服务,只取消当前 Promise 的消费。晚到结果携带请求 generation,页面已换 query 或 scope 时直接丢弃。丢弃 UI 结果不等于索引清理,清理由独立 RevokeCleanupJob 完成。

相似度分数只在同一个 scope、同一个模型版本内排序。撤权过滤后,不拿 shared 的结果补 personal 的空位,也不把不同版本分数直接混排。页面宁可显示四张合法图片,也不能为了视觉完整跨越授权边界。

缩略图缓存也跟着 generation 失效。仅删除向量而保留缓存,用户仍可能在最近搜索或系统截图中看到已撤权内容。Demo 在记录进入 REVOKED 时先隐藏 UI,再异步删除缩略图和向量;清理报告分别记录两类资源,任何一项未完成都不能宣告一致。

查询日志只记 queryHash,不记原始文本。“夜景中的古建筑”出现在 Demo 页面用于验收,正式诊断文件只保留散列、scope 别名、候选数和过滤数。技术排查不需要复制用户的搜索语句,更不需要打印完整私人路径。

开发图左侧展开 vision / registry / cleanup / search / database,中间打开 RevokeCleanupJob.ets,右侧模拟器显示 ScopeLens Lab。底部日志严格使用:batch=scope_20261001_07、library=236、revoked=18、purgedVectors=18、orphans=0、generation=12、results=6 state=CONSISTENT。

六、孤儿向量不是靠猜出来的

索引能力不一定提供完整向量清单,所以 Demo 的“孤儿 0”来自可验证的业务定义:所有被能力返回的路径,都必须在当前 scope 的 ACTIVE 账本中存在;所有 REVOKED 与 PURGED 路径,都不能再次进入搜索结果。测试使用一组已知查询覆盖十八张撤权图片的标签,并在清理前后重复搜索。

我还加了三类破坏性场景。第一类在第九条删除后强制结束进程,重启后从 RETRYABLE/PURGING 恢复;第二类让同一路径同时存在于 personal 与 shared,撤销 personal 不能影响 shared;第三类在搜索返回前把 generation 从 11 提升到 12,旧结果必须整批作废。

清理报告把 purgedVectors=18 与 orphans=0 分开。前者是执行数量,后者是一致性检查结果。十八次 delete 都返回成功,也可能因为 scope 拼错而删了错误空间;只有搜索验证和 RDB 关系同时通过,状态才是 CONSISTENT。

性能上,删除按小批次串行执行,避免和前台搜索争抢模型资源。当前页面可见时,搜索优先;进入后台后再提高清理吞吐。若授权变化涉及上千张图,应持久化游标、设置电量与温度约束,不能照搬十八张的同步循环。

一致性扫描也不应每次启动都全量跑。正常启动先检查未完成批次、过期租约和最近发生授权变化的 scope;只有诊断发现计数不闭合,才扩大到全库。这样既能发现孤儿,也不会让 236 张的小 Demo 逻辑在上万张图库上变成启动瓶颈。

我把最终报告保存在应用私有目录,内容包括批次 ID、起止 generation、撤权数、删除结果分类、查询验证数和耗时,不包含图片本身。用户反馈“撤权后仍能搜到”时,可以导出这份结构化证据,而不是让他重新授权以便开发者复现。

七、运行页把权限变化做成可解释结果

07:41 的运行页展示查询“夜景中的古建筑”,当前 scope 为 personal,图库 236,撤权 18,已清理向量 18,孤儿 0,generation 12,合法结果 6,最终状态 CONSISTENT。

红色标注只指向“撤权 18 → 清理 18”和“孤儿 0”。前者证明清理数量闭合,后者证明撤权内容没有继续从搜索返回。页面不展示不可访问图片的残留缩略图,也不把灰块当作正常搜索结果。

实际产品还要处理账户退出、共享空间成员变化、相册重新授权与模型版本升级。重新授权不能直接把旧 PURGED 行改回 ACTIVE,而应按当前文件摘要重新登记;共享成员变化则需要新的 scope generation。日志不记录查询对应的私人路径,只记录脱敏 ID、数量和状态。

这次最重要的判断是:scope 不只是提高检索效率,它还是数据边界的一部分。Core Vision Kit 负责相似度与向量能力,RDB 负责说明业务授权,清理任务负责让两者最终一致。只有页面不可见、索引不可搜、账本可追踪三件事同时成立,撤权才真正完成。

参考资料:

  • textSearchImage API:按 scope 插入、搜索与删除
  • Core Vision Kit API 26 变更
  • 关系型数据库开发指导
赞(0)
未经允许不得转载:171主机测评 » HarmonyOS 7 Core Vision Kit + RDB:文搜图索引的 scope 隔离、相册撤权与孤儿向量回收【鸿蒙心迹】
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址