文搜图 Demo 跑到第二天,索引数量比相册照片还多。不是用户多拍了照片,而是同一张竖图以横向像素、旋转预览和编辑副本三种姿态进了向量库。

一、检索能找到照片,索引账却对不上

这次小工具叫 LensVault Indexer,批次 ID 为 index_20261001_14。目标并不复杂:从 PhotoAccessHelper 读取相册资产,生成端侧图像向量,让用户输入“夜景里的红色招牌”时能够找到对应照片。

首轮结果看起来不错,问题出在增量扫描。相册里共有 1,284 个资产,索引表却不断增长。几张从聊天应用保存的照片带有 EXIF 方向信息,原始像素是横的,系统预览时再旋转;裁剪后的副本虽然视觉几乎相同,却拥有新的资产 ID。旧流程用 assetId 当唯一键,又在每次扫描时直接追加向量,最终把方向差异和轻微压缩当成不同内容。

这不是单纯的“去重算法不够准”。在向量化之前,像素方向必须统一;方向统一后,近似副本才能进入同一感知簇;确认代表资产后,索引写入还要具备幂等性。任意一层缺失,重跑都会继续制造脏数据。

本轮扫描 1,284 个资产,其中 173 个完成方向归一,46 个感知重复簇里跳过 89 个副本,最终索引 1,195 个代表资产。使用 vision_embed_v3、768 维向量,耗时 18.4 s。同一批次再次执行时新增写入为 0,状态 INDEX_STABLE。

二、先把“看到的方向”变成模型真正收到的方向

有些图片查看器会自动应用 EXIF orientation,因此肉眼看到的竖图没有问题;模型输入如果直接来自原始像素,却可能仍是横向。这样同一画面在不同来源下会得到明显不同的向量。

下面的适配器解决“显示方向正确但模型输入方向错误”。它把元数据转换为旋转与镜像操作,并返回一个新的标准方向 PixelMap;原始 PixelMap 的释放仍由调用方负责。

export type ExifOrientation =
  | 'NORMAL' | 'ROTATE_90' | 'ROTATE_180' | 'ROTATE_270'
  | 'FLIP_X' | 'FLIP_Y'

export interface NormalizedImage {
  pixelMap: PixelMap
  applied: ExifOrientation
  width: number
  height: number
}

export async function normalizeOrientation(source: PixelMap,
  orientation: ExifOrientation): Promise<NormalizedImage> {
  const transform = orientationToTransform(orientation)
  const output = await PixelMapAdapter.transform(source, transform)
  const info = await output.getImageInfo()
  return {
    pixelMap: output,
    applied: orientation,
    width: info.size.width,
    height: info.size.height
  }
}

PixelMapAdapter 是工程封装,内部统一处理旋转、镜像和目标色彩格式。文章不把它写成一个神奇的系统 API,是因为真正容易出错的是所有权:normalizeOrientation 返回的新对象交给后续任务,任务完成或失败都必须 release;source 则由读取资产的上层 finally 释放。若 orientation 为 NORMAL,适配器也不会直接把同一对象交给两层释放,而是使用明确的借用标记。

方向归一不是随意把长边转成竖向。横屏照片本来就应该保持横向,判断依据只能来自资产元数据和解码信息。镜像方向也不能漏掉,否则自拍或扫描件会出现左右颠倒。173 个修正数来自本次相册,不应该当作通用比例。

归一后的图像还会缩放到模型输入尺寸,但缩放发生在方向修正之后。若先缩放再旋转,宽高约束会对不同方向产生不同裁剪,感知哈希和向量都会受到影响。调试日志会记录 assetId、orientation 和归一后宽高,不记录原图内容。

三、感知去重看的是视觉近似,不是文件名和资产 ID

同一张照片经过压缩、轻裁剪或另存后,文件摘要会变化;只比较 SHA-256 无法识别视觉副本。我们用低分辨率灰度签名做快速候选,再对候选计算汉明距离。它不是为了删除用户照片,只决定哪些资产共享一个向量代表。

下面的代码解决“轻微压缩副本重复入库”。签名固定为 64 位字符串,距离小于等于 6 时进入同一候选簇;同时要求拍摄时间与尺寸比例落在合理范围,避免相似构图被误合并。

