1. 项目缘起与整体设计思路
相册搜图这件事,听起来像是“给照片打个标签,然后搜标签”这么简单,但真正落到端侧、落到 HarmonyOS 7.0 的工程环境里,坑远比想象中多。我这次做的项目,核心目标很明确:在设备本地完成“用一段自然语言描述,找到相册里对应照片”的完整链路,不依赖云端推理,不上传任何原始图像。之所以选这个方向,是因为相册场景对隐私极度敏感,用户愿意让系统理解自己的照片,但不愿意让照片离开设备;而端侧 AI 的算力在近两年已经足够支撑轻量级的图文匹配,这才让“文搜图”从演示 Demo 变成可落地的工程。
这个项目适合谁参考?如果你正在做 HarmonyOS 应用、对端侧 AI 推理感兴趣、或者手上有大量本地媒体资源需要做语义检索,那这套思路基本可以直接抄作业。它不要求你是算法专家,但需要你对移动端工程、模型部署、性能调优有基本的认知。我会把整条链路拆成“设计选型—核心细节—实操实现—问题排查”四块来讲,每一块都尽量给到能复现的参数和判断依据。
先说整体设计。文搜图在端侧的本质,是把“文本”和“图像”映射到同一个向量空间,然后做相似度检索。这个思路和云端方案没有区别,区别在于:云端可以用大模型、大显存、批量并发,端侧必须把模型压到几百 MB 以内、单次推理控制在百毫秒级、内存峰值不能把系统拖垮。所以我的整体架构是“双塔模型 + 本地向量索引 + 分层缓存”:
- 双塔模型:文本塔和图像塔各自独立编码,输出同维度向量。选双塔而不是交叉编码,是因为交叉编码虽然精度高,但每次检索都要把“文本+图像”一起过模型,端侧根本扛不住;双塔可以提前把相册所有图片编码好存起来,检索时只跑文本塔,成本低一个数量级。
- 本地向量索引:图片向量预先算好,存进本地索引。索引结构我选的是 HNSW 的轻量实现,而不是暴力遍历。原因很简单:一个用户相册动辄几千到几万张图,暴力遍历每次都要算几万次余弦相似度,延迟不可控;HNSW 在召回率和速度之间平衡得比较好,端侧内存占用也可接受。
- 分层缓存:文本塔的输出做短时缓存,热门查询直接命中;图片向量做持久化,新增图片增量编码,避免每次全量重算。
这套设计背后有一个关键判断:端侧文搜图的瓶颈不在模型精度,而在“首次索引构建时间”和“内存峰值”。很多方案 Demo 跑得通,一上真机就卡死,就是因为忽略了这两个指标。我在设计阶段就把“首次全量索引”拆成后台任务,允许用户边用边建,而不是阻塞式等待。
提示:双塔模型的文本塔和图像塔必须用同一套训练数据对齐,否则向量空间不对齐,检索结果会完全不可用。这一点在选模型时就要确认,不能拿两个独立模型硬拼。
2. 核心细节解析与实操要点
2.1 模型选型:为什么不用通用大模型
端侧模型选型,我踩过的第一个坑就是“贪大”。一开始想用通用多模态大模型,觉得效果好,结果模型体积 2GB 起步,推理一次要几秒,内存直接爆。后来换成专门为移动端优化的轻量双塔模型,图像塔用 MobileNet 级别的骨干,文本塔用小型 Transformer,整体压到 200MB 以内,单次文本编码在 50ms 左右,图像编码在 80ms 左右,这才算能用。
选型时我重点看三个指标:模型体积、单次推理延迟、向量维度。向量维度不是越高越好,512 维在端侧已经足够,再高索引内存会线性增长。模型体积要控制在 300MB 以内,否则安装包和首次加载都会成为问题。推理延迟要区分“文本编码”和“图像编码”,文本编码是检索时的热路径,必须快;图像编码是索引时的冷路径,可以稍慢,但也不能太慢,否则首次索引要等太久。
| 指标 | 可接受范围 | 我的实测值 | 说明 |
|---|---|---|---|
| 模型体积 | < 300MB | 约 210MB | 含文本塔和图像塔 |
| 文本编码延迟 | < 100ms | 约 50ms | 中端机型 |
| 图像编码延迟 | < 150ms | 约 80ms | 单张 1080P |
| 向量维度 | 256~768 | 512 | 兼顾精度和内存 |
| 索引内存 | < 100MB | 约 60MB | 1 万张图片 |
2.2 向量索引:HNSW 参数怎么调
HNSW 有几个关键参数:M(每个节点的连接数)、efConstruction(构建时的候选队列大小)、efSearch(检索时的候选队列大小)。这三个参数直接决定索引质量、构建速度和检索速度。
我的经验是:M取 16 到 32 之间,太小召回率不够,太大内存涨得快;efConstruction取 100 到 200,构建时多花点时间换更好的图结构;efSearch取 50 到 100,检索时在速度和召回之间平衡。实测下来,1 万张图片、512 维向量,M=16、efConstruction=150、efSearch=64,索引构建约 3 秒,单次检索约 15ms,召回率在 95% 以上。
注意:HNSW 的构建是增量式的,但增量插入过多会导致图结构退化。我的做法是每插入 500 张新图,就触发一次局部重建,而不是无限增量。
2.3 图像预处理:别小看这一步
图像预处理看起来简单,但坑很多。相册里的图片格式五花八门,有 JPEG、HEIC、PNG,还有带旋转信息的。如果预处理没做好,编码出来的向量和实际内容对不上,检索结果就会莫名其妙。
我的预处理流程是:先解码成统一格式,再按 EXIF 信息做旋转校正,然后缩放到模型输入尺寸(一般是 224x224 或 256x256),最后做归一化。这里有一个细节:缩放时保持宽高比,用填充而不是拉伸。拉伸会改变图像内容的比例,导致向量偏移。填充虽然会引入黑边,但对模型来说更友好。
另外,HEIC 格式在部分设备上解码较慢,我会在后台索引时优先处理,避免阻塞用户操作。如果解码失败,要有降级策略,比如跳过该图并记录日志,而不是让整个索引任务崩掉。
2.4 文本预处理:分词和归一化
文本塔的输入是用户查询,比如“去年在海边拍的照片”。端侧文本预处理要做两件事:分词和归一化。分词用模型自带的分词器即可,归一化包括转小写、去多余空格、处理标点。这里有一个容易忽略的点:中文查询里的时间词和地点词,模型不一定能理解。比如“去年”这种相对时间,模型编码出来的向量和实际图片的时间特征可能对不上。
我的做法是在文本预处理阶段做一层“查询改写”,把相对时间转成绝对时间范围,把常见地点词做同义扩展。这层改写不依赖大模型,用规则+小词典就能做,成本低但效果明显。实测下来,加了这层改写后,时间类查询的命中率提升了约 20%。
3. 实操过程与核心环节实现
3.1 环境准备与依赖配置
先说一下我的开发环境:HarmonyOS 7.0 SDK、DevEco Studio、一台中端真机。端侧 AI 推理我用的是系统提供的 AI 推理框架,模型格式转成框架支持的格式。依赖配置上,核心是推理引擎和向量索引库,其他都是常规的 UI 和文件操作。
{ "dependencies": { "ai-inference": "1.0.0", "vector-index": "1.2.0", "image-codec": "1.1.0" } }配置时要注意:推理引擎的线程数不要设太大,端侧 CPU 核心有限,设成 2 到 4 个线程比较合适,太多反而会因为线程切换导致延迟增加。向量索引库要确认支持持久化,否则每次启动都要重建索引,用户体验会很差。
3.2 首次全量索引:后台任务怎么做
首次全量索引是整个链路里最耗时的环节。我的做法是把它拆成一个后台任务,分批次处理,每批处理 50 张图片,处理完一批就更新进度并让出 CPU,避免阻塞 UI。
具体流程是:先扫描相册拿到所有图片路径,然后按批次读取、解码、预处理、编码、插入索引。每批处理完后,把索引持久化一次,这样即使任务中断,下次也能从断点继续。实测下来,1 万张图片的全量索引在中端机型上约 8 到 12 分钟,用户可以在后台慢慢建,不影响正常使用。
提示:索引任务要监听系统电量,低电量时暂停,充电时恢复。这是端侧应用的基本素养,不然用户会骂人。
3.3 增量索引:新图片怎么处理
用户每天都会拍新照片,增量索引必须做。我的方案是监听相册变化,有新图片就加入一个待处理队列,队列满 20 张或空闲时触发一次增量编码。增量编码只跑图像塔,不重建整个索引,直接插入 HNSW。
这里有一个坑:增量插入的向量和全量构建的向量,如果模型版本不一致,会导致检索结果混乱。所以我在索引元数据里记录了模型版本号,模型更新时强制全量重建,而不是增量插入。
3.4 检索流程:从查询到结果
检索流程分四步:文本预处理、文本编码、向量检索、结果排序。文本预处理包括分词、归一化、查询改写;文本编码跑文本塔,得到查询向量;向量检索在 HNSW 里找 Top-K 近邻;结果排序按相似度分数排,同时可以叠加时间、地点等元数据做二次排序。
def search(query, top_k=20): query = preprocess_text(query) query_vec = text_encoder.encode(query) candidates = index.search(query_vec, top_k * 2) results = rerank(candidates, query) return results[:top_k]检索时top_k我一般取 20,先召回 40 个候选,再用元数据做二次排序,最后返回 20 个。这样做的原因是:纯向量相似度有时候会把“视觉相似但语义不符”的图片排前面,叠加元数据能有效纠正。
3.5 性能优化:内存和延迟怎么压
性能优化我做了三件事:模型量化、索引分片、结果缓存。模型量化把 FP32 压到 FP16,模型体积减半,推理速度提升约 30%,精度损失在可接受范围内。索引分片是把大索引拆成多个小索引,按时间或相册分片,检索时只查相关分片,减少计算量。结果缓存是把最近 50 次查询的结果缓存起来,相同查询直接命中。
实测下来,优化后文本编码延迟从 80ms 降到 50ms,检索延迟从 25ms 降到 15ms,内存峰值从 180MB 降到 110MB。这些数字看起来不大,但在端侧就是“能用”和“卡顿”的区别。
4. 常见问题与排查技巧实录
4.1 检索结果不相关:排查思路
检索结果不相关是最常见的问题,原因可能出在预处理、模型、索引三个环节。我的排查顺序是:先看文本预处理有没有问题,比如分词是否正常、查询改写是否过度;再看模型输入是否对齐,比如图像预处理有没有做旋转校正;最后看索引是否损坏,比如增量插入过多导致图结构退化。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 结果完全不相关 | 模型版本不一致 | 检查索引元数据 | 强制全量重建 |
| 结果部分相关 | 预处理不一致 | 对比预处理前后图像 | 统一预处理流程 |
| 结果排序混乱 | 索引退化 | 检查增量插入次数 | 触发局部重建 |
| 时间查询不准 | 查询改写缺失 | 检查改写规则 | 补充时间词典 |
4.2 首次索引太慢:优化技巧
首次索引慢,核心原因是图像解码和编码耗时。我的优化技巧是:并行解码、批量编码、跳过损坏图片。并行解码用 2 到 4 个线程同时解码,批量编码把多张图片拼成一个 batch 过模型,跳过损坏图片避免单个失败拖垮整批。
另外,索引任务要分优先级:用户最近拍的照片优先索引,老照片后台慢慢来。这样用户感知上会快很多,因为最近的照片才是高频检索对象。
4.3 内存峰值过高:怎么压
内存峰值过高通常是因为一次性加载了太多图片。我的做法是:流式处理,处理完一张释放一张,不要把所有图片都读进内存再处理。另外,模型推理的输出要及时释放,不要累积。如果还是高,就降低 batch size,用时间换空间。
注意:端侧内存管理没有银弹,核心原则是“用完即释放”。任何缓存都要有上限,不能无限增长。
4.4 模型更新后检索失效:版本管理
模型更新后检索失效,是因为新旧向量不在同一空间。我的解决方案是:索引元数据记录模型版本,模型更新时触发全量重建。重建可以在后台进行,重建期间用旧索引继续服务,重建完成后原子切换。这样用户无感知,但需要额外的存储空间存新旧两份索引。
4.5 低端机型跑不动:降级策略
低端机型算力有限,硬跑完整链路会卡。我的降级策略是:降低向量维度、减少索引连接数、缩小模型输入尺寸。比如向量维度从 512 降到 256,M从 16 降到 8,输入尺寸从 256 降到 224。精度会降一些,但至少能用。如果还是跑不动,就只做文本检索,不做图像编码,等用户充电时再后台补。
5. 几个我踩过的坑和独家心得
第一个坑是忽略 EXIF 旋转。相册里很多图片带旋转信息,如果预处理不做校正,编码出来的向量和实际内容对不上,检索结果就会莫名其妙。这个坑我踩了两天才发现,因为肉眼看图片是正的,但模型看到的是旋转后的。
第二个坑是增量插入过多导致索引退化。HNSW 增量插入是支持的,但插多了图结构会变差,召回率下降。我的经验是每 500 张触发一次局部重建,不要无限增量。
第三个坑是文本查询改写过度。一开始我加了很多改写规则,结果把用户原意改没了。后来我改成“只改时间词和地点词,其他不动”,效果反而更好。改写要克制,不能喧宾夺主。
第四个坑是索引持久化格式不兼容。不同版本的索引库持久化格式可能不一样,升级库版本时要确认兼容性,否则用户升级后索引全废,要重建。我的做法是持久化时带版本号,不兼容就重建。
第五个坑是忽略低电量场景。索引任务在低电量时继续跑,导致用户手机发热、耗电快,体验很差。后来我加了电量监听,低电量暂停,充电恢复,用户反馈好很多。
最后分享一个小技巧:检索结果里混入少量随机图片做“探索位”,可以提升用户发现感。这个不是技术必需,但产品体验上很加分。我在实际使用中发现,用户有时候不知道搜什么,给几个随机但高质量的结果,反而能引导他们继续用。