最近在给手头一个内部知识库系统做升级,碰到一个很典型的场景:资料库里大量文档是图文混排的,既有正文文字,也有产品截图、表格照片、流程示意图。传统的做法是把 PDF 抽成纯文本再做向量化,结果图里的关键信息全丢了。折腾了一圈之后,我换了一条路——用 EmbeddingGemma 2 这个 740M 参数的多模态嵌入模型,把图片和文本统一编码到同一个向量空间,离线跑完整套 RAG。今天这篇就把这套方案的原理、架构、最小可用代码和踩坑过程完整写出来,给同样被"图文混合检索"卡住的人一个可直接参考的落地版本。
先说清楚它解决什么问题:EmbeddingGemma 2 是面向多模态检索的嵌入模型,输入可以是文本、也可以是图片,输出都是同一个空间下的向量。740M 参数意味着什么?意味着显存占用在 1.5GB 到 3GB 之间,CPU 也能推理,完全能部署在内网离线环境。离线 RAG 指的就是检索增强生成链路里的向量化、索引、召回、重排全部本地完成,不调用任何云端接口。这篇文章适合正在做企业知识库问答、内部文档检索、合同票据分析,但又受限于数据不能出内网的技术同学。我会把模型原理、整体架构、操作步骤、性能调优和真实踩坑一次讲透。
1. 为什么 740M 的多模态嵌入模型值得单独拿出来讲
1.1 纯文本 RAG 的盲区到底在哪
大多数团队做 RAG 的第一版都是纯文本路线:把 PDF 抽成文字,按 chunk 切分,用文本 embedding 模型转成向量,再扔进向量库。这个路线对纯文字文档完全够用,但一旦资料里出现截图、表格图片、流程图,信息就直接断了。
举一个我实际遇到的例子:一份设备保修条款 PDF,里面有一张"保修期计算流程图",是图片格式。纯文本管线只能提取到图片前后的说明文字,图里的关键判断条件——比如"延保一个月需满足 X 条件"——完全丢失。用户问"什么情况下保修期延长一个月",检索系统召回的只是周围文本,真正的答案藏在那张图里。
这种情况下常见的补救办法是"先 OCR 再塞进文本管线"。但 OCR 有几个绕不开的问题:一是版面结构丢失,表格的对应关系、图上的箭头关系全没了;二是 OCR 错字会污染语义检索,一个型号编码识别错,整个检索结果就偏了;三是遇到流程图、结构图这种语义强依赖视觉布局的内容,纯文本根本不是一个好的表征形式。
1.2 多模态嵌入解决的是"图文同域"问题
多模态嵌入模型做的事情,是把图像和文本映射到同一个向量空间。图片可以直接编码成一个向量,文本也可以编码成向量,两者之间用同一个度量方式计算相似度。这意味着查询"保修期延长条件"时,可以直接拿这段文本向量去匹配流程图的图片向量。
这个能力放开了一个以前只能靠人工标注来弥补的口子。过去要让文本检索到图片,得先给图片写一句描述文字,再用描述进索引;现在图片本身就是索引单元,query 直接跨模态比较,不需要任何中间改写。实测下来,对于"以图查文""以文查图"两类需求,这种方案的效果都明显好于"给图片写说明再进文本索引"的老路。
1.3 "离线"在这里不只是省钱
离线 RAG 最重要的价值往往是数据合规。企业的合同、报价、内部 SOP 这些内容,数据管理要求不允许出内网。云上多模态接口效果确实不错,但每传一张图就等于把数据送出边界一次。离线部署以后,整个链路全部在内网完成,文档解析、向量化、检索、生成都不依赖外部服务,这是能通过安全评审的关键。
而模型的参数规模直接决定"能不能离线跑"。740M 是一个我个人认为比较舒服的档位:单卡 4GB 显存就能带得动,CPU 上也能推理,同时 embedding 质量比 100M 到 300M 的轻量模型高出一截。再往上走,2B 以上的多模态模型效果更好,但部署门槛和推理延迟也跟着翻倍。740M 恰好卡在"能落地"和"效果好"之间的平衡点上。
2. EmbeddingGemma 2 的内部结构:740M 到底在做什么
2.1 三件套架构:视觉编码器、文本编码器、投影层
多模态 embedding 模型的基本结构可以拆成三块:视觉编码器、文本编码器、投影融合模块。
视觉编码器会把图片切成固定大小的小 patch,比如把 224x224 的图切成 196 个 16x16 的 patch,每个 patch 展平成向量后再送进视觉 Transformer。文本侧则是把句子通过 tokenizer 切词,送进文本 Transformer 提取特征。两条支路各自算出特征以后,再经过投影层统一映射到同一个共享向量空间。
训练目标通常是对比学习,让"图文匹配对"的向量相似度尽量高,"图文不匹配对"的相似度尽量低。这个投影过程,可以理解成给两个各说各话的翻译官配了同一个目标语言,最后你用余弦相似度比较翻译结果就知道两句话是不是同一个意思。
2.2 740M 参数大概是怎么分配的
虽然模型结构文档没有完全公开,但从同类模型的常见配置推断,740M 这个量级大概是这么分配的:文本编码器占大头,约 400M 到 450M;视觉编码器约 150M 到 200M;投影融合和位置编码等占剩下的部分。
文本编码器比重偏大是一个普遍设计趋势,原因是 RAG 场景里 query 绝大多数是文本,文本表征的质量直接决定检索效果。视觉编码器不需要做得过分巨大,因为图像特征有很强的局部性和平移不变性,小一些的视觉骨干在中小规模数据上并不吃亏,反而更容易训练。
在部署侧,我的实际观测是:模型全部参数按 FP16 存储大约 1.5GB,推理时算上激活值,4GB 显存整卡可以跑。如果切到 INT8,权重能压到 800MB 左右,对离线内网部署来说是非常友好的资源需求。
2.3 输出向量的语义粒度与维度选择
EmbeddingGemma 2 默认输出的向量维度在 1024 到 1536 之间,实际以模型配置为准。向量维度高,表达能力强,但索引阶段的内存压力也要同步算清楚。一亿条向量的场景下,1536 维 float32 光原始向量就要 600MB,加上索引结构可能再翻一倍,这个体量在离线机器上不是每个都能扛住。
还有一点值得注意:视觉端输出的向量可能不只有"全局向量",还有 patch 级细粒度向量。全局向量适合做图文语义匹配,patch 级向量适合做版面定位或者局部检索。如果只是做 RAG 的文档块匹配,用全局向量就够了,千万不要把每个 patch 的特征都塞进索引,否则索引体积会以几十倍的速度膨胀,检索速度也会被拖垮。
3. 离线 RAG 的整体骨架:从文档解析到混合检索
3.1 文档预处理:图片进索引前要做的几件事
离线 RAG 的输入一般有两类:一类是本身带有图像的文档,比如 PDF、PPT、网页导出的 HTML;另一类是扫描件。前者优先把页面直接渲染成图片,再做版面分析,切出文本块和图像块;后者必须先过 OCR,但 OCR 的结果要保留坐标信息,尽量恢复原始版面结构。
我在预处理管线里固定做三件事:渲染原始页面、按版面区域切分、记录元数据。每个区域的元数据至少包含来源文件名、页码、区域类型、所在标题路径。这样检索到一张流程图的图片块时,系统能准确告诉用户"这是某某文档第 5 页的图",而不是只给一个无法定位的向量。
3.2 文本和图像如何切成可检索的单元
文本分块我按语义级别走:先按标题层级切 section,超过模型最大长度再按段落切。图片分块则是另一个思路——不要按像素均匀切,而是按版面区域切。一张 A4 页面如果包含 3 张图,优先把每张图切成独立索引块,这样检索粒度更细,召回准确率更高。
图像块切完以后要统一缩放。最好控制在模型预期的尺寸上限内,比如长边不超过 512 像素。缩放过大,模型推理慢,而且会把无关背景细节也编码进去;缩放过小,图片里的关键文字又会模糊。我一般会保留原图的宽高比,把长边压到 512,短边不足 512 的用黑色填充补到正方形。
3.3 混合检索:向量召回不能是唯一的召回
我最终落地使用的是混合检索:文本块走 BM25 关键词召回,图文块走 EmbeddingGemma 2 向量召回,两路结果合并后再进入重排阶段。
为什么一定要加 BM25?因为多模态向量模型对精确词面匹配并不敏感。比如型号编码"GW-2700",语义向量大概率会把它和"GW-2000"认为是相近的,但用户要的就是字面完全一致的型号。反过来,BM25 对语义扩展能力很差,用户搜"设备保修期延长条件"时,它很难匹配到"延保一个月"这种词面不同但语义相同的表述。两条腿走路之后,检索稳定性提升非常明显。
重排阶段可做可不做。索引量低于 5 万条时,用一个小的排序模型对前 100 条结果重新打分,效果提升明显;索引量很大或者对延迟敏感时,可以跳过重排,直接用向量分数和 BM25 分数的加权合并作为最终排序。
4. 实操:把 EmbeddingGemma 2 跑起来的最小闭环
4.1 环境准备与依赖清单
软件环境方面,我使用的是 Python 3.10、深度学习框架相关组件、Pillow 处理图像、faiss 做向量索引。模型权重提前下载好,放到固定目录下离线加载,不依赖外网下载。
硬件门槛拆成两档:纯测试阶段用 CPU 加 16GB 内存就能跑推理,每张 256x256 的图片大约耗时 200 毫秒;真正批量建索引阶段建议用 NVIDIA GPU,显存 6GB 以上,体验会顺畅很多。下面是我搭建环境时的核心命令示意:
# 创建虚拟环境 python -m venv ./venv source ./venv/bin/activate # 安装基础依赖 pip install torch pillow numpy faiss-cpu4.2 加载模型与特征抽取
在代码层面,我封装了一个针对多模态嵌入模型的加载和推理类。核心逻辑是:加载模型权重和配置,把模型切到推理模式,对外提供文本编码和图像编码两个接口。示意代码如下:
from multimodal_embedding import EmbeddingGemma2 from PIL import Image model = EmbeddingGemma2.from_pretrained( "./models/embeddinggemma2-740m", precision="fp16", ) model.eval() # 文本向量 text_vec = model.encode_text("保修期延长一个月的适用条件") # 图像向量 image_vec = model.encode_image(Image.open("warranty_flow.png"))这里有一个容易被忽略的细节:模型返回的向量必须已经做了 L2 归一化,才能直接用内积计算相似度。如果模型没有默认归一化,一定要在入库之前统一手动归一化,否则向量长度会把相似度分数带偏,检索结果会很不稳定。
4.3 图文混合 RAG 最小 Demo
我把完整的最小 Demo 拆成三步:读取文档块并编码入库、把索引保存到磁盘、查询时加载索引并召回。核心代码逻辑如下:
import faiss import numpy as np dim = 1024 index = faiss.IndexFlatIP(dim) # 归一化向量使用内积 chunks = [] for item in doc_items: if item["type"] == "text": vec = model.encode_text(item["content"]) else: vec = model.encode_image(item["image"]) index.add(np.array([vec], dtype=np.float32)) chunks.append(item) # 保存索引与元数据 faiss.write_index(index, "./faiss.index") np.save("./chunks_meta.npy", np.array(chunks, dtype=object), allow_pickle=True)查询阶段更简单,编码 query 后直接检索:
query_vec = model.encode_text("什么情况下保修期延长一个月?") scores, ids = index.search(np.array([query_vec], dtype=np.float32), k=5) for score, idx in zip(scores[0], ids[0]): print(score, chunks[idx]["type"], chunks[idx]["source"])跑通这个闭环之后,核心链路就打通了。接下来无非是两件事:把文档解析做得更健壮,把索引和模型部署成常驻服务。但最小 Demo 的意义在于,它能让你用最少的工作量验证"多模态离线 RAG"这件事到底可不可行,再决定要不要投入资源做工程化。
5. 离线场景下的工程细节与内存/性能调优
5.1 精度选择:FP32、FP16、INT8 怎么取舍
离线部署的资源是固定的,精度选择直接决定你能跑多大数据量。我实际测试下来的结论如下表:
| 精度 | 权重体积(约) | 显存占用(约) | 检索效果影响 | 适用场景 |
|---|---|---|---|---|
| FP32 | 约 3GB | 约 4GB | 基准 | 内存富裕、无优化需求 |
| FP16 | 约 1.5GB | 约 2GB | 基本无损 | 默认首选 |
| INT8 | 约 800MB | 约 1GB | 轻微下降 | 资源紧张、追求低内存 |
| INT4 | 约 450MB | 约 600MB | 1%-2% 检索下降 | 嵌入式设备或极端环境 |
我的建议是:FP16 作为默认配置,INT8 只在内存确实紧张时用。INT4 非必要不碰,虽然省下来的资源很诱人,但检索质量下降在长尾 query 上会被放大,出问题时排查成本反而更高。
5.2 批量编码:别一张一张喂模型
索引阶段真正的瓶颈通常不在模型计算,而在图片读取和预处理。我的做法是:预处理用线程池并行做,推理用 batch 一次打包 32 到 64 张。这个改动通常能让建索引速度提升三到五倍,同时让 GPU 的利用率明显提高。
还要注意一点:大尺寸渲染图一定要在预处理阶段先缩放。我第一次跑的时候直接把整页 A4 渲染图丢给模型,单张就差点把显存撑爆。后来统一做"版面切块 + 长边缩放 512"之后,单张显存开销降到了原来的十分之一以下。
5.3 向量索引:一百万条以下别过度设计
文档块数量在一百万条以内时,faiss 的 IndexFlatIP 暴力检索完全够用,单次查询在 10 毫秒内就能返回。这个阶段追求 HNSW、IVF 这类索引优化的收益非常有限,反而会把问题复杂化。
如果数据量真的超过百万,再考虑 HNSW。HNSW 的 M 和 efConstruction 参数别直接照抄默认,小数据集上调低 M 能显著减少内存占用,efConstruction 设到 200 到 400 即可。查询时 efSearch 可以动态调整,离线环境如果允许几十毫秒延迟,efSearch 调大一点召回更稳。
5.4 纯 CPU 机器上的加速手段
部分离线环境没有 GPU,740M 模型也不是不能跑。我的做法是:把模型导出成 ONNX,线程数固定设置为物理核数而不是逻辑核数,去掉动态图带来的调度开销。实测下来 CPU 推理一张图能到 60 到 80 毫秒,纯 CPU 做小批量增量索引和查询是可用的,但扛不住大并发。
CPU 环境下另一个实用的做法是把 tokenizer 和图像预处理的缓存做起来。同一个文档反复被重新索引时,预处理缓存能省掉大量重复的缩放和填充计算。
5.5 打包成常驻离线服务
最终部署时,我把模型权重、全部依赖、索引文件和代码一起打进了 Docker 镜像。服务对外提供两个接口:一个负责增量写入文档,一个负责查询召回。服务启动时预热模型,把权重加载到显存并常驻,避免每次请求都重新加载模型。
接口交互数据用 JSON 传输,图片用 base64 编码或者文件路径两种方式都支持。这样企业知识库前端要接入时,不需要理解任何模型细节,只要会调 HTTP 就好。
6. 跑通之后我踩过的坑与备选方案
6.1 最典型的五个坑
| 坑 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 中文截图检索分数异常低 | 文本 query 检索中文截图,top1 分数不到 0.5 | 截图分辨率太高,模型缩图后关键内容看不清 | 先缩放图片,把关键区域单独切块再编码 |
| 图片索引体积爆炸 | 2000 张图占用了 2GB 以上内存 | 每张图把 patch 级向量也塞进了索引 | 只保留全局向量,丢弃细粒度向量 |
| 查询时维度不一致 | 跑 search 时报维度 mismatch | 文本和图像输出经过了不同的后处理 | 检查模型配置,统一投影层后的固定维度 |
| 离线环境依赖装不上 | pip 安装直接编译报错 | 内网没有镜像源,也没有提前备 wheel 包 | 在可联网环境把所有依赖导出 wheel,搭本地源 |
| 图上的关键词检索不到 | 流程图文字信息被漏掉 | 图片里文字过小,模型缩图后看不清 | 混合检索兜底,必要时先 OCR 再补充文本块 |
逐个说下排查思路。第一个坑最隐蔽,因为分数低不代表模型坏,而是模型把整张截图缩小到 224 或 512 分辨率时,图里的局部文字已经模糊成噪点。解决方法是预处理时先做目标检测或版面分析,把包含关键信息的子区域单独裁剪出来再编码。第二个坑则是贪心导致的,总以为向量越多召回越准,实际多模态模型的 patch 级向量是为局部任务设计的,和全局语义检索不匹配。
第三个坑通常出现在"文本向量走一套逻辑、图像向量走另一套逻辑"的代码里。比如文本侧最后接了一个平均池化,图像侧却直接取了 [CLS] token,两边输出维度虽然都是 1024,但语义对齐方式不同。这个没法靠检索补救,只能回到模型配置层面统一。
第四个坑在离线项目里几乎是必然遇到的。我后来形成了一套固定动作:先在联网机器上用虚拟环境把所有依赖锁版本,再导出全部 wheel 包,放到内网用本地 pip 源安装。第五个坑则说明了多模态 embedding 不是万能药,遇到小字密集的截图,OCR 后把文字作为文本块补充进索引,能明显提高召回率。
6.2 什么时候不该无脑上多模态嵌入
不是所有场景都需要 EmbeddingGemma 2。如果索引内容全部是纯文本,而且用户查询以型号、编号等精确词面匹配为主,直接用 BM25 或者 SQLite FTS 会更简单更可靠。多模态嵌入是给"图文混合、语义查询"场景准备的,不要为了技术而技术。
机器内存小于 4GB 时,740M 模型会比较吃力,可以换到 200M 左右的多模态小模型,或者对图片侧做更激进的分块,保证单张图片占用的显存可控。相反,如果离线资源很充裕、对检索质量要求极高,比如要做跨模态问答和图文互查,可以上更大的 2B 到 7B 级模型,用 INT8 量化跑 GPU,效果上限更高。
还有一个工程上的建议:不要一上来就堆模型。先拿一百条混合文档把全链路跑通,确认解析、编码、检索三个环节各自是正常的,再考虑加大数据量。这个顺序看起来慢,但出了问题你能快速定位是文档解析的问题、模型编码的问题还是索引检索的问题,比在几千条数据上瞎猜高效得多。
最后分享一点个人体会:离线 RAG 这类系统,关键从来不在模型参数有多大,而在于整条链路稳不稳定。EmbeddingGemma 2 这个 740M 模型给了我一个很舒服的起点——它在资源受限的离线环境里能跑,效果又比轻量模型明显高一个档次。把文档解析、混合检索、精度取舍这些细节做扎实之后,图文混合的离线知识库,是真的可以稳稳跑起来的。