年初我把一个内部知识库项目翻出来重做,目标很简单:给我那台不能联网的办公机器加一个本地问答能力,资料里既有 PDF 文档,也有大量产品截图、扫描件和表格图片。试了两版纯文本 RAG,效果都很泄气——表格被切得稀碎,图片里的关键指标全部丢失。后来换成 EmbeddingGemma 2 的 740M 多模态嵌入模型跑离线 RAG,问题才真正解决。这篇文章把我从模型下载、离线部署到检索联调的全过程原原本本写出来,适合正在做离线知识库、又不想被云端 API 绑架的人。
1. 为什么离线 RAG 会需要"多模态嵌入"这层
1.1 文本切分在面对带图文档时的短板
传统 RAG 的流程大家都很熟:把文档切成 chunk,用文本嵌入模型转成向量,建索引,检索,再送给大模型生成。问题在于,这一套默认"所有知识都在文本里",图像只是插画。
但真实业务文档不是这样。我手上的资料里,很多关键信息就是一张截图:某个配置项的值、某个版本的界面入口、一张对比表。之前用文本嵌入模型时,我尝试过两种补救方案:
- 先跑 OCR 把图像里的文字抠出来,再作为普通文本去切分。
- 只在文本里留下图片的文件名和路径。
方案二基本没用,因为大模型在没有图片内容的上下文时,只能瞎猜。方案一稍好,可一旦图像是复杂排版,OCR 结果一团乱,表格结构全丢,切出来的 chunk 语义也支离破碎。更重要的是,OCR 本身是另一套模型,离线部署环境里又多了一个不稳定环节。
1.2 多模态嵌入把图文放进同一个向量空间
EmbeddingGemma 2 这类多模态嵌入模型,思路完全不同:它直接把图像作为输入,和文本一起编码到同一个向量空间。
举个例子,以前我要检索"登录页的验证码开关在哪一张图",我得靠图片的 OCR 文本去撞运气;现在可以直接把这张图变成向量,查询文本变成另一个向量,两者在同一个空间里做相似度比较。图像不需要被"翻译"成文字,它本身就是可检索的语义单元。
这一点对离线 RAG 特别重要。离线环境里模型资源有限,如果我还要单独维护 OCR 模型、文本嵌入模型、图像嵌入模型三套东西,部署成本和故障点都会翻倍。多模态嵌入模型用一个模型把文本和图像统一建模,链路直接缩短。
1.3 选择 740M 档位而不是更大模型的理由
EmbeddingGemma 2 系列里有不同大小的版本,我选的是 740M 参数这一档,核心原因有三个:
第一,离线机器通常是老 GPU 或者纯 CPU,显存和内存都抠抠搜搜。740M 虽然不算小,但用 bf16 精度加载,显存占用不到 1.5GB,加上 KV Cache 也完全能放进一张 6GB 显存的卡。
第二,多模态模型是要被高频调用的,每次问答都要把 query 转向量,索引构建阶段更是要对成千上万个 chunk 做推理。参数越小,吞吐越高,离线系统响应越快。
第三,我实测下来,740M 这一档在图文检索任务上的表现已经够用。它比更小的模型多出的容量,主要花在了视觉编码上,对表格、截图这类密集型图像的理解明显更强,但又不至于大到部署困难。
2. 离线部署的前置工作:权重、依赖、缓存一个都不能少
2.1 最小依赖清单
离线机器最麻烦的是装环境。没有外网,pip 装包会让你怀疑人生。我总结的最小依赖清单如下,都是跑通这个模型实际需要的:
| 组件 | 用途 | 备注 |
|---|---|---|
| transformers | 加载模型和预处理器 | 版本尽量和下载权重时的环境一致 |
| torch | 模型推理后端 | CPU 版和 CUDA 版二选一 |
| Pillow | 图像读取和预处理 | 处理截图、扫描件 |
| numpy | 向量数值运算 | 几乎所有环节都要用 |
| faiss-cpu | 向量索引与检索 | 离线场景 CPU 版完全够用 |
| rank-bm25 | 关键词检索兜底 | 混合检索会用 |
在离线机器上装这些,最稳妥的做法是:在有网机器上把requirements.txt里所有包下载成.whl或 tarball,打包拷贝过去,再用 pip 本地安装。千万别到离线机器上才发现少了某个传递依赖,那来回折腾的时间够你吃两顿饭。
2.2 离线模型包怎么准备
模型权重不能在线下载,就得在有网机器上先拉好,再整体拷贝。
我习惯的目录结构是这样:
/models/embeddinggemma2-740m/ ├── config.json ├── model.safetensors ├── processor_config.json ├── preprocessor_config.json └── tokenizer/拷到离线机器后,加载路径直接指向这个目录。
这里有个容易踩的坑:transformers 加载模型时默认会去检查是否有最新版本。离线环境下它会超时、报错,甚至在某些版本里直接抛异常。解决办法是显式开启离线模式:
import os os.environ["HF_HUB_OFFLINE"] = "1" os.environ["TRANSFORMERS_OFFLINE"] = "1"这两行代码建议放在加载模型的脚本最顶部,能省掉大量奇怪的网络报错。
另外,拷贝模型包时不要只对.safetensors文件做校验,配置文件和 tokenizer 文件少了任何一个,都会在运行到一半时才暴露出问题。我一般打包时生成一份 sha256 清单,拷过去之后先核对一遍再开始写代码。
2.3 740M 参数的实际账本
很多人一听说"740M 多模态模型",第一反应是"很大,带不动"。我们把账算清楚。
假设模型权重用 fp32 存储,740M 参数 x 4 字节 ≈ 2.96GB。如果用 bf16,直接减半到 1.48GB。int8 量化后约 740MB,int4 量化后约 370MB。
磁盘占用是这个数,但部署时更重要的还有推理过程中的临时显存:激活值、注意力中间结果、图像 token 序列的 KV Cache。740M 模型在 batch size 为 8、图像分辨率不高的情况下,推理峰值显存大概是权重占用的 3 到 5 倍。
我最终选择 bf16 精度加 CPU offload 的方案。加载时模型放在 GPU,文本和图像的预处理走 CPU,实际 embedding 推理在 GPU 上完成。这样一张 8GB 显存的卡完全够用,甚至还有余量。
3. 模型加载与 embedding 调用:把一张图和一段文字变成同一个向量
3.1 加载模型和处理器
代码层面,我使用 transformers 的 AutoModel 和 AutoProcessor 两个接口。下面是我在离线机器上跑通的最小加载代码:
import os os.environ["HF_HUB_OFFLINE"] = "1" os.environ["TRANSFORMERS_OFFLINE"] = "1" import torch from transformers import AutoModel, AutoProcessor model_dir = "/models/embeddinggemma2-740m" model = AutoModel.from_pretrained( model_dir, torch_dtype=torch.bfloat16, device_map="cuda:0", ) processor = AutoProcessor.from_pretrained(model_dir) model.eval()注意两个细节:
一是torch_dtype必须和权重保存时的精度一致,否则会触发额外的转换开销,甚至影响输出数值稳定性。二是加载后一定要执行model.eval(),embedding 模型不需要 dropout 和 BatchNorm 的训练逻辑,不切换成 eval 模式会得到不确定的结果。
3.2 文本侧与图像侧的预处理
多模态嵌入模型最核心的一点是:文本有文本的 tokenizer,图像有图像的 preprocessor,两者最终要喂给同一条模型主干。
文本侧,我是把一个 chunk 的上下文拼好再传入。比如一个 chunk 可能包含"文档标题 + 页面标题 + 正文摘要",目的是让模型在编码时看到语义边界。拼接模板可以简单用分隔符,也可以按照模型仓库推荐的 instruction 格式。我在自己的数据上做过对比,带文档标题和页面编号的 chunk,检索准确率优于纯正文 chunk,原因很直观:模型知道这段文字属于哪个文档的哪一页,语义定位更准。
图像侧,需要先经过 preprocessor。这里要特别说明:图像直接输入模型,不需要 OCR,但分辨率不能乱来。我的做法是保持原始宽高比,等比缩放到模型期望的最大边长,然后用黑色像素补齐到正方形。这样做的原因是,模型在预训练时看到的大多是规范尺寸的图像,强行拉伸会让文字变形,字都扭曲了,模型自然编码不出来。
from PIL import Image img = Image.open("/data/imgs/product_login.png").convert("RGB") inputs = processor( text="登录页验证码开关在哪里", images=img, padding=True, truncation=True, return_tensors="pt", ).to("cuda:0") with torch.no_grad(): embeddings = model(**inputs).embeddings # shape: [1, dim] print(embeddings.shape)这段代码的关键在于processor同时接收 text 和 images 两个参数。最终组合好的输入既包含图像 token,也包含文本 token,经过模型编码后,取到对应的 embedding 向量。
3.3 输出向量为什么要归一化
模型输出的向量维度取决于 pooling 方式。740M 这一档在检索场景下,输出向量维度通常在几百维上下,足够表达图文语义。
我拿到 embedding 后的第一个固定动作是 L2 归一化:
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)原因很多人知道但容易忽略:向量检索库在做相似度计算时,如果用点积(inner product),那向量的模长会直接影响分数。两个语义完全一致的向量,如果模长不同,点积分数也会不同,导致排序被"向量长度"干扰。归一化之后,点积就等于余弦相似度,分数范围稳定在 [-1, 1],检索排序才干净。
还有就是建索引和查索引时,两边都必须做同样的归一化。我见过有人建索引时归一化了,查询时忘掉,结果召回率忽高忽低,排查半天才发现是这一步漏了。
4. 把离线 RAG 链路完整搭起来
4.1 文档预处理:把 PDF 和图片拆成可嵌入单元
离线 RAG 的数据源往往不是干净的文本,而是 PDF、扫描件、截图混合体。我在预处理阶段做了三件事。
第一,把 PDF 按页拆开,每页转成图像。这一步是为了让多模态模型能直接看整页版面,保留表格和排版信息。
第二,对识别出来的文本页,再走一层文本抽取,得到干净的文本块。这里有个原则:文本块和该页的页面图要绑定在一起。也就是说,一个 chunk 同时包含"这页的文字内容"和"这页的图像内容"。
第三,按视觉布局切分,而不是按字数硬切。比如一页里有两个独立卡片,那就切成两个 chunk;一页只有一个大表格,就别切。硬性切 200 字对多模态模型是灾难,因为你要么把表格行拆散,要么把图像从中截断。
我会为每个 chunk 保存一份元数据:
chunk_meta = { "doc_id": "manual_001", "page": 12, "type": "mixed", # text / image / mixed "text": "……", "image_path": "……", "ocr_fallback": "……" # 备用,见第 5 章 }这里的ocr_fallback是先留的备用字段,后面会遇到。
4.2 向量索引与混合检索
建索引我用 faiss-cpu 的 IndexFlatIP,配合 IDMap,因为要支持增删改。
import faiss import numpy as np dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) index_with_ids = faiss.IndexIDMap2(index) # 假设 chunk_vectors 是归一化后的向量 index_with_ids.add_with_ids( np.ascontiguousarray(chunk_vectors.astype("float32")), np.arange(len(chunk_vectors)).astype("int64"), )检索时,query 向量归一化后直接index.search(query, top_k)。
但向量检索不是万能的。有些场景下,用户问的是"第一章有哪些小节",这种问题更像是关键词匹配,向量检索可能召回一堆语义相近但不相关的段落。所以我加了 BM25 做混合检索:
from rank_bm25 import BM25Okapi tokenized_corpus = [chunk["text"].split() for chunk in chunks] bm25 = BM25Okapi(tokenized_corpus) bm25_scores = bm25.get_scores(query_tokens) vector_scores = faiss_search_results # 归一化到 [0,1] final_scores = 0.7 * vector_scores + 0.3 * bm25_scores混合检索的权重我调过几次。离线场景下,资料领域相对垂直,关键词命中往往是强信号,所以 BM25 权重给到 0.3 到 0.4 都可以,不会太抢向量模型的戏,但又能兜住冷门专有名词。
4.3 生成模型怎么吃检索结果
检索召回的 chunk 里可能既有纯文本,也有图片。生成阶段的大模型,如果是纯文本模型,根本不能直接看图。这里我当时的做法是,图像 chunk 在送入生成模型之前,把 OCR 文本或图像所在页的文本块拼进上下文,让模型"间接看到"图像内容。
提示词模板大概是这个结构:
根据以下参考资料回答问题。 参考资料: [文档:manual_001,第 12 页] 验证码开关位于登录页右上角,默认关闭…… 问题:登录页验证码开关在哪里? 回答:如果生成模型本身是多模态的,那可以直接把图像一并传入;但离线环境里,更常见的是文本模型,所以 OCR 文本兜底几乎是必选项。
5. 实测避坑:多模态嵌入不是装上就能用
5.1 图像预处理的"隐形正确性"
多模态嵌入模型最大的坑不在模型本身,而在图像预处理。
我调试过程中遇到一个典型问题:扫描件有阴影、倾斜,等比缩放后内容区域只占图像的一小块,补黑边之后有效信息被压缩得很小,检索命中率立刻掉。
解决方法看起来有点土但很有效:先做简单的版面裁切,把阴影和无关边框裁掉,再做缩放和补边。另一个问题是图像方向。手机拍的扫描件经常是横竖错乱的,模型对倒置图片的编码能力有限。我加了一步根据 OCR 方向检测自动旋转,虽然这又引入了 OCR 依赖,但对图像质量差的资料确实有用。
裁剪、旋转、补边这些操作,建议直接写成一个preprocess_image函数,单独做单元测试。图像处理环节的 bug 不会报错,只会让你检索结果悄悄变差。
5.2 图文混合 chunk 的召回和排序问题
第二个坑是图像 chunk 和文本 chunk 混在一个索引里时,排序会偏。
原因很微妙:多模态嵌入模型对图像和文本输出向量的分布不完全一致,图像向量的检索分数往往偏高或偏低,具体取决于训练数据的配比。我遇到过的问题是,检索 top 10 里几乎全是图像 chunk,但用户问题是纯文本的,答案其实在文本页里。
后期我做了两种缓解:
一个是在 metadata 里记录 chunk 类型,检索后按"类型 + 分数"综合重排。比如文本 chunk 和图像 chunk 各保留若干名额,再合并排序。另一个是,如果召回的是图像 chunk,在下游生成里优先使用ocr_fallback文本,而不是只给模型一张图片路径。
这一步本质上是承认:多模态检索的原始排序不完全可信,需要通过业务规则做二次干预。
5.3 速度瓶颈:embedding 吞吐与并发设计
离线 RAG 的另一个痛点是索引构建速度。一个几千页的文档库,假设有一万多个 chunk,每个 chunk 都要经过模型推理。如果不做优化,单查一张图上 GPU 要 100 到 200 毫秒,串行跑一万次得半小时以上。
我的优化方案是批量 embedding:
from torch.utils.data import DataLoader # 把图像和文本组成 batch batch_size = 8 for batch in DataLoader(chunks, batch_size=batch_size): inputs = processor( text=batch["texts"], images=batch["images"], # 注意图像要预先 resize 到同样尺寸 padding=True, truncation=True, return_tensors="pt", ).to("cuda:0") with torch.no_grad(): vecs = model(**inputs).embeddings注意,一个 batch 里的图像必须统一尺寸,否则无法拼成一个张量。我的做法是先裁切到固定宽高,比如边长 384 或 448,损失一点细节但换来吞吐大幅提升。批量推理的吞吐大约是串行的 5 到 6 倍。
在离线机器上,CPU 推理也是可行方案,但吞吐会低不少。如果只有 CPU,建议把输入图像分辨率降一点,batch size 调大,勉强能跑通小规模知识库。真要处理几千页文档,还是尽量找一块 GPU 吧。
6. 针对 740M 档的个人调优记录
6.1 量化对检索正确性的影响
为了压榨离线机器的性能,我试过 int8 和 int4 量化。结论是:检索场景可以接受一定量化损失,但不能无脑量化。
int8 量化下,检索分数的绝对值会有波动,但 top-k 的排序基本不变。对 RAG 来说,只要排序没乱,召回的那批 chunk 就不会变,生成结果也就不会受影响。
int4 就不同了。我的测试集里,int4 量化的模型在图表类图像上明显出现"语义漂移",比如一张价格对比表,模型把它编码成了和"折扣活动介绍"更接近的向量,检索排序错乱。最终我的建议是:如果显存紧张到只能上 int4,优先保证文本 chunk 的精度,对图像 chunk 单独用 bf16 做一遍缓存,以此避免最坏情况。
6.2 embedding 缓存与增量更新
离线知识库不是建完一次就结束,文档每周都在更新。如果每次都全量重算 embedding,完全是浪费算力。
我采用了一个比较简单的增量方案:给每个源文件计算文件哈希,建立一张映射表file_hash -> [chunk_ids]。检测到文件变化时,只重新计算该文件对应的 chunk embedding,然后通过 faiss 的remove_ids和add_with_ids做局部更新。
index.remove_ids(chunk_ids_to_delete) index.add_with_ids(new_vectors, new_chunk_ids)这套逻辑一开始就要设计好,否则后面文档多起来,全量重建会越来越痛苦。
6.3 当前最终配置与一点体会
我现在离线机器上的最终配置是:EmbeddingGemma 2 740M,bf16 精度,GPU 推理,输入图像统一到 448 分辨率;faiss-cpu 做向量索引,BM25 混合召回;生成端接了一个量化过的本地文本模型。整套系统跑在 8GB 显存的显卡上,索引构建、检索、生成全部离线完成。
最后说一点经验之谈。多模态嵌入模型的引入,改变了离线 RAG 的设计重心——以前我花大把时间搞文本分块和 OCR 清洗,现在很多结构信息直接由模型消化,我更多精力花在图像预处理、chunk 类型管理和混合检索调权上。数据质量、预处理稳定性和混合排序策略,才是决定最终问答效果的关键。跑通一次不难,能稳定服务才是本事。