文搜图 Demo 跑久以后,我遇到过一个很容易被误判成“模型搜索不准”的问题:用户最初允许应用访问 62 张照片,索引也正常建立;后来把相册权限改成“部分照片”,应用能访问的资产只剩 54 张,但 Core Vision Kit 的本地索引里还残留着原来的路径。此时再搜“雪山 木屋”,结果列表里会混进已经不可访问的图片。

这不是搜索算法问题,而是媒体访问集合变化以后,业务索引没有跟着收敛。

这次我把 Demo 收缩成 PhotoScopeRepairLab,只做一件事:重新获取可访问资产快照,找出已经失效的索引项,删除它们;对内容发生变化的图片再做增量重索引,最后用一次真实查询验收结果。

一、先把“可访问资产”和“已建索引”分成两套账

本次固定数据为:

  • Session:scope_repair_20261001_13
  • Permission:LIMITED
  • Scope:travel26
  • Previous Indexed:62
  • Accessible:54
  • Stale Removed:8
  • Changed Reindexed:4
  • Query:雪山 木屋
  • TopK:8
  • Hits:5
  • State:SYNCED
  • Last Cost:214 ms

我之前的实现只保存“索引成功了多少张”,没有保存来源资产的指纹。这样一旦用户调整相册授权范围,业务侧根本不知道哪些 index 已经失效。

现在每个索引记录至少保留 assetId / sandboxPath / scope / modifiedTime / indexedAt。其中 assetId 用来做稳定身份,sandboxPath 给 textSearchImage 使用,modifiedTime 决定是否需要重建特征。

二、第一步不是重建全部索引,而是重新扫描当前可访问集合

页面重新回到前台,或者用户从系统权限页面返回后,我会执行一次轻量扫描。

interface AssetSnapshot {
  assetId: string
  path: string
  modifiedTime: number
}

private async scanAccessibleAssets(): Promise<AssetSnapshot[]> {
  const helper = this.photoHelper
  const result = await this.assetScanner.queryAccessibleImages(helper)

  return result.map(item => ({
    assetId: item.assetId,
    path: item.path,
    modifiedTime: item.modifiedTime
  }))
}

这里我把 PhotoAccessHelper 的具体查询封装到 AccessibleAssetScanner,页面只拿业务需要的字段。正式项目里还要及时关闭 FetchResult,避免把媒体查询对象长期挂在页面上。

这一步只解决“现在还能看到什么”。它不修改 Core Vision 索引。

三、用 assetId 做差集,不要拿路径字符串硬比

第二段代码解决“哪些应该删,哪些应该重新索引”。

private buildDiff(
  current: AssetSnapshot[],
  indexed: IndexedRecord[]
): RepairDiff {
  const currentMap = new Map(
    current.map(item => [item.assetId, item])
  )

  const indexedMap = new Map(
    indexed.map(item => [item.assetId, item])
  )

  const toRemove = indexed.filter(
    item => !currentMap.has(item.assetId)
  )

  const toReindex = current.filter(item => {
    const old = indexedMap.get(item.assetId)
    return old !== undefined &&
      old.modifiedTime !== item.modifiedTime
  })

  return { toRemove, toReindex }
}

我没有用 sandbox path 作为主键,因为路径可能因为导入、编辑或业务缓存策略发生变化。assetId 负责身份判断,路径只作为当前一次 Core Vision 调用参数。

这次差集结果很明确:62 个已索引记录里,有 8 个已经不在可访问集合;另外 4 个资产虽然仍可访问,但修改时间变化,需要重建。

四、删除失效特征,再对变化项做增量重建

真正修改 Core Vision 索引时,我没有 clearData() 全量清库,而是按差集处理。

private async repairIndex(diff: RepairDiff): Promise<void> {
  this.state = 'REINDEXING'

  for (const item of diff.toRemove) {
    await textSearchImage.deleteImage(
      item.path,
      'travel26'
    )
    await this.indexStore.remove(item.assetId)
  }

  for (const item of diff.toReindex) {
    const old = await this.indexStore.get(item.assetId)

    if (old) {
      await textSearchImage.deleteImage(
        old.path,
        'travel26'
      )
    }

    const ok = await textSearchImage.insertImage(
      item.path,
      'travel26'
    )

    if (ok) {
      await this.indexStore.upsert(item)
    }
  }
}

这段代码最需要注意的是顺序:变化资产不是直接 insert 一次,而是先删除旧 path 对应的特征,再插入当前版本。

如果中途失败,正式项目不应该只靠内存继续跑,而要把本次 repair session 记录下来,下次从未完成项继续。Demo 为了看清主线,只保留了 generation token,避免用户连续触发两次修复时旧任务覆盖新结果。

五、修复完成后必须用搜索结果验收

索引层显示 SYNCED 还不够。我会立刻跑一次固定查询:

private async verifySearch(): Promise<void> {
  const result = await textSearchImage.search(
    '雪山 木屋',
    'travel26',
    8
  )

  this.hits = result.length

  if (this.hits !== 5) {
    throw new Error(
      `unexpected hits: ${this.hits}`
    )
  }

  this.state = 'SYNCED'
}

本次 TopK=8,最终命中 5 张。更重要的是,5 张结果全部来自当前授权范围,不再出现点击后无法读取的失效照片。

六、我把调试页做成了“权限变化后的对账面板”

项目结构保持得很小:

entry/src/main/ets/
├─ pages/ScopeRepairPage.ets
├─ media/AccessibleAssetScanner.ets
├─ index/VisionIndexRepairer.ets
└─ model/AssetSnapshot.ets

HiLog 固定输出:

permission=LIMITED
snapshot indexed=62 accessible=54
stale removed=8
changed reindexed=4
query=雪山 木屋 topK=8 hits=5
State: REINDEXING -> SYNCED

七、手机运行结果里我更关注“8”和“4”

最终页面并不是为了展示漂亮搜索卡片,而是为了确认修复链条真的执行过。

Stale Removed=8 说明旧索引确实被清掉;Changed Reindexed=4 说明仍然可访问但内容发生变化的资产被重新建索引;Accessible=54 与当前权限范围一致。

搜索区仍然用 雪山 木屋,最终 5 张结果都能正常打开。

八、几个真实项目里容易遗漏的边界

第一,权限缩小和媒体删除不是一回事。对当前应用来说结果都是“不可访问”,但日志里最好分开记录来源,便于解释为什么索引突然减少。

第二,PhotoAccessHelper 的资产变化通知适合触发轻量失效标记,但不要每来一次通知就立即全量扫描。图片批量导入时通知可能很密,可以做短时间合并。

第三,Core Vision Kit 的 deleteImage() 需要你仍然知道旧 path,所以索引元数据不能只存 assetId。

第四,模型能力更新出现需要 clearData() 的场景,应该与普通权限差集修复分开处理。前者是模型版本迁移,后者是业务资产集合变化,不能混成同一个按钮。

九、这次真正修掉的是“索引比权限活得更久”

文搜图工程里,搜索结果不只取决于 query 和模型,还取决于“索引里的图片是不是当前应用仍然有权访问”。

我最后把这条链路固定成:

SNAPSHOT → DIFF → CLEANING → REINDEXING → SYNCED

这样用户调整相册授权以后,不需要重建整个数据库,也不会继续保留已经失效的图片特征。

从实际开发角度看,这类问题比“怎么调用 search()”更值得提前设计,因为索引一旦脱离真实媒体权限集合,后面的排序再准,也只是对一份过期数据做准确计算。

Logo

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

更多推荐