首页 / 资讯中心 / 文章详情

离线RAG落地实践:基于EmbeddingGemma 2的多模态向量检索部署

离线RAG落地实践:基于EmbeddingGemma 2的多模态向量检索部署 ★ FEATURED ARTICLE
1. 为什么是 740M多模态离线 RAG 的选型逻辑1.1 离线环境的四堵墙做离线 RAG 和联网跑 Demo 完全是两码事。联网的时候你可以随手调一个几百亿参数的商用 embedding API丢几张图进去也能拿到向量但一旦环境封闭所有东西都得自己扛这时候选型标准会彻底倒过来。我在做的就是这样一个内部知识库几百份技术文档、产品截图、流程图混在一起网络不通只有一台 32G 内存的服务器和一张消费级显卡还要保证每天有人真的在用它搜东西。这种场景下模型选型首先撞上四堵墙。第一堵是体积墙太大参数的模型权重动辄十几个 GB还要留出加载时的峰值内存离线环境往往带宽有限搬模型本身就是灾难。第二堵是依赖墙很多多模态模型依赖一堆在线下载的预训练权重和 tokenizer 文件离线装完才发现缺一个配置文件就够你折腾半天。第三堵是部署墙模型不能只跑通一次要稳定提供服务推理框架得能常驻内存、低延迟响应。第四堵是精度墙离线环境下通常没有二次微调条件开箱即用的零样本效果必须足够好。在这四堵墙的夹击下EmbeddingGemma 2 这个 740M 的模型就变得很有吸引力。权重体积小FP16 精度下 1.5GB 左右加上依赖包也能塞进一个相对干净的镜像能力上又是正经的多模态嵌入模型文本和图像能进同一个向量空间这对我的混合资料集来说价值很大。选型结论说白了就是一句话在离线的资源约束下要找的是体面够用的模型而不是账面最强的模型。1.2 和纯文本嵌入、CLIP 类模型的对比我在定方案之前其实先试过两条路。一条是用纯文本嵌入模型比如常见的 300M 到 500M 级别的文本向量模型配合 OCR 把图片里的文字抠出来再索引。这条路的优点是生态成熟、部署资料多但问题很快暴露色块图、流程图、界面截图里大量信息靠的是空间位置关系而不是文字本身OCR 抠出来的文本顺序经常是乱的检索时语义对不上。另一条路是直接用 CLIP 风格的图像-文本对比模型。这类模型做图文匹配确实很强但它的训练目标决定了它更擅长这张图和这段描述对不对得上对于长文档、长句子的细腻语义理解偏弱。比如拿一句用户登录失败三次之后应该看到什么提示去检索CLIP 类模型经常会召回一堆外观相似但语义不相关的截图。而 EmbeddingGemma 2 给我的感觉是它在文本侧保留了生成式语言模型的语义理解底子在图像侧又做了对齐映射所以两头都不算偏科。展开来说这是两类模型训练目标的本质差别。CLIP 类模型做的是 contrastive 对齐拉近的是整体图和整体文的距离而嵌入模型的训练里往往加入了更细粒度的文本语义损失对关键词命中但语境不同的情况会更敏感。对于 RAG 场景来说我们需要的是找得准、找得全而不是看得像后者的训练目标明显更匹配。1.3 740M 的参数甜点位为什么不是 100M 级也不是 3B 级我个人的判断是100M 级的多模态模型在复杂语义理解上明显吃力embedding 维度通常也压得比较低索引做大了之后表达能力跟不少而 3B 级模型虽然语义更强但在 CPU 上跑推理延迟会很难看即使是 GPU 也得考虑显存占用和批处理吞吐。740M 刚好卡在一个实操上很舒服的位置。FP16 权重 1.5GBINT8 量化之后不到 800MBINT4 更是能压到 400MB 左右。这个体积意味着即使是纯 CPU 环境加载进内存也就 2 到 3GB 的占用完全可以和检索索引、生成模型共存一台机器。在实际测试中我用 FP16 在 GPU 上跑单条文本嵌入大概 20ms 级别图像嵌入 30ms 级别CPU 上则分别是 200ms 和 300ms 左右。这个量级对绝大多数内部知识库场景都够用——毕竟不是每秒钟都在并发嵌入新内容真正的在线压力都在查询侧而查询侧每次只需要嵌入一条 query。还有一个容易忽略的点模型参数量决定了 embedding 维度的天花板。740M 级别模型的输出维度通常在 1024 左右这个维度做 10 万级别文档块的索引用扁平索引都能保持不错的分化和召回如果降到 384 维维度太低区分度就差如果冲到 3B 模型可能输出 2048 维甚至更高索引和检索开销又上来了。所以 740M 1024 维是工程上很平衡的组合。2. EmbeddingGemma 2 的向量设计文本和图像如何共用一张表2.1 双塔结构加对齐层多模态嵌入模型的主流做法是双塔结构文本塔处理 token 序列视觉塔处理像素块两个塔各自产出特征再通过一个对齐层映射到同一个向量空间。EmbeddingGemma 2 走的也是这个路子文本侧保留 Gemma 系列的 decoder 风格语义编码能力视觉侧用标准的 ViT 风格结构提取图像特征最后两侧的特征被投影到同一个 1024 维空间。理解这个结构对使用方式影响很大。我在第一次接入的时候下意识以为图像和文本的向量可以直接相加或者拼接试了之后发现效果很怪。原因在于双塔结构训练时是让同一语义下的图文向量靠近而不是让向量各分量之间有明确的对应关系。正确的用法是各自产出向量、各自归一化然后放到同一个检索空间里去比较。向量空间里相邻代表语义相近但不代表某个维度的具体含义可解释。简化理解的话可以把向量空间当成一个翻译好的仓库索引文本被翻译成 1024 个数字的编号规则图片也被翻译成同一套编号规则。不同塔负责翻译各自的输入但最终都落在这套编号规则里。所以检索的时候就可以拿文本向量去查图片也可以拿图片向量去查文本这是多模态 RAG 比纯文本 RAG 多出来的核心能力。2.2 输出向量的使用规范拿到模型输出之后第一步不是急着存进索引而是检查两件事。第一件事是确认输出向量是否已经做过归一化。从我查到的模型卡片和社区反馈来看EmbeddingGemma 2 的原始输出层后面通常建议使用者再手动做一次 L2 归一化。归一化前后的差别在计算余弦相似度时体现得最明显——如果两边都归一化了内积就等于余弦相似度分数范围固定阈值设置才稳定。第二件事是确认 embedding 是从正确的网络层取出来的。很多嵌入模型在使用时不能用最后一层分类头的输出要用倒数第二层或者特定的 pooler 输出。这个细节虽然看起来小但影响巨大。我用一个简单的文本对测试过用最后隐藏层做平均池化得到的向量和用模型默认输出层得到的向量在同一批文档上的检索 top5 结果重叠率只有六成多。所以在接模型之前务必先用官方示例或模型说明确认输出层位置不要在推理框架里随手取一个输出就开跑。另外要注意归一化应该在 batch 维度逐行做。我的代码里会写vec vec / np.linalg.norm(vec, axis-1, keepdimsTrue)而不是直接按整个矩阵的标量范数归一。这个低级错误偶尔会出现在网上某些例子里会导致相似度分数全部失真检索结果完全不可用。2.3 相似度分数的真实含义多模态嵌入模型的相似度分数和纯文本模型还不太一样。纯文本模型通常是在同模态数据上训练的分数分布相对稳定多模态模型做的是图文对齐分数分布天然会比文本对文本更分散一些因为图像特征和文本特征之间本来就存在模态鸿沟。实操中我的经验是不要拿着分数绝对值去卡硬阈值比如只有分数大于 0.7 才算命中。我跑了一批真实查询文本到文本的相似度中位数大约在 0.55 左右文本到图像的中位数在 0.48 左右但偶尔有非常准的匹配也只有 0.5。这说明分数绝对值会因为模态差异产生系统性偏移。更合理的做法是固定召回数量比如统一 top20再结合后续的重排逻辑决定最终给生成模型喂哪些内容。这也解释了为什么很多 RAG 框架里的相关性分数只能做排序依据不能做绝对可信度。如果产品上需要给用户展示置信度我建议先收集一批人工标注数据把分数映射成百分位或者等级而不是直接把原始相似度暴露给业务层。3. 离线部署实操从模型文件到可调用的本地服务3.1 模型文件离线获取与目录规划离线部署的第一步是在联网环境中把模型完整地拉下来包括权重、配置、tokenizer、甚至预处理所需的归一化参数然后整体打包内网分发。一个常见的坑是只下载了权重文件等到离线环境一加载才发现缺了处理器配置文件而有的推理框架在文件缺失时不会报具体文件名只会给一个含糊的解析错误。我在实际操作中会把模型目录整理成这样embeddinggemma2/ ├── config.json ├── tokenizer.json ├── tokenizer_config.json ├── special_tokens_map.json ├── preprocessor_config.json ├── model.safetensors ├── vision_encoder/ │ ├── config.json │ └── vision_model.safetensors └── text_encoder/ ├── config.json └── text_model.safetensors这个布局不是为了美观而是为了后续切换到不同后端时能快速定位文件。比如模型目录里同时存在原版权重和转换好的 ONNX 文件时一定要用清晰的子目录区分不要混放在一起。我见过同事把 ONNX 模型和 safetensors 放在同一层目录结果推理框架自动加载时优先找到了错误的文件折腾了两个小时。下载阶段的另一个建议是记录每个文件的 SHA256 校验和内网传输完成后先做一次校验再开始部署。离线环境里一旦模型文件损坏修复成本极高因为要重新走一遍外网到内网的传输链路这个时间成本在排障时很容易被低估。3.2 推理后端与量化档位模型文件到手之后需要选择推理后端。我在同一种权重上试过三种方案直接用 Python 深度学习框架加载、导出到 ONNX Runtime 跑、还有用 llama.cpp 生态跑 GGUF 量化版。三者的取舍区别很明显。直接用深度学习框架最省事环境里装好对应版本的库几行代码就能加载。但缺点是启动慢、内存开销大而且依赖版本一旦和系统其他组件冲突排障非常痛苦。ONNX Runtime 的好处是轻量、启动快、CPU 和 GPU 都能跑而且可以固定一个独立的动态库很适合服务化部署。llama.cpp 则是在 CPU 上做量化的首选GGUF 格式对内存压得很低但多模态部分的视觉编码器支持相对不完善我试下来视觉侧推理偶尔会有兼容性问题。量化档位的选择我的建议是如果机器有 GPU 且显存大于 4G直接用 FP16省事效果也好如果纯 CPU 环境用 INT8只有在内存非常紧张时才考虑 INT4。我在测试集上对比过INT8 相对 FP16 的检索 top5 准确率下降约 2% 到 3%在可接受范围内INT4 的下降则会到 8% 左右并且出现过 query 侧和文档侧量化误差叠加导致的明显排序漂移。3.3 最小嵌入接口示例选 ONNX Runtime 的话写完一个最小可用的嵌入接口大概几十行代码。下面这个例子是我实际在用的简化版方便你直接抄框架。要注意的是输入预处理必须和模型训练时保持一致尤其是图像侧的缩放和归一化参数。import numpy as np import onnxruntime as ort from PIL import Image from transformers import AutoTokenizer sess ort.InferenceSession( embeddinggemma2-fp16.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider], ) tokenizer AutoTokenizer.from_pretrained(./embeddinggemma2) def embed_text(text: str) - np.ndarray: enc tokenizer( text, max_length512, truncationTrue, paddingmax_length, return_tensorsnp, ) vec sess.run( None, { input_ids: enc[input_ids], attention_mask: enc[attention_mask], }, )[0][0] vec vec / np.linalg.norm(vec, keepdimsTrue) return vec.astype(np.float32) def embed_image(image_path: str) - np.ndarray: img Image.open(image_path).convert(RGB) # 统一缩放到模型要求的尺寸这里以 336 为例 img img.resize((336, 336), Image.BICUBIC) arr np.asarray(img).astype(np.float32) / 255.0 # 如果模型需要 ImageNet 风格归一化按 preprocessor_config.json 里的 mean/std 处理 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) arr (arr - mean) / std vec sess.run(None, {pixel_values: arr[None, ...]})[0][0] vec vec / np.linalg.norm(vec, keepdimsTrue) return vec.astype(np.float32)务必注意pixel_values的通道顺序是 RGB 还是 BGR不同模型的预处理要求不同必须看它的preprocessor_config.json。我在这上面踩过坑图像向量在 RGB 和 BGR 混用的情况下检索结果会整体变差但又不是完全不可用排查起来非常隐蔽。3.4 批量处理与并发限制嵌入调用从单条跑通到服务化中间还隔着一层批处理和并发控制。我的做法是把模型封装成一个常驻的进程内服务接口接收一个列表内部做动态 batching短文本凑一批长文本单独处理图像和文本分开走两个入口但在检索层统一写入索引。批处理的时候最需要注意的是显存或内存的峰值。ONNX Runtime 在处理批量输入时内存峰值不是线性增长的有时候 batch 从 16 涨到 32显存占用会突然跳到接近三倍。原因可能是输入 padding 导致计算图内部张量膨胀。所以建议在服务启动时做一个基准测试把最大 batch 压测出来然后在接口层做并发排队。我在代码里用一个简单的信号量控制最大同时推理数量这样在突发查询时不会被 OOM 直接打死。另外ONNX Runtime 的 session 创建开销不小但创建之后要复用同一个 session不要在每次请求里重新加载。这个看似基础的点在实际部署里经常被忽略——尤其是在快速写原型代码的时候容易顺手把ort.InferenceSession写在函数内部结果每次调用都在重新初始化模型延迟直接飙到秒级。4. 搭建检索与生成链路PDF、截图一起进索引4.1 多模态文档解析流程部署好嵌入模型只是第一步真正的重头戏在数据侧。我的资料集里 PDF 占了大头里面既有文字层又有表格和截图。最开始我偷懒只把 PDF 的文字层抽出来做了索引结果用户一查带界面的问题就搜不到——因为界面的信息全在截图里。后来我把文档解析流程改成了三层结构。第一层用 PDF 解析库抽文本按标题层级切分成段落块第二层把每一页渲染成 PNG 图片跑一遍 OCR把识别出的文字作为该页图片的附加文本第三层把 PDF 中内嵌的图片对象单独抽取出来连同它在页面中的上下文标题一起构成一个图像块。这三层各有用途。文本段落块走文本编码器渲染页面的缩略图走图像编码器内嵌图片对象也走图像编码器。OCR 文字不单独建索引而是作为附加文本在检索到对应图像块时一起返回给生成模型。这样既保留了图像的视觉信息又能让生成模型通过文字补充理解图像内容效果明显好于只用其中任何一种。4.2 向量索引构建与持久化索引构建本身不复杂但有几个设计决策要提前定。首先是使用 FAISS 的哪个索引类型。数据量在 10 万块以内时我推荐直接用IndexFlatIP——就是暴力精确检索最简单也最稳定。很多人一上来就上 HNSW觉得高级索引更快但 10 万级别的数据量下扁平索引的检索延迟本身就在毫秒级完全没必要牺牲 100% 召回率去换那点速度。其次是必须把文档块和图片块分开管理元数据但放进同一个向量索引。做法是为每个向量记录一个source_type字段检索时统一从同一个索引召回再根据后续重排需要决定是合并还是分开处理。如果一开始就建两个索引检索时就得分别查再合并逻辑上多一层而且跨模态召回的效果会打折扣。import faiss import numpy as np dim 1024 index faiss.IndexFlatIP(dim) # vectors 是 [N, 1024] 已经 L2 归一化的向量 index.add(vectors) faiss.write_index(index, kb_index.faiss)这里还有一个容易被忽略的持久化问题FAISS 的索引文件只保存向量不保存元数据。我建议把每一条向量对应的doc_id、chunk_id、source_type、text_content单独存一份并保证列表顺序和向量加入索引的顺序完全一致。检索拿到indices之后直接用对应的列表位置取元数据。如果中途重新构建过索引而元数据没同步重建检索结果会全部乱套。4.3 召回策略与重排我用的是多路召回 重排的方式。查询进来后先同时跑两条路径一条是纯文本的向量检索一条是如果查询里包含图片直接用图片向量检索。两条路径各自取 top30合并去重后再做一次重排。重排这一步有很多现成的工具但离线环境里不一定能用上需要联网下载的模型。我的做法是退而求其次用字符级和词级的重叠度做轻量重排计算 query 与候选文档的 ROUGE-L 或者直接算连续公共子串的命中比例加权叠加到向量相似度上。这个方法的原理很简单——向量检索引擎擅长捕捉语义相似但在精确匹配面前仍有盲区比如用户输入的 ID 号、版本号等专有名词。叠加词法信号能把这些精确信号的召回准确率提上去。这个轻量重排在离线环境的价值特别大因为很多 RAG 翻车现场不是语义理解不够而是专有名词没被精确匹配到。重排之后的 top5 作为最终上下文再拼接提示词发给生成模型。4.4 和本地生成模型对接生成阶段我直接接了一个本地部署的对话模型上下文窗口足够容纳 top5 的被检索内容。关键点在于多模态 RAG 的生成模型不一定要支持视觉输入因为图像块已经通过 OCR 附加文本和上下文标题转化成了文字信息。真正需要看图的任务比如这张截图里按钮在哪个位置那属于视觉问答的范畴普通文本生成模型处理不了应该在产品设计时就界定好边界。提示词模板上我会把检索到的每个块按来源类型打上标签文本块标注[TEXT_CHUNK]图像块标注[IMAGE_CHUNK]图像块的描述文本作为内容传入。这样生成模型至少知道哪些信息来自图片回答时会更有依据。输出时要求模型在回答末尾列出引用的来源编号方便用户回查原始文档。离线 RAG 的链路到这里就完整了文档解析、向量化、索引、检索、重排、生成每一环都能在本机运行不需要任何外部接口。5. 实测效果与踩坑连续跑了五天之后的记录5.1 三类典型查询的实测结果我给这套系统跑了五天真实查询把效果分成三类。第一类是纯文本查询比如某个服务启动失败后应该检查哪些日志这类查询的表现最好top5 命中率大概在八成以上因为文本嵌入模型对这类语义查得很准。第二类是带图像的查询比如我拿一个报错截图去搜历史文档里有没有类似问题。这个场景下的 top5 命中率大约在六成比纯文本低一些但足够实用。要注意的是用户上传的截图通常分辨率不一致预处理时必须做统一缩放缩放方式对结果影响很大。我测试下来保持宽高比的resize加pad比直接拉伸要好直接拉伸会把细小的报错文字挤变形导致向量偏移。第三类是跨模态语义查询比如登录流程中令牌过期后的处理逻辑答案藏在流程图里。这种查询向量能检索到图块但能不能答好取决于生成模型对流程图文本描述的推理能力。实测中大约一半的情况下能给出正确答案另一半会答得模棱两可需要后续在提示词里强化多文档综合要求。5.2 五个高频坑第一个坑是图像预处理不一致。模型训练时图像的缩放、归一化参数都有定数但不同推理框架的预处理实现不相通。我在 ONNX Runtime 里手动写了预处理就得完全对齐官方配置。后来排查一个检索结果整体不对的问题最后发现是图像的通道顺序写反了而且是全局性的系统性错误不逐一对比根本看不出来。第二个坑是文本截断导致关键信息丢失。代码块、表格转成的文本经常超过 512 token直接截断会把结尾的结论截掉。我的解决办法是切分时优先按代码块边界切切出来还是太长就拆成语义相对完整的多段绝不从中间硬截。第三个坑是量化后 batch 推理报错。ONNX Runtime 的 INT8 模型偶尔在特定 batch size 下会触发未优化的算子报错解决办法是把 batch 固定成测试过的值或者在模型导出时对动态轴做限制。第四个坑是 OCR 对低分辨率截图的表现。有时候原图清晰度不够OCR 识别结果里全是错字检索时把图像块带偏。我加了一层兜底当 OCR 文字平均置信度低于阈值时该图像块只保留视觉向量不让错误 OCR 文本参与重排。第五个坑是内存泄漏。跑了一天后服务占用从 6G 慢慢涨到 12G最终定位到是图像预处理中 PIL 对象没有释放。这是个纯粹的代码层面问题但很隐蔽。建议在服务里加一个简单的内存监控超过阈值就重启进程或者主动清理缓存。5.3 性能参考数字最后给一组我在实际环境里测的参考数字配置是 8 核 CPU、一张 8G 显存的 GPU、32G 内存。FP16 模型加载到显存约 1.6G文本嵌入单条 GPU 约 18 到 25ms图像嵌入约 25 到 40ms纯 CPU 模式下文本嵌入约 180 到 260ms。批量嵌入 2000 个文档块GPU 大概 40 秒跑完。检索阶段10 万块以内用 FlatIP 查询单次时间在 3 到 8ms非常快。整套系统跑起来GPU 显存总占用不到 4G给生成模型留出了充足空间。6. 一点个人体会整个项目做下来我最大的感受是离线 RAG 的瓶颈往往不在模型效果而在工程链路的每个小细节。很多人一开始把精力全放在找最强模型上但实际上嵌入模型决定的是检索上限后续的解析、预处理、索引、重排每一步都在蚕食这个上限。EmbeddingGemma 2 这种 740M 级别的多模态嵌入模型胜在均衡它给足了你串联起一条完整离线链路的可能又不会因为体积和依赖问题把部署成本抬到不可接受。如果让我给后来者一个最实质的建议那就是先别急着做全量索引。拿出一百个有代表性的真实查询先把文档解析、向量化、检索这条最小闭环跑通用肉眼检查检索结果的质量再决定要不要加 OCR、要不要加重排、要不要换量化档位。这套方法让我少走了很多弯路也希望对你手头的离线项目有参考价值。
阅读完成 · 觉得有帮助?
咨询建站