资讯动态

离线RAG+多模态嵌入:EmbeddingGemma 2图文混合知识库实践

发布时间:2026/10/15 5:28:48 来源:尧图企业网站定制
最近有个项目要在完全离线的环境里搭一套私有知识库问答数据是公司的产品手册、售后工单和技术流程图文档里除了文字还有大量截图和示意图。常规做法是文本embedding加OCR串起来但我实际搭完发现管线绕、信息丢得多图文之间的关联基本靠手工维护。后来换了个思路用740M的EmbeddingGemma 2模型直接出多模态嵌入把文本和图片映射到同一个向量空间再走离线RAG检索生成。这套方案跑下来非常干净本地CPU也能推得动正好满足数据不出域的要求。这篇文章我就完整记录一下整个落地过程包括模型底细、离线部署、向量库搭建、图文混合检索的完整代码以及我实际踩过的那些坑。适合正在做私有化知识库、文档问答、多模态检索的同学参考尤其是对“离线”和“图文混合”都有硬性需求的场景。1. 先把离线RAG这件事想清楚1.1 为什么一定要离线跑很多团队一开始会先调在线API把流程跑通但真正放到生产环境就会发现企业内部的知识问答几乎都绕不开隐私和合规。比如售后工单里带着客户编号、设备序列号产品手册里有未公开的硬件参数这些内容不允许出内网。另外在线API还有两个隐形问题一是单条文本按token计费全量文档切块后跑一遍embedding成本不低二是外部接口的延迟和限流不可控晚上批量重建索引时经常超时。离线方案把embedding、向量检索、生成全部拉回本地数据不出服务器速度也完全由自己掌控。这里需要注意离线RAG并不只是“模型跑在本地”这么简单。它包含三个层面模型权重必须在本地文件系统向量库的存储和检索必须在本地进程内完成整个问答链路不能依赖任何外部网络调用。这意味着模型下载、缓存目录、依赖包都要提前准备好真正部署的机器可能连外网都不通。我建议从一开始就按“完全断网”来设计省得后期内网测试时到处补依赖。1.2 RAG链路的核心环节拆解一套标准的RAG系统无论离不离线链路都是固定的文档解析、文本切块、向量化、索引入库、查询检索、结果生成。其中向量化是决定召回质量的上限后面的检索和生成都只是从上游拿结果。传统文本RAG的处理对象是纯文字文档解析之后直接做切块每块文本生成一个向量查询时把用户的问题也变成向量去向量库做相似度检索取topK文本块拼给大模型生成回答。这套流程已经很成熟但一旦文档里混入图片、表格截图、流程图传统方案就断层了。比较原始的土办法是先把所有图片OCR成文本再参与检索可问题在于OCR出来的文字经常丢排版结构流程图的逻辑关系也完全无从体现。而图片本身作为最直观的信息载体直接就被丢弃了。1.3 多模态嵌入给RAG带来的变化多模态嵌入模型的核心能力是把文本和图像都映射到同一个向量空间。查询文本可以直接和图片向量算相似度图片也可以作为召回结果返回给大模型作为上下文。这样RAG的检索对象就从“纯文本块”扩展成了“图文混合的知识单元”。我做这个项目之前前端同事问了个很直接的问题既然图片最终没法塞进大模型的文本上下文里那召回图片到底有什么用这里其实有个关键点多模态RAG召回图片后可以在展示层把图片路径返回给用户让用户看到“依据是第几页的哪张图”同时在生成层我们要做的不是把图片传给大模型而是把图片对应的文本描述、OCR文本、周边文字块一起作为上下文喂给大模型。模型做的是图文的联合嵌入所以文本查图片、图片查文本都能命中这比硬用OCR再拼文本要自然得多。2. EmbeddingGemma 2 的模型底细与选型逻辑2.1 740M参数的具体配置EmbeddingGemma 2目前主要分为2B和740M两个版本我用的740M版本对这个体量的模型来说单条embedding的生成速度、显存占用和召回效果之间平衡得比较合适。从实际加载得到的配置来看这条链路输出的是768维向量文本侧最大输入长度约8K tokens图像侧会先经过视觉编码器把图片压缩成固定长度的视觉特征再映射到和文本相同的向量空间。模型文件在fp32精度下大概1.5GBfp16大约750MB量化为int8之后还能进一步压缩部署压力很小。有一点要提醒不同版本的输出维度、图像输入尺寸、是否支持长文档官方配置会有差异。动手之前先看模型卡和config.json不要想当然地按我的数值去硬套尤其是向量维度这个参数后面所有代码都要用到错了直接检索崩掉。2.2 多模态对齐是怎么做到的这类多模态嵌入模型大多采用对比学习的方式训练。训练时会构造大量“图文对”让匹配的图文向量在空间中距离更近不匹配的距离更远。经过几个阶段的对齐模型内部相当于建立了一个统一的语义空间“一张设备的安装图”和“设备安装步骤”这两者的向量无论在文本侧还是图像侧距离都很近。这和CLIP的思路有相似之处但EmbeddingGemma 2本身保留了完整的文本编码能力不只是做图文匹配而是能同时承担文本embedding和图像embedding两种职责。这个特性在实际项目里非常关键。一条知识库里既要有纯文本条目又要有图片条目。文本用同一个模型出向量图片也交给同一个模型出向量这样检索阶段才能用同一个索引、同一个相似度算法不需要维护两套索引再去做结果合并。如果混用不同的模型做文本和图像向量维度、空间都不一致那才是真正的灾难。2.3 和常见文本embedding模型对比模型方向参数量输入模态输出维度离线部署难度适用场景常规文本embedding100M-500M文本512-1024低纯文本知识库EmbeddingGemma 2 740M740M文本图像768中低图文混合检索EmbeddingGemma 2 2B2B文本图像高中效果优先的跨模态检索通用多模态大模型7B以上文本图像高高微调/复杂理解任务纯文本场景完全没必要上多模态模型体积和延迟都是浪费。但只要你文档里存在图片信息需要被检索到那多模态嵌入就是省事的正路。740M这个档位适合知识量在几十万条以内的私有化部署再往上建议考虑2B版本或者做模型量化部署。2.4 选型背后的实际考量我最终选740M还有几个实际原因。第一部署机器是一台只有16G内存的服务器没有独立显卡2B版本跑推理虽然能跑但慢批量embedding要等很久740M在CPU上跑一批32条文本大概几秒完全可以接受。第二需要处理的文档规模不大切块后大约几万条记录768维的向量用FAISS扁平索引就能扛住。第三这个模型是开源的没有商业授权问题离线分发不用签乱七八糟的协议。选型很多时候不是选最强的而是选够用且不折腾的。3. 环境准备与模型离线部署实操3.1 硬件需求与依赖安装先说我实际使用的配置CPU是8核16线程的普通服务器内存16GB无GPU。这个配置跑740M的EmbeddingGemma 2比较吃力但能跑推理时内存占用大概5GB左右批量编码过程中会短暂上升到8GB只要不同时跑别的大型服务就还好。如果只有8G内存建议在推理时加torch.set_num_threads(4)限制线程数防止内存溢出。依赖方面我使用的是Python 3.10核心包是torch、transformers、sentence-transformers、Pillow、numpy、faiss-cpu。有个经验离线环境装依赖前一定要先在联网机器上把所有wheel包下载好然后整体拷贝过去。不要想当然地以为pip install会从内网走通很多内网机器的pip源根本没配。建议用一个干净的venv环境打包避免和系统Python冲突。3.2 模型离线下载与本地加载离线下载模型是整个链路里最容易忽略的一步。很多人在联网环境执行了一次模型加载以为模型已经在本地了结果换到离线机器上还是报连接错误。原因是transformers默认的缓存路径在用户主目录下的.cache/huggingface这个目录没有拷贝过去。我的做法是这样在一台能联网的机器上先把模型显式下载到指定目录再把整个目录连同tokenizer文件一起拷到离线服务器然后用local_files_onlyTrue强制本地加载。下载时可以用官方提供的下载脚本也可以直接在代码里加载一次让它写缓存然后把缓存目录整个打包。更稳妥的方式是直接访问模型托管平台的页面把模型仓库里的文件列表逐个下载包括model.safetensors、config.json、tokenizer.json、tokenizer_config.json、special_tokens_map.json等。这些文件一个都不能少少一个在离线机器上就会报初始化失败。3.3 文本向量生成完整代码sentence-transformers库封装得比较好加载本地模型直接传本地路径就行。from sentence_transformers import SentenceTransformer model_dir /data/models/embeddinggemma-2-740m model SentenceTransformer(model_dir, devicecpu) texts [ 如何排查设备无法上电的问题, 指示灯绿色但设备无反应的常见原因, 售后工单填写规范 ] vecs model.encode(texts, normalize_embeddingsTrue) print(vecs.shape) # (3, 768)这里normalize_embeddingsTrue一定要加上。FAISS的IndexFlatIP用的是内积归一化之后内积就等于余弦相似度后续算出来的分数范围统一在-1到1之间调阈值好调得多。如果忘了归一化score的量级完全由向量模长决定不同数据源的分数没法比较。3.4 图像向量生成代码与预处理图像向量生成同样简单sentence-transformers内部会按模型要求处理图像。from PIL import Image image Image.open(/data/manual_images/install_flow.png) vec_img model.encode(image, normalize_embeddingsTrue) print(vec_img.shape) # (768,)图像分辨率不宜过大模型内部会把图片缩放到规定尺寸一般是224x224或者336x336。我在测试时发现原图如果是一张超长的流程图直接缩放到224会导致文字完全看不清嵌入效果很差。这种图要做预分割按区域切成几张图分别嵌入或者先做一次放大补点再交给模型。还有一类情况是白底黑字的截图直接跑出来的向量会和纯文本“安装说明”的向量距离较远我实际测试下来把识别出的文字作为图片的额外文本描述一起嵌入效果会稳定很多。3.5 CPU推理的速度优化740M模型在CPU上跑纯文本embedding速度还算能接受单条大约几十到一百多毫秒批量编码时能到每秒几十条。但如果文档量大还是要做并发和批处理优化。先把所有文档块攒成一个list按batch_size32送进去不要循环单条编码这样能充分利用CPU的向量化指令。图像编码耗时比文本高不少一张图大概几百毫秒到一秒批量图片编码时建议把batch_size调小到8否则内存会飙得很高。另外一个优化点是把模型转成半精度但CPU推理对fp16支持不稳定反而可能更慢。对于CPU场景我建议保持fp32不要盲目的性能优化。如果是GPU环境model.half()之后推理速度会有明显提升7B以下的小模型在消费级显卡上完全能跑。4. 向量库构建与多模态索引设计4.1 图文混合文档的切块策略切块质量直接决定检索效果这里没有一个万能参数但有几个通用原则。对于纯文本我用的切块大小是600字符重叠150字符。这个参数是根据文档类型调的产品手册这类说明文语义比较完整600字符基本能涵盖一个完整的操作步骤如果切太短容易截断语义太长则会让一个片段混入多个主题检索时噪声很大。重叠的目的是防止关键句子刚好被切在分界处。图片的切块方式和文本不同。每张图片单独作为一个知识单元元数据里记录它来自哪个文档、哪一页、在页面上的位置。这一步相当重要因为召回图片后要能追溯来源。流程图、框图、界面截图都建议单独入库图片文件名要规范化比如manual_p120_install_flow.png这样后期调试时一眼看出图片在哪个章节。表格类的信息我个人的习惯是如果表格简单转成文本块入库如果表格复杂或长宽跨页截图入库同时把表格标题和首行内容做成文本描述附加到图片的meta信息里。4.2 向量索引选型与参数设定我见过的团队有直接用Chroma、Milvus、Elasticsearch的但对一个离线单机的场景其实FAISS完全够用而且没有额外服务依赖。FAISS是Meta开源的向量检索库纯本地运行支持向量持久化。数据量在几万条时用IndexFlatIP做精确检索速度是毫秒级数据量到百万级再切换成IndexHNSWFlat通过牺牲一点精确度换取更快的搜索速度。索引类型的选择不要一开始就上HNSWHNSW需要调M和efConstruction两个参数调不好会导致召回率莫名其妙下降。我这次用的是IndexFlatIP直接把所有向量放进内存。启动时加载索引查询时做矩阵乘法一次性返回topK。整个过程没有网络开销离线机器上跑得稳稳的。索引持久化用faiss.write_index把索引写到本地文件重启服务后faiss.read_index读回来非常简单。4.3 向量检索与相似度阈值import faiss import numpy as np dim 768 index faiss.IndexFlatIP(dim) # 假设已有 doc_vecs 的 numpy 数组shape (N, 768) index.add(doc_vecs) query_vec model.encode([指示灯绿色但设备不上电], normalize_embeddingsTrue) scores, ids index.search(query_vec, k5) for score, idx in zip(scores[0], ids[0]): print(fscore: {score:.4f}, doc_id: {idx})余弦相似度的分数通常集中在0.3到0.9之间。查询文本和文档文本语义相关的一般在0.5以上跨模态的图文匹配分数会偏低一些我在项目里把召回阈值设在0.35低于这个分数的直接丢弃。这个阈值要根据你自己数据的分布来调可以先跑一批测试query把score分位数打印出来再确定合适的阈值。盲目设高会漏召回设低会引入噪声。4.4 图文联合召回策略因为文本和图片在同一个向量空间所以一次查询可以同时从文本和图片两类知识单元里召回。做法是检索时不区分类型直接统一召回topK然后根据元数据里的content_type区分。文本块和图片块的相似度分数分布略有差异图片块整体分数会低一些如果发现文本召回老是压过图片可以用分组topK策略文本取top3、图片取top2最后合并再重排。我实测的效果是混合召回比纯文本召回更贴合真实问答场景尤其“这张图在哪”“这个流程怎么走”这类问题图片召回命中率非常高。5. 实战案例图文混合手册的离线问答5.1 场景与数据准备这次我用一份虚拟的“某型号工业设备安装维护手册”做测试手册共120页包含文字章节和80张截图、流程图。目标问题包括“安装时指示灯绿色但设备不上电怎么办”“接线端子如何标识”“润滑周期是多久”。这类问题的答案有些在文本里有些则在图上比如端子标识图、润滑周期表。处理流程是先用解析工具把PDF转成文本和图片文本部分按上文策略切块图片统一保存到本地目录再用EmbeddingGemma 2把文本块和图片各向量化混合写入同一个FAISS索引。每个索引条目都对应一条元数据doc_id、content_type、source_page、原始文本或图片路径。5.2 完整构建与查询代码import os import json import numpy as np import faiss from sentence_transformers import SentenceTransformer from PIL import Image # 加载模型 model SentenceTransformer(/data/models/embeddinggemma-2-740m, devicecpu) # 元数据与向量容器 meta_list [] vec_list [] # 1. 文本块入库 text_chunks [] # 由文档切分得到 for i, chunk in enumerate(text_chunks): vec model.encode(chunk, normalize_embeddingsTrue) vec_list.append(vec) meta_list.append({ doc_id: ftext_{i}, content_type: text, text: chunk, source_page: chunk_page[i] }) # 2. 图片块入库 image_paths [] # 由PDF提取得到 for j, img_path in enumerate(image_paths): img Image.open(img_path) vec model.encode(img, normalize_embeddingsTrue) vec_list.append(vec) meta_list.append({ doc_id: fimg_{j}, content_type: image, image_path: img_path, text: f图片来自第{img_page[j]}页文件名{os.path.basename(img_path)} }) # 3. 构建索引并保存 mat np.vstack(vec_list).astype(float32) index faiss.IndexFlatIP(768) index.add(mat) faiss.write_index(index, /data/vector_db/manual.index) with open(/data/vector_db/meta.json, w, encodingutf-8) as f: json.dump(meta_list, f, ensure_asciiFalse)查询时加载索引和meta做检索后按阈值过滤。def search(query, top_k5, threshold0.35): qv model.encode([query], normalize_embeddingsTrue) scores, idxs index.search(qv, top_k) results [] for score, idx in zip(scores[0], idxs[0]): if score threshold or idx 0: continue results.append((score, meta_list[idx])) return results这一步的核心是meta和index的编号必须对齐顺序不能乱否则检索结果张冠李戴。我的习惯是写完索引后立刻做一次自检随机挑几条文本拿它自己的向量去检索确认top1就是它自己分数接近1。5.3 生成环节怎么接离线生成我用的是一个7B量级本地大模型加载后用简单prompt把检索到的文本块拼接起来图片路径作为引用展示。生成阶段的细节不多关键是上下文不要塞太满检索出的top5里可能混着不相关的内容大模型会把噪声也一起“合理化”。所以我会在prompt里直接告诉模型“以下是参考资料如果与问题无关请忽略。”同时把score作为排序依据先排列一遍分数最高的放最前面。5.4 召回效果评估评估不能拍脑袋说“看起来还行”。我准备了一批测试query把每个问题的正确答案对应的doc_id标注好然后计算命中率和MRR。命中率统计top5里有没有正确的doc_idMRR看正确答案排在第几位。指标推荐可以用脚本批量跑把每个query的检索结果拿出来和标注对比出一个表格比人工一个个翻页看效率高很多。我实测这批数据上的hit5大约在0.82左右图文混合文档能达到这个水平已经可以满足内部辅助问答的需求了。如果命中率偏低优先检查切块策略和图片预处理这两项的影响比调模型大得多。6. 常见问题与排查技巧实录6.1 显存或内存不足740M模型如果跑GPU显存占用大约1.5GB加载时还要额外预留输入输出的buffer。没有GPU跑CPU时16G内存是底线同时关闭其他大型进程控制批量大小。如果内存仍然不够两个思路一是给文本和图片分别建索引查询时分两次跑再合并降低单次内存峰值二是把模型转成int8量化推理质量和内存占用之间的折中需要实测对比不要想当然。6.2 图片嵌入效果差这次项目里最头疼的就是图片嵌入质量不稳定。很多截图是白底黑字模型对这类图片的视觉特征提取效果一般和文本的语义对齐不明显。我实测的解法是双通道图片本身输入模型得到一个向量同时把图片里能OCR出来的文字、图片所在章节的标题拼接成一段描述文本再用同一个模型对描述文本生成向量最后把这两个向量平均归一化作为这条图片的最终向量。效果提升非常明显而且不增加额外索引复杂度。对流程图这类强结构图片我建议先分割再嵌入把一个大图切成几个局部每个局部独立入库检索时更稳定。6.3 向量维度不一致维度不一致是最常见的低级错误。比如有的模型输出1024维代码里index维度写768查询时直接报错。还有更隐蔽的模型版本混用一部分向量是老模型生成的一部分是新模型生成的维度相同但空间不一致检索结果完全失真。严格做法是上线前锁定模型文件版本所有数据用同一份模型权重生成向量一旦决定换模型就必须全量重建索引。向量维度一定要在执行时打印出来确认不要看文档写多少就信多少。6.4 检索结果明显不相关检索结果偏掉的原因主要有三个。第一切块粒度和文档结构不匹配比如一个说明书的章节被拦腰截断语义碎掉第二当前阈值设得太低把不相关的噪声也放进来了第三query和文档的表达方式差异太大比如query用口语“机器开不了机”文档里写“设备上电故障”。这三点分别对应调整切块参数、提高阈值、引入query改写。离线场景下没有大模型做改写的话可以用同义词扩展或者维护一份领域词典先把query展开成多个候选再分别检索最后合并取最高分。6.5 模型加载和推理慢离线加载模型时如果发现首次加载要等很久先检查tokenizer和模型配置有没有被重复下载。正确做法是加载后把model.cache_dir里的内容完整备份出来部署时直接把local_files_onlyTrue和本地路径一起传进来。CPU推理慢是常态但有一个容易被忽略的提速点把文档块一次性批量编码不要让模型单挑一条编码一条批量编码的吞吐量能高几倍。图像编码的瓶颈在解码尽量提前把所有图片统一转换成JPEG或者PNG的固定尺寸避免推理时反复做格式转换。最后分享一点实际操作中的体会这套链路我从零到跑通大概花了两周最耗时间的反而不是模型选型或者代码调试而是图片数据的预处理。多模态嵌入模型确实能同时处理文本和图片但它不是万能的图片拍得糊、截图分辨率不够、流程图太长这些都会让向量质量大幅下降。动手之前一定要先拿真实文档抽20个样本把“文本检索图文”的闭环跑一遍确认效果可接受再做全量。另一个体会是别迷信复杂方案FAISS扁平索引、几十行代码、一个模型就能解决大部分私有知识库的图文混合检索需求。以后如果有增量更新的场景我会在这个基础上给每条知识加一个时间戳按天重建增量索引再配合混合检索整个系统的上限还能再拔一截。

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

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

免费获取报价 →
↑