☰
离线多模态RAG实战:EmbeddingGemma 2实现图文统一向量检索
2026/10/21 3:52:05 网站建设 项目流程

我手上的项目是从七月份开始搭的:一个完全离线的知识库问答系统,材料里有大量扫描合同、产品截图和流程示意图。查了一圈现成方案,文本RAG倒是烂大街了,可一旦把图片、表格截图加进去,传统嵌入模型基本就废了。直到我把 EmbeddingGemma 2(740M 多模态嵌入版本)跑通,才真正解决了“离线环境里同时检索文字和图片”这件事。这篇文章就把整套方案从选型到落地完整拆开讲,包括我踩过的坑。


1. 为什么是EmbeddingGemma 2:离线RAG的真实痛点与选型逻辑

1.1 普通RAG对非文本内容基本是瞎子

先说说离线RAG这个词。所谓离线,核心是整套系统跑在封闭内网,不调任何云端API,数据不出内网。常见做法是文档切块后扔进向量数据库,检索阶段把用户问题向量化,用余弦相似度召回TopK文档,再交给本地大模型生成答案。

这个流程对纯文本文档没问题,可现实里的企业资料很少是干净的文本。我这次处理的材料里,扫描件占了三成,流程图画在Visio里导出的PNG占了两成,剩下才是Word和PDF里的正文。传统做法得先把图片做OCR或人工誊文字,再走文本嵌入。问题有两个:一是OCR的准确率直接决定检索质量,遇到印章、手写批注基本翻车;二是图片里的版式和语义关系丢了,比如一张架构图拓扑关系,OCR成文本后就变成一段没头没尾的列表。

多模态RAG的思路是让嵌入模型直接看图片,把图像内容和文本内容映射到同一个向量空间。查询的时候,不管是输入“按这张流程图,异常订单应该先走哪一步”,还是直接给一张类似的截图,模型都能找到语义对应的材料。这是纯文本方案做不到的。

1.2 为什么720多M这个量级值得关注

其实多模态嵌入模型并不少,但能做离线部署的少。很多多模态模型参数量从几十亿到上百亿不等,就算推理端能扛,嵌入阶段要处理的是整个知识库的所有文档,千万量级的chunk如果全用80B模型过一遍,时间成本没法接受。

EmbeddingGemma 2的卖点就是740M参数量。740M什么概念?BF16精度下模型权重大概1.5GB,INT8量化后不到800MB,一张普通办公显卡或者纯CPU都能跑。这个体量决定了它可以在普通工作站上完成千万级文档的离线嵌入,而不是让运维再配备一台推理服务器。

另外注意它叫EmbeddingGemma 2,说明它不是对话模型,而是专门为生成嵌入向量设计的。这跟拿通用LLM硬抠最后一层hidden state做向量是两码事——嵌入模型在训练时专门优化了语义相似度目标,输出向量的空间分布更适合检索类的相似度计算。

1.3 离线RAG整体的架构长什么样

我把整套系统的结构画成了一个清晰的流水线,放在脑中先规划好:

  • 文档接入层:解析PDF、Word、扫描件,按结构切成文本块或图像块
  • 嵌入层:EmbeddingGemma 2将文本块、图像块、图文混合块转换为1280维向量
  • 存储层:本地向量库(我用的FAISS),存储向量和原始内容的索引
  • 检索层:用户查询同样走EmbeddingGemma 2,召回TopK相关片段
  • 生成层:把召回结果拼成上下文,交给本地部署的量化对话模型生成答案

这个链路里最关键也最容易被低估的就是嵌入层。它决定了一篇文档被转成什么样的向量,向量质量差,后面的检索和生成再怎么调优都是白搭。


2. EmbeddingGemma 2核心细节拆解:740M里装了什么

2.1 模型基础参数与架构要点

先说一组我实测确认的关键参数:

参数项数值
参数量约740M(7.4亿)
输出向量维度1280维
支持模态文本、图像
文本上下文长度实测支持到4096以上
语言覆盖中、英、日、法、德、西等主流语言
BF16权重体积约1.5GB
INT8量化后体积约800MB
推荐运行方式GPU显存≥6GB,或纯CPU 16GB内存

