我给图片搜索加了向量,然后发现还不够

如果只记得一张图的内容,却忘了文件名,怎么把它找出来?PicIndex 就是为了回答这个问题。

我用多模态 Embedding 把图片和文字映射到同一个向量空间。这样一来,搜索“赛博朋克风格的雨夜街道”或者“一只坐在窗边的猫”,系统就能按照画面语义找到相近的图片。功能跑起来以后,问题也接着冒了出来:图片里的订单号搜不准,大图调用模型太慢,同一个查询还会一遍遍地计算向量。

这些问题最后都没有靠换一个更强的模型解决。PicIndex 后来的大部分调整,发生在模型之外。

搜得到“雨夜街道”,却未必搜得到订单号

向量检索对模糊描述很友好。用户不需要知道图片的文件名,也不需要准确记住某个标签,只要描述画面的大致内容,就有机会找到想要的结果。这正是我做 PicIndex 时最想要的体验。

但图片里经常还有另一类信息:海报文案、合同中的人名、票据编号、产品型号。这些内容不模糊,反而越精确越好。模型可以理解两张海报在表达相似的主题,却不一定能稳定区分只差一位的编号。

所以我没有继续把所有希望都压在向量上,而是给图片增加了 OCR。搜索时,向量检索去理解“这张图大概在说什么”,BM25 则从识别出的文字里找精确命中。比如“适合作为科技公司背景图”主要依赖前者,“订单号 A20251014”则更需要后者。

两条检索链路保留下来以后,模糊语义和精确文字都有了各自的入口。接下来的麻烦变成了:它们各自返回一批结果,到底该怎么排在一起?

两路结果都有了,排序反而更麻烦

我最先碰到的问题是,两边的分数根本不在同一把尺子上。向量检索使用余弦相似度,数值范围相对稳定;BM25 的得分没有统一上限,还会受到词频、字段长度和命中方式影响。

如果把两个分数直接相加,数值更大的那一路很容易压过另一边。表面上做了混合检索,最终排序可能还是几乎只由文本决定。

PicIndex 里尝试了两种融合办法。现在更常用的是 RRF:不比较绝对分数,只看一张图片在两组结果中分别排第几。它如果在向量检索和文本检索里都靠前,合并后的排名也会比较高。这个方法不需要先把两套分数调到相同区间,对没有大量标注数据的个人项目很合适。

另一种办法是先归一化,再做线性加权:

1
最终得分 = α × 向量得分 + β × BM25 得分

线性加权的好处是可以针对场景调整。素材图更看重视觉语义,就提高向量权重;票据和扫描件更依赖文字,就提高 BM25 权重。不过没有离线评估集时,这些参数很容易变成“看起来还行”。因此我把 RRF 作为默认选择,只有场景足够明确时才考虑权重。

搜索结果页上的“匹配度”也做了单独处理。Elasticsearch 返回的 _score 直接展示没有什么意义,我用分段映射把它转成相对直观的百分比。这个数字只能帮助用户比较结果,不代表某张图片有 92% 的概率符合查询。

慢的可能不是模型

调用多模态模型时,我曾经直接把 OSS 里的原图地址传过去。功能正常,但接口耗时不太理想。手机照片很容易达到几 MB,分辨率也可能超过 4000 像素;模型即使会在内部缩放图片,也要先把原文件完整下载一遍。

一个直接的处理方式是让后端下载原图,压缩以后再发给模型。但这样又多了一段文件传输,CPU、内存和临时文件也都落到了应用服务器上。

最后我用了 OSS 自带的动态图片处理。后端生成预签名地址时,顺便加上缩放、质量压缩和格式转换参数。原图仍然只保存一份,模型访问这个地址时,OSS 返回的是处理后的图片,应用服务器不需要参与压缩。

在我的测试数据里,发给模型的图片体积平均减少了约 85%,Embedding 接口的端到端耗时下降了 300ms 以上。具体收益会受原图大小、网络环境和模型服务影响,但这次优化至少帮我定位清楚了一件事:看到模型接口慢,不能先入为主地把时间都算在推理上。文件大小和网络传输同样值得查。

这也影响了图片入库的处理方式。文件由前端直传 OSS,后端拿到 Object Key 后再异步执行 OCR、图片理解和 Embedding。上传请求不用等待模型处理,应用服务器也不再中转文件流。

不要缓存搜索结果

每次搜索之前,查询文本也要先转换成向量。像“红色跑车”这样的查询反复出现时,模型做的是完全相同的计算。它既增加等待时间,也产生额外调用成本。

缓存整个搜索结果虽然直观,却不太适合 PicIndex。图片会继续入库,也可能被删除;一旦缓存的是结果列表,就要考虑什么时候失效,否则用户看到的可能一直是旧数据。

我最后只缓存查询文本对应的 Embedding。相同或归一化后相同的查询再次出现时,直接从 Redis 里取向量,然后照常去 Elasticsearch 搜索。模型调用被省掉了,结果仍然来自最新索引。

测试中,命中缓存后,获取查询向量的耗时从约 500ms 降到了约 20ms。这里缓存的只是查询文本的计算结果,还算不上严格的“语义缓存”。如果要让“红色汽车”和“红色跑车”共享缓存,还需要额外维护查询向量索引,并接受语义判断错误带来的误命中。对当前项目来说,这层复杂度还没有必要。

图片变成向量以后,还缺少筛选条件

语义召回解决了“先把大致相关的图片找出来”,但用户往往还会继续缩小范围。搜索“周末露营”以后,可能想只看山地场景、白天、有帐篷、没有人物,或者横向构图的图片。

这些条件不适合全部塞进向量相似度,也不方便直接展示成界面上的筛选项。于是我在图片入库时又增加了一步,让视觉模型提取场景、风格、主体和时间等结构化信息:

1
2
3
4
5
6
{
"scene": ["户外", "山地"],
"style": ["自然", "旅行摄影"],
"objects": ["帐篷", "汽车", "草地"],
"time": ["白天"]
}

清洗后的标签以 keyword 字段存进 Elasticsearch。用户可以先用自然语言召回,再用这些字段过滤结果。它有点像电商网站的分面搜索,只是图片原本没有现成的品牌、价格和类目,需要先从非结构化内容里提取出来。

到这里,PicIndex 中的一张图片不再只有一个向量。它同时有 OCR 文本和一组可筛选的标签:向量负责模糊语义,OCR 补上精确文字,标签则让结果更容易过滤。

Java 没有让我觉得别扭

这个项目的后端一直是 Spring Boot,我没有因为要调用多模态模型,就额外拆出一套 Python 服务。PicIndex 并不训练模型,Java 需要处理的是上传、权限、异步任务、Elasticsearch 检索、Redis 缓存,以及对外部模型服务的调用。

这些事情本来就在 Java 擅长的范围里。需要注意的是,模型接口不能被当成一个稳定的内部方法:它会慢、会限流、会超时,返回格式可能不稳定,模型版本也可能变化。PicIndex 把模型调用隔离在适配层中,并为超时、重试、状态记录和降级留出位置。

从应用层看,接入 AI 服务和接入支付、短信或第三方风控有相似之处。模型决定能力的上限,外围工程则决定这项能力能不能稳定地放进产品流程。对这部分工作来说,Java 并没有成为阻碍。

这个项目还在验证什么

PicIndex 现在更像一次工程验证,还谈不上完整的生产系统。混合检索的参数主要靠经验调整,项目里没有足够的标注数据做系统的离线评估;视觉模型提取的标签也可能不稳定,异步任务的重试和补偿还需要继续完善。数据量再往上涨,索引构建和召回性能也要重新测试。

PicIndex 还会继续迭代,不过判断一项改动值不值得保留,仍然看三个很朴素的结果:搜索有没有更准,系统有没有更好维护,增加的复杂度有没有带来实际收益。


项目源码GitHub