
1. 为什么我要折腾本地图库的语义搜索我的图库里有大概四万多张照片从2016年到现在手机拍的、相机拍的、截图、表情包、素材图全混在一起。以前用文件夹分类后来发现根本管不住——你拍了一张“傍晚的海边”当时觉得以后肯定能找到结果半年后想用搜“海边”搜不出来因为文件名是IMG_20230812_183452.jpg。搜“傍晚”更没戏系统相册的标签识别只认“天空”“水”“日落”这种粗颗粒的词稍微带点意境的描述就歇菜了。这个痛点其实很普遍传统图库搜索靠的是文件名、EXIF信息、手动标签本质上都是“元数据检索”而不是“内容检索”。你脑子里想的是画面语义但计算机只认字符串匹配。这两者之间的鸿沟就是语义搜索要填的坑。我试过几种方案。最早用本地部署的CLIP模型做零样本分类效果有但精度不够尤其是中文查询——CLIP原版对中文支持很差你得先把中文翻译成英文再编码中间损失一层语义。后来也试过一些在线图库服务但照片上传到别人服务器这件事我始终不太放心尤其是一些家庭照片和工作素材。直到最近接触到蓝耘元生代这个平台它提供了OpenAI兼容协议的接口可以调用多模态模型和文本模型。我一看这个组合脑子里立刻蹦出一个方案用多模态模型给每张图生成一段中文描述再用文本模型把描述和用户的自然语言查询做语义匹配。这样“傍晚的海边”这种查询就能通过描述文本的语义相似度找到对应的图。这个方案的核心逻辑其实不复杂但实操中有很多细节决定成败。下面我把整个实战过程拆开讲从架构设计到代码实现再到踩过的坑全部摊开。2. 整体方案设计与核心思路拆解2.1 为什么选“图生文文搜文”而不是“图生向量文生向量”市面上主流的语义搜图方案是CLIP那套图片编码成向量文本编码成向量然后算余弦相似度。这个方案理论上很优雅但实操中有几个硬伤。第一中文语义对齐问题。CLIP的中文能力是后来才补上的原版在中文短句上的表现很不稳定。你搜“傍晚的海边”它可能给你返回“夜晚的城市”或者“白天的沙滩”因为“傍晚”这个时间概念在向量空间里和“夜晚”靠得太近。第二细粒度描述能力弱。CLIP的文本编码器对长句、复杂描述的编码能力有限你没法用“一个穿红色外套的小女孩在沙滩上捡贝壳”这种句子去搜它会把“红色外套”和“沙滩”拆散。第三可解释性差。向量相似度算出来一个0.87你根本不知道它为什么匹配。是颜色像构图像还是内容真的对而“图生文文搜文”的方案本质上是把视觉问题转化成了自然语言处理问题。多模态模型先给每张图生成一段中文描述比如“傍晚时分海浪拍打沙滩天空呈橙红色远处有几个人影”。然后用户搜“傍晚的海边”文本模型把查询和描述做语义匹配。这个方案的优势在于中文原生支持描述和查询都是中文语义空间一致。可解释性强匹配上了你能看到是哪段描述匹配的为什么匹配。灵活度高想加过滤条件比如“只要横构图”“只要2023年之后的”直接在描述文本上做文章就行。当然这个方案也有代价每张图都要调一次多模态模型生成描述成本比纯向量方案高。但考虑到我图库只有四万多张而且描述生成是一次性的后续搜索只调文本模型这个成本完全可以接受。2.2 蓝耘元生代在方案中的角色定位蓝耘元生代在这个方案里扮演的是模型能力提供方的角色。它提供了OpenAI兼容的API接口意味着我可以直接用OpenAI的SDK来调用不需要额外适配。具体来说我用到了两类模型多模态模型负责看图说话输入图片输出中文描述。我选的是支持视觉输入的模型具体型号就不说了反正接口是兼容的。文本模型负责语义匹配输入查询和候选描述输出相似度分数或者直接做排序。这里有个关键点OpenAI兼容协议意味着我可以把蓝耘元生代的接口地址配到任何支持OpenAI SDK的工具里比如LangChain、LlamaIndex或者我自己写的Python脚本。这个兼容性省了我大量适配工作。2.3 数据流设计从图片到可搜索的文本索引整个系统的数据流是这样的图片预处理遍历图库目录过滤掉非图片文件提取图片的路径、拍摄时间、尺寸等元数据。描述生成对每张图片调用多模态模型生成一段中文描述存入数据库。查询处理用户输入自然语言查询调用文本模型将查询与数据库中的描述做语义匹配。结果排序按相似度分数排序返回Top-K结果附带图片路径和描述。这个流程里描述生成是瓶颈。四万张图如果每张调一次API按每次2秒算就是22个小时。所以必须做并发和断点续传。我后面会详细讲这块的优化。3. 核心细节解析与实操要点3.1 多模态模型生成图片描述Prompt设计是关键让多模态模型看图说话听起来简单但Prompt设计直接决定描述质量。我试过几种Prompt效果差异很大。最初我用的是“描述这张图片。”结果模型返回的是“这是一张照片里面有天空、水、沙滩。”这种描述太泛了搜“傍晚的海边”根本匹配不上因为“傍晚”这个信息丢了。后来我改成“请用一段中文详细描述这张图片的内容包括场景、时间、天气、颜色、主要物体、人物动作。如果画面中有文字请一并提取。”这个Prompt好一些但模型有时候会过度发挥比如把“傍晚”说成“黄昏”把“海边”说成“海滩”虽然语义相近但匹配时会有偏差。最终我用的Prompt是这样的你是一个图片描述生成器。请用一段不超过100字的中文描述这张图片要求包含场景类型如海边、城市、室内、山林包含时间线索如清晨、正午、傍晚、夜晚包含天气和光线如晴天、阴天、逆光、暖色调包含主要物体和人物如有人在海边散步、桌上有咖啡杯不要编造画面中不存在的内容直接输出描述不要加任何前缀这个Prompt的好处是结构化模型会按维度去观察图片而不是泛泛而谈。实测下来“傍晚的海边”这种查询的命中率从最初的30%提升到了85%以上。还有一个细节图片分辨率。多模态模型对图片的输入分辨率有限制太大会被压缩太小会丢细节。我一般把图片缩放到最长边1024像素再传这样既保证细节又控制token消耗。3.2 文本模型做语义匹配为什么不用向量数据库很多人会问既然有文本模型为什么不把描述向量化存到向量数据库然后用向量检索我试过这个方案用的是文本模型的embedding接口把每段描述转成1536维向量存到FAISS里。查询时把查询也转成向量算余弦相似度。这个方案速度快四万条向量检索毫秒级。但问题在于embedding模型对短文本的语义区分度不够。比如“傍晚的海边”和“夜晚的海边”在向量空间里距离很近但语义上“傍晚”和“夜晚”是两个不同的时间段。向量检索会返回一堆“夜晚的海边”把真正“傍晚”的图淹没了。所以我最终用的是文本模型直接做相关性打分。具体做法是把查询和候选描述拼成一个Prompt让文本模型判断相关性输出0-100的分数。这个方案慢一些但精度高很多。为了平衡速度和精度我做了两阶段检索粗筛用embedding向量检索从四万条里选出Top-200候选。精排用文本模型对这200条做相关性打分返回Top-20。这样既保证了速度又保证了精度。粗筛阶段召回率很高精排阶段准确率很高。3.3 数据库设计SQLite就够了很多人一上来就上PostgreSQL、Milvus我觉得没必要。四万条数据SQLite完全扛得住。我的表结构很简单CREATE TABLE images ( id INTEGER PRIMARY KEY, file_path TEXT UNIQUE, file_name TEXT, shoot_time TEXT, width INTEGER, height INTEGER, description TEXT, embedding BLOB, created_at TEXT );description存多模态模型生成的描述embedding存文本模型生成的向量用pickle序列化成BLOB。查询时先加载所有embedding到内存用numpy算余弦相似度选出Top-200再调文本模型精排。这个方案的好处是零依赖不需要额外部署向量数据库一个SQLite文件搞定。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的Python 3.10主要依赖就三个pip install openai pillow numpy tqdmopenai是官方SDK用来调蓝耘元生代的接口。pillow处理图片缩放。numpy算向量相似度。tqdm显示进度条。配置API的时候需要把base_url指向蓝耘元生代的接口地址api_key填你自己的密钥。具体地址和密钥在蓝耘元生代的控制台里找这里不赘述。from openai import OpenAI client OpenAI( base_urlhttps://你的蓝耘元生代接口地址/v1, api_key你的API密钥 )注意蓝耘元生代的接口是OpenAI兼容的所以SDK用法和OpenAI一模一样。如果你之前用过OpenAI迁移成本几乎为零。4.2 图片遍历与预处理遍历图库目录过滤出图片文件。我支持的格式包括jpg、jpeg、png、webp、heic。heic是iPhone拍的需要额外装pillow-heif。import os from PIL import Image import pillow_heif pillow_heif.register_heif_opener() def scan_images(root_dir): exts {.jpg, .jpeg, .png, .webp, .heic} images [] for dirpath, _, filenames in os.walk(root_dir): for f in filenames: if os.path.splitext(f)[1].lower() in exts: images.append(os.path.join(dirpath, f)) return images预处理主要是缩放。多模态模型对图片大小有限制我统一缩放到最长边1024像素保持宽高比。def resize_image(path, max_size1024): img Image.open(path) img img.convert(RGB) w, h img.size if max(w, h) max_size: scale max_size / max(w, h) img img.resize((int(w*scale), int(h*scale)), Image.LANCZOS) return img4.3 调用多模态模型生成描述这是核心步骤。我把缩放后的图片转成base64塞进消息里发给多模态模型。import base64 from io import BytesIO def image_to_base64(img): buffered BytesIO() img.save(buffered, formatJPEG, quality85) return base64.b64encode(buffered.getvalue()).decode() def generate_description(img): b64 image_to_base64(img) prompt 你是一个图片描述生成器。请用一段不超过100字的中文描述这张图片要求 1. 包含场景类型如海边、城市、室内、山林 2. 包含时间线索如清晨、正午、傍晚、夜晚 3. 包含天气和光线如晴天、阴天、逆光、暖色调 4. 包含主要物体和人物如有人在海边散步、桌上有咖啡杯 5. 不要编造画面中不存在的内容 6. 直接输出描述不要加任何前缀 response client.chat.completions.create( model你的多模态模型名称, messages[ { role: user, content: [ {type: text, text: prompt}, {type: image_url, image_url: {url: fdata:image/jpeg;base64,{b64}}} ] } ], max_tokens200, temperature0.3 ) return response.choices[0].message.content.strip()这里有几个参数值得说temperature0.3降低随机性让描述更稳定。太高了模型会编造太低了又太死板。max_tokens200描述不超过100字200个token足够。quality85JPEG压缩质量85在文件大小和画质之间平衡得比较好。4.4 并发处理与断点续传四万张图串行处理太慢。我用concurrent.futures做并发线程数控制在8-10太高了容易被限流。from concurrent.futures import ThreadPoolExecutor, as_completed import sqlite3 def process_image(path): try: img resize_image(path) desc generate_description(img) return path, desc, None except Exception as e: return path, None, str(e) def batch_process(image_paths, db_path, max_workers8): conn sqlite3.connect(db_path) cursor conn.cursor() # 查询已处理的图片 cursor.execute(SELECT file_path FROM images WHERE description IS NOT NULL) done {row[0] for row in cursor.fetchall()} todo [p for p in image_paths if p not in done] print(f待处理: {len(todo)} 张) with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {executor.submit(process_image, p): p for p in todo} for i, future in enumerate(as_completed(futures)): path, desc, error future.result() if error: print(f失败: {path} - {error}) continue cursor.execute( INSERT OR REPLACE INTO images (file_path, description) VALUES (?, ?), (path, desc) ) if i % 100 0: conn.commit() print(f进度: {i}/{len(todo)}) conn.commit() conn.close()断点续传的逻辑是每次启动时先查数据库里哪些图片已经有描述了跳过这些只处理新的。这样即使中途断了重启后也能接着跑。实操心得并发数不要超过10。我试过20结果频繁触发限流反而更慢。8-10是比较稳的区间。4.5 查询接口实现查询分两步粗筛和精排。粗筛用embedding向量。先把所有描述转成向量存起来查询时算余弦相似度。import numpy as np def get_embedding(text): response client.embeddings.create( model你的文本embedding模型名称, inputtext ) return response.data[0].embedding def coarse_search(query, top_k200): query_vec np.array(get_embedding(query)) conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(SELECT id, file_path, description, embedding FROM images WHERE embedding IS NOT NULL) results [] for row in cursor.fetchall(): img_id, path, desc, emb_blob row emb np.frombuffer(emb_blob, dtypenp.float32) sim np.dot(query_vec, emb) / (np.linalg.norm(query_vec) * np.linalg.norm(emb)) results.append((sim, img_id, path, desc)) results.sort(reverseTrue) return results[:top_k]精排用文本模型打分。把查询和描述拼成Prompt让模型输出相关性分数。def rerank(query, candidates, top_k20): scored [] for sim, img_id, path, desc in candidates: prompt f请判断以下图片描述与用户查询的相关性输出0-100的分数。 用户查询{query} 图片描述{desc} 只输出一个数字不要解释。 response client.chat.completions.create( model你的文本模型名称, messages[{role: user, content: prompt}], max_tokens10, temperature0 ) try: score float(response.choices[0].message.content.strip()) except: score 0 scored.append((score, sim, img_id, path, desc)) scored.sort(reverseTrue) return scored[:top_k]这个精排逻辑虽然简单但效果很好。实测“傍晚的海边”这个查询精排后的Top-5全是真正的傍晚海边照片没有混入夜晚或白天的。5. 常见问题与排查技巧实录5.1 描述生成失败图片格式不支持heic格式的图片如果不装pillow-heifPIL会直接报错。我一开始没装结果iPhone拍的照片全部处理失败。解决办法就是pip install pillow-heif然后在代码开头pillow_heif.register_heif_opener()。还有一种情况是图片损坏PIL打不开。这种直接跳过记录到日志里不要让它阻塞整个流程。5.2 接口限流如何优雅地重试蓝耘元生代的接口有速率限制并发太高会返回429。我的处理策略是指数退避重试import time def call_with_retry(func, max_retries5): for i in range(max_retries): try: return func() except Exception as e: if 429 in str(e) or rate in str(e).lower(): wait 2 ** i print(f限流等待{wait}秒后重试) time.sleep(wait) else: raise e raise Exception(重试次数耗尽)这个逻辑很简单第一次等1秒第二次等2秒第三次等4秒以此类推。实测下来最多重试3次就能成功。5.3 描述质量不稳定如何让模型少说废话多模态模型有时候会“过度解读”比如把一张普通的街景说成“充满故事感的城市角落”。这种描述虽然文艺但搜“街道”的时候反而匹配不上。我的解决办法是在Prompt里加一条“不要使用比喻、拟人、夸张等修辞手法用平实的语言描述。”加了这条之后描述变得朴实多了搜索命中率也上去了。还有一个技巧让模型输出结构化描述。比如要求它按“场景... 时间... 物体...”的格式输出。这样后续做关键词过滤也方便。5.4 查询结果不相关检查embedding模型是否匹配粗筛阶段如果召回率低大概率是embedding模型的问题。不同的embedding模型对中文语义的捕捉能力差异很大。我试过几个模型有的对“傍晚”和“黄昏”区分得很好有的就混在一起。排查方法很简单拿几个典型查询手动算一下embedding相似度看看排序是否符合直觉。如果不符合换一个embedding模型试试。5.5 性能瓶颈SQLite查询慢怎么办四万条数据SQLite查询其实不慢。但如果你的图库超过十万张就需要考虑加索引或者换数据库了。我加了一个简单的索引CREATE INDEX idx_description ON images(description);另外embedding向量不要每次查询都从数据库读启动时一次性加载到内存用numpy数组存着。四万条1536维向量内存占用大概240MB完全扛得住。问题类型典型表现排查思路解决方案格式不支持heic图片报错检查PIL是否支持该格式安装pillow-heif接口限流返回429错误查看并发数和调用频率降低并发加指数退避重试描述质量差搜索命中率低检查Prompt是否太开放加约束条件要求平实描述召回率低相关图片没出现在粗筛结果检查embedding模型换模型或调整相似度阈值查询慢响应时间超过3秒检查数据量和索引加索引embedding加载到内存6. 实测效果与优化空间6.1 实测数据命中率和响应时间我用一千张图片做了测试集人工标注了每张图的场景、时间、物体。然后构造了50个查询包括“傍晚的海边”“桌上的咖啡杯”“穿红衣服的人”“雪后的街道”等。测试结果Top-5命中率86%即86%的查询前5个结果里有至少一个相关图片Top-20命中率94%平均响应时间粗筛0.3秒精排2.1秒总计2.4秒这个响应时间对于本地图库搜索来说完全可以接受。毕竟你不是每秒都在搜偶尔等两秒没什么感觉。6.2 还能怎么优化第一个优化方向是缓存。同样的查询如果重复出现直接把上次的结果返回不用重新算。我用了一个简单的LRU缓存命中率大概30%。第二个优化方向是批量精排。现在是一条一条调文本模型如果改成批量一次传10条描述速度能快不少。但蓝耘元生代的接口是否支持批量需要看具体文档。第三个优化方向是混合检索。除了语义匹配还可以结合EXIF信息做过滤。比如用户搜“2023年傍晚的海边”可以先按拍摄时间过滤再做语义匹配。这个逻辑在SQL层面就能实现不需要额外调模型。6.3 一个意外的收获我原本只是想解决“搜不到图”的问题但做完之后发现这些自动生成的描述本身就有价值。比如我想找一张“有绿色植物和木质桌面的图”做设计参考以前只能靠记忆翻文件夹现在直接搜就行。甚至有些图我自己都忘了拍过搜的时候才发现“原来我还有这张”。另外描述文本还可以用来做自动标签。比如把所有描述里出现“海边”的图归到一个集合出现“咖啡”的归到另一个集合。这个功能我还没做但思路已经有了。7. 一些踩坑之后的真心话这个项目从起意到跑通大概花了我三个周末。中间踩的坑不少但回头看核心难点其实就两个Prompt设计和并发控制。Prompt决定了描述质量并发决定了处理效率。这两个搞定了剩下的都是工程细节。如果你也想做类似的事情我的建议是先跑通一百张图的流程再扩展到全量。不要一上来就处理四万张那样调试成本太高。先用小样本验证Prompt和模型选型确认效果后再批量处理。还有一点不要追求完美。我一开始想让每张图的描述都精准无比后来发现不可能。多模态模型再强也有看走眼的时候。但只要Top-20里能找到你要的图这个系统就是成功的。语义搜索不是精确匹配它给你的是一个“大概率相关”的结果集你从中挑就行了。最后分享一个小技巧描述生成的时候让模型顺便输出几个关键词。比如“傍晚 海边 沙滩 橙红色 海浪”。这些关键词可以用来做快速过滤也可以用来做标签云。我现在的描述字段里就包含了关键词搜索的时候先做关键词匹配再做语义匹配效果更好。