这个1280维的向量维度需要在配置向量库时特别注意。市面上很多老项目用的是768维或者1024维的向量库,如果你之前用的向量库索引已经按旧维度建好,切换模型时必须重建索引,否则直接报维度不匹配。我后面会讲这个坑。

架构上它走的还是encoder路线:文本走tokenizer编码,图像走视觉塔编码,之后拼接起来送入主干网络,最终输出一个固定维度的向量。这样的好处是文本和图像被压到同一个向量空间,查询和文档可以直接算相似度。

2.2 文本和图像怎么进同一个向量空间

这是多模态嵌入的核心机制。

文本侧并不特殊,和普通文本嵌入模型一样,tokenizer把句子拆成token序列,过embedding层和Transformer层,得到每个token的向量表示。

图像侧则多了一个视觉编码器。输入图片会被缩放成固定尺寸,比如224x224或者更高分辨率,切成patch后变成patch序列,视觉塔把这些patch编码成一组视觉特征向量。之后视觉特征与文本特征在序列维度上拼接,共同经过模型主干,让文本token和图像patch在每一层都能互相做attention。这就实现了跨模态语义对齐。最后取特定位置的输出作为整个输入的嵌入向量。

生活化类比:文本和图像在模型内部的关系,很像同声传译里译员同时接收中英文两个声道,在脑子里做对齐理解后再输出统一语义。这里的“统一语义”就是1280维向量。

需要强调的是,这个模型不是把图像单独编码成一个向量然后和文本向量拼接,而是让图像和文本在同一个前向过程里互相调节。同一张架构图,单独看是一种理解,加上旁边的说明文字后,注意力机制会把图像patch和说明文字关联起来,最终得到的向量里已经融合了两边的信息。这正是多模态RAG需要的:查询“流程图里订单审核超时的处理分支”时,模型能理解这个查询指向的是一张流程图里的某个分支区域,而不是简单匹配图的文件名或标题。

2.3 输出层与相似度计算方式

嵌入模型输出的1280维向量并不是直接拿原始输出做相似度。官方推荐的做法是L2归一化后,用余弦相似度或者点积检索。

归一化这一步有几个实际影响。第一,归一化后向量模长变为1,点积等价于余弦相似度,所以FAISS里可以直接用内积索引IndexFlatIP。第二,归一化能一定程度弱化文本长度对相似度的影响,避免长文本因为累加出更大模长而天然获得更高得分。建议把所有入库向量都做L2归一化。

我用一个快速验证来确认输出质量:拿三句话做相似度对比——A句“申请退款需要提供订单编号和支付凭证”,B句“退款时请备好订单号和付款证明”,C句“今天天气不错适合爬山”。A和B计算余弦相似度大概在0.85左右,A和C在0.35左右。这个区分度足够支撑TopK召回了。

2.4 部署资格与引入时的合规注意

离线部署不意味着可以随意分发模型文件。EmbeddingGemma 2使用特定的模型许可协议,商用或者大规模内部使用前,需要确认授权类型。务必检查你获取模型时的原始网站上的许可说明。特别是如果你打算把模型量化后嵌入到自研产品里分发,更要逐条对照协议。很多团队在这里草率,后面吃了法律风险,完全不值得。


3. 动手搭建离线多模态RAG:从环境准备到检索闭环

3.1 硬件与依赖环境准备

先列一下我实际跑通的硬件配置,作为参考基线:

  • CPU:8核16线程
  • 内存:32GB
  • GPU:NVIDIA显卡,显存8GB(可选但强烈推荐)
  • 硬盘:NVMe SSD,剩余空间20GB以上
  • 系统:Ubuntu 22.04,Python 3.10

如果只有CPU,也不代表不能跑,后面我会讲CPU模式下的优化方法。推荐优先有GPU,主要因为嵌入一批文档的时间差距太大。

Python依赖方面,核心就这几个库:

pip install transformers torch sentencepiece Pillow accelerate pip install faiss-cpu

FAISS这里装CPU版就够了。GPU版本虽然能用IndexFlatIP跑得更快,但在离线场景下CPU版稳定性更好,而且几千甚至几十万条向量的检索,CPU完全能扛。

