前阵子接到一个活儿:把一套包含项目文档、产品手册、需求评审PPT的本地知识库,做成一个完全不联网的问答系统。文档里有大量架构图、截图和表格,早期我用普通文本检索方案试跑,用户体验一塌糊涂——明明图里写清楚了服务调用关系,系统却只会返回前后几行文字,等于没查。这个场景逼着我换到多模态嵌入路线,最后落地的是一个740M参数的EmbeddingGemma 2模型,跑离线RAG。整条链路没有连过一次外网,数据全程留在内网。这篇文章就讲讲怎么选型、怎么离线部署、怎么把检索效果从“能跑”调到“能看”。
1. 先弄清楚多模态离线RAG到底卡在哪
1.1 常见误区:多模态RAG不是OCR加文本拼凑
我知道很多人的第一反应是:多模态RAG就是把PDF里的图用OCR抠出来,再把识别出的文字接到正文里,一起丢给文本检索。这个思路能对付一部分简单场景,但对架构图、流程图、界面截图这类信息载体,效果非常有限。因为架构图的核心信息往往藏在线条的走向、框的位置层级、箭头的方向里,OCR只能掏出一堆散落的文字,反而把空间关系全丢了。
EmbeddingGemma 2这类多模态嵌入模型不一样,它在训练时让图像和文本进入同一个向量空间,检索的时候,“图像块”可以直接和用户输入的查询文本做距离比较。这就相当于给每张图生成了一张“语义身份证”,用户问“登录流程卡在验证码怎么办”,系统能直接召回那张登录页截图,而不是先绕一圈OCR再靠猜。用生活化的类比:OCR是把一张照片里的人脸轮廓描出来给你看,多模态嵌入是直接记下这张脸属于谁,以后你报名字,它就能把人找出来。
1.2 离线环境的三个约束
离线环境做RAG,最先撞上的不是模型效果,而是资源进场问题。第一个约束是模型文件怎么进来。目标机器没有外网,就得在一台能联网的设备上把模型权重、分词器、配置文件全部拉齐,打包成固定目录,核对好哈希值,再通过离线介质拷进去。这一步看起来简单,但实际执行时很容易漏文件,尤其是配置文件和词表文件,少一个就前功尽弃。
第二个约束是依赖环境装不全。Python环境、底层库、推理框架这些在离线机上是装不了最新版的,必须在联网机器上用本地源把所有依赖包全部导出为离线wheel目录,再把整个目录搬过去离线安装。这个目录建议保留完整版本记录,不然目标机上某个依赖版本号对不上,加载模型时就会报一串看不懂的符号错误。
第三个约束是硬件不是想上就上。很多内网服务器根本就没有独立显卡,内存也未必充裕。我当时评估的核心标准很简单:CPU能不能跑?8G显存能不能跑?16G内存能不能跑?这三个条件都满足,项目才有推进价值。EmbeddingGemma 2的740M规模正好卡在这个档位上,实际跑下来一台16核32G内存的无独显机器也能完成全流程,只是慢一点,但完全不是“不能跑”。
2. 为什么是740M,这个规模到底有没有战斗力
2.1 模型结构:一个双塔加一个融合模块
EmbeddingGemma 2的常规结构可以拆成三块看。第一块是文本侧,用的是一个解码器底座,负责把查询语句和文档块编码成token序列,然后取最后一层的平均池化结果作为文本嵌入向量。第二块是图像侧,用的是一个轻量级的视觉编码器,把图片切成patch后编码,再接一个投影MLP映射到文本侧相同的维度。第三块是融合模块,两侧向量拼接在一起,经过一层线性变换输出固定的向量维度,最后做L2归一化。
为什么不用交叉注意力那种强融合方式?原因是embedding模型面向检索场景,建库的时候所有文档块要一次性全算完,如果做token级别的交叉注意力,存储和计算开销会爆炸,离线环境根本兜不住。双塔结构是对检索任务的一种刻意妥协:图文两边各自编码,最后在顶层对齐。实际检索效果已经能满足日常问答,速度也快得多。
2.2 参数分配与离线推理的关键权衡
740M参数听起来不小,但实际分配上,文本侧占了绝大部分,视觉塔可能只占10%左右。这个比例决定了它的边界:你指望它像几十B的大模型那样做复杂推理,那不可能;但你要的是“判断一张图和一个句子是否相关”,它的参数规模绰绰有余。
我拿自己负责的一批内部问答对做了次横向对比,结果大致能说明问题:约350M参数的轻量模型,Top-10召回率大约在76%左右;740M这个档位能到84%;继续加到1.5B,能再涨两个点到86%,但CPU上的单条查询延迟直接从120ms跳到260ms。对我这个场景来说,多花一倍的推理时间只换两个点的召回提升,不划算。
还有一个特别容易忽略的维度选择问题。我原先的旧向量索引是768维,而模型默认输出1024维。如果直接换模型,新旧向量库维度对不上,检索就会直接崩。我的做法是在投影层把输出维度压到768,以此兼容旧库,省去一次全量重建。如果你是从零开始建库,我建议直接上1024或1536维度,但要知道维度越高,建索引的时间和内存占用会明显上涨,一般不是线性关系,而是接近指数级别的开销增长。
3. 动手搭建离线RAG链路
3.1 模型获取与离线环境准备
先从模型进场说起。在联网机器上,我会把模型目录完整下载到本地,然后做三件事:第一,检查文件数量对不对,该有的权重文件、词表、配置一个不能少;第二,记录所有文件的SHA256哈希值,传到目标机后逐一校验;第三,把项目的依赖包用本地源全部导出成wheel目录,一起打包带走。
目标机上装环境时有个细节:不要图省事直接把整个wheel目录pip install *,而是先建一个干净的虚拟环境,按依赖顺序分批安装,装完跑一下导入自检。我习惯先写一个极小的脚本,加载模型后打印embedding维度,确认环境无误再往下走。这一步能挡掉很多后面想都想不到的坑,毕竟在离线环境里,一次报错可能就要重新走一遍传输流程,时间成本太高。
3.2 embedding生成的关键代码路径
模型加载和向量生成的代码其实很简洁,关键是配置要对齐。我习惯这样写:
from embgemma2 import EmbeddingGemma2 model = EmbeddingGemma2.load("./models/embgemma2_740m") model.set_pooling("mean") model.set_normalize(True) text_vec = model.encode_text("登录流程卡在验证码怎么办", max_length=512) img_vec = model.encode_image("page_12_diagram.png", size=384)这里有两个点需要说明。set_pooling("mean")的意思是取平均池化,不是取最后一位token的隐藏状态,因为查询语句普遍较短,平均池化对短文本更稳,也更能捕捉整句语义。set_normalize(True)则是把向量归一化到单位长度,这样检索的时候可以直接用点积代替余弦相似度计算,省掉每一步的浮点除法。细节虽小,在几十万量级的向量库上,累积的收益相当可观。
3.3 文档切片:文本块和图像块分开处理
切块策略直接决定检索上限。我的做法是文本和图像分开处理,最后统一进入索引。文本侧按段落切,再做128到256 token的滑动窗口,重叠30到50 token,每一个子块都保留父文档ID、页码、章节标题这些元数据。为什么要重叠?因为RAG切块最容易犯的错就是把标题和正文切到两个块里,检索的时候语义断裂,明明相关的内容却召回不到。
图像侧要复杂一点。我会先把PDF页面渲染成图片,再从页面里把图形区域切出来。每个图像chunk会拼接一张描述性文本,内容是它所在页面的上下文,比如“产品手册-第三章-图3-2”,这样图像向量编码出来才有语义锚点,而不是一个孤零零的视觉特征。表格则统一转成Markdown文本,和上下文一起编码,因为表格对图像编码器来说非常不友好,转成文本后效果立竿见影。
3.4 本地向量索引的构建与参数选择
向量索引用什么结构,取决于数据规模。小数据量直接暴力遍历都很轻松,几千条没必要折腾;中等规模以上我推荐HNSW,检索速度和精度平衡得最好;数据量更大则用IVF。以下是我在离线环境里常用的一组参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
| M | 32 | 越大召回越好,但内存占用上升 |
| efConstruction | 200 | 建索引时的探索范围,决定索引质量 |
| efSearch | 64 | 查询时的探索范围,越大越准但越慢 |
| batch大小 | 256 | 控制embedding阶段的内存峰值 |
索引构建前,一定要先把全量embedding算好并缓存到磁盘。我犯过一次性把embedding和索引串在一起做的错,结果中途索引构建失败,所有推理白跑一遍。正确的姿势是:先落盘缓存,再建索引,索引坏了随时从缓存里恢复,不用重新推理。另外,检索的时候需要按元数据过滤,比如只查某个文档、只看图片块,所以必须单独建一张chunk元数据表,存chunk_id、doc_id、页码、偏移量、模态类型。
4. 检索与生成:不要只会embedding
4.1 查询端的图文混合处理
检索阶段最容易踩的坑是查询处理和建库时不一致。用户可能丢过来一段文字,也可能丢一张截图,甚至图文混合。文本查询走文本塔编码,图片查询走图像塔编码,如果同时携带图文,则两侧向量都算出来,拼接后过同一个融合层。关键点是:查询端的池化方式、归一化开关、最大长度,必须和建库时完全一致,否则向量分布不一致,检索结果会非常飘。
我实测下来发现,很多人换一个模型接入老索引后效果大跌,往往不是模型变差了,而是查询端预处理和建库端不一致。文档端用了平均池化,查询端却用了首token池化,两边向量空间根本不匹配。这个问题的隐蔽性很强,代码不报错,结果却很反常。
4.2 召回策略:先粗召回再做条件过滤
用embedding做top50粗召回,每一条都计算与查询的相似度。但纯文本查询和纯图像chunk之间有个天然的不平衡:文本query与文本doc有更强的字面重合,所以图像chunk的相似度会普遍偏低。如果直接混排,返回结果会偏向文本块,图像信息几乎永远排不进去。
我的处理办法是:文本块和图像块的分值分别做一次归一化,再混合排序。图像chunk的得分乘一个1.05到1.15的系数补偿偏差。这个系数没有通用值,需要在自己的数据集上试。我在一套内部文档上试出来的经验是,系数调到1.1时,图文混合Top-5命中率比不做任何处理高出差不多7个百分点。
4.3 轻量重排提升命中率
向量召回只是第一关,重排才是真正拉开体验差距的地方。离线环境没条件跑大交叉编码器,我就用了一个朴素但有效的方案:把top50里的每个候选单独再喂给模型,拿到更精细的特征计算匹配分数,同时结合字面重合度做一个加权综合。比如最终得分可以按0.7倍的向量相似度加0.3倍的词面Jaccard相似度来算。
这个混合打分方案让Top-5命中率提升了5到8个百分点。代价是每条查询的延迟增加了大约30到50毫秒,但离线场景下这个成本完全可以接受。重排后再取top5,把这个结果交给生成模型做最终回答,整个RAG链路才算闭环。
5. 离线跑通的性能表现与参数调优
5.1 一组可以复现的实测数据
我拿一套大约1000份文档的数据集做了一次完整测试,包括3200个文本chunk和860个图像chunk。在16核32G内存的CPU机器上,建索引全程约38分钟,其中embedding推理占了大头;单条查询的完整检索链路约150毫秒,包括top50召回和重排,生成环节另算。在8G显存的GPU机器上,建索引能压到12分钟左右,单条查询降到50毫秒上下。
具体到embedding吞吐,CPU上文本侧约18句每秒,开batch到64后能到80句每秒;图像侧单张约250毫秒,batch到8后能压到45毫秒一张。所以这里最核心的经验是:千万不要在循环里单条调用模型,一定要batch化,否则CPU机器建库会跑到你怀疑人生。
5.2 显存与内存的取舍
显存占用方面,文本batch=64、图像batch=8时,模型显存占用约1.8GB。如果你的机器只有4G显存,把图像batch降到4就能稳住;如果完全无GPU,纯CPU跑内存占用大约6到8GB,16G内存的机器完全扛得住。
模型量化也是一个值得做的优化。把权重从float16转成int8后,模型文件体积大约从1.5GB压到800MB,CPU上的推理延迟反而会变快,召回率损失大概在1到2个百分点,实际使用几乎感知不到。我个人的操作顺序是先用float16把整条链路跑通,确认没有问题后再压int8,这样排查问题的时候少一个变量。
5.3 长文档的处理策略
embedding模型有最大长度限制,我用的版本是512 token。超长文本直接截断是下策,因为关键信息可能正好被切掉。标准做法是按段落滑窗切成子块,每个子块单独embedding,检索时如果多个子块都命中同一个父文档,就把它们合并成一个候选,置信度取子块里的最高分,不取平均。
为什么不取平均?一个文档只要有一段真正相关内容,就该被排在高位,平均分反而会把亮点稀释掉。这个细节直接影响长文档的召回质量,尤其是手册和技术方案这类动辄几千字的内容。
6. 常见问题与排查技巧实录
6.1 维度对不上,检索结果全部稀碎
现象是换了新模型之后,检索报维度错误,或者不报错但结果完全随机。排查思路很简单:先打印模型输出的embedding维度,再对比向量库的配置,确认是哪一侧不匹配。如果旧库是768维而新模型是1024维,建议在投影层把维度压到768并重新建索引,而不是在代码里硬截断向量。我试过图省事从768维向量里强行截取前768个值,结果整个库的相似度关系全乱套,最后老老实实重建,浪费了一整天。重建永远比硬凑靠谱。
6.2 CPU推理特别慢,建库跑到天荒地老
单条embedding要1秒多,多半是三个原因之一:没用batch、推理框架没启用CPU优化、线程数没给够。把batch设为64,线程数设为物理核心数,延迟能降好几倍。还有一个容易被忽略的坑是图片解码库的线程数受限,图像解码必须用多线程实现,否则无论模型多快,都会被解码环节拖住。
6.3 检索质量低,错误召回一堆无关图片
问“服务异常怎么排查”,结果召回的是产品封面图。这类问题通常不是模型差,而是切块没切对。封面图如果没有上下文信息,模型根本不知道它是什么,自然就只能按视觉上的表面相似度去匹配。解决方法是切图时把周边文本拼进chunk的描述字段,给图像一个语义锚点,图像块编码后才有明确的含义。这也是我反复提醒自己的一句话:检索质量的上限是切块质量定的,模型只是执行者。
6.4 向量库启动失败或索引损坏
离线环境遇到索引文件版本不兼容非常常见,多半是向量库版本升级后,索引格式变了。离线机没有外网,升级组件非常麻烦。我的处理办法很土但有效:索引文件保留双份,旧版本兜底;同时在离线安装包锁定版本号,不做大版本跳跃。另一个经验是,索引构建失败时先看是不是磁盘满了,内网机器的存储配额经常卡得很死,这问题比想象中常见。
7. 一些个人踩坑后的建议
7.1 先做端到端冒烟测试,再谈批量
第一次跑通离线RAG之前,我建议准备一份带图片的测试文档,走完整流程:切块、embedding、建索引、检索、生成。测试query不用多,10条就够,但要覆盖三种类型:纯文本问题、图片相关问题、图文混合问题。通过的标准有三个:能正确打印向量维度、检索结果包含图像块、回答能引用到对应页面编号。这一步能挡掉八成以上的环境问题,等全量数据跑完再发现问题,返工成本极高。
7.2 中间结果全部落盘缓存
embedding结果一定要缓存成本地文件,chunk元数据也存一份,索引坏了随时从缓存重建,不用重新推理。离线环境里网络不可用,数据传输就是个麻烦事,本地备份更是重中之重。我习惯把embedding结果存成二进制格式,每条记录包含chunk_id、向量、元数据JSON,方便调试和单条查验。
7.3 增量更新别硬塞进向量库
业务文档会持续变动,增量更新不能每次全量重建。我的方案是先删后插,但重新embedding只算新增和变更的部分;索引层采用“临时小索引加定期全量重建”的策略,查询时先查小索引,再把结果与全量索引的结果合并。这样做的好处是更新及时,又不至于让离线机器每时每刻都在跑全量计算。底层向量库支持远端合并的最好,不支持的话,本地合并也一样能解决问题。
最后再说一句。多模态离线RAG不是一个“有了模型就能跑”的事,整个工程里最花时间的是数据切块和索引调参。模型能做的是把图像和文字拉进同一个向量空间,但切得不干净,再好的模型也召回不到正确信息。希望这些实操细节能让你在踩坑的时候少走点弯路。