1. 从文件名搜索到语义搜索本地图库的检索困境我电脑里存了大概四万多张照片从2016年到现在按年份分了文件夹按月份建了子目录文件名基本是IMG_20230815_183422.jpg这种格式。以前找图全靠回忆时间点比如去年秋天去海边那次然后翻到对应月份文件夹里一张张看。这个方式在照片量少的时候还能凑合一旦超过几千张基本等于大海捞针。后来我试过给照片打标签用Lightroom或者Eagle这类工具手动标注海边日落家人这些关键词。问题是标注这件事本身就是个体力活四万张图标注完我估计得花掉整整一个月的业余时间。而且人的记忆是模糊的我当时标了海边过半年想找的是傍晚海边有礁石的那张标签根本覆盖不了这么细的语义。这就是传统本地图库搜索的核心痛点它只能匹配文件名、文件夹路径或者你手动打的标签无法理解图片的实际内容。你搜傍晚的海边它不会知道哪张图是傍晚拍的、哪张图里有海。文件名里没有这些信息标签也未必打得这么细。语义搜索要解决的就是这个问题。它的逻辑是把图片和文字都转换成同一套语义向量然后计算相似度。你说傍晚的海边系统把这句话转成一个向量再去和每张图片的向量做比对找出最接近的那些图。这个过程不需要你手动标注也不需要文件名包含关键词纯粹靠模型对图像内容的理解。我这次做的事情就是在本地搭一套完整的语义搜索流程用多模态模型把图片转成向量用文本模型把搜索词转成向量然后做相似度匹配。模型调用走的是蓝耘元生代的API它兼容OpenAI协议所以代码写起来很顺手。整套方案跑下来四万张图的索引大概花了几个小时之后每次搜索响应在几百毫秒级别效果比我预期的好不少。这篇文章我会把整个搭建过程拆开讲清楚为什么选多模态模型而不是传统CV方案、蓝耘元生代的API怎么接、向量索引怎么建、搜索时怎么调参、以及我踩过的几个坑。如果你也有一个乱七八糟的本地图库想让它变得能听懂人话这套方案可以直接抄作业。2. 为什么是多模态向量而不是标签关键词2.1 传统方案的死穴语义鸿沟在动手之前我先想清楚了一件事为什么不用传统的关键词匹配或者标签系统答案很简单文字和图像之间存在天然的语义鸿沟。你看到一张图能立刻判断出这是傍晚的海边有礁石天边有橙色的云。但计算机看到的是一堆像素值RGB矩阵它不知道什么叫傍晚什么叫海边。传统方案靠人工打标签来跨越这个鸿沟但标签的粒度是有限的。你打海边覆盖不了傍晚你打傍晚海边覆盖不了有礁石你打傍晚海边礁石下次想搜清晨的海边又找不到。语义向量的思路完全不同。多模态模型比如CLIP架构那一类在训练时见过海量图片-文本配对它学会了把傍晚的海边这句话和对应的视觉特征映射到向量空间里相近的位置。也就是说模型本身已经理解了傍晚和海边的视觉含义你不需要手动告诉它。我实测过一个对比同一张傍晚海边的照片用标签系统搜日落能搜到因为我标了搜黄昏搜不到没标这个词搜傍晚也搜不到。但用语义向量这三个词都能搜到而且傍晚的海边这种组合描述也能精准命中。这就是语义搜索的价值。2.2 多模态模型选型的几个考量选多模态模型时我主要看三个维度中文支持、向量维度、调用成本。中文支持是首要的。很多开源CLIP模型是在英文语料上训练的你输入傍晚的海边它可能理解得不如evening beach准确。我测试过几个模型中文语义理解能力差异很明显。蓝耘元生代上提供的多模态模型对中文的支持比较好这也是我选它的主要原因之一。向量维度影响存储和检索效率。常见的维度有512、768、1024等。维度越高表达能力越强但存储成本也越高。四万张图如果每张存1024维的float32向量大概是160MB左右完全可接受。如果图片量到百万级就得考虑降维或者量化了。调用成本方面蓝耘元生代的计费方式是按调用量算的具体价格我不在这里展开但整体比我预想的低。四万张图索引一遍的成本大概相当于几杯咖啡的钱。2.3 文本模型和图像模型必须同源这里有个容易被忽略的坑图像向量和文本向量必须来自同一个模型或者至少是同一个向量空间。我一开始想省事图像用A模型编码文本用B模型编码结果搜出来的东西完全不对。原因很简单A模型把傍晚的海边映射到向量空间的位置XB模型把同一句话映射到位置YX和Y根本不在一个坐标系里算余弦相似度毫无意义。正确的做法是用同一个多模态模型既编码图像也编码文本。蓝耘元生代的API支持这种用法同一个模型端点传图片就返回图像向量传文字就返回文本向量两者天然在同一空间。这一点在选型时一定要确认清楚否则后面全白做。3. 蓝耘元生代API接入OpenAI兼容协议的实际用法3.1 为什么OpenAI兼容协议省事蓝耘元生代的API兼容OpenAI协议这意味着我可以直接用OpenAI的Python SDK只需要改一下base_url和api_key。不需要学一套新的SDK不需要重新封装请求逻辑现有的代码几乎可以无缝迁移。我之前的项目里已经有一套基于OpenAI SDK的调用封装这次接蓝耘元生代改动量大概就三行改base_url、改api_key、改模型名称。这种兼容性对于快速验证想法来说太重要了不用在环境适配上浪费时间。3.2 环境准备和依赖安装先说一下我的环境Python 3.10主要依赖就几个。pip install openai numpy pillow tqdmopenai官方SDK用来调蓝耘元生代的APInumpy向量运算和相似度计算pillow读取和预处理图片tqdm显示索引进度四万张图没进度条会焦虑如果你打算用FAISS做向量检索图片量大的话建议用再加一个pip install faiss-cpu四万张图的量级其实用numpy做暴力检索也够快但如果你图库更大FAISS会更合适。3.3 初始化客户端和第一个调用初始化客户端的代码很直接from openai import OpenAI client OpenAI( base_urlhttps://api.lanyun.net/v1, # 蓝耘元生代的API地址 api_keyyour-api-key-here )这里有个细节base_url的末尾要带/v1这是OpenAI协议的标准路径。有些平台的base_url不带这个后缀需要确认一下文档。第一次调用我建议先用一张小图测试确认返回结构import base64 from PIL import Image import io def image_to_base64(image_path, max_size512): img Image.open(image_path) img.thumbnail((max_size, max_size)) buffer io.BytesIO() img.save(buffer, formatJPEG, quality85) return base64.b64encode(buffer.getvalue()).decode(utf-8) img_b64 image_to_base64(test.jpg) response client.embeddings.create( modelmultimodal-embedding-model, input[{type: image_url, image_url: {url: fdata:image/jpeg;base64,{img_b64}}}] ) print(len(response.data[0].embedding))返回的embedding就是图像向量。文本向量的调用方式类似只是input换成纯文本。3.4 图片预处理压缩和格式统一这里有个实操经验不要直接把原图丢给API。我手机拍的照片动辄四五MB直接base64编码后请求体巨大传输慢而且模型内部也会resize传原图纯属浪费带宽。我的做法是统一缩放到最长边512像素JPEG质量85。这个尺寸对语义理解来说足够了文件大小能压到50KB以内请求速度快很多。实测下来512像素和原图的搜索结果差异几乎可以忽略但索引速度能快三到五倍。另外要注意格式统一。PNG、HEIC、WebP这些格式Pillow不一定都能直接读。HEIC需要额外装pillow-heif我图库里iPhone拍的照片不少是HEIC所以这个依赖也加上了。pip install pillow-heiffrom pillow_heif import register_heif_opener register_heif_opener()注册之后Pillow就能直接打开HEIC了省得手动转格式。4. 四万张图的索引构建从遍历到落盘4.1 遍历策略递归扫描和格式过滤图库的目录结构是年份/月份/图片所以需要递归遍历。我用os.walk配合扩展名过滤import os SUPPORTED_EXTS {.jpg, .jpeg, .png, .heic, .webp, .bmp} def scan_images(root_dir): image_paths [] for dirpath, dirnames, filenames in os.walk(root_dir): for fname in filenames: ext os.path.splitext(fname)[1].lower() if ext in SUPPORTED_EXTS: image_paths.append(os.path.join(dirpath, fname)) return image_paths四万张图扫下来大概几秒钟不是瓶颈。瓶颈在API调用。4.2 批量调用与并发控制一张一张调API太慢四万张图如果每张200毫秒串行要两个多小时。我用concurrent.futures做并发但并发数不能太高否则容易触发限流。我的经验是并发数控制在5到10之间比较稳。太高了API会返回429太低了速度上不去。我最后用的是8个并发四万张图大概跑了三个多小时平均每张图不到0.3秒。from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm def encode_image(path): try: img_b64 image_to_base64(path) response client.embeddings.create( modelmultimodal-embedding-model, input[{type: image_url, image_url: {url: fdata:image/jpeg;base64,{img_b64}}}] ) return path, response.data[0].embedding except Exception as e: return path, None def build_index(image_paths, max_workers8): results {} with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {executor.submit(encode_image, p): p for p in image_paths} for future in tqdm(as_completed(futures), totallen(futures)): path, embedding future.result() if embedding is not None: results[path] embedding return results这里有个细节失败的图片要记录不要直接丢弃。我跑完第一遍发现有几十张图返回了None原因是图片损坏或者格式异常。这些图我单独列出来后面手动处理而不是让它们静默消失。4.3 断点续传别让一次失败毁掉三小时四万张图跑三个小时中间如果网络抖动或者程序崩溃全部重来会让人崩溃。所以我加了断点续传机制每处理完一批比如500张就把结果追加写入一个JSONL文件。import json def save_batch(results, output_file): with open(output_file, a, encodingutf-8) as f: for path, embedding in results.items(): f.write(json.dumps({path: path, embedding: embedding}, ensure_asciiFalse) \n)下次启动时先读取已完成的路径集合跳过这些图def load_completed(output_file): completed set() if os.path.exists(output_file): with open(output_file, r, encodingutf-8) as f: for line in f: data json.loads(line) completed.add(data[path]) return completed这个机制救过我一次。跑到两万多张的时候程序因为内存问题崩了重启后直接从断点继续没有浪费前面的时间。4.4 向量落盘numpy还是FAISS四万张图的向量我算了一下假设每张1024维float32那就是40000 × 1024 × 4字节 ≈ 164MB。这个量级用numpy存一个.npy文件完全够用加载到内存也就一百多MB。import numpy as np paths list(results.keys()) vectors np.array([results[p] for p in paths], dtypenp.float32) np.save(image_vectors.npy, vectors) with open(image_paths.json, w, encodingutf-8) as f: json.dump(paths, f, ensure_asciiFalse)检索时用numpy做矩阵乘法算余弦相似度四万条向量的检索时间在几十毫秒级别完全可接受。如果你的图库超过十万张再考虑上FAISS。注意向量一定要做归一化否则余弦相似度算出来不对。归一化可以在存盘前做也可以在检索时做我选择存盘前做省得每次检索都算一遍。def normalize(vectors): norms np.linalg.norm(vectors, axis1, keepdimsTrue) return vectors / (norms 1e-10) vectors normalize(vectors)5. 搜索端实现把傍晚的海边变成一次向量比对5.1 查询文本的编码搜索端的逻辑很直接把用户输入的查询词用同一个多模态模型编码成文本向量然后和所有图像向量算余弦相似度取Top-K。def encode_text(query): response client.embeddings.create( modelmultimodal-embedding-model, input[{type: text, text: query}] ) return np.array(response.data[0].embedding, dtypenp.float32)注意这里用的模型必须和编码图像时是同一个。如果蓝耘元生代有多个多模态模型一定要确认模型名称一致。5.2 相似度计算与Top-K返回def search(query, vectors, paths, top_k20): query_vec encode_text(query) query_vec query_vec / (np.linalg.norm(query_vec) 1e-10) similarities vectors query_vec top_indices np.argsort(similarities)[::-1][:top_k] results [] for idx in top_indices: results.append({ path: paths[idx], score: float(similarities[idx]) }) return resultsvectors query_vec这一步就是矩阵乘法四万条向量算下来几毫秒。argsort取Top-K也是毫秒级。整个搜索响应时间主要花在文本编码的API调用上大概几百毫秒。5.3 相似度分数的解读多少分算匹配余弦相似度的范围是-1到1但实际用下来多模态模型的分数分布比较集中。我实测的经验值是分数区间含义实际效果0.35以上高度相关基本就是你要找的图0.25-0.35中度相关主题接近但细节可能不符0.15-0.25弱相关有某些共同元素但整体不太对0.15以下基本无关可以忽略这个阈值不是绝对的不同模型的分数分布不一样。建议你先用几张已知的图测试一下找到适合你模型的阈值。我一般返回Top-20让用户自己看。因为语义搜索有时候会有意外惊喜比如你搜傍晚的海边它可能返回一张清晨的湖边因为两者在视觉特征上有相似之处。这种误召回不一定是坏事有时候反而能帮你发现遗忘的照片。5.4 多关键词组合查询的处理傍晚的海边这种组合查询直接编码成一整个句子效果最好。不要拆成傍晚和海边分别编码再合并那样会丢失组合语义。但如果你想要更精细的控制可以试试加权组合def search_weighted(query_parts, weights, vectors, paths, top_k20): combined np.zeros(vectors.shape[1], dtypenp.float32) for part, weight in zip(query_parts, weights): vec encode_text(part) vec vec / (np.linalg.norm(vec) 1e-10) combined weight * vec combined combined / (np.linalg.norm(combined) 1e-10) similarities vectors combined top_indices np.argsort(similarities)[::-1][:top_k] return [(paths[i], float(similarities[i])) for i in top_indices]比如search_weighted([傍晚, 海边], [0.4, 0.6], ...)让海边的权重更高一些。这个方式适合你对查询意图有明确权重分配的场景。6. 实测效果与几个意料之外的发现6.1 中文查询的实际表现我用几十个中文查询词测了一轮整体效果比我预期的好。几个典型例子搜傍晚的海边Top-5里有4张确实是傍晚海边第5张是黄昏的湖边也算合理。搜猫在窗台上Top-10里有7张是猫其中5张在窗台附近。搜生日蛋糕Top-10全部命中。但也有翻车的时候。搜穿红衣服的人返回的结果里红色衣服的占比不高模型似乎对颜色属性的敏感度不如对场景和物体的敏感度。搜模糊的照片这个基本搜不到因为模型编码的是内容语义不是图像质量。6.2 跨语言查询的意外收获有个意外发现中文查询能搜到英文场景的图反之亦然。我图库里有一些在国外拍的照片文件名是英文的但我用中文搜教堂雪山地铁站都能准确命中。这说明多模态模型的向量空间是跨语言对齐的中文和英文在同一个语义空间里。这个特性很实用。你不需要记住照片是用什么语言命名的直接用母语搜就行。6.3 那些搜不到的情况有几类查询效果明显不好抽象概念搜孤独快乐这种情绪词返回结果很随机。模型对抽象情感的捕捉能力有限。精确数量搜三个人它可能返回两个人的图也可能返回四个人的。数量这种精确属性向量表示不够敏感。文字内容搜写着生日快乐的牌子它可能返回生日蛋糕但不一定能识别牌子上的文字。OCR和语义搜索是两回事。特定人物搜张三如果模型没见过张三它不知道张三长什么样。除非你用专门的人脸识别方案否则语义搜索搞不定特定人物识别。这些边界情况在选型时就要想清楚。语义搜索擅长的是场景、物体、氛围、颜色、构图这些视觉语义不擅长精确属性、抽象概念和特定身份。7. 踩过的坑和几条实操建议7.1 坑一base64编码的图片太大导致请求超时一开始我没做图片压缩直接把原图base64编码发过去。结果很多请求超时尤其是那些手机拍的HEIC转JPEG后还有好几MB的图。后来统一缩放到512像素问题解决。图片预处理这一步绝对不能省。7.2 坑二并发数设太高触发限流我一开始设了20个并发跑了几百张就开始大量返回429。降到8之后稳定了。并发数不是越高越好要根据API的限流策略调整。建议从5开始试稳定后再往上加。7.3 坑三向量没归一化导致相似度计算错误这个坑比较隐蔽。我一开始忘了归一化算出来的相似度分数范围很奇怪有的超过1有的全是负数。后来加上归一化分数分布就正常了。余弦相似度的前提是向量已归一化或者你在计算时手动除以模长。7.4 坑四断点续传文件格式不一致我中途改过一次向量维度换了个模型但断点续传文件里还是旧维度的向量。结果加载时numpy报错维度不匹配。换模型或者改维度时一定要清空旧的索引文件重新跑不要想着复用。7.5 几条实操建议先小规模验证再全量跑。拿100张图先跑通全流程确认搜索效果符合预期再上四万张。否则全量跑完发现方案不对浪费的是几个小时。保留原始向量文件。我习惯把image_vectors.npy和image_paths.json备份一份。万一后面要换检索方案比如从numpy换到FAISS不用重新调API编码。定期更新索引。新拍的照片要增量编码追加到向量文件里。我写了个小脚本扫描比上次索引时间新的图片只编码这些新增的。搜索界面可以很简单。我用Gradio搭了个最简单的界面一个输入框一个结果展示区够用了。不需要复杂的UI核心是搜索质量。8. 后续可以扩展的方向这套方案跑通之后我还在想几个可以继续优化的点。混合检索语义向量 传统关键词匹配两者结合。比如搜2023年海边语义部分负责海边关键词部分负责2023年这个时间过滤。这样能弥补语义搜索在精确属性上的不足。以图搜图现在只做了文搜图其实图搜图更简单把查询图片编码成向量直接和库里的向量比对就行。代码几乎不用改只是把encode_text换成encode_image。本地缓存常用的查询词可以缓存文本向量避免重复调API。比如海边猫生日这些高频词缓存下来能省不少调用量。向量量化如果图库继续增长到几十万张可以考虑用PQProduct Quantization做向量压缩把存储和检索成本降下来。不过四万张这个量级暂时用不上。这套东西的核心价值在于它把找图这件事从回忆翻文件夹变成了描述搜索。你不需要记住照片是什么时候拍的、存在哪个文件夹只需要描述你记得的画面内容。对于我这种图库混乱的人来说这个体验提升是质的飞跃。