我建议在虚拟环境里安装,避免污染系统Python。用python -m venv venv建一个干净环境,这步别偷懒。

3.2 离线准备模型文件与本地加载

这一步是整个离线部署的关键。在能访问官方模型仓库的机器上,用下载工具把模型目录整体拉下来,然后传到离线机器上。注意需要下载的不只是权重文件,还包括配置文件、tokenizer词典、图像处理器配置等,缺一不可。最稳妥的做法是直接同步整个模型文件夹。

离线机器上确认路径结构完整:

/data/models/embeddinggemma-2-multimodal/ ├── config.json ├── model.safetensors ├── tokenizer.json ├── tokenizer_config.json ├── preprocessor_config.json └── 其他依赖文件

加载模型时,因为该模型的自定义实现代码,加载时通常需要加上trust_remote_code=True。这意味着transformers会执行模型目录里附带的Python代码。离线环境务必确保代码完整,否则加载会失败。

实际加载代码很简单:

import torch from transformers import AutoModel, AutoProcessor MODEL_PATH = "/data/models/embeddinggemma-2-multimodal" model = AutoModel.from_pretrained( MODEL_PATH, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="auto" ).eval() processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=True)

这里的device_map="auto"是让模型自动加载到GPU,如果GPU显存不够会自动把部分层放到内存。实测下来8G显存跑这个模型很宽松。

有一个细节:加载时如果提示缺少某个计算算子,确认一下PyTorch版本是否太老。我用的是torch 2.2以上版本,低版本对一些新算子支持不全,容易在推理时报错。

3.3 文本和图像的嵌入生成实操

嵌入生成分为三种输入形态:纯文本、纯图像、图文混合。

先说纯文本嵌入。把文本块列表一次性交给处理器,设置padding和truncation,批量生成:

