ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多模态向量化实战:让本地图库支持自然语言语义搜索

多模态向量化实战:让本地图库支持自然语言语义搜索 先说个真事。上个月我想找一张去年在海边拍的、天色快暗但还有云霞的照片在电脑里翻了二十分钟。文件名全是这样IMG_20231005_183412.jpg DSC_0023.JPG 微信图片_20231005200314.jpg按日期翻到那一堆里眼睛都快花了。这不是我一个人遇到的尴尬——本地图库这十几年基本没变过靠文件夹、按文件名、靠手工打标签它根本听不懂傍晚的海边这种描述。这篇文章就是把我最近跑通的一整套本地图库语义搜索方案记录下来把图库全部交给蓝耘元生代的多模态语义接口做向量化生成索引后用自然语言就能搜到对应图片。整个方案不需要专业显卡一台普通电脑就能运行适合有大量照片或素材想本地检索的读者也适合想了解多模态向量检索实际怎么落地的开发者。1. 痛点复盘本地相册为什么一直搜不动1.1 你翻的不是照片是文件名和记忆本地图库的搜索能力十年来几乎原地踏步。稍微新一点的相册App能做时间轴、地点聚合但你要搜傍晚的海边穿红色裙子的朋友去年冬天阳台上那只猫它们还是只能靠你按月份慢慢翻。我自己的照片库大概有3000多张分散在手机导出的文件夹、相机SD卡备份、聊天记录自动接收目录里。文件名系统各不一样相机里是_DSC7492.RW2这种序号手机导出的清一色IMG_20231005_183412.jpg微信接收的文件干脆叫mmexport1696509057641.jpg。文件系统能记录的只有拍摄时间和目录路径而傍晚海边云霞这些视觉信息全都在图片的像素里文件名一个都没存。所以传统搜索的真实工作方式其实是靠两样东西文件名匹配和人工整理好的目录树。你要是从一开始就勤快地把每张照片改名、归档那确实好搜。可绝大多数人的图库是常年堆下来的早期没分类后期懒得补最后变成几千个垃圾箱文件夹。1.2 关键词匹配的两大死穴我试过市面上的本地图片管理工具能搜到一定程度的关键词但本质还是那几个套路常见方案实现方式实际问题文件名全文索引匹配IMG_20231005这种命名字符串里没有任何语义信息EXIF信息检索读拍摄日期、相机型号、GPS只能按时间和地点筛没法知道画面里有什么人工标签给每张图手动打海边傍晚几百张就坚持不下去了我实测打300张就放弃了OCR识别提取图片中的文字风景照、人像照里几乎没有文字关键词匹配的死穴有两个。第一图片本身没有文字索引。照片的内容是像素不是字符。传统全文索引再快也只能处理文件名和标签没法处理图片像什么。第二标签天然没法规模化。一张照片可能有几十个属性海边、傍晚、云、人物、开心、构图……人工标注不可能覆盖全部维度而用户搜图时往往带着非常主观的上下文比如去年我们吵架和好之后在海边那张。这种描述任何关键词系统都束手无策。1.3 语义搜索到底在搜什么我在2024年之后被朋友反复安利多模态模型才意识到问题的解不在更好的关键词匹配而在把图片和文字都变成同一套数学表示然后比较它们的距离。所谓语义搜索核心是让文字和图片落入同一个向量空间。在这个空间里傍晚的海边不是一个字符串而是一个几百维的浮点数字列表图片也不只是RGB矩阵而是通过模型编码后的另一个浮点列表。紧接着计算这两个列表的接近程度——通常用余弦相似度如果离得近就认为这张图能对得上这句描述。这套思路把搜索从文件名匹配变成了语义距离比较理论上任何一个能用语言描述的画面都能在库里找到对应图片。剩下的问题就两个谁来把图文变成向量向量存在哪里、怎么比较这就是接下来说的重点。2. 蓝耘元生代接入思路多模态向量化是怎么把图片翻译成数字的2.1 多模态模型如何对齐图文要让傍晚的海边能搜到图关键一步是图文对齐。蓝耘元生代提供的多模态语义向量接口做的就是这件事它内部由一个视觉编码器和一个文本编码器组成训练阶段通过大量图文对学习让一张海边黄昏的照片和傍晚的海边这句话在向量空间中尽可能靠近。我在最初调研时也想过自己本地跑CLIP模型实测下来有几个绕不开的麻烦普通CPU上跑CLIP推理单张图还好批量3000张就是煎熬没有NVIDIA显卡的话某些模型加载到大内存里也非常吃力。蓝耘元生代这类云端API方案把编码模型托管在算力平台上我只需要把图片传过去接口返回固定维度的向量这块的开销就完全抹掉了。接口返回的向量维度以版本为准我实际接的版本返回的是一个embedding数组。这个数组包含的信息是抽象语义指纹——它不会告诉你像素里有一抹橙色但会记录这个画面的语义特征和文字日落海面安静是相近的。有了这个指纹图片库就不再是一个个文件而是一组可计算、可比较的坐标点。2.2 为什么用API而不是自己训练模型很多读者看到多模态向量化会觉得门槛很高其实选API方案最大的理由就三个算力门槛归零。多模态编码模型动辄数亿参数就算推理版也要不小显存。蓝耘元生代这种平台已经把模型跑在云端算力上我用普通办公笔记本就能完成整套索引构建。语义统一性有保证。自己训练一个图文匹配模型需要海量图文对数据、GPU集群和调参经验一个项目里完全担不起这个成本。直接用成熟的多模态接口至少在基准场景下语义对齐效果是经过验证的。按量付费成本可控。3000张图片的embedding调用成本远低于换一块显卡。不过各家定价不同实际跑之前最好先拿100张图测一下耗时和费用再决定是不是要全量入库。提示蓝耘元生代控制台里需要先开通对应的模型服务拿到API Key。不同地区、不同账号看到的服务名可能有差异我下面代码里的API_BASE和MODEL_NAME只是占位务必替换成你自己控制台里的实际值。2.3 接口设计与认证方式蓝耘元生代的多模态向量接口在调用形式上和其他大模型API很像都是HTTP POST加JSON。官方文档给的认证方式是请求头带Authorization: Bearer API_KEY。我封装了两个核心函数一个处理图片一个处理文本。代码里我用统一的encode_media()入口图片走base64格式直接提交文本走普通JSON字符串。import os import io import time import base64 import requests from PIL import Image, ImageOps API_KEY os.environ.get(YONGSHENG_API_KEY) API_BASE https://api.yuanshengdai.example.com/v1 # 换成控制台实际地址 MODEL_NAME multimodal-embedding-v1 # 换成实际模型名 HEADERS {Authorization: fBearer {API_KEY}, Content-Type: application/json} def get_text_embedding(text: str): 把一段自然语言描述转换成向量 payload { model: MODEL_NAME, input: [{type: text, text: text}], encoding_format: float } resp requests.post(f{API_BASE}/embeddings, jsonpayload, headersHEADERS, timeout30) resp.raise_for_status() data resp.json() return data[data][0][embedding]这里有一个容易踩的坑不同厂商对input字段的结构要求可能不同。我一开始按OpenAI那套input[text]的写法直接传结果蓝耘元生代控制台返回了参数校验错误。所以保险做法是先看文档里示例请求体确认input到底是数组还是对象再写代码。3. 图库入库批量向量化与本地索引的完整实现3.1 环境准备和项目结构整个项目我放在一个普通文件夹里没有用什么重型框架。依赖也就四个requests发请求、Pillow处理图片、numpy做向量计算、sqlite3做本地存储。Python 3.9以上就能跑。pip install requests pillow numpy项目文件分模块方便后面单独跑入库或检索photo_search/ ├── config.py # API配置与路径配置 ├── index_build.py # 图库扫描、向量化、存储 ├── search.py # 自然语言检索入口 ├── data/ │ └── photo_index.db └── my_photos/ # 你的本地图库目录配置部分我习惯用环境变量配合一个简单的常量文件# config.py import os API_KEY os.environ.get(YONGSHENG_API_KEY) API_BASE https://api.yuanshengdai.example.com/v1 MODEL_NAME multimodal-embedding-v1 PHOTO_ROOT rD:\photo_library DB_PATH rD:\photo_library\data\photo_index.db SUPPORTED_EXTS {.jpg, .jpeg, .png, .webp, .bmp, .gif} BATCH_SIZE 16 # 并发批次大小 MAX_IMAGE_SIDE 720 # 图片最长边超了会压缩 TIMEOUT 30 RETRY_TIMES 53.2 扫描图片文件与预处理入库不是简单遍历入库第一件事是把目录下所有图片找出来。我直接用os.walk遍历过滤扩展名。但这里有两个细节值得说。细节一HEIC格式别漏。手机导出的图经常是.heicPillow默认打不开需要额外装pillow-heif。如果你的图库里有这种格式建议加装并注册from pillow_heif import register_heif_opener register_heif_opener()细节二超大图必须压缩再送接口。相机原图动辄4000x3000像素JPEG编码后可能5MB以上。直接base64提交不仅浪费流量接口耗时也会明显上升。我在测试的时候发现把最长边压到720像素、JPEG质量85向量质量几乎没有肉眼可见的下降但接口响应时间能缩短一半以上。def preprocess_image(path: str) - str: 压缩图片并转base64返回可提交的data字符串 with Image.open(path) as im: im ImageOps.exif_transpose(im) # 处理EXIF旋转后面细说 im.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE)) if im.mode in (RGBA, P, LA): im im.convert(RGB) buf io.BytesIO() im.save(buf, formatJPEG, quality85) return base64.b64encode(buf.getvalue()).decode()3.3 批量向量化与本地存储图片预处理完就该调embeddings接口了。如果逐张串行调3000张图会等得人崩溃。我在index_build.py里用了一个非常朴素的并发池ThreadPoolExecutor同时开16个线程。from concurrent.futures import ThreadPoolExecutor, as_completed def get_image_embedding(path: str): img_b64 preprocess_image(path) payload { model: MODEL_NAME, input: [{type: image, data: img_b64}], encoding_format: float } for attempt in range(RETRY_TIMES): try: resp requests.post(f{API_BASE}/embeddings, jsonpayload, headersHEADERS, timeoutTIMEOUT) resp.raise_for_status() return path, resp.json()[data][0][embedding] except requests.exceptions.RequestException: time.sleep(2 ** attempt) # 简单指数退避 return path, None然后是存储。向量本身是几百维的浮点数组直接塞进SQLite的TEXT列不太舒服我选择存成二进制BLOB。配合副表记录图片路径、处理时间形成这样一个表CREATE TABLE IF NOT EXISTS image_index ( path TEXT PRIMARY KEY, embedding BLOB NOT NULL, width INTEGER, height INTEGER, updated_at REAL );写入的时候用numpy.float32转tobytes()从库里读出来时再np.frombuffer()还原。这样做的优点是一个字段能装下完整向量查询时可以直接numpy批量加载不需要逐行解析JSON。def save_index_batch(batch): conn sqlite3.connect(DB_PATH) cur conn.cursor() cur.execute(BEGIN) for path, vec, w, h in batch: cur.execute( INSERT OR REPLACE INTO image_index (path, embedding, width, height, updated_at) VALUES (?, ?, ?, ?, ?), (path, np.asarray(vec, dtypenp.float32).tobytes(), w, h, time.time()) ) conn.commit() conn.close()3.4 断点续跑与增量更新全量重跑是最笨的办法第一次全量入库跑完后面再往图库里新增照片如果重新扫一遍全目录等于把钱和时间又烧一遍。我在扫描前先把数据库里已有的路径取出来做集合对比只对新文件调用向量化接口。def load_indexed_paths(conn): rows conn.execute(SELECT path FROM image_index).fetchall() return set(r[0] for r in rows) def scan_new_files(root, indexed_paths): new_files [] for dirpath, _, filenames in os.walk(root): for name in filenames: ext os.path.splitext(name)[1].lower() if ext not in SUPPORTED_EXTS: continue full os.path.join(dirpath, name) if full not in indexed_paths: new_files.append(full) return new_files另外我还做了一层修改时间校验如果图片的mtime比数据库里记录的updated_at新说明这张图被替换过需要重新向量化。不然用户用编辑软件改了一张图库里还是老向量搜出来就不是最新内容。入库这一步跑完之后图库就从文件夹集合变成了一个真正可检索的向量数据库。3000张图在我这边的实测成本和耗时我会在第五节给一张对比表。4. 语义检索从傍晚的海边到命中照片的链路设计4.1 查询文本向量化索引建好了检索反而是最轻松的部分。用户在搜索框输入一句话我调用同一个embeddings接口把文本变成向量query_text 傍晚的海边 query_vec np.array(get_text_embedding(query_text), dtypenp.float32)注意这里查询文本和图片使用的是同一个模型服务而不是两个不同的模型。原因在于多模态向量化接口训练的目标就是让图片和文字进入同一个向量空间。如果文本用一个模型的向量、图片用另一个模型的向量两者之间没有可比性算出来的相似度就毫无意义。这点在接任何视觉语义模型时都要先确认清楚。文本向量拿到后剩下就是最传统的向量检索问题给定一个查询向量在几千个图片向量里找出与它最接近的几条。我的图库规模不大3000多条向量用精确遍历完全可行不需要上FAISS这类专门的向量索引。后续如果图库涨到几十万再迁移到FAISS或Qdrant也不迟。4.2 余弦相似度计算numpy矩阵运算相似度我用余弦相似度公式是similarity (A · B) / (||A|| * ||B||)它衡量的是两个向量的方向是否一致对向量长度不敏感。在多模态语义模型里向量的模长通常不携带太多语义信息所以余弦相似度比欧氏距离更常用。3000条向量如果写成Python循环逐个点乘虽然也能跑但没必要。我用numpy做成一个矩阵乘法几毫秒就出全库结果def search_images(query_vec, top_k20): conn sqlite3.connect(DB_PATH) rows conn.execute(SELECT path, embedding FROM image_index).fetchall() if not rows: return [] paths [r[0] for r in rows] matrix np.vstack([np.frombuffer(r[1], dtypenp.float32) for r in rows]) matrix / np.linalg.norm(matrix, axis1, keepdimsTrue) 1e-8 qvec query_vec.reshape(1, -1) qvec / np.linalg.norm(qvec) 1e-8 scores (matrix qvec.T).flatten() top_idx np.argsort(-scores)[:top_k] results [] for i in top_idx: results.append((paths[i], float(scores[i]))) return results这里把矩阵按行归一化、查询向量也归一化之后点积就等于余弦相似度。vstack是典型的空间换时间方案3000行x几百维的float32矩阵内存占用大概十几MB完全在可接受范围内。4.3 阈值怎么定才不幻觉相似度分高不代表一定相关分低也不代表完全无关。这张表是我在3000多张实拍图里用20多个查询词测出来的典型值查询词最高相似度最低相似度可用阈值傍晚的海边0.31-0.120.20 以上红色汽车0.27-0.090.18 以上聚会合影0.25-0.100.17 以上故宫0.24-0.080.16 以上这个分布和纯文本embedding不一样多模态向量空间的相似度绝对值普遍偏低不要拿0.8才算相关的惯性思维套。我实际开发的默认策略是阈值默认设为0.18低于这个值的结果直接不进候选列表如果某次搜索一条符合的结果都没有自动把阈值降到0.12再试一次前端展示时会显示每条结果与查询的相似度分数方便人工判断是否误报。注意阈值和向量维度、模型训练数据分布强相关换一个模型版本后要重新标定。我建议上线前拿自己图库里的100张图跑一遍查询-人工标注的小实验把阈值调准再来定默认值。4.4 让结果看得见命令行打印路径是最简单的但用户体验很差。我写了一个快速预览功能用Pillow把前9张结果拼成一张3x3缩略图直接弹出来看。def montage_results(results, thumb_size(200, 200)): grid Image.new(RGB, (thumb_size[0] * 3, thumb_size[1] * 3), (30, 30, 30)) for idx, (path, score) in enumerate(results[:9]): try: im Image.open(path) im.thumbnail(thumb_size) x (idx % 3) * thumb_size[0] y (idx // 3) * thumb_size[1] grid.paste(im, (x, y)) except Exception: pass grid.save(preview.jpg)至此核心链路已经完整自然语言 → 文本向量 → 全库余弦相似度 → 排序 → 缩略图展示。我看完第一版跑通结果后确实有种这才叫图库搜索的感觉。5. 效率调优与规模验证3000张图的实际表现5.1 三个性能瓶颈必须优先处理检索本身很快真正的瓶颈全在入库阶段。我是在跑完第一轮全量向量化、记录了各步骤耗时后才逐一优化的。三个瓶颈按影响从大到小排列API调用串行等待、图片传输体积过大、每次检索都重新加载数据库到内存。API调用串行等待最直观。单张图片从压缩到接口返回少则1秒慢则5秒我一开始一张张调3000张跑完快两个小时。后来改成16线程并发后时间压到了10分钟以内提速十倍以上。但要注意并发开太猛会被限流我的实测是每秒不超过50次调用比较稳妥。图片传输体积的影响很多人会忽略。原始相机原图转成base64之后一个字符串动辄5MB接口传输时间和处理时间都会明显增加。我把最长边压到720像素后请求体平均缩小到150KB左右向量结果和原图传入的差异肉眼根本分辨不出来。有测试显示对傍晚的海边这类语义查询720像素的图已经能提供足够特征。最后那个检索时重复加载数据库的问题我在前面代码里已经避开了每次搜索都把全表读进内存做矩阵运算。如果每次搜索都重读磁盘3000行数据的SQLite查询和numpy拼接大概要200ms虽然在交互场景还能接受但做成后台服务频繁调用时就会积少成多。稳妥做法是启动时加载一次之后内存态检索class LocalSearcher: def __init__(self): self.paths, self.matrix self._load() def _load(self): conn sqlite3.connect(DB_PATH) rows conn.execute(SELECT path, embedding FROM image_index).fetchall() paths [r[0] for r in rows] matrix np.vstack([np.frombuffer(r[1], dtypenp.float32) for r in rows]) matrix / np.linalg.norm(matrix, axis1, keepdimsTrue) 1e-8 return paths, matrix5.2 优化前后的实测数据我拿家里的3000多张图片做了一次完整验证环境是办公笔记本家庭宽带。优化前和优化后的数据对比如下指标全量串行、原图传输16线程并发、720px压缩图提升幅度3000张入库总耗时约110分钟约13分钟8.5倍平均单张API耗时2.1秒0.26秒8倍请求体大小均值4.8MB148KB32倍单次检索耗时203ms3ms67倍失败重试率6.2%1.4%4.4倍其中失败重试率下降的原因也很直接原图体积大接口处理超时的概率更高一旦超时就触发重试整个流程雪上加霜。压缩小图反而提升了稳定性。5.3 大规模扩展思路如果你手里的图片量是5万、10万张上面这套全量矩阵点积的方法就不太合适了。内存翻倍不说vstack构建矩阵的时间也会线性上涨。我建议的扩展路线是图向量仍然存在SQLite但检索改用FAISS建立IndexFlatIP索引批量加载后搜索时间能压到毫秒级数据量到10万级以上再上Qdrant或Milvus这类专用向量数据库那时候还要考虑增量索引更新和磁盘存储策略图片文件可以全部哈希命名避免重复入库路径上做一层去重。我用过FAISS做过一次5000张规模的对比IndexFlatIP建索引耗时不到1秒100张图的检索在1毫秒内返回。但普通个人图库SQLite矩阵法就够了不要为了技术栈而技术栈。6. 踩坑记录API接入中那些文档里没有的细节6.1 EXIF方向坑图片会躺倒这是所有接入图片处理的人都可能踩的坑。手机拍的照片很多时候在EXIF里存了一个方向值但像素数据本身是横着的。如果你直接用Pillow打开再缩略保存出来的缩略图可能是旋转90度的最后在缩略图拼接里就是一张歪图。我一开始没处理这个问题结果傍晚的海边搜出来的预览图里好几张是横躺的看着非常奇怪。修复方法是在预处理时加上ImageOps.exif_transpose(im)这个过程会把EXIF方向应用到像素上自动摆正。6.2 超时、限流和重试策略多模态embedding接口的响应时间浮动比纯文本接口大得多。图片并发一高经常出现单张请求超过30秒的情况。我的应对方案是requests.post的timeout同时设置连接超时和读超时比如timeout(5, 30)避免一个卡死的请求拖住整个线程对429限流和5xx错误做指数退避重试第一次等2秒、第二次4秒、第三次8秒最多5次调用线程数从16降到8之后限流问题的出现概率大幅下降总耗时反而更稳定。如果你遇到大量429不要简单重试更不要无限加大并发。应该把批次并发调到4或者2让服务端的配额消耗曲线更平缓而不是一波流打爆。6.3 查询词怎么组织才更准最后这一点完全来自试用体验。同样是搜索海边傍晚的照片不同写法的结果差异很大查询写法效果傍晚的海边理想结果语义完整傍晚 海边相关度略降模型可能把两个独立词分开理解海边傍晚的照片结果不稳定偶发无关项在海边看日落能搜到但和傍晚的海边侧重点略有不同我的体会是用短句而不是关键词堆叠。这和多模态模型训练的数据分布有关——模型更擅长处理自然语言表达而不是搜索引擎式关键词。像傍晚的海边这种带场景、带氛围的短句往往比干巴巴的海边 傍晚效果更好。另外中文描述比英文描述通常更准。如果你的图库主要是中文用户产生的照片优先用中文查询词。我试过把同样一张图分别用中文和英文描述去检索中文命中率明显更高。最后想说的整套方案跑下来我现在给朋友演示的时候最常看到的表情是他们先不相信然后自己输入一句傍晚的海边看到预览图里一堆海边晚霞的照片后愣了几秒。这种图库终于能听懂人话的感受确实只有实际跑通了才有说服力。如果你也想搭一套我建议从100张测试图片开始先把脚本跑通再全量入库。首次投入大概一个晚上物超所值。后续我打算把视频关键帧也纳入索引让搜到某个画面变成搜到某段视频再把数据库换成Qdrant处理家里那几万个文件的旧图库。搜图这件事值得更聪明一点。
返回列表