最近我花了两天时间,把 EmbeddingGemma 2 这个 740M 参数的多模态嵌入模型,整套拉到了本地。跑的是离线 RAG 管道,不连外网,模型、依赖、索引全在本地,纯内网开发机上能稳定出结果。这篇文章不聊 PPT,直接说清楚三件事:为什么选 740M 的嵌入式多模态模型,离线环境里怎么部署和封装,以及多模态向量库构建和检索时那些不试不知道的坑。
如果你正在做私有化知识库、图纸库检索、本地个人笔记,或者被“数据不出内网”卡住,又不想为了一个检索功能上大模型推理服务,那这篇应该能帮你少走不少弯路。文章里所有步骤我都是按一台不带公网的 Linux 机器来写的,有 GPU 最好,没有也给了 CPU 下的优化建议。
1. EmbeddingGemma 2 到底解决什么问题:动手之前先想清楚
1.1 740M 参数意味着什么
很多人一听到多模态模型,脑子里全是那种十几B甚至几十B的大家伙,觉得要几块卡才能跑。EmbeddingGemma 2 不是这种路线,它定位是嵌入模型,参数只有 740M。这是什么概念?加载半精度权重大约占 1.5GB 显存,float32 全精度也就不到 3GB。一张老一点的 8GB 卡能轻松跑,纯 CPU 加 16GB 内存也能把推理勉强撑起来,只是速度慢一点。
这个体量在 RAG 场景里其实非常合适。检索这个环节的任务是把文本、图像映射成向量,让相似语义在向量空间里靠近,而不是去生成新的内容。任务单一,模型就不需要大。740M 的模型处理一段文本基本是几十毫秒级别,处理一张图也就在一两百毫秒上下,吞吐量足够支撑中小规模知识库的批量入库和实时查询。相比之下,硬上一个多模态大模型做嵌入,资源占用翻了十几倍,检索质量未必能跟着涨多少。
我个人的选型逻辑是:RAG 管道里,嵌入模型和生成模型是两条线,生成可以用更大的模型,嵌入尽量选“够用、省资源、方便替换”。EmbeddingGemma 2 这种小尺寸模型,换起来也容易,哪天有更合适的嵌入模型发布,直接替换文件重跑一遍索引就行,不用动整体架构。
1.2 多模态嵌入和文本嵌入的核心差异
传统 RAG 里用的嵌入模型,比如常见的文本向量模型,只能处理文字。文档里的图片要么被丢掉,要么通过 OCR 把图里的字抠出来再检索,但图的构图、版式、视觉特征就完全丢了。产品手册里一张故障指示灯的照片,你光靠 OCR 文字很难搜到它。
多模态嵌入模型解决的是这个问题。文本和图像会被映射到同一个向量空间里面,在这个空间里,一段文字和一张图可以直接算相似度。你输入“仪表盘上黄色三角灯亮了”,模型能返回对应的故障说明和现场照片;你用一张截图作为查询,也能返回文字资料里相关的段落。这种“以文搜图、以图搜文”的能力,是普通文本嵌入模型做不到的。
实际业务里,很多知识库根本不只是文字库。检修记录里有照片,合同扫描件里有盖章页,产品目录里有规格图。过去这些非结构化内容很难进检索体系,多模态嵌入模型相当于把“图”真正纳入了知识库的索引范围。这一点是选型时最核心的考量。
嵌入向量的输出维度通常在 1024 上下,具体以模型的发布文档为准。这个维度在 Faiss 索引里单个向量 float32 占用约 4KB,100 万条也就是 400MB 左右,离线单机完全扛得住,不需要上分布式向量库。
2. 离线环境部署:模型文件一次备齐,断网也能用
2.1 依赖和模型文件的离线准备
离线部署永远绕不开一个问题:软件安装包和模型文件怎么进去。EmbeddingGemma 2 本身的依赖不算复杂,核心是 PyTorch、Transformers 库、图像处理用的 Pillow,以及向量索引用的 Faiss。这些库在 PyPI 上都能直接下载,关键是提前在有网环境把 wheel 包全部拉下来。
我用的办法是 pip download 全部依赖到一个目录,然后把整个目录拷贝到内网机器,再通过 pip install --no-index --find-links 离线安装。注意 PyTorch 的包比较大,有 CUDA 版本和 CPU 版本之分,内网机器如果是纯 CPU 环境,记得下载 CPU 版 wheel,不要图省事把 CUDA 版拷进去,否则装上后没法运行。
模型文件是另一个坑。EmbeddingGemma 2 发布时是一整个仓库目录,里面除了权重文件,还有配置文件、分词器、图像处理器配置。很多人图省事只下载了 safetensors 权重文件,加载时报错说找不到 tokenizer。正确做法是,在有网机器上把整个模型仓库同步下来,连目录结构一起原样拷贝。要特别注意,一些模型平台为了管理方便会生成快照目录,加载时读的是 snapshots 下的文件,你把整个仓库根目录拷进去最稳妥。
拷进去之后,把模型目录地址写死到配置里。加载时设置本地缓存路径,环境变量指向内网机器的目录。第一次启动时如果代码里不小心写了联网参数,即使模型已经在本地,也会因为请求超时卡半天。所以离线部署时,务必在加载代码里明确设置 local_files_only=True,这个参数决定了模型加载器只读本地,不做任何远程检查。
2.2 加载与推理封装:一个函数收编文本和图像
离线 RAG 的推理封装不用搞太复杂。我不建议在这种场景起步就上独立的推理服务,除非你要同时给多个系统提供嵌入接口。单机内部用 Python 函数调用就够了,省掉网络开销,排查问题也容易。
模型加载部分可以写成一个工具模块,核心逻辑类似这样:
import torch from transformers import AutoModel, AutoProcessor MODEL_DIR = "/data/models/embedding_gemma_2" cache_dir = "/data/model_cache" processor = AutoProcessor.from_pretrained( MODEL_DIR, local_files_only=True, cache_dir=cache_dir ) model = AutoModel.from_pretrained( MODEL_DIR, local_files_only=True, torch_dtype=torch.float16, cache_dir=cache_dir, device_map="auto" ) model.eval()推理时文本和图像都走同一个 processor 入口,只是传的参数不一样。文本传入 text,图像传入 image。关键点是向量出来后做 L2 归一化,因为后续检索我用的是内积相似度,归一化之后内积就等于余弦相似度,计算效率和语义准确性都能兼顾。
def embed_texts(texts): inputs = processor( text=texts, return_tensors="pt", padding=True, truncation=True, max_length=512 ).to(model.device) with torch.inference_mode(): outputs = model(**inputs) vecs = outputs.last_hidden_state.mean(dim=1) return torch.nn.functional.normalize(vecs, p=2, dim=-1).cpu().tolist()图像走一个类似函数,区别只是传 image 参数。项目刚开始时我犯过一个小错误,直接拿了模型默认输出当向量用,结果检索结果乱成一锅粥。后来仔细看了模型文档才发现 pooling 方式需要和模型设计匹配,有的模型不是简单取 last_hidden_state 平均值,而是有专门的 pooler。这里我只能说一句:千万别想当然,以模型发布文档里的用法为准。
推理封装就两个函数,embed_texts 和 embed_images,返回数组。后续所有业务代码只跟这两个函数打交道,就算以后换了底层嵌入模型,也只需要改这个模块,业务层完全不动。
2.3 硬件选型与性能预估
离线环境下硬件方案要提前算清楚。我测试用的机器是一块 8GB 显存的 GPU,float16 加载模型后剩余显存还有挺多,文本单条推理在 20 毫秒级别,图像在 100 毫秒上下。如果你只有 CPU,不是说不能跑,但要有心理准备,批量处理 10 万条文本可能要跑几个小时。
给一个粗略的性能参考表,基于我自己的环境,不同机器差异会很大:
| 输入类型 | GPU 推理耗时 | CPU 推理耗时(参考) |
|---|---|---|
| 短文本(<128 token) | 10-20ms | 80-150ms |
| 长文本(256-512 token) | 30-50ms | 200-400ms |
| 单张图像 | 80-150ms | 500-1500ms |
入库阶段的向量计算是离线任务,对延迟不敏感,可以慢慢跑。真正要求低延迟的是查询阶段,一次查询只需要计算一条 query 的向量,所以即使是 CPU 机器,查询体验也不会太差。真正吃资源的是批量入库,个人知识库规模下,晚上挂着跑就行。
3. 离线 RAG 管道核心设计:切分、入库、混合检索
3.1 数据准备:文本切块和图像预处理
数据质量决定检索质量,这句话在 RAG 项目里永远排第一。多模态嵌入库之前的预处理,比模型选型更重要。
文本切块不能拿固定字符数硬切,那样会把一个完整语义段截成两半,检索时既搜不全也容易搜乱。我用的策略是先按 Markdown 或者文档的标题层级分块,以章节为边界,每个章节再按段落拆成适当大小的块。块大小控制在 512 token 以内,相邻块之间可以留 64 token 的重叠,这样跨段落引用的句子不会因为切分边界的问题丢失上下文。
表格内容需要单独处理,原样把 Markdown 表格塞进切块器里效果很差。我会把表格按行转成自然语言的描述,比如“第 2 行第 3 列:阈值设置为 0.35”,这样嵌入模型对语义的捕捉会稳定很多。
图像预处理就更直接了。统一转成 RGB 三通道,去掉 EXIF 方向信息,按模型要求的输入分辨率做缩放。常见模型输入分辨率可能要求 336 或 512,具体看模型文档。这里有个容易忽略的坑:RGBA 通道的 PNG 图片直接送进去会报维度错误,处理管道里一定要先转 RGB。
如果原始扫描件质量很差,建议在入库前做一次对比度增强,或者用 OCR 跑一遍把文字层提取出来存成文本块。图像向量捕获的是视觉特征,扫描件上的小字它不一定能识别清楚,OCR 文本块和原图同时入库,检索效果通常比只存图片好得多。
3.2 批量向量化入库与索引选择
数据准备好后进入入库流程。强烈建议用批量推理而不是单条循环,GPU 和 CPU 在批处理时的吞吐差异非常大。文本一次给 64 条,图像一次给 16 张,我实测下来整体吞吐能提升好几倍。批量处理时要注意 batch 大小不要超过显存,万一内存溢出就把 batch 减半重试。
入库的数据组织方式,我的习惯是 SQLite 存元数据,Faiss 存向量,两个体系通过自增 id 对齐。每条记录包含类型、来源路径、原始内容、入库时间。Faiss 索引库只存向量和 id,这样查询时先用 Faiss 拿到候选 id 列表,再回 SQLite 捞元数据,两边职责清晰。
对于索引类型,离线场景我推荐直接用 Faiss 的 IndexFlatIP,它是暴力精确检索,不损失召回率,查询速度在百万级以内完全够用。不要一上来就上 IVF 或 HNSW,虽然这些索引理论性能更好,但 HNSW 有构建参数需要调,IVF 还需要训练步骤,小数据量上优势体现不出来,反而引入额外的不确定因素。数据量哪天真的过了千万级,再换 HNSW 也来得及。
查询时如果走 HNSW,记得把 efSearch 参数调大一点,比如 128,牺牲一点速度换召回率。IndexFlatIP 没有这些参数,省心很多。
3.3 多模态检索的分数归一化与结果融合
多模态检索和普通文本检索最大的区别,就是检索结果里既有文本向量也有图像向量,它们的得分不能直接放一起排序。原因很简单,文本块和图像块在向量空间里的分布范围、得分数值可能完全不在一个量级。直接按原始分数排序,很可能会出现某一类结果永远排在前面。
我采取的做法是先把两个查询结果分别做 rank 归一化,再用线性权重融合。具体来说,查询向量分别在文本索引和图像索引里各取 topK,然后对每个候选的原始相似度分数做 min-max 归一化,映射到 0 到 1 区间,最后计算融合分数。融合权重根据业务调节。比如以文档为主的知识库,文本权重给 0.6,图像权重给 0.4;以图集为主的库,反过来。这个权重我建议在配置中心做成参数,不同场景可以直接微调。
查询阶段如果用户上传的是一张图片,那 query 走图像嵌入分支,同时把图片对应的视觉特征向量在文本和图像两个索引里都检索一遍。这种“图文互查”的检索模式在图纸、产品图片管理场景很有用。
检索出来后,还有一个容易被忽略的步骤:阈值过滤。不要直接取 top1 结果,因为模型对无关内容也会给出一个相似度分数,只是分数偏低。我的经验是把阈值先设得宽松一点,比如 0.3 附近,把候选集多放进来几个,然后交给生成模型做最终筛选。离线 RAG 的场景下,宁可多召回,不要漏召回,后期靠重排序或提示词过滤都能救回来,漏了就是真没了。
整个离线 RAG 管道串联起来之后,流程很简单:query 过嵌入模型拿向量,两个索引各取 topK,归一化融合排序,过滤阈值,取出元数据,连同命中的图片路径一起组装进提示词,交给本地部署的生成模型输出答案。图片路径可以直接用 file:// 协议,多模态生成模型能直接读到本地文件。
4. 常见问题与排查技巧实录
4.1 模型加载失败和离线环境文件缺失
离线模型加载最常见的问题是启动时报 various 文件找不到。我排查这类问题有一个固定套路:先确认模型目录下有哪些文件,再看报错信息里缺的是哪一个。大多数情况下是缺了 processor 配置文件或者 tokenizer 文件,而不是权重文件。这个平台的模型仓库是目录整体管理的,文件存储路径看起来像快照目录加一串哈希值,加载器默认会去查这个结构,你只在根目录放了部分文件,它当然匹配不上。
解决办法是从源头解决:在有网环境就把整个目录完整同步下来,包括目录结构,然后整体拷贝。不要自己挑文件复制,你永远猜不到加载器的文件组织逻辑是什么。
4.2 批量入库时内存溢出和进程崩溃
批量向量化的时候,最容易出问题的是图像全量加载。有些开发者会把所有图片路径读出来之后一次性转到 PIL Image 列表,再送进处理器,结果几万张图直接挤爆内存。
我后来改成生成器模式:每批次从磁盘读取指定数量图片,处理完立刻释放引用,PyTorch 的 dataloader 思路也是一样。如果显存还是不够,把 batch size 从 64 降到 8,宁可多跑几轮,也不能让进程被杀。还有一个提升稳定性的方法,推理时用 torch.inference_mode() 而不是 torch.no_grad(),前者对内存管理更激进,能省不少显存。
4.3 检索结果质量差:从哪几个方向排查
如果向量已经入库,但检索出来的结果不相关,我建议从以下四个方面去排查。我把平时总结的排查清单放在这里,是按优先级排的:
- 向量是否做了 L2 归一化。没归一化的时候用内积算相似度,向量模长会影响排序结果,长文本和短文本的分差特别明显。
- 切块粒度是否合适。512 token 是参考值,如果文档是结构化的技术手册,按章节切分效果比按固定长度切好很多。
- 图像的预处理是否符合模型输入要求。分辨率过低、通道不对会直接损害图像语义表达。
- 多模态融合时是否做了分数归一化。没有归一化的话,某一模态可能霸榜。
这四类问题里,前三类属于数据和代码问题,第四类是逻辑设计问题。我自测的时候习惯跑一个“回归集”:预先准备五十个已知答案的查询,每次改完代码都跑一遍,看 top5 命中率变化。这个方法能很快暴露配置改动导致的检索质量下降。
4.4 推理速度慢的优化经验
离线部署场景下,速度优化优先级是:半精度 > 批处理 > 索引参数。模型用 float16 加载,torch_dtype 设成 torch.float16,推理速度和显存占用都有明显改善。如果机器支持 bfloat16,优先用 bfloat16 更稳,数值精度损失更小。CPU 机器可以顺手看一下是不是只有两个核心在跑,如果是,检查 PyTorch 的线程数配置。
索引层面,Faiss 的 IndexFlatIP 本身检索是毫秒级的,瓶颈通常不在检索而在于向量计算。如果你觉得查询响应慢了,先看是不是调用了 embed_texts 作为独立进程启动,每次重启都要重新加载模型,这个加载时间比推理时间长得多。正确做法是把模型做成长期驻留的服务,或者至少在服务内做单例加载,只初始化一次,后续查询只走推理。
做完了这些优化,我对离线 RAG 的整体感受是:模型选择其实不是最大的成败因素,反而是数据切分、向量归一化、评分融合这些“周边工程”决定了检索到底好不好用。项目一开始我把所有向量塞进同一个索引里图省事,结果图像和文本混在一起排序后,某个模态总是霸榜,后来拆开索引、加分数归一化,效果马上就正常了。如果你也在做类似的事,建议先拿一个小规模数据集把整条管道走通,反复调几轮评分权重,确认每个环节的输出都符合预期,再放大到全量数据。这个流程无论你以后换什么嵌入模型,都是完全通用的。