export function hammingDistance(a: string, b: string): number {
  if (a.length !== 64 || b.length !== 64) return 64
  let distance = 0
  for (let i = 0; i < 64; i++) {
    if (a.charAt(i) !== b.charAt(i)) distance++
  }
  return distance
}

export function canJoinCluster(current: AssetDigest,
  candidate: AssetDigest): boolean {
  const timeGap = Math.abs(current.takenAt - candidate.takenAt)
  const ratioGap = Math.abs(current.aspectRatio - candidate.aspectRatio)
  return hammingDistance(current.pHash, candidate.pHash) <= 6 &&
    timeGap <= 120000 && ratioGap <= 0.03
}

这里没有使用 BigInt,避免不同运行环境对位运算宽度的处理差异。64 字符签名更占一点空间,但日志、测试和跨语言对照更直观。正式项目可以改为字节数组,只要编码顺序固定,不能升级后把同一签名解释成另一套位序。

120 秒与 0.03 同样是当前相册样本调出的门槛。连拍场景里多张照片可能非常相似,却各自有价值,因此还要检查来源类型和编辑关系。LensVault 只把下载副本、编辑派生和完全相近的压缩图归为候选,最终代表资产优先选择分辨率更高、方向信息完整的一张。

46 个簇共跳过 89 个副本,并不意味着删除 89 张照片。搜索结果会把簇成员保留下来,用户展开后仍能看到各个资产;只是主索引只保存代表向量,减少重复命中和不必要的模型计算。资产被用户删除时,代表权可以转移给簇内下一张,而不是让整个搜索结果消失。

四、指纹决定要不要重算,批次决定能不能晋升

去重完成后,旧实现仍会在每次启动时重写 1,195 条向量。写入过程若中途退出,还可能留下一半新模型向量和一半旧模型向量。我们为每个代表资产构造内容指纹,并用批次 staging 表完成原子晋升。

下面的 IndexWriter 解决“重复扫描继续追加”和“半批次覆盖当前索引”。指纹同时包含资产修改时间、方向、代表签名与模型版本;只有指纹变化才重新生成向量。

export class IndexWriter {
  private readonly batchId = 'index_20261001_14'
  private readonly modelVersion = 'vision_embed_v3'

  fingerprint(asset: RepresentativeAsset): string {
    return [
      asset.assetId,
      asset.modifiedAt.toString(),
      asset.orientation,
      asset.pHash,
      this.modelVersion
    ].join('|')
  }

  async upsert(asset: RepresentativeAsset, vector: Float32Array): Promise<boolean> {
    const fp = this.fingerprint(asset)
    const previous = await IndexStore.findByAsset(asset.assetId)
    if (previous?.fingerprint === fp) return false

    await IndexStore.putStaging(this.batchId, {
      assetId: asset.assetId,
      fingerprint: fp,
      modelVersion: this.modelVersion,
      dimensions: 768,
      vector
    })
    return true
  }

  async commit(expectedCount: number): Promise<void> {
    await IndexStore.promoteBatch(this.batchId, expectedCount)
  }
}

第一次运行,1,195 个代表资产写入 staging,数量核对无误后整体晋升。第二次运行时每个指纹都与当前记录相同,upsert 返回 false,新增写入为 0;这就是手机页显示 Rerun writes 0 的来源,而不是简单比较批次 ID。

expectedCount 也不是从“扫描数减去跳过数”随手计算。批次清单在聚类结束后冻结,里面列出每个代表资产、成员列表和指纹;写入阶段只能消费这份清单。若相册在索引途中新增照片,它会进入下一批,不临时改变当前计数。这样 1,195 是一份可核对的输入合同,而不是运行过程中不断移动的目标。

提交前还会随机抽查 20 条 staging 记录,确认向量维度为 768、没有 NaN 或无限值,并验证代表资产仍可访问。任意一项失败,整个批次保持在 staging,不切换 activeBatch。抽查不能替代全量的结构校验,但能在模型输出异常或资产权限突然变化时尽早阻断明显坏批次。