texts = [ "申请退款需要提供订单编号和支付凭证", "异常订单需先由客服标记,再进入人工审核", "流程图:退货申请 -> 库存校验 -> 退款" ] batch = processor( text=texts, padding=True, truncation=True, max_length=512, return_tensors="pt" ).to(model.device) with torch.no_grad(): outputs = model(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"]) # 取序列首位的向量作为整句表示,并做L2归一化 embeddings = outputs.last_hidden_state[:, 0] embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)

这里取首位token的向量是常规做法。如果你拿到模型输出后发现效果不理想,也可以尝试对最后几层做平均池化,效果可能略好一点,但会增加一些计算量。

图像嵌入也走同一个处理器:

from PIL import Image image = Image.open("/data/raw_images/流程图.png").convert("RGB") batch = processor( text="", # 文本可为空 images=image, return_tensors="pt" ).to(model.device) with torch.no_grad(): outputs = model(pixel_values=batch["pixel_values"]) image_emb = outputs.last_hidden_state[:, 0] image_emb = torch.nn.functional.normalize(image_emb, p=2, dim=1)

图文混合的情况更常见。比如PDF的某一页里既有文字说明又有配图,你可以把页面的文字内容单独提取后,和配图路径一起作为一条记录处理。如果想让模型充分理解图文关系,就把这段文字和这张图同时传入,生成一个融合向量,这样检索时这段图文联合体被视为一个整体段落:

batch = processor( text="流程说明文档,包含退货流程和审批节点信息", images=image, return_tensors="pt" ).to(model.device) with torch.no_grad(): outputs = model( input_ids=batch["input_ids"], attention_mask=batch["attention_mask"], pixel_values=batch["pixel_values"] ) joint_emb = outputs.last_hidden_state[:, 0] joint_emb = torch.nn.functional.normalize(joint_emb, p=2, dim=1)

实测下来,图文混合嵌入口罩的检索准确度比分开嵌入后拼接向量高一大截。因为attention机制能让文本token直接“看到”图像patch,语义对齐发生在模型内部,而不是靠外部简单拼接。

3.4 向量存储与TopK检索

向量入库我用FAISS。先建索引,再逐批加入向量。注意向量维度必须写对:1280。维度写错是离线RAG最常见的崩溃原因,没有之一。

import faiss import numpy as np embedding_dim = 1280 index = faiss.IndexFlatIP(embedding_dim) # 内积索引,配合L2归一化后等价余弦相似度 # 假设所有文档向量已经统一归一化 all_embeddings_np = np.vstack(doc_emb_list).astype("float32") index.add(all_embeddings_np) # 保存到本地,便于重启复用 faiss.write_index(index, "/data/index/faiss_index.bin")

检索时,把用户查询向量也归一化,然后调用搜索:

query_emb_np = query_emb.detach().cpu().numpy().reshape(1, -1).astype("float32") scores, ids = index.search(query_emb_np, k=5) for rank, (score, doc_id) in enumerate(zip(scores[0], ids[0])): print(f"Top{rank+1}: score={score:.4f}, doc_id={doc_id}, content={docs[doc_id][:80]}...")

这里补充一个经验:向量入库后不要把FAISS索引文件放到机械硬盘上,加载速度和检索速度都会慢到让你怀疑人生。SSD是底线。

索引文件要定期备份,离线环境里重新灌库非常消耗时间,尤其文档量达到几十万级别时,备份索引比备份原始向量文件省事得多。

3.5 接上本地生成模型完成RAG闭环

检索只完成了一半。用户要的是答案,不是检索结果片段,所以还需要一个本地部署的对话模型把召回内容整合成答案。

我这边用的是量化后的对话模型,通过Ollama框架调用。整个流程写成一个函数:

def answer(query_text): query_emb = embed_text(query_text) scores, ids = index.search(query_emb, k=4) context_parts = [] for doc_id in ids[0]: doc = docs[doc_id] if doc.meta_type == "image": context_parts.append(f"[图片] {doc.caption}") else: context_parts.append(doc.content) context = "\n\n".join(context_parts) messages = [ {"role": "system", "content": "你是一个企业内部知识库助手,请严格根据给定资料回答,不要编造。资料中未提及的内容,请明确说明没有找到。"}, {"role": "user", "content": f"资料:\n{context}\n\n问题:{query_text}"} ] # 本地对话模型的调用接口,不同部署方式路径不同 response = ollama_chat(messages=messages) return response

这里有几个工程细节需要注意。第一,召回结果里如果包含图像块,直接把图片二进制塞给对话模型是行不通的——除非你的生成模型本身支持视觉输入。常规做法是把图像块的说明文字或者OCR结果作为检索上下文的替代文本,或者在最终展示界面单独返回图像路径。第二,Prompt里要求模型严格基于资料回答,能显著降低幻觉,这个系统提示词不要省。

跑完整链路后,我用三类测试查询做验证:

查询类型示例结果
纯文本查询“退款需要哪些材料”精准召回对应文档段落
图像查询上传一张退货流程图截图成功召回相似图中对应说明
图文混查“审核超时后如何处理”召回融合了说明文字与应用场景图的段落

这个效果是纯文本方案做不到的。


4. 常见问题与排查技巧实录

4.1 向量维度不一致导致检索失效

这是搭建过程中出现频率最高的问题。

现象:首次入库用768维向量建索引,换成EmbeddingGemma 2后忘记重建索引,直接对新查询向量调用index.search,FAISS报错维度不匹配:IndexFlatIP::search requires train vectors to be of dimension 768, query vector has dimension 1280。

解决方式很简单:重新创建索引,灌入新维度向量。但如果你用的是自定义索引,修改维度可能涉及分片配置,遇到这类情况最简单粗暴的方式就是全量重建。

这里额外提醒一点:如果你在同一个内网环境里维护多个RAG项目,不同项目用的嵌入模型可能输出不同维度。建议索引命名时把维度和模型版本写进去,比如faiss_index_embgemma2_1280.bin,避免混用。

4.2 离线加载模型时报错缺少tokenizer文件

离线机器上第一次加载时,出现了tokenizer_config.json缺失的错误,因为我只拷贝了safetensors权重文件和一个没拷全的配置文件。

判断是否齐全的方法:对比一站式模型目录里的文件列表,确保每个配置文件都完整。最保险的做法是整目录打包传输,不要只挑选“看起来重要”的文件。模型加载器会严格查找全部依赖文件,缺一个就启动失败。

如果已经拷到离线环境里发现缺失,解决办法只能回到有网的机器重新补齐文件再传一次。因此我强烈建议在传输之前先在本地跑一次AutoProcessor.from_pretrained做检查,能成功加载再打包。

4.3 批量嵌入文档时GPU显存溢出

在处理一批高分辨率扫描件时,显存一度拉满导致OOM。原因是图像输入没有统一缩放,部分扫描件分辨率过高,patch数量爆炸,batch里单个样本就占了大量显存。

解决方法有三级。第一级,统一预处理输入图片,批量缩放至固定长边,比如最长边设为512像素,同时保持宽高比,这一步能显著压缩显存占用。第二级,调低batch size,从32降到8,甚至降到4,稳定优先。第三级,模型加载时加torch_dtype=torch.float16而不是bfloat16,虽然精度稍低一点,但显存占用进一步降低,对检索场景影响微乎其微。

实测下来,三招齐上,8G显存可以稳定批量处理几千张图片不OOM。

4.4 CPU环境下嵌入速度过慢怎么办

纯CPU跑这个模型的体验是:少量测试还行,但给几万份文档做全量嵌入时,等待时间会很难熬。

我的建议按投入产出比排序:能上GPU就上GPU;暂时没有GPU就先用量化版本降低计算量。模型量化为INT8后,在CPU上嵌入速度大约能提升两到三倍,解码质量损失对检索来说基本无感。

另一个实用思路是并行化。提前切分文档列表,用多进程分别加载模型嵌入,各进程独立写一个临时向量文件,最后合并。注意内存够不够,每开一个进程都会占一份模型内存。8核16G的机器,开3个进程比较稳妥,再多容易被内存压垮。

4.5 中文场景下的检索效果调优

EmbeddingGemma 2对中文的支持本身是够的,但原始分块策略会影响效果。如果你直接把一个长段落整块嵌入,向量会包含太多冗余信息,查询的相似度得分被稀释。建议分块时保持每块100到300个汉字,并用重叠窗口保留上下文语义。

另外中文文档经常有全角和半角标点混用,搜索引擎类工具对这类文本做清洗后再分块效果更稳定,清理掉不可见字符和一些特殊换行符。我还在文档块里追加了简单的元信息作为前缀,比如“部门:客服部,文档类型:SOP,标题:退款流程”,这样向量里能带出类别信息,检索召回的准确率有可感知的提升。

4.6 “trust_remote_code”离线下加载失败

离线环境加载该模型必须加trust_remote_code=True,但加了之后仍可能加载失败,原因大多是模型的远程代码文件没有下载完全。下载时只下载了权重,代码文件被漏掉。

对策还是同一个:整目录全量同步。在离线机器的模型目录里确认包含模型的自定义代码实现文件。缺什么就补什么,传输后再次校验目录完整性。

这类问题有一个好处:一旦成功加载一次,文件就不会变了,后续所有环境复用同一套拷贝即可。我在内网三台机器上部署,都是同一份模型目录复制三份,没有出现环境差异问题。


5. 关于这套方案的一些个人体会

几个月的离线多模态RAG跑下来,我最大的感受是:嵌入模型的选择决定系统的上限,而工程细节决定这个上限能不能兑现。EmbeddingGemma 2的740M量级正好卡在了一个很舒服的位置——它不像大模型那样对硬件挑三拣四,但多模态嵌入的能力足够把RAG带去一个更高的起点。

如果后续要继续扩展,我可以给出两个明确的方向。第一是引入重排模型,对召回结果做二次精排,解决TopK候选里语义相近但并非最优的问题。第二是给图像块维护独立的向量索引,查询时文本和图像索引并行检索,最后融合排序,这对纯图像素材特别多的场景提升更明显。

最后分享一个平时容易忽视的小经验:定期用一组固定测试问题回归检索质量,任何一次改版都要重新跑一遍。我曾在调整分块大小时,主观感觉效果好了,实测才发现有几类查询召回的准确率反而下降了。没有固定测试集做对照,你根本不知道改动是变好还是变坏。这套回归集不多,二十条有代表性的查询就够了,但它能帮你省下大量盲目调参的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询