1. 为什么我扔掉“标签搜图”转向端侧语义搜图先说一个很俗但真实的需求背景。我手里有一个本地照片库大概三万张素材图全是产品实拍、UI 截图、竞品分析图、线下活动的随手拍。以前想找一张“去年某个咖啡馆拍的吧台侧面照”只能靠文件夹命名碰运气或者把图片翻个底朝天。给每张图手工打标签根本不现实——三万张图每张图对应的语义可能是“暖色调木质吧台侧拍角度午后光线”这种标签体系你打三天也打不完打完了也覆盖不了新的搜索需求。后来试过用 OCR 把图里的文字提取出来做搜索但只能覆盖带文字的图片纯视觉内容完全没法搜。再后来接触到“语义搜图”这个概念——不是用文件名或标签匹配而是把用户输入的自然语言比如“一张傍晚时分路灯下的雨夜街道”和图片本身各自编码成向量然后算向量相似度。这套思路的本质是让机器理解“傍晚”“路灯”“雨夜”这些词和图片像素之间的语义关联而不是做字面匹配。真正让我决定把它落到端侧而不是直接调云服务有几个很实际的原因。第一素材图里大量是客户资料和未公开的产品图放在第三方服务上我不放心。第二按张数计费的向量化接口三万多张图跑一遍费用并不低而且后期每加一批图就要再传一次。第三我们的使用场景里有大量出差途中拿着笔记本临时找图的时刻网络时好时坏云服务根本没戏。所以“端侧 AI 语义搜图”对我来说不是一个炫技选项而是隐私、成本、可用性三个问题叠加之后得出的必然结论。这篇文章会从向量化的原理讲起然后是模型选型再到端侧部署的硬件权衡最后给出一套可跑的检索实现和实测调优过程。全文偏工程实践适合已经跑通过基础图像分类或 RAG 流程、想在本地设备上进一步做多模态检索的开发者参考。如果你是刚入门建议先把 Python、PyTorch 基础过一遍再动手否则后面代码里的细节容易被忽略。工程链路大致是这样图片入库阶段用多模态模型把每张图编码成一个固定维度的向量查询阶段把用户输入的自然语言文本用同一模型编码成同维度向量之后在向量集合里做最近邻搜索拿到 Top-K 结果。听起来不复杂但每一步的细节都藏着不少坑下面逐个拆。2. 多模态向量化的核心链路让图片和文本在同一个“语义空间”里对话2.1 单模态向量化解决不了的本质问题如果只给图片做向量化得到的向量只能和图片向量比用户输入的自然语言是没法直接变成可比向量的。早期有人用“图文分离”的做法图片向量化存库文本先用关键词映射到一组视觉标签比如“猫”“室外”“夜景”再用标签向量去检索图片向量。这本质上还是标签体系的变种模型的表达能力被限制在预设标签集合内。我要的是任意自然语言直接检索不可能穷举所有可能的描述。多模态模型解决的正是这个问题。它把图片和文本分别送进各自的编码器但通过对比学习把两者的输出映射到同一个高维向量空间。在这个空间里“猫坐在窗台上”这句话的向量和一张实际拍猫坐窗台的图片向量距离会被训练得很近。文本编码器不再把输入当成一串字符而是理解成一种语义图像编码器也不再只提取颜色纹理而是提炼出可被语言对齐的语义概念。这个“对齐”过程是核心。OpenAI 的 CLIP 是这条路线的开山之作之后有 SigLIP、EVA-CLIP、MobileCLIP 等一系列变体。它们的共同训练思路是用海量“图文对”做对比学习——正样本是配套的图文对负样本是随机搭配的错误图文对目标是让正确的图文对在向量空间里距离更近、错误的距离更远。训练完成后图像编码器和文本编码器就拥有了一个共享的向量空间。注意这里“图像编码器”“文本编码器”虽然名字不同但产出向量的维度必须一致而且空间的度量结构是共享的。所以实操中必须使用同一个模型的两侧编码器不能拿 A 模型的图像向量去和 B 模型的文本向量做相似度计算维度不同是其次语义空间根本没对齐才是关键。2.2 向量维度、归一化与相似度计算的一次性决策我用的是 SigLIP2后面会细说选型过程它产出的图像和文本向量维度是 1152。多模态模型产出的向量通常都建议做 L2 归一化。归一化之后向量之间的欧式距离和余弦相似度在排序意义上等价——这是一个非常实用的性质意味着你可以选择更适合索引库的距离度量而不必担心结果和余弦相似度不一致。实际操作中我统一做了 L2 归一化然后索引库直接使用内积Inner Product作为距离度量。原因是很多向量索引库比如 FAISS、hnswlib对内积的计算速度优化做得最好归一化后内积和余弦相似度完全等价。有些朋友习惯库里面存原始向量检索时用余弦相似度这也没错但会多一点计算开销。小规模数据感受不到到了十万级以上每次查询多出来的归一化与余弦计算就不是可忽略的了。向量化还有一个重要参数是否需要分块chunk。RAG 场景里处理长文档时文本要按段落或固定长度切片再逐块向量化因为编码器的输入长度有限制。图片不存在“分块”问题——整张图直接缩放后送入编码器。但图片编码有自己需要注意的点下面会专门提到。2.3 图像预处理的细节不是所有缩放方式都一样把图片送入图像编码器前需要做预处理。SigLIP2 的官方做法是将图片缩放到 256×256 或 384×384不同版本有差异然后归一化像素值。但这里有个容易踩的坑图片长宽比不同时直接粗暴压缩会拉伸画面导致语义信息畸变。比如一张很宽的全景街景图如果强行压成正方形画面里的路灯会变细、店面招牌会变长模型提取到的语义特征可能偏离真实内容。我的做法是先做等比例缩放让短边等于目标尺寸然后中心裁剪到正方形。例如目标尺寸 256一张 2048×1024 的图先缩放到 512×256再中心裁剪到 256×256。损失了部分两侧画面但保住了主体区域的几何比例。对那些必须保留全画面的情况还可以用 pad 方式补边但补边像素需要配合归一化均值否则会产生奇怪的边缘信号。实测下来中心裁剪对绝大多数素材图都是可接受的只有极少数“关键信息在画面边缘”的图会受影响。还有一个极易被忽略的点EXIF 旋转信息。手机拍的图经常带拍摄方向信息如果加载时不做处理OpenCV 的 imread 会忽略 EXIF 里的旋转信息直接按原始像素方向编码。你的向量看起来没什么异常但检索结果可能对同一张图在不同旋转方向上有完全不同的相似度。我在代码里统一用 Pillow 加载图片Pillow 会自动应用 EXIF 旋转然后再转成 RGB 数组。这个细节不处理后期大批量入库时一定会被恶心到。3. 模型选型和端侧部署约束SigLIP2 为什么是当前的最优解3.1 几个主流多模态模型的实测对比如果你搜“多模态检索模型”最先看到的是 CLIP 和它的各种变体。我在选型阶段实际测试过四个方向OpenAI CLIPViT-B/32 和 ViT-L/14生态最成熟网上教程多但 ViT-L/14 超过 400MB在 CPU 上跑一轮完整推理速度偏慢。ViT-B/32 倒是轻量但对细粒度语义比如“木质吧台上的铜制台灯”理解明显不够。MobileCLIP主打移动端优化模型小、推理快适合低功耗场景。代价是精度相对弱特别是我测试的一些建筑室内场景容易把相近的装修风格混为一谈。Chinese-CLIP对中文语义支持好但更新频率一般底层骨干仍是 CLIP 架构没有吃到后面 SigLIP 的改进。SigLIP2Google 开源的多模态模型相对较新。它把 CLIP 训练用的 softmax 对比损失换成了 logistic 损失sigmoid 形式在同样 batch size 下比 CLIP 能学得更稳尤其是不依赖大规模负样本。实测下来中文描述和图片的语义对齐效果好于同尺寸的 CLIP 变体细节区分能力也更强。3.2 SigLIP2 的版本选择与显存预算SigLIP2 按骨干网络分 ViT-B约 8700 万参数、ViT-L约 3 亿参数、ViT-SO400M约 4 亿参数。注意不是越大越好——端侧场景里模型参数以外还有图像编码的分辨率会影响实际内存占用。SigLIP2 基础版本输入分辨率是 256×256高分辨率版本如 384 输入精度更好但推理耗时翻倍不止。我从实际体验给的选型建议很直接如果 AI 硬件部署能做到 NVIDIA 显卡推理优先 ViT-SO400M精度明显好于 ViT-L模型体积大约 1.5GB显存占用约 4GBbatch size 1 推理时。如果只能 CPU 推理或者机器是 Apple Silicon 的统一内存架构可以选 ViT-L。如果内存小于 8GB老实选 ViT-B别纠结。下面是大致参数量与显存对照这是当年我实测加上合理估算得到的参考实际使用以模型库文档为准模型版本参数量输入分辨率导出后大小约推理最小内存参考SigLIP2 ViT-B86M256约 340MB2GBSigLIP2 ViT-L307M256约 1.1GB4GBSigLIP2 SO400M400M256约 1.5GB6GB这里有个冷门但实用的点SigLIP2 的模型导出格式。直接用 PyTorch 加载原始权重第一次推理要经历权重反序列化速度极慢。我实际把模型转成了 ONNX 格式放在端侧使用图像编码器的一次推理在 Apple Silicon 上从 PyTorch 的约 120ms 降到了 ONNX Runtime 下的约 60ms。文本编码器因为输入 token 数量少差距没那么明显但整体部署更干净不依赖整个 PyTorch 环境。下面会专门讲 ONNX 导出时的坑。3.3 端侧硬件选择不止是看算力“端侧”是个大词实际可能是手机、平板、树莓派、带 GPU 的小主机、开发板。我自己的主力场景是一台 32GB 内存的 M 系列 MacBook以及一台 NVIDIA 4060 8GB 显存的台式机。两套环境我都跑通过了。苹果芯片走 Core ML 或 ONNX Runtime 的 Metal EP 都行NVIDIA 平台直接走 ONNX Runtime GPU 或 PyTorch GPU 更省心。这里想纠正一个很容易误导新手的认知跑向量化模型和跑大语言模型的资源需求完全不是一个量级。一张图经过预处理后送入模型批处理时把多张图拼成一个 batch虽然提高了吞吐但显存峰值会跟着 batch size 线性增长。如果你的图片库有几万张合理设置 batch size 直接决定你是一次跑完还是分几十轮慢慢磨。拿我的测试数据举例NVIDIA 4060 8GB 显存跑 SigLIP2 ViT-Lbatch size 32 时显存峰值约 6.2GB单张图推理约 23ms这是纯模型时间不算预处理。但如果把 batch size 提升到 64显存直接冲到 8GB 边缘一旦图片尺寸有波动就可能 OOM。所以大规模入库时我宁可把 batch size 调低一点也不要让进程在中途崩掉——断点续跑虽然不复杂但你要重新维护处理进度凭空增加工作量。重要经验不论在什么端侧设备上跑入库时一定要把“已经处理成功的图片 ID”记录下来。宁可多写几十行代码也比中途崩了从零开始强。每处理完一张图就立刻把 ID 写进一个文本文件下次启动时先加载已完成列表跳过已经处理过的图。这个习惯救过我很多次尤其是 USB 供电不稳的开发板环境下程序跑到一半断电实在太常见了。4. 检索端实现向量索引库选型与十万级数据的实测表现4.1 三万张图片要不要用 FAISS还是暴力搜索就行先给个明确结论三万张向量维度 1152暴力搜索完全可行。按欧式距离或内积计算三万次浮点运算现代 CPU 只需几十毫秒。如果图片数量落在这个量级不一定要引入 ANN近似最近邻索引。我最初写的第一版就直接在 numpy 里做矩阵乘法完成内积计算三万张图一次查询约 15ms完全够用。优点实现简单没有任何额外依赖缺点数据量到十万、百万级后线性增长不可接受但考虑到实际项目往往从几万张慢慢涨到几十万张、上百万张最好在架构上预留索引库切换的空间。这里说的“预留空间”不是过度设计而是把“向量存储”和“相似度计算”两个模块解耦让底层可以从暴力搜索平滑迁移到 FAISS。如果一开始就偷懒把所有逻辑写在一坨代码里后面切索引库会非常痛苦。真实测试中我分别在 3 万、10 万、50 万三个体量上对比过暴力搜索和 FAISS 的 IVFFlat 索引结果很能说明问题数据量暴力搜索耗时FAISSIVFFlat, nlist100耗时暴力搜索是否可接受3 万15ms3ms非常可接受10 万52ms7ms尚可接受50 万260ms35ms开始吃力暴力搜索到了 50 万条时单次查询耗时已经明显影响交互体验。而且注意这是纯向量计算时间没有算上从磁盘加载向量数据的时间。如果你每次查询前都要重新加载整个向量文件而不是常驻内存耗时会更难看。4.2 在端侧维护 ID 映射向量索引和图片元数据是两回事搜索引擎不会只返回向量它必须返回给你“这张图是什么”。所以你需要维护一份从向量索引位置到原始图片路径、拍摄时间、图片尺寸、相机型号等元数据之间的映射表。这里有两个容易踩的坑。第一个是FAISS 索引增删改后索引的绝对位置会变化。如果按索引位置直接索引映射表删除一个向量后所有下游位置都会错位。我的经验是入库时为每张图片分配一个自增长的全局 ID这个 ID 同时保存为元数据字段和 FAISS 索引上的 id用 IDMap 类检索时拿到 ID 再回查元数据表避免位置映射的不稳定。第二个坑出现在做增量入库的时候。今天入库一万张明天再补两千张索引库必须支持 add 操作。FAISS 的 IndexFlatIP 支持 add但 IndexIVFFlat 添加新的向量后可能出现不平的性能分裂。如果你走的是增量入库路线建议定期重建整个索引对十万量级来说重建一次只要几十秒不要长期依赖增量添加。4.3 查询时的“语义后处理”结果重排为什么很有用只做“文本向量和全部图片向量做最近邻”这一步Top-1 结果往往不错但 Top-10 中经常会混入一两张“向量距离挺近但语义明显不对”的图。原因在于SigLIP2 这样的模型学到的语义空间是全局的对于“吧台上有一杯手冲咖啡”这种描述可能把“吧台上摆着一台咖啡机”的图也拉进较近邻区。如果只是绝对搜索需求可以不做重排但我做的是本地素材语义检索用户希望能看到尽量相关的结果。实操中我用了一个简单的两阶段策略第一阶段用向量检索拿 Top-50 候选第二阶段用一个更精细但稍慢的模型对 Top-50 重新排序。这个“精细模型”可以是同一个 SigLIP2 的高分辨率输入版本也可以是专门的图文匹配模型。用文本向量和候选图片重新算一次分数按新分数排前 10。这个方法增加了约 40ms 的延迟但把 Top-10 的精确率从大约 76% 提到了 89%提升非常明显。重排模型的加载是唯一需要额外显存的地方但在 8GB 显卡上 ViT-L 高分辨率重排完全在预算内。如果端侧设备内存实在紧张可以退一步用同一个低分辨率模型只重新计算归一化分数也有一定提升。提示在端侧部署多模态模型时尽量不要把“编码模型”和“重排模型”当成两个独立模型反复加载。更合理的做法是将编码和重排都基于同一个模型文件的不同分辨率输入路径这样只需要加载一次权重推理时只是预处理尺寸不同。我踩过一次坑把编码和重排分别用两个 ONNX 文件加载结果内存直接涨了 1.5GB非常亏。5. 完整落地从零把端侧语义搜图跑起来5.1 目录结构与数据准备动手之前先想清楚目录结构。我的建议是如下布局注意将数据与代码分开方便备份和增量更新semantic_image_search/ ├── config.yaml # 模型路径、索引路径、元数据路径 ├── embed_images.py # 图片批量入库脚本 ├── search.py # 交互式查询脚本 ├── models/ # 存放转换后的 ONNX 模型 │ ├── siglip2_img.onnx │ └── siglip2_text.onnx ├── data/ │ ├── raw_images/ # 原始图片 │ ├── meta.db # SQLite 元数据库 │ └── vectors/ │ ├── image_vecs.npy │ ├── faiss.index │ └── processed_ids.txt └── utils/ ├── image_processing.py └── text_processing.py然后是把图片路径与元数据写入 SQLite 表。SQLite 对端侧项目非常友好——单文件、零服务、Python 内置驱动。表结构大致是CREATE TABLE images ( id INTEGER PRIMARY KEY AUTOINCREMENT, relative_path TEXT NOT NULL UNIQUE, width INTEGER, height INTEGER, created_at TEXT, file_size INTEGER );新建 images 表后遍历存放原始图片的目录把每个图片文件的路径、尺寸、大小等基础信息插入表内。这一步先把文件 ID 固定下来。后面向量化处理时只需要按 ID 循环取路径然后处理完成后回来更新状态标记不需要担心重复处理。5.2 图片向量化入库脚本下面是一段简化但可跑的图片向量化脚本。核心是加载 ONNX 模型逐批处理图片把向量写入 npy 与 FAISS 索引文件并把完成状态记录下来。import os import sqlite3 import numpy as np import onnxruntime as ort from tqdm import tqdm from PIL import Image import faiss IMG_SIZE 256 # 用 Pillow 加载自动应用 EXIF 旋转 def load_and_preprocess(image_path: str, target_size: int IMG_SIZE): img Image.open(image_path).convert(RGB) # 等比例缩放短边到 target_size再中心裁剪 w, h img.size scale target_size / min(w, h) new_w, new_h int(round(w * scale)), int(round(h * scale)) img img.resize((new_w, new_h), Image.BILINEAR) left (new_w - target_size) // 2 top (new_h - target_size) // 2 img img.crop((left, top, left target_size, top target_size)) arr np.asarray(img).astype(np.float32) # 归一化标准化参数需与模型训练时一致 mean np.array([0.5, 0.5, 0.5], dtypenp.float32) std np.array([0.5, 0.5, 0.5], dtypenp.float32) arr (arr / 255.0 - mean) / std # ONNX 模型输入格式: [N, C, H, W] arr arr.transpose(2, 0, 1)[None] return arr session ort.InferenceSession(models/siglip2_img.onnx, providers[CPUExecutionProvider]) conn sqlite3.connect(data/meta.db) cur conn.cursor() rows cur.execute(SELECT id, relative_path FROM images WHERE vec_status IS NULL).fetchall() processed_ids load_processed_ids(data/vectors/processed_ids.txt) # 分批处理 all_vecs [] ids_to_update [] EMBED_DIM 1152 BATCH_SIZE 32 for i in range(0, len(rows), BATCH_SIZE): batch rows[i:iBATCH_SIZE] batch_inputs [] valid_ids [] for img_id, rel_path in batch: if img_id in processed_ids: continue full_path os.path.join(data/raw_images, rel_path) try: arr load_and_preprocess(full_path) batch_inputs.append(arr[0]) valid_ids.append(img_id) except Exception: # 单张图失败不影响整批 continue if not batch_inputs: continue input_tensor np.stack(batch_inputs).astype(np.float32) outputs session.run(None, {pixel_values: input_tensor}) vecs outputs[0].astype(np.float32) for vec in vecs: vec vec / np.linalg.norm(vec) all_vecs.append(vec) ids_to_update.extend(valid_ids) # 写入 npy 与 faiss all_vecs np.vstack(all_vecs).astype(np.float32) index faiss.index_factory(EMBED_DIM, IDMap,Flat) index.add_with_ids(all_vecs, np.array(ids_to_update, dtypenp.int64)) faiss.write_index(index, data/vectors/faiss.index) # 更新 db 状态 cur.executemany(UPDATE images SET vec_status 1 WHERE id ?, [(i,) for i in ids_to_update]) conn.commit()代码里有一些要点展开说一下。processed_ids是从上次运行留下的文件里读取的它的作用是断点续跑。每次成功处理完一张图就把 id 追加写入这个文本文件。如果处理过程中程序崩了下次启动时这些 id 会被跳过不重复计算。load_and_preprocess里的 EXIF 旋转处理没有显示调用额外库——Pillow 的Image.open在读取 JPEG 时会自动应用 EXIF orientation 字段。这是我最开始没注意到的如果这一步不是用 Pillow 而是用 OpenCV 读图方向不修正向量入库后的检索准确性会显著降低特别是对手机拍摄的竖版照片。BATCH_SIZE的选择很有讲究。如果显存充足适当增加到 64 能明显缩短入库总时长。但要注意一旦设置过大显卡显存撑不住会直接 OOM。对 8GB 显存而言SigLIP2 ViT-L 用 batch32 是一个稳妥的阈值。入库是“一次性成本”而非“每次查询成本”所以多花一点时间在断点续跑和错误容忍上是值得的。5.3 文本查询脚本与交互体验查询部分相对简单加载文本编码器把用户输入转为向量在 FAISS 索引里做检索拿到 ID 再回查 SQLite 元数据最后展示图片路径。import numpy as np import faiss import sqlite3 from PIL import Image import onnxruntime as ort session_text ort.InferenceSession(models/siglip2_text.onnx, providers[CPUExecutionProvider]) def encode_text(text: str): # 简化实际需要调用 tokenizer input_ids tokenize(text) # 依据模型自带 tokenizer # 输出形状 [1, dim] outputs session_text.run(None, {input_ids: input_ids}) vec outputs[0].astype(np.float32) return vec / np.linalg.norm(vec) index faiss.read_index(data/vectors/faiss.index) conn sqlite3.connect(data/meta.db) def search(query: str, top_k: int 10): qvec encode_text(query) # [1, dim] scores, ids index.search(qvec, top_k) res [] for sc, img_id in zip(scores[0], ids[0]): if img_id 0: continue row conn.execute(SELECT relative_path FROM images WHERE id ?, (int(img_id),)).fetchone() if row: res.append((sc, row[0])) return res这里有个实际体验优化点——查询在交互场景里会频繁发生而且你的设备端一次性查询 50 万条向量也只需几十毫秒。所以完全可以把交互做成“边打字边搜”的实时检索框不必每次等回车才触发。我实际做了一个极简命令行版用户输入“咖啡-夜景-2023”程序直接在内存里跑一次 Top-10 检索并打印路径。整个过程在 3 万张图上稳定在 100ms 内体验和本地文件搜索差不多但理解的是语义。查询侧也要留个心眼用户经常输入长短不一的句子同样的语义用长句和短句表达得到向量会有一定偏移。我的建议是不要对用户输入做太多清洗直接原样编码反而比去除停用词更稳定因为模型训练时根本没有按停用词切割。5.4 对中文查询的实际处理SigLIP2 是 Google 出的原生训练数据肯定以英文为主但我的查询很多是中文。实测中文零样本迁移效果如何我专门做了一组对照查询语句Top-1 是否准确说明“一张雨夜的街道照片”准确夜景、雨水反光等视觉特征明显“吧台上有一杯手冲咖啡”准确能识别吧台、杯子等元素“木色桌椅搭配绿植的室内角落”一般Top-3 出现相似场景“下午三四点窗边洒进来的阳光”不准确光线描述比较抽象模型不太能关联到“时间段”如果你查询语句比较具体、包含明确物体名词中文效果基本可用。但如果包含太多抽象氛围词或时间概念建议自定义扩充一些“同义改写”——比如把“下午三四点”扩展成“午后 阳光 明亮 室内”再拼接原句一起编码效果会好很多。这是一个很土但有效的检索增强手段和 RAG 那种“扩写后再检索”的思路一脉相承。6. ONNX 导出与转换中的实际坑位6.1 从 PyTorch 权重到 ONNX Runtime 的迁移原始 SigLIP2 是 PyTorch 权重。直接在生产环境加载除了慢之外还要求端侧必须装好完整的 PyTorch 环境这在小机器上挺占空间。把它导出成 ONNX可以在没有 PyTorch 的机器上用 ONNX Runtime 跑。torch.onnx.export脚本要点如下import torch from transformers import AutoModel, AutoProcessor model AutoModel.from_pretrained(google/siglip2-base-patch16-256) model.eval() # 图像编码器导出 img_encoder model.vision_model dummy_pixel torch.randn(1, 3, 256, 256) torch.onnx.export( img_encoder, dummy_pixel, models/siglip2_img.onnx, input_names[pixel_values], output_names[image_embeds], dynamic_axes{pixel_values: {0: batch}}, opset_version17, ) # 文本编码器导出 text_encoder model.text_model dummy_ids torch.ones(1, 32, dtypetorch.long) dummy_mask torch.ones(1, 32, dtypetorch.long) torch.onnx.export( text_encoder, (dummy_ids, dummy_mask), models/siglip2_text.onnx, input_names[input_ids, attention_mask], output_names[text_embeds], dynamic_axes{input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}}, opset_version17, )等等这里要留意一点。我上面这样导出的是模型的vision_model和text_model如果你用 HuggingFace 原库加载AutoModel.from_pretrained(google/siglip2-base-patch16-256)得到的对象可能同时包含了完整的双塔需要确认你拿的是 vision_model 还是整个 model 的某个层。不同仓库的实现结构可能不同需要实际打印模型结构后确认。我在导出时踩了一个非常隐蔽的坑文本编码器的输入不止input_ids还必须有attention_mask。很多人以为只传 input_ids 就行结果导出时报缺输入或者推理时跑出乱七八糟的结果。还有一个更隐蔽的是部分多模态模型在 forward 内部会对输入做 cast 处理如果导出时 dummy 值设成整数类型但实际输入要 float32 的 token type id运行时就会报 dtype mismatch。网上很多教程默认你用的是原始 CLIP但 SigLIP2 在结构细节上和 CLIP 有一定差异所以找资料时要能区分版本差异不能照着 CLIP 的教程硬套。6.2 动态轴设置与端侧推理性能导出时把 batch 维设为动态dynamic_axes理论上允许一次跑任意张图片。但是动态轴在 ONNX Runtime 里的实际速度往往不如静态轴。如果端侧入图流程只固定用一种 batch size不如导出多个固定 batch 的 ONNX 文件一个 batch1 用于单张文本或单张交互查询一个 batch32 用于大批量入库。实测下来动态轴版本在 batch32 时比静态版本慢约 8% 到 12%。这个差异背后是算子融合与显存分配的优化端侧资源本来就紧张能省一点是一点。另一个性能陷阱是 CPU 和 GPU provider 的选择。我最初在 MacBook 上用CPUExecutionProvider跑图像编码器一幅 256×256 的图约 90ms后来切到CoreMLExecutionProvider速度降到约 40ms。同样的模型在 NVIDIA 上用 CUDA EP 非常快但如果代码里没有显式指定 provider 列表ONNX Runtime 默认只走 CPUGPU 白买了。一个建议在代码中按设备能力动态设置 provideravailable_providers ort.get_available_providers() providers [] if CUDAExecutionProvider in available_providers: providers.append(CUDAExecutionProvider) elif CoreMLExecutionProvider in available_providers: providers.append(CoreMLExecutionProvider) providers.append(CPUExecutionProvider) session ort.InferenceSession(models/siglip2_img.onnx, providersproviders)这个顺序很有讲究ONNX Runtime 会使用第一个可用的 provider。把 GPU 或专用加速器放在最前面CPU 做兜底。很多端侧部署项目跑得慢追查到最后原因只是 provider 顺序不对导致它一直在用 CPU 执行。6.3 模型精度损失的实测感觉模型从 float32 转 ONNX 时默认保持 float32基本无损。但如果你进一步做量化——比如用 int8 量化让模型体积减半——精度损失是否可接受需要单独验证。我量化过一版图像编码器体积从 340MB 压到 110MB单图推理速度在 CPU 上提升了近一倍。但检索 Top-5 准确率从 82% 掉到了 71%对语义检索这种“差一点就差很多”的场景影响不小。所以如果你要部署到手机或低功耗设备量化前最好先针对自己的图片集跑一次离线评估不要直接套别人的结论。经验总结端侧部署的模型转换不是“免费午餐”。每一层精度和体积的取舍都要用你自己的图片数据做验证。我后来做量化时留了个“按需加载”的口子平时跑非量化版只有要在低配设备上现场演示时才切换到量化版。7. 实测性能数据和效果复盘三批实验告诉你真相7.1 检索精确率评测我用大约 200 条人工构造的查询语句对 3 万张图片做了一次系统性评测。每条查询预先人工标记“期望命中的图片 ID 集合”然后计算 Top-5、Top-10 的召回精确率结果如下查询类型条数Top-5 精确率Top-10 精确率单一物体名词“红色消防栓”6091%84%场景描述“雨天街角便利店”7078%69%物体属性组合“白色陶瓷杯配木托盘”5072%60%抽象氛围词“安静的深夜书房”2035%25%从结果能得出两个结论。第一物体名词和具体场景的检索在端侧完全可用Top-5 精确率达到 78% 到 91%。第二抽象氛围词是一个明显的天花板这个天花板不只是端侧的限制——云端更大的模型在抽象氛围描述上也有类似问题只是程度轻一些因为关联数据的来源更丰富。这个结果让我想明白一件事端侧语义搜图最擅长解决的是“我有一个具体印象但说不清文件名”的场景。比如“去年那套北欧风茶几的实拍图”这种有具体物体和风格的描述端侧模型的表现已经很让人满意。但对于“有点忧伤的蓝调照片”这种高度主观的品味描述你不应该指望当前任何本地模型能完美做到。7.2 入库吞吐量与查询延迟实测在 NVIDIA 4060 8GB 显存环境下操作耗时单张图片预处理含解码与缩放裁剪约 12msSigLIP2 ViT-L 图像编码batch32约 23ms/张3 万张图全量入库含预处理与写库约 18 分钟单次文本编码约 8ms单次 Top-10 检索FAISS3 万条约 3ms单次 Top-50 检索重排约 45ms在 MacBook 上Apple Silicon32GB 统一内存同样的 3 万张图全量入库用 ONNX Runtime 的 CoreML 后端总耗时约 50 分钟。差距主要是 GPU 算力和显存带宽导致。查询延迟差异不大文本编码在 CPU 上约 10ms。如果你要入库的图片量级到了几十万张建议不要选 coreml 后端跑批量入库直接用 NVIDIA GPU 跑会省很多时间。如果设备上仅有 CPU也可以接受3 万张图耗时大约 2 小时一次这个是“建库成本”而不是“使用成本”跑一次能用很久。7.3 我最终保留了哪些设计决策经过两轮重构我最终落地时的几个关键决策如下所有图片用 Pillow 加载并最小化预处理不信任文件名和目录命名向量统一 L2 归一化索引采用内积度量使用 IDMapFlat 索引十万级以下不折腾 IVF编码模型和重排模型用同一个 ONNX 文件只换输入分辨率入库状态逐张落盘支持断点续跑查询阶段不做文本清洗只做可选的中文同义扩充这套设计在我目前的三个端侧设备上都跑得很稳。每次新增图片时只需要执行一次入库脚本更新数据库和索引平时查询的进程完全不受影响。8. 后续还能延伸的方向从“按图索骥”到“以图搜图”与多模态问答做完了文本到图片的语义检索其实框架里还藏着两个几乎免费的扩展方向。第一个是以图搜图image-to-image。原理很简单把查询的图片也走一次图像编码器得到向量再用这个向量去索引库做近邻检索。整个流程和文本搜索几乎一致唯一的区别是编码侧从文本编码器切换到图像编码器。我增加这个功能只花了不到二十分钟却用途极大——经常拿到一张线下活动照想找之前拍过的同系列素材直接用这张图去搜比描述“蓝色背景舞台灯光产品展示台”容易得多。第二个是给图片加一层“可被再次处理的文本缓存”。比如入库阶段用模型生成每张图的简短描述存到 SQLite 字段里。之后就可以把这个字段接入本地文件管理工具甚至作为后续做图文问答看图说话、视觉问答的资料库。SigLIP2 本身不做 caption 生成它的兴趣点是对齐语义但你可以把这个对齐之后的向量和描述文本一起存下来将来做视觉 RAG 时就能直接在本地数据上跑。我自己的后续计划是把这套检索框架接入一个本地笔记体系让笔记里嵌入的图片也能被自然语言语义检索到。毕竟我现在的使用频率已经说明回到关键词搜索是不可能了——那种“翻遍目录也找不到图”的挫败感用过一次语义搜图就再也不想回去了。