模型从 v3 升级时,指纹必然变化,旧向量不会被误认为可复用。新批次完成前,查询仍使用上一套完整索引;只有维度、数量和模型版本全部通过检查,才切换 activeBatch。应用被系统终止时,staging 可在下次启动清理,不会污染线上查询。

向量属于较大的连续内存。写入后及时释放任务侧缓冲区,查询侧只加载需要的分片,不能把 1,195 个 Float32Array 长期留在页面状态中。页面离开不停止后台索引服务,但会取消 UI 订阅;真正取消批次时才递增 generation,让晚到的模型回调失效。

五、从 1,284 个资产得到 1,195 条稳定记录

验收没有只测“能不能搜到”。我们准备了方向标签、压缩副本、裁剪副本和一组构图相似但内容不同的照片。方向修正后,同一画面的向量距离明显收敛;感知簇只合并预期副本;构图相似的两张街景因为时间和比例条件不满足,仍保留独立记录。

HiLog 的关键摘要为:batch=index_20261001_14 scanned=1284;normalized=173 clusters=46 skipped=89;indexed=1195 rerunWrites=0;model=vision_embed_v3 dims=768 cost=18.4s state=INDEX_STABLE。任何一项对不上,页面都不会显示稳定状态。

手机运行图时间是 14:48,顶部状态栏包含 5G、Wi‑Fi、信号和 79% 电量。页面把扫描数、方向修正、重复簇、跳过副本、索引数、重跑写入和耗时放在同一张结果卡里,还展示了两条代表资产的方向变化与簇归属。

六、去重策略的边界比算法名字更重要

感知哈希不是事实判定器。两张连续拍摄的舞台照片可能只有灯光略有变化,哈希距离很小,却都值得保留;一张图片加了大边框,视觉主体相同,哈希距离又可能变大。因此它只负责候选召回,资产来源、时间、比例与编辑关系共同决定是否共享代表。

隐私边界也要明确。方向修正、签名计算与向量生成都在端侧完成,诊断日志不记录缩略图、文件路径和搜索文本。哈希与向量依然属于由用户内容派生的数据,应放在应用沙箱,并在相册授权撤销或资产删除后同步清理。

资产删除后的处理不是立刻全量重建。若删除的是普通簇成员,只更新成员映射;若删除的是代表资产,则从同簇选择分辨率和元数据更完整的下一项,重新核对指纹后继承搜索入口。簇已空时才删除向量。这个过程仍使用独立 generation,防止删除回调与正在进行的批次提交互相覆盖。

编辑照片则分两种情况。只改标题、收藏状态等非像素信息,不触发向量重算;裁剪、旋转、滤镜或内容调整会改变 modifiedAt 与方向/签名,指纹随之变化。我们没有仅凭 modifiedAt 决定重算,因为部分来源在复制文件时会改时间但不改像素,感知签名可以帮助避免一次无意义的模型调用。

性能指标除了 18.4 秒总耗时,还拆成解码、归一、签名、模型和写入五段。若以后相册增到数万张,总耗时本身很难定位瓶颈;分段后能看出是 PixelMap 解码过慢,还是模型任务并发过高导致背压。LensVault 限制同时存在的标准方向 PixelMap 数量,宁可让队列等待,也不让峰值内存随资产数增长。

生命周期上,PhotoAccessHelper 返回的资产句柄、解码 PixelMap、标准方向 PixelMap 和模型输入缓冲区各有释放责任。一个资产失败不能中断整批,但失败原因必须进入批次摘要;连续失败达到阈值时暂停任务,避免损坏资产造成无限重试。前后台切换不会重新创建同一批次,generation 用于隔离旧任务回调。

这轮优化最有价值的不是少算了 89 次向量,而是让“再跑一次”成为可预测操作。原始像素先归一到统一方向,近似副本再进入可解释的感知簇,最后由内容指纹和 staging 批次保证幂等晋升。三层分别解决输入一致性、内容身份和写入原子性。

文搜图一旦进入真实相册,资产 ID 并不等于视觉内容,文件相同也不等于模型输入相同。只有把这两组差异拆开处理,INDEX_STABLE 才有工程含义:当前 1,195 条记录可以被追溯、可以重跑,也可以在模型升级或权限变化后安全替换。

Logo

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

更多推荐