跑离线 RAG 这件事最容易被低估的其实是嵌入模型。很多人搭检索链路先盯生成模型随便拿一个通用向量接口顶上结果文档切了一堆、索引也建了一查出来全是噪音。等想换成多模态场景发现原来的嵌入模型根本不读图整个架构又得推翻重来。最近我把一个小体量的多模态嵌入模型 EmbeddingGemma 2740M拉下来做了一轮离线 RAG两条经验很直接一是这个量级的多模态嵌入模型完全能在单机环境跑得动二是只要把嵌入层想清楚离线 RAG 的整体复杂度会降一大截。下面把这个项目从设计到排错的过程完整写出来给正在做同类方案的人当个参照。1. 项目拆解EmbeddingGemma 2 是什么为什么适合离线 RAG1.1 多模态嵌入模型在今天解决什么问题过去做检索基本是两条路分开走文本检索用文本向量模型图像检索用视觉特征模型。两边出来的向量不在同一个空间里想实现“用一张图去找相关文档”或者“用一段描述去找图片”就得额外做跨模态映射工程上非常绕。EmbeddingGemma 2 这一类多模态嵌入模型做的事情是把文本和图像都映射到同一个向量空间。文本经过 tokenizer 得到 token 序列图像则被切成 patch 后转成 patch token 序列两者一起送入同一个编码器最终输出一整个句向量。句向量经过 L2 归一化之后可以直接拿余弦相似度比较。也就是说一个文本的向量和一张图片的向量处于同一套坐标系跨模态检索变成了一个普通的向量检索问题。这种统一空间带来的好处很实际。内部知识库经常是图文混排的产品说明书里有文字有插图设备维修记录里有现场照片。过去要维护两套索引再手工做关联现在直接让模型把图文都嵌入成向量检索时就按相似度统一排序少掉一大段胶水代码。1.2 740M 参数意味着什么740M 参数放在生成模型堆里算小个子但在多模态嵌入模型里属于一个比较实在的体量。按 FP16 计算光权重就要占用约 1.5GB 内存再加上图像 patch 序列的中间激活、注意力矩阵、临时缓存单条推理吃 2 到 4GB 显存或者等价的内存是正常水平。消费级显卡或者 16GB 内存的普通工作站都能扛得住这是一切的起点。模型小最直接的影响是“一切都可以自己做”。可以放在本地跑批处理晚上把文档库重新嵌入一遍可以同时起多个进程做并发不需要排队等外部接口的限流甚至可以裁剪成 INT8权重直接减半换取更低的推理延迟。这个体量还带来了一个很容易被忽视的优势故障排查成本低。模型小算子简单跑挂了看报错一眼就能定位不需要在大模型部署的复杂依赖里翻来翻去。1.3 离线 RAG 对这个模型提出的要求所谓离线 RAG不只是模型跑在本地这么简单。我的理解是整条链路从文档解析、切分、嵌入、索引到检索和生成全程不依赖外部 API数据不离开机器模型权重全部本地加载。这对嵌入模型提出了几个具体要求。第一个要求是推理可控。离线环境通常没有自动扩容能力模型如果太重单机吞吐跟不上索引就建不起来。第二个要求是向量维度适中。维度太高存储成本翻倍太低又损失区分度需要找一个平衡点。EmbeddingGemma 2 这个量级给出的向量维度对我来说正合适既能在小库上体现语义差异又不至于让向量库文件膨胀到难以维护。第三个要求是支持批处理。离线重建索引经常要处理几十万条文本和数万张图只有批量嵌入才能把时间压缩到可接受的范围。2. 整体设计离线 RAG 的链路如何搭2.1 四段式流程离线 RAG 的整体链路可以拆成四段文档接入、索引构建、在线检索、生成回答。文档接入这一步要处理 PDF、Word、网页截图、产品图等各种来源。文字内容直接抽取成文本块图片内容要么作为独立条目要么嵌入在文本块里。索引构建时把文本块和图片块分别经过 EmbeddingGemma 2 编码得到向量后写入本地向量库。在线检索时用户 query 同样走一遍嵌入然后去向量库做 Top-K 相似度召回。生成回答时把召回的文本片段以及图片对应的文字描述拼进 prompt交给本地生成模型。四段之间靠一个统一的数据结构衔接我习惯叫它“知识单元”。每个知识单元包含原始内容、元信息、向量。元信息里记录来源、页码、更新时间、模态类型。这样不管后续怎么查都能知道这条知识是从哪来的。2.2 嵌入模型在整个链路中的位置嵌入模型决定了召回的上限。生成模型再强也没法从没被召回的片段里变出正确答案。我踩过不少次这个坑一开始觉得随便找个文本嵌入模型凑合就行结果用户用口语化描述去搜召回的结果和问题完全不在一个频道上。换了语义理解更强的嵌入模型之后同样的问题命中率明显提升。在多模态 RAG 场景里嵌入模型的作用更关键。传统文本嵌入模型只能接收文本用户上传一张故障图query 侧就必须先找人写文字描述再拿描述去检索。这个过程既慢又容易丢失视觉细节。EmbeddingGemma 2 这类多模态模型直接吃图像输入query 是图片就编码图片query 是文字就编码文字全部在同一个向量空间里比相似度链路就简单多了。2.3 搭建前先回答的四个问题动手搭之前我都会先问自己四个问题确定后再定实施方案。第一个问题我的知识库里到底有哪些模态。如果只有纯文本多模态嵌入的部分可以先压后如果图文混杂那多模态就不是锦上添花而是必需项。第二个问题向量维度对存储的影响能否接受。假设输出维度是 1024100 万条知识单元每个向量 4 字节的 FP32光向量就要 4GB加上索引结构和原始内容磁盘占用要提前算好。第三个问题索引更新频率。离线环境里全量重建比增量更新好维护但如果每天都有新数据进来就要设计定时批处理任务。第四个问题query 长什么样。是关键词式提问、整句提问还是图片提问。query 形式直接决定检索时要不要做 query 重写。3. 本地部署实操从权重到可用的嵌入服务3.1 资源估算与依赖准备我在正式部署前先做了一个很粗糙的资源表按单条推理占 3GB 内存估算批处理时开 4 个并行 worker峰值占用在 12GB 左右。普通 16GB 内存的机器有富余。如果还要同时跑 7B 级生成模型建议把嵌入服务和生成服务放在两台机器或者共享 GPU 时严格控制并发数。依赖准备方面核心环境就是一个 Python 3.10 以上的解释器加上模型加载和数值计算的基础库。建议用虚拟环境隔离避免和系统里的其他服务互相污染。我一般还会准备两个脚本一个是单条推理测试脚本用来验证模型加载和向量输出一个是批处理脚本用来跑全量索引。测试脚本很重要不要上来直接跑全量先拿一条样本确认维度对不对。3.2 模型加载与向量计算的代码框架EmbeddingGemma 2 的加载方式和其他本地模型类似。核心逻辑是指定本地权重目录加载编码器把输入文本或图像预处理成模型需要的 token 序列前向计算后取出句向量再做归一化。下面是一个简化版的加载和推理流程去掉细节后骨架长这样# 伪代码加载多模态嵌入模型并计算向量 from embedding_gemma import GemmaMultimodalEncoder model GemmaMultimodalEncoder.from_local( ./models/EmbeddingGemma2-740M, dtypefp16 ) # 文本嵌入 text_vector model.encode_text(设备启动失败指示灯常亮) # 图像嵌入 image_vector model.encode_image(./photos/failure_case_001.jpg) # 向量归一化 text_vector text_vector / text_vector.norm() image_vector image_vector / image_vector.norm() print(text_vector.shape) # (1024,)这里有几个点需要留意。第一加载时显式指定 dtype默认用 FP32 的话内存直接翻倍。第二模型输出的是整句话的语义向量不是每个 token 的向量做 RAG 时用的是前者。第三编码图像前模型内部会先把图缩放到固定分辨率常见是 336×336 或者 448×448再切成 patch。这一步会丢失原图的细节比例所以源图最好提前裁切好主体区域不要塞一张带大量留白的截图进去。3.3 文本嵌入与图像嵌入的输入规范文本输入规范和传统嵌入模型类似但有两点需要格外注意。一是最大序列长度。EmbeddingGemma 2 这类模型一般有 2048 或者 4096 的上下文上限超长文本会被截断。我建议把文档主动切成 512 token 左右的块这样既不会触发截断单块的语义密度也够高。切分时要注意别在一句话中间硬切最好按段落边界或者句子边界切。图像输入规范容易被忽略。模型对图像的分辨率和宽高比有要求毕竟是固定 patch 尺寸。实际操作中我会先对图像做预处理统一格式为 RGB去除 EXIF 方向信息必要时做中心裁剪把主体放在画面中间。图像里的文字如果太密集模型可能提取不充分这种情况我会先把图像交给 OCR 提取一遍文字把文字作为额外的文本块和图像块一起索引效果会稳定很多。3.4 批量编码与数据落盘单个样本验证没问题之后再跑批量。批量编码最大的坑是“一次读完所有数据直接编码”。数据量大的时候内存直接爆掉。我的做法是写一个生成器逐条读取本地文件组装成 batch 后交给模型得到一个向量就尽快落盘。落盘格式我推荐用 .npy 加一份元信息 JSON。向量数组单独存成二进制检索时用内存映射方式加载不会占用太多内存。元信息 JSON 记录每个向量的来源、类型、时间戳。下面是一个简化的批处理流程# 伪代码批量编码并写入本地向量库 import numpy as np all_vectors [] meta_records [] for batch in doc_reader(batch_size32): vectors model.encode_documents(batch) all_vectors.append(vectors.numpy()) meta_records.extend(batch.meta) all_vectors np.concatenate(all_vectors, axis0) np.save(./index/vectors.npy, all_vectors) save_json(./index/meta.json, meta_records)这个过程跑完之后索引还是一个原始的数组。要在上面做相似度检索有两种方案一种是用轻量级向量数据库把向量导入进去直接提供检索接口另一种是数据量可控时直接用 NumPy 计算余弦相似度省掉中间件。我两种都试过小于十几万条向量时后者足够快且少一个依赖再往上就老老实实引入向量数据库。4. 检索与生成把嵌入结果用起来4.1 相似度计算与 Top-K 召回向量都归一化之后相似度就是简单的点积。检索时把 query 向量拿出来和索引里的每个向量做内积取最大的 K 个。这里先说一个容易搞错的点如果你用的模型没有做 L2 归一化那么点积等价于余弦相似度这个结论不成立。很多模型输出的原始向量带长度信息长度不均匀会影响排序建议显式做一次归一化再入索引。Top-K 的 K 值选择也要花点心思。K 太小容易漏召回K 太大又会把无关内容带进上下文。我通常先设 K20把召回结果交给一个重排序层从中再挑 5 个最相关的片段传给生成模型。重排序层可以是同样道理的双塔打分也可以用一个专门做相关度打分的交叉编码器。这一步虽然增加了一点计算开销但对最终回答质量的提升非常明显。4.2 多模态混合检索的实用策略多模态混合检索实际操作起来不是一句“都能编码”就完事。我比较推荐把知识库中的图文拆成三类索引纯文本块、纯图像块、图文关联块。纯文本块归文本索引纯图像块归图像索引图文关联块则同时保留两部分。检索时采取“双路召回再融合”的策略。用户输入是文本时先用文本向量检索文本索引同时用文本向量去图像索引里搜相关图片用户输入是图片时用图像向量检索图像索引同时去文本索引里找与图片内容相关的文档。两路结果各自打分再做归一化融合。分数可以按来源调整权重比如文本命中权重 0.6图像命中权重 0.4。这种策略比单一路径召回稳健得多实测下来混合查询的整体命中率能提升不少。4.3 与本地生成模型串联的调用范式嵌入检索跑通之后要和本地生成模型串起来核心就两个字拼 prompt。生成模型本地的推理服务我单独起一个进程通过本地 HTTP 调用。调用时把召回片段按相关度顺序拼进 prompt并标注来源方便生成模型在回答中引用。这里需要特别提醒召回片段不要无脑全塞。上下文越长生成模型越容易出现“抓不住重点”的问题也会拖慢推理速度。我一般控制召回片段总字数在 1000 字以内多余信息宁可不放。生成侧还有一个常见误区以为嵌入了多模态模型生成模型就能“看懂”图片。实际上很多本地生成模型不支持图像输入。如果想让最终回答包含图片信息做法是把图像块对应的描述文本比如 OCR 结果或模型生成的 caption放进上下文里。也就是说EmbeddingGemma 2 负责把图片“翻译”成语义向量用于检索而生成模型只负责基于文本回答。4.4 性能调优与资源控制我这轮项目里最有效的三个性能优化手段分别是批量大小调优、向量索引内存映射、以及推理并发控制。批量大小不是越大越好。模型虽然支持多 batch但 commit 到 GPU 或 CPU 后显存占用暴增。我的经验是以 1.5 倍单条推理占用为基准逐步往上加 batch size直到接近内存上限的 70% 就停。推理并发控制上如果嵌入服务和生成服务跑在同一台机器必须限制嵌入服务最多 2 个并发否则生成服务会被抢资源两个服务一起卡死。性能数据方面我这台普通配置机器上长文本单条嵌入大概是几十毫秒到一百多毫秒单张图像嵌入大概几百毫秒级别。批处理开起来后一小时处理几万条文本或几千张图是可行的。这个吞吐对中小型知识库完全够用甚至离线全量重建也只要一顿饭的功夫。5. 疑难杂症排查手册5.1 常见错误和现象对照表下面这些坑都是真实跑过的整理成对照表方便排查。现象可能原因解决办法文本向量长度不稳定输入未截断或未归一化统一切分长度显式 L2 归一化图像嵌入结果全部差不多图像预处理失败全部变成黑图或同一张占位图检查图像加载路径和格式转换检索结果相关性极差文档切分太碎或语义隔断改成按段落切分并加入重叠 token中文短 query 召不回嵌入模型词表对中文覆盖不足做 query 扩展或补同义词兜底批量编码内存溢出batch_size 过大调小 batch改用迭代器逐批落盘生成模型回答和召回内容无关prompt 拼错或召回片段被截断检查 prompt 模板限制召回片段长度5.2 排查方法论遇到检索问题的时候我的建议是先拆问题边界。先用一条明确的文本 query 查文本索引如果这一步召不回大概率是嵌入模型或切分的问题先不碰生成模型。接着用同一套 query 去查图像索引看跨模态对齐是否正常。如果文本路径和图像路径单独都正常但混合查询效果差问题基本出在分数融合的权重上面。排查时一定要保留中间结果。我给每个 query 都记录下召回片段列表和相似度分数出了问题直接看分数分布能很快发现是哪一段出了问题。这个习惯帮我省了很多时间。5.3 我的三条独家避坑经验第一条别迷信“大模型自动理解长文本”。嵌入模型虽然支持长上下文但“支持”和“效果好”是两码事。我实测过同一模型在 512 token 和 2048 token 切分下的召回质量前者明显更稳定。不要让切分块太大。第二条图像预处理比模型选择还重要。有段时间图像检索效果差我以为是模型能力不行最后发现是部分图片带透明通道或 CMYK 色彩模式预处理时没有统一转换成 RGB模型拿到的是损坏输入。现在我的图像流程强制加一步格式校验任何一张图在进入模型前都必须过一遍统一预处理管道。第三条离线环境要预留一个“重嵌入”的途径。模型更新或者切分策略调整时索引必须全部重建。我建议把嵌入脚本与主流程解耦单独做成命令行工具参数化输入目录和输出目录。这样每次调整后跑一次重建脚本就好不用改业务代码。6. 后续还能怎么玩这个方案跑通之后扩展空间不小。我目前已经在做两个方向一是把 EmbeddingGemma 2 的向量结果同时用于聚类和分类比如给知识库自动打标签二是通过定期增量重嵌入把新产生的图文资料自动并入索引不用每次全量重建。多模态嵌入模型在离线 RAG 里真正打开了“图文互搜、全本地处理”这条路740M 这个体量也给了普通项目足够的折腾空间。如果你也在搭离线 RAG我建议多花一点时间在嵌入模型选型和数据预处理上这两件事做扎实了后面几步会很顺。