ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于多模态模型与向量检索的本地图库语义搜索实战

基于多模态模型与向量检索的本地图库语义搜索实战 1. 为什么我要给本地图库做语义搜索我的图库大概是从2018年开始失控的。那会儿手机拍照越来越方便出去旅游一趟就是几百张加上平时工作截图、素材收集、表情包囤积硬盘里陆陆续续堆了将近四万张图。一开始我还挺自信按年份建文件夹按事件建子文件夹命名也算规整。但到了真正要找图的时候这套体系基本等于没有——我记得有一次想找一张傍晚的海边的照片做封面翻遍了2021-青岛2022-厦门两个文件夹愣是花了二十多分钟才从一堆相似的海景里挑出来。问题出在哪出在传统文件管理靠的是我记得它在哪而不是它长什么样。文件名、文件夹、标签这些都是人手动打上去的元数据一旦数量上去了维护成本指数级上升。更别说很多图我压根没命名就是一堆IMG_20210815_183042.jpg。后来我试过几种方案。最早是本地跑一个开源的以文搜图工具效果一般中文理解尤其差搜傍晚的海边经常给我返回一堆白天的沙滩照。再后来想用云端服务但四万张图全传上去隐私和流量都是问题而且很多服务按调用次数收费长期用下来不划算。直到我把蓝耘元生代的多模态能力接进本地图库才算真正解决了这个问题。核心思路很简单用多模态模型给每张图生成一段语义描述存进本地向量库搜索时把查询语句也转成向量做相似度匹配。整个过程图片不出本地只有描述文本和查询语句走模型接口隐私和成本都可控。这套方案适合谁我觉得有三类人特别值得试一是像我这样图库过万、靠文件夹已经管不过来的个人用户二是做设计、自媒体、电商需要频繁按感觉找素材的从业者三是想入门多模态应用开发但不想一上来就啃论文的开发者。下面我把整套流程拆开讲包括我踩过的坑和最后跑通的配置。2. 整体方案设计与技术选型思路2.1 为什么是描述生成向量检索而不是端到端图搜市面上做以文搜图主流有两条路。一条是端到端的多模态嵌入比如CLIP这类模型直接把图片和文本映射到同一个向量空间搜的时候算余弦相似度。另一条是先生成描述、再对描述做文本检索也就是我最终选的方案。CLIP路线听起来更原生但我在实际测试里发现两个问题。第一CLIP对中文短查询的理解不够细腻傍晚和黄昏在它眼里可能差不多但傍晚的海边和夜晚的海边它又分不太开返回结果的排序经常让我哭笑不得。第二CLIP的向量维度固定想换模型就得全量重算四万张图重算一次成本不低。而描述生成文本检索这条路好处是描述文本是人类可读的。我可以直接打开数据库看某张图被描述成了什么如果描述不准我能立刻定位是模型的问题还是图片本身的问题。这种可解释性在调试阶段太重要了。另外文本检索这套技术栈非常成熟向量库、全文索引、混合排序工具链齐全我想怎么调就怎么调。代价是多了一步描述生成四万张图跑一遍需要时间。但这是一次性成本跑完之后新增图片增量处理就行完全可以接受。2.2 蓝耘元生代在这里扮演什么角色蓝耘元生代提供的是多模态理解能力通过OpenAI兼容协议暴露接口。这一点是我选它的关键原因——兼容协议意味着我可以用现成的OpenAI SDK直接调用不用为它单独写一套客户端代码迁移成本几乎为零。具体到我的流程里它承担两个任务一是图片转描述把每张图喂进去让它输出一段包含主体、场景、时间、氛围、色彩的中文描述二是查询改写把用户输入的傍晚的海边这种口语化短句扩展成更适合检索的描述性文本比如日落时分、海面、暖色调天空、沙滩、黄昏光线。为什么查询也要过一遍模型因为用户的查询往往太短、太模糊。直接拿傍晚的海边去和图片描述做匹配召回率会受影响。让模型把查询翻译成更丰富的语义表达再去做检索效果提升很明显。这一步是我实测下来最值得做的优化之一。2.3 本地向量库怎么选向量库我对比过几个。FAISS快但它是库不是服务持久化和增量更新要自己写Chroma轻量适合小规模但四万条以上查询性能开始吃紧Milvus功能全但部署重我一个人用没必要。最后我选了Qdrant理由是它单机部署简单Docker一条命令起来支持持久化、支持增量写入、支持过滤条件比如按拍摄年份筛而且Python客户端用起来很顺手。四万条向量对它来说毫无压力查询基本在毫秒级。维度方面我用的是文本嵌入模型输出的768维向量。这里有个细节描述生成用的多模态模型和文本嵌入模型是两回事。前者负责看图说话后者负责把话变成向量。我文本嵌入用的是本地部署的中文优化模型这样查询和描述都在本地转向量只有描述生成那一步走蓝耘元生代接口。2.4 整体数据流把上面几块串起来完整流程是这样的扫描本地图库目录收集所有图片路径对每张图调用蓝耘元生代生成中文描述把描述文本用本地嵌入模型转成向量向量描述图片路径元数据拍摄时间、尺寸等一起写入Qdrant用户输入查询先经模型改写再转向量在Qdrant里做相似度检索返回Top-K图片可选对结果做一次重排序提升精度这个架构的好处是每一层都可以单独替换和调优。描述生成不满意换模型嵌入效果不好换嵌入模型检索排序不理想加个重排。模块化带来的灵活性是端到端方案给不了的。3. 核心细节解析与实操要点3.1 描述生成的提示词设计这一步是整个方案的地基。描述生成得好不好直接决定后面检索的上限。我前后改了七八版提示词总结出几个关键点。第一要求模型输出结构化但自然的描述。太结构化比如纯JSON字段会丢失语义连贯性检索时匹配效果反而差太自由又容易漏掉关键信息。我最后用的提示词大意是请用一段话描述这张图片包含主体、场景、时间氛围、色彩基调、可能的拍摄视角语言自然控制在80字以内。第二强制包含时间与光线信息。这是傍晚的海边能搜到的关键。很多模型默认描述会忽略光线只说海边有沙滩和海水。我在提示词里明确要求描述时间氛围和光线比如黄昏暖光逆光室内冷光。第三避免主观臆断。早期我让模型描述图片讲述的故事结果它开始编把一张普通街景描述成忙碌的上班族赶着回家。这种幻觉会污染检索。后来改成只描述可见内容不推测意图。提示描述长度控制在60到100字之间比较合适。太短信息不足太长会稀释关键词权重检索时反而不精准。3.2 批量处理的并发与限流四万张图如果一张一张串行调用按每张两秒算得跑二十多个小时。这显然不行。我做了并发但并发不是越高越好。我一开始把并发开到20结果接口开始返回超时和限流错误而且部分请求失败了还没重试导致有几百张图描述为空。后来改成并发8配合指数退避重试稳定多了。实测下来四万张图大概跑了三个多小时可以接受。这里有个经验一定要做断点续传。我维护了一个处理状态表记录每张图的处理状态待处理/成功/失败。程序中断后重启只处理待处理和失败的不用从头再来。这个设计在我调试阶段救了我无数次。另外失败重试要区分错误类型。网络超时值得重试但如果是图片本身损坏或者格式不支持重试多少次都没用直接标记跳过避免死循环。3.3 图片预处理不能省直接拿原图去调接口有两个问题。一是大图传输慢二是很多接口对图片尺寸有限制。我在调用前统一做了预处理长边缩放到1024像素保持宽高比转成JPEG质量85。这个尺寸是权衡的结果。太小比如512会丢失细节模型看不清远处的东西太大没必要1024对语义理解已经足够而且传输快。质量85在肉眼几乎无损的前提下文件体积能压到原来的三分之一左右。还有个细节EXIF方向要处理。手机拍的照片很多带旋转信息如果不校正模型看到的可能是躺着的图描述就会出错。我用Pillow的ImageOps.exif_transpose统一校正这一步千万别省。3.4 向量库的Schema设计Qdrant里的每条记录我存了这些字段字段名类型用途id整数主键用图片路径哈希生成vector768维浮点数组描述文本的嵌入向量description字符串模型生成的描述原文path字符串图片本地路径shot_time时间戳拍摄时间用于过滤width/height整数图片尺寸status字符串处理状态shot_time这个字段特别有用。有时候我明确知道要找某年拍的照片就可以在检索时加时间过滤把范围缩小精度和速度都提升。Qdrant支持在payload上建索引做过滤查询时带上条件即可。注意向量维度一旦确定后续所有写入和查询必须一致。换嵌入模型时维度可能变这时候要么重建集合要么做维度对齐别混着写。3.5 查询改写的分寸查询改写能提升召回但改过头会引入噪声。我试过让模型把傍晚的海边扩展成一大段结果检索时把很多不相关的海边图也拉进来了因为扩展文本里出现了沙滩度假旅行这些泛化词。后来我把改写策略调整为适度扩展保留原词。具体做法是改写结果里必须包含原始查询词再补充同义或相关的时间、光线、场景词。比如傍晚的海边改写成傍晚 黄昏 日落 海边 海面 沙滩 暖色调光线。这样既丰富了语义又不会跑偏。实测下来改写后的召回率比不改写提升了大概三成而精度基本没掉。这个投入产出比很划算。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我的运行环境是Ubuntu 22.04Python 3.10。核心依赖就几个pip install openai qdrant-client pillow sentence-transformers tqdmopenai调用蓝耘元生代的兼容接口qdrant-client操作向量库pillow图片预处理sentence-transformers本地文本嵌入tqdm进度条处理几万张图时看着进度心里有底Qdrant用Docker起docker run -d --name qdrant -p 6333:6333 -v $(pwd)/qdrant_data:/qdrant/storage qdrant/qdrant-v把数据挂到本地目录容器删了数据还在这点很重要。4.2 调用蓝耘元生代生成描述因为走的是OpenAI兼容协议代码和调OpenAI几乎一样只是base_url和api_key换成蓝耘的。下面是我实际用的核心函数做了简化from openai import OpenAI import base64 client OpenAI( api_key你的API_KEY, base_url蓝耘元生代的兼容接口地址 ) def image_to_description(image_path): with open(image_path, rb) as f: img_b64 base64.b64encode(f.read()).decode(utf-8) prompt ( 请用一段自然的中文描述这张图片包含主体内容、场景环境、 时间氛围与光线、色彩基调、拍摄视角。控制在80字以内 只描述可见内容不要推测意图或编造故事。 ) resp client.chat.completions.create( model多模态模型名称, messages[{ role: user, content: [ {type: text, text: prompt}, {type: image_url, image_url: {url: fdata:image/jpeg;base64,{img_b64}}} ] }], max_tokens200, temperature0.3 ) return resp.choices[0].message.content.strip()几个参数说明一下。temperature设0.3是为了让描述稳定不要每次都不一样。max_tokens给200足够描述本身不长。图片用base64内联传省去上传步骤。提示base64编码后体积会增大约三分之一所以预处理压缩图片这一步对传输效率影响很大别跳过。4.3 本地嵌入与写入向量库描述拿到后用本地嵌入模型转向量。我用的是中文语义优化过的sentence-transformers模型768维from sentence_transformers import SentenceTransformer from qdrant_client import QdrantClient from qdrant_client.models import PointStruct, VectorParams, Distance embedder SentenceTransformer(你的本地嵌入模型路径) qdrant QdrantClient(hostlocalhost, port6333) # 建集合只需执行一次 qdrant.recreate_collection( collection_namemy_gallery, vectors_configVectorParams(size768, distanceDistance.COSINE) ) def index_image(img_id, path, description, shot_time): vec embedder.encode(description, normalize_embeddingsTrue).tolist() qdrant.upsert( collection_namemy_gallery, points[PointStruct( idimg_id, vectorvec, payload{ path: path, description: description, shot_time: shot_time } )] )normalize_embeddingsTrue很重要归一化后余弦相似度计算更稳定。距离度量用COSINE和归一化配套。4.4 批量处理的完整流程把上面几步串起来加上并发控制和断点续传就是主处理脚本。我用concurrent.futures的线程池因为主要瓶颈在网络IO多线程足够from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm import sqlite3, hashlib, os def process_one(path): try: img_id int(hashlib.md5(path.encode()).hexdigest()[:15], 16) # 检查是否已处理 if is_done(img_id): return skip desc image_to_description(preprocess(path)) index_image(img_id, path, desc, get_shot_time(path)) mark_done(img_id) return ok except Exception as e: mark_failed(img_id, str(e)) return fail paths scan_gallery(/path/to/gallery) with ThreadPoolExecutor(max_workers8) as pool: futures [pool.submit(process_one, p) for p in paths] for f in tqdm(as_completed(futures), totallen(futures)): f.result()状态用SQLite存轻量又可靠。is_done和mark_done就是简单的查表和更新。这套跑下来四万张图三个多小时处理完失败率不到千分之三失败的重新跑一遍基本都能过。4.5 查询接口的实现查询分两步改写和检索。def search(query, top_k20, yearNone): # 第一步改写 rewritten rewrite_query(query) # 第二步转向量 qvec embedder.encode(rewritten, normalize_embeddingsTrue).tolist() # 第三步检索可带时间过滤 flt None if year: flt Filter(must[FieldCondition( keyshot_time, rangeRange(gteyear_start(year), ltyear_end(year)) )]) hits qdrant.search( collection_namemy_gallery, query_vectorqvec, limittop_k, query_filterflt ) return [(h.payload[path], h.payload[description], h.score) for h in hits]rewrite_query就是调模型把短查询扩展成描述性文本。返回结果里带上score方便我判断哪些是强相关、哪些是勉强沾边。4.6 效果验证搜傍晚的海边跑通之后我做了个对比测试。同一批图分别用文件名搜索、纯CLIP方案、我的方案搜傍晚的海边看前20个结果里真正符合的有几个。方案前20命中数平均耗时文件名搜索0极快纯CLIP11快描述向量检索17稍慢含改写文件名搜索直接挂零因为没人的文件名叫这个。CLIP能搜到一些但把白天的海景也混进来了。我的方案命中17个剩下3个是光线接近但场景略有偏差的可以接受。耗时上因为多了改写这一步慢了几百毫秒但换来精度提升我觉得值。5. 常见问题与排查技巧实录5.1 描述生成质量不稳定的排查现象同一张图有时候描述很准有时候漏掉关键信息。排查思路先看是不是temperature设高了。我早期设0.7描述每次都不一样后来降到0.3稳定多了。如果还不行检查提示词是不是太笼统把要求拆细明确列出必须包含的维度。另一个坑图片本身质量差。模糊、过暗、过曝的图模型也看不清描述自然差。这类图我建议单独标记检索时降权或者直接排除。5.2 检索结果不相关的处理现象搜傍晚的海边返回一堆清晨的山。排查思路先看描述文本。如果描述里压根没提傍晚和海边那是描述生成的问题回去调提示词。如果描述里有但检索还是不准那是嵌入模型的问题考虑换一个中文语义更强的嵌入模型。我踩过的坑嵌入模型和描述语言不匹配。我一开始用了个英文为主的嵌入模型中文描述转出来的向量区分度很差。换成中文优化的模型后效果立竿见影。5.3 处理中断与数据一致性现象程序跑到一半崩了重启后不知道哪些处理过。解决状态表是必须的。我用SQLite记录每张图的img_id和status重启后先查状态跳过已成功的。另外写入Qdrant和更新状态表要尽量保证原子性我的做法是先写向量库成功后再更新状态这样即使中间崩了最多是重复处理不会丢数据。5.4 常见问题速查表问题可能原因解决方向描述为空接口超时/限流降并发加重试描述跑偏提示词太开放收紧提示词降temperature检索不准嵌入模型不匹配换中文优化嵌入模型处理中断丢数据无状态记录加SQLite状态表图片方向错未处理EXIF用exif_transpose校正查询召回低查询太短加查询改写步骤5.5 几个独家避坑技巧第一先小批量验证再全量跑。我一开始直接上全量跑到一半发现提示词有问题白跑了两小时。后来改成先拿200张图试确认描述质量和检索效果都OK再全量。第二保留原始描述文本。向量库里的描述别只存向量原文一定要留着。调试时直接看描述比看向量直观一万倍。第三定期备份向量库。Qdrant的数据目录定期打包备份万一集合损坏重建的成本很高。我吃过一次亏后来养成了每周备份的习惯。第四给检索加个重排序。如果对精度要求高可以在向量检索返回Top-50后再用一个交叉编码器做精排取Top-10。这一步能再提升一截精度代价是查询慢一点。我平时不开做重要检索时才开。6. 后续可扩展的方向这套跑通之后我又顺手做了几个扩展。一个是按图搜图把某张图当查询找相似的图原理一样只是查询向量换成图片描述向量。另一个是自动打标签从描述里抽关键词生成标签云方便快速浏览。还有个我觉得挺有意思的方向结合拍摄时间做时间线检索。比如去年夏天在海边拍的查询里带时间范围配合语义检索能精准定位。这个我还在调主要是时间解析那部分需要处理各种口语化表达。如果你也想给自己的图库做一套我的建议是先跑通最小闭环拿100张图走完描述生成、向量写入、查询检索全流程确认效果符合预期再考虑全量和优化。别一上来就追求完美架构容易卡在半路。这套方案的门槛其实不高核心代码加起来也就两三百行难的是把每个环节的细节调到位。
返回列表