资讯动态

离线RAG新方案:用EmbeddingGemma 2多模态嵌入解决图文检索难题

发布时间:2026/10/17 11:51:46 来源:尧图企业网站定制
年初我把一个内部知识库项目翻出来重做目标很简单给我那台不能联网的办公机器加一个本地问答能力资料里既有 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 量化后约 740MBint4 量化后约 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_dtypetorch.bfloat16, device_mapcuda: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登录页验证码开关在哪里, imagesimg, paddingTrue, truncationTrue, return_tensorspt, ).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, p2, dim1)原因很多人知道但容易忽略向量检索库在做相似度计算时如果用点积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 毫秒串行跑一万次得半小时以上。我的优化方案是批量 embeddingfrom torch.utils.data import DataLoader # 把图像和文本组成 batch batch_size 8 for batch in DataLoader(chunks, batch_sizebatch_size): inputs processor( textbatch[texts], imagesbatch[images], # 注意图像要预先 resize 到同样尺寸 paddingTrue, truncationTrue, return_tensorspt, ).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 740Mbf16 精度GPU 推理输入图像统一到 448 分辨率faiss-cpu 做向量索引BM25 混合召回生成端接了一个量化过的本地文本模型。整套系统跑在 8GB 显存的显卡上索引构建、检索、生成全部离线完成。最后说一点经验之谈。多模态嵌入模型的引入改变了离线 RAG 的设计重心——以前我花大把时间搞文本分块和 OCR 清洗现在很多结构信息直接由模型消化我更多精力花在图像预处理、chunk 类型管理和混合检索调权上。数据质量、预处理稳定性和混合排序策略才是决定最终问答效果的关键。跑通一次不难能稳定服务才是本事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