
接到一个知识库检索的项目需求看起来并不复杂库里有几百份 PDF、部分扫描件和产品截图用户希望提问后能给出带原文出处的答案。真正跑下来才发现问题不在“检索”这个动作而在“喂给检索的内容”本身——很多信息藏在图片、图表和版式里压根没进到传统文本索引中。后来我切换到 Elasticsearch 配合 JINA 的多模态嵌入模型把文本和图像统一映射到同一个向量空间再走 RAG 生成答案才把这套链路真正跑顺。多模态搜索的 RAG本质是解决三个层次的问题。第一层是入口问题用户既可能用一段文字查询也可能直接拿一张图片来找相似资料传统关键词检索无法同时处理这两种输入。第二层是表征问题一份文档内部本来就是图文混排的把图片和正文拆开存成两个孤立系统会导致跨模态的关联完全丢失。第三层是生成问题光把候选片段找出来还不够RAG 还要把这些候选交给生成模型让回答带上来源、上下文和可读的表述。这篇记录我尽量写成“踩过坑之后怎么说人话”的风格把架构、代码和调优细节一次讲清楚。1. 先有个共识多模态搜索的 RAG 到底要解决哪一层问题1.1 传统搜索的痛点文本提取就丢掉一半信息过去处理 PDF 知识库最常规的思路是用解析库把文本抽出来、搞一个倒排索引再用 BM25 打分。这套流程对“纯文字文档”够用可一旦文档里有产品手册、技术方案截图、票据扫描件文本层就变得稀薄。我去看一个真实案例时发现某份设备操作手册里最关键的接线图说明是以整张图片的形式嵌在页面里的解析后这一段只剩下图片名用户搜“接线方式”根本命中不了。这不是解析库的问题而是信息本身的形态问题。图像里的知识天然是非结构化的人眼能看懂搜索引擎不会。传统做法会先接 OCR 把它转成文字但 OCR 只解决“图像里有没有文字”的问题解决不了“图像的内容和旁边的文字有什么语义关系”的问题。比如一张柱状图OCR 出来的是“40%、60%、华北、华东”可用户真正想问的是“哪个区域增长最快”单靠 OCR 结果只能做字面匹配很难理解图表背后的趋势语义。1.2 多模态嵌入是怎么把“看图说话”变成向量计算的多模态嵌入的思路是用同一个模型把文本和图像映射到同一个向量空间让“一段描述图表的文字”和“那张图表本身”在空间里距离足够近。做向量搜索的人喜欢把它比喻成“语言和视觉的翻译”模型不关心输入是文字还是像素只负责把它压缩成一个固定长度的稠密向量然后靠余弦相似度判断谁更接近谁。JINA 这类多模态模型的价值在于它不要求你自己训练一套 CLIP而是把成对图文预训练的能力封装成可直接调用的接口。实操中你可以把一句查询文本和一个图片 URL 扔进同一个接口得到同样维度的向量后续在 Elasticsearch 里做 kNN 检索时它们天然可比。这也意味着索引里的一个向量字段既能被文字查询命中也能被图片查询命中这是多模态 RAG 和普通文本 RAG 最本质的差异。1.3 谁适合做这套方案谁不适合说句踩过坑之后的实话不是所有知识库都需要多模态 RAG。如果你的资料以纯文本为主标题、正文、代码块都能被解析器老老实实抽出来那多模态属于锦上添花加了反而增加维护成本。真正适合的场景有三类一是大量扫描件和照片型资料二是图文混排的产品手册、审计底稿三是查询本身带图像的“以图搜图”需求。不适合的场景也有一个共性数据量很小、查询模式固定、答案可以直接从数据库里算出来。这时候硬上 RAG 是把简单问题复杂化多模态更是过度设计。做技术选型最忌讳的是因为某个方案听起来“新”就套上去先把问题定义清楚再决定要不要引入这个复杂度这套判断比任何模型选型都重要。2. 选型组合的底层逻辑Elasticsearch 管召回JINA 管统一表征多模态 RAG 并不强制要求某个底座但 Elasticsearch 加 JINA 的组合在实际项目里非常顺手。这不是因为它们在宣传上“绑定”而是二者确实各管一段JINA 负责把复杂内容变成向量Elasticsearch 负责把这批向量变成可用的大规模检索服务。2.1 Elasticsearch 在向量召回侧的看家本领Elasticsearch 从 7.x 开始引入 dense_vector 字段8.x 把 ANN 检索变成一等公民底层是 HNSW 图索引支持 cosine、dot_product、l2 三种相似度度量。数据量在百万级以内时跑起来非常轻。更关键的是它先天具备“混合检索”能力一个查询里可以同时写 keyword 匹配和 knn 子句再用 RRF 把两种打分合并这对多模态场景几乎是刚需。为什么刚需因为纯向量检索的弱项是精确匹配。用户问“2024 年 Q3 的设备故障数”向量检索可能在语义上找到一堆“故障处理办法”却对“2024 Q3”这种数字型条件束手无策。反过来纯倒排检索又抓不到图片内容。Elasticsearch 能让我在一个索引上同时维护倒排索引和 HNSW 图用一个语法完成混合召回这是很多专职向量数据库做不到的。2.2 JINA 这类多模态模型解决了工程化的什么麻烦如果只从算法角度看任何 CLIP 类模型都可以完成多模态嵌入工程化反而是最大的差别。JINA 这类模型在实际使用中的体验是入参设计收敛、输出维度明确文本和图像走的是同一套调用逻辑不用自己处理“文本要过 tokenizer、图像要过 preprocess”的差异。这对团队里没有专职算法工程师的项目尤其友好普通后端开发也能在一个下午把链路调通。另一个容易被忽略的点是后续扩展性。如果要从纯图文扩展到嵌入音频、视频JINA 的接口设计是逐步叠加的不需要把 Elasticsearch 索引里的原有向量推倒重来。这听着很虚但做过一次“嵌入模型换代导致全量重灌”的人都明白检索管道最贵的成本往往不是 GPU而是数据重放和重新校验。2.3 和纯文本 RAG 相比这套组合多出来的成本我不喜欢把方案说得只有好处没有代价。多模态 RAG 相比纯文本 RAG明显多了三个成本点。第一是存储图像向量带来的索引体量会显著膨胀一个图文对可能要存文本向量加图像向量两份数据磁盘和内存预算都要重新评估。第二是质量治理图文配对是否合理直接决定检索效果而配对质量没有银弹只能靠业务侧校验和抽查。第三是端到端链路变长文件解析、图片抽取、配对融合、嵌入、索引、检索、重排、生成任何一环失败都会表现为“答案不对”排查起来比纯文本复杂得多。所以我对这个组合的定位是它适合那些已经确认真实存在多模态检索痛点的项目而不是拿来当通用脚手架。架构上多做一层抽象至少让“换模型”“换底库”“换切块策略”这些动作成本降到最低这才值得把多模态复杂度引进来。3. 入库链路和向量索引设计别等数据来了再拍脑袋多模态 RAG 的成败八成取决于入库链路而不是检索代码。很多项目在 demo 阶段表现惊艳一接入真实数据就崩原因就是入库阶段对数据形态没有做结构化处理。这一章讲我自己验证过的那套入库链路以及索引映射怎么设。3.1 从文件到块解析、切块、图文配对入库流程分为四步。第一步是文件解析用通用解析服务把 PDF、Word、PPT 转成带结构的中间对象至少保留“页-段落-图片”三级结构。第二步是切块段落按 token 数切分默认 512 token、重叠 64 token具体数值视文本密度调整。第三步是图文配对把每个段落块相邻的图片挂到该块上形成“图文对”如果图片是独立的大图则单独切成一个图片块。第四步才是嵌入和写入。图文配对这一步最容易被跳过但它恰恰是多模态 RAG 区别于文本 RAG 的核心。假设一个页面左侧是图片、右侧是说明文字它们应该被视为一个整体进入向量空间而不是各走各的。配对规则很简单图片出现在某个文本块的可视范围内就把图片的嵌入和该文本块的嵌入做融合。融合方式可以是向量平均、拼接后降维也可以直接存成多个子字段参与后续检索具体选哪种取决于模型和场景。3.2 映射设计dense_vector 的参数该怎么给索引映射是入库链路里最不能拍脑袋的部分因为 mapping 一旦建好修改成本极高。我习惯在项目里预设如下结构{ mappings: { properties: { doc_id: { type: keyword }, page_no: { type: integer }, chunk_id: { type: keyword }, content: { type: text, analyzer: standard }, modality: { type: keyword }, image_ref: { type: keyword }, content_vec: { type: dense_vector, dims: 1024, index: true, similarity: cosine, index_options: { type: hnsw, m: 16, ef_construction: 100 } } } } }几个参数拆开讲。dims 必须和嵌入模型的输出维度严格一致这里用 1024是因为所选模型输出就是 1024如果换模型mapping 必须同步改。similarity 用 cosine它对向量绝对值不敏感适合内容长度差异大的文本块和图像块混合检索。index_options 里的 m 和 ef_construction 会影响构建索引时的召回质量数值越大越慢但召回越好一般 m16、ef_construction100 是个折中起点。3.3 批量写入的基础设施细节bulk 与 refresh 策略批量写入我会用 _bulk 接口每次提交 500 到 1000 条文档而不是一条条地写。写入过程中把 refresh 间隔临时改成 -1 或 30s避免每写一条就触发一次 refresh写入完成后手动 refresh 一次。如果数据量是十万级这个差异是分钟级和小时级的区别。另一个容易忽略的点是分片数。向量字段的 HNSW 图在内存里检索分片越多跨节点查询开销越大。数据量不大时我建议主分片数按节点数走不需要为了“将来扩展”预留过多分片。给后续留扩展性用索引别名比用分片数更靠谱也方便之后做原地重建或灰度切流。4. 完整的 Python 实操从文档解析到 kNN 检索再到 RAG理论讲完下面是我在模拟项目 X 里跑通的完整链路代码读者可以直接照着改。整体分四部分连接、入库、检索、生成。4.1 初始化连接 ES加载嵌入模型from elasticsearch import Elasticsearch es Elasticsearch( http://localhost:9200, basic_auth(elastic, changeme), request_timeout60 )嵌入层我封装了一个Embedder它在内部根据配置决定走在线接口还是本地模型。在线接口的好处是省去 GPU 资源坏处是需要网络和密钥配置所以我在代码里把这一层做成接口方便随时切换。实际项目里密钥通常放在环境变量或配置中心不会写死在代码中。import requests class JinaEmbedder: def __init__(self, api_key: str, model: str jina-clip-v2): self.api_key api_key self.model model def embed(self, items): resp requests.post( https://api.jina.ai/v1/embeddings, headers{ Content-Type: application/json, Authorization: fBearer {self.api_key} }, json{model: self.model, input: items}, timeout120, ) resp.raise_for_status() return [item[embedding] for item in resp.json()[data]]如果不想依赖在线服务也可以换成本地开源模型只需保证输出向量维度一致即可。接口层面做统一封装换模型时业务代码一行都不用改这是我认为做 RAG 工程最值得投资的一点。4.2 文本块嵌入与索引含代码切块后的文本先过嵌入再拼成 Bulk 请求写到 ES。关键是把字段完整映射好再写避免写入时报映射错误from elasticsearch.helpers import bulk def index_chunks(chunks, embedder, index_namekb_mm_001): actions [] for c in chunks: vec embedder.embed([{text: c[content]}])[0] actions.append({ _index: index_name, _id: c[chunk_id], _source: { doc_id: c[doc_id], page_no: c[page_no], chunk_id: c[chunk_id], content: c[content], modality: text, image_ref: c.get(image_ref, ), content_vec: vec, }, }) bulk(es, actions, chunk_size500)这里要注意embedder.embed的入参是个列表模型支持一次传多段文本批量嵌入比逐条请求快得多。我习惯先收集 64 条文本再发一次请求接口压力的量级完全不一样。如果数据量再大还可以用生产环境的消息队列做异步消费入库任务和检索服务真正解耦。4.3 图片/图文对的嵌入处理含代码图片块的处理有两种形态。一种是独立图片直接把图片 URL 传给模型的 image 字段另一种是图文对需要把文本语义和图像语义融合。向量平均是最稳的基线方法复杂拼接方法在数据量不大时未必带来收益def embed_image_text_pair(text: str, image_url: str, embedder): vec_text embedder.embed([{text: text}])[0] vec_image embedder.embed([{image: image_url}])[0] return [round((a b) / 2, 6) for a, b in zip(vec_text, vec_image)]需要注意向量平均必须保证两个向量都来自同一模型同一版本否则空间不对称。另外如果图片本身包含大量文字比如截图我建议先用 OCR 把图上文字抽出来拼进 text再做图文对而不是只依赖图像向量。这个细节在真实知识库里几乎每天都会遇到截图型资料比想象中多得多。4.4 查询侧实现混合召回 RAG 拼 Prompt查询侧同样先做嵌入然后同时走两个召回通道一个用knn子句找语义相近的块一个用match子句做关键词精确匹配最后用 RRF 合并。代码结构如下def hybrid_search(query_text: str, embedder, index_namekb_mm_001, top_k5): vec embedder.embed([{text: query_text}])[0] es_query { size: top_k, query: { bool: { should: [ {match: {content: query_text}}, {knn: { content_vec: { vector: vec, k: top_k, num_candidates: 100 } }} ] } }, rank: {rrf: {window_size: 50}} } resp es.search(indexindex_name, bodyes_query) return resp[hits][hits]拿到 Top-K 候选之后把它们拼成一段上下文连问题一起交给生成模型。上下文里必须带来源信息让生成模型既有的放矢又能在追问时定位到原文def build_prompt(hits, question): context_parts [] for hit in hits: src hit[_source] context_parts.append(f[来源:{src[doc_id]} 第{src[page_no]}页]\n{src[content]}) context \n\n.join(context_parts) return f根据以下资料回答问题若资料中没有相关信息请直接说明。\n\n资料\n{context}\n\n问题{question}\n答案这一步其实已经完成了检索增强生成的闭环——检索给生成提供了上下文生成再给用户一个组织好的答案。整个链路看起来不复杂但把每一步的质量都控制住才是真正的难点。5. 实测踩坑为什么召回稳定了答案是错位的哪怕代码全部跑通真实项目的坑依然集中在几个隐蔽位置。下面是我在几轮迭代里最典型的几个问题按排查顺序写出来。5.1 维度不匹配与模型版本漂移最基础也最坑的就是 dims 不一致。Elasticsearch 写文档时一旦遇到向量维度与 mapping 不符会直接把整批请求打回。我遇到一次是换了嵌入模型新模型输出 1024索引还是旧的 512当时排查了半天最后从报错信息里看到维度提示才反应过来。最稳妥的做法是建索引前先跑一段探针代码打印一次实际输出的维度再把它填进 mapping。另一个类似的坑是模型版本漂移。JINA 更新模型版本后输出分布会有变化旧向量和新向量混在同一索引里检索排序会莫名其妙地劣化。所以我在项目中会对向量字段加上模型版本标识一旦决定换模型就全量重建索引而不是在新旧向量混用状态下硬扛。5.2 相似度度量选错排序结果全乱Elasticsearch 的 dense_vector 字段支持 cosine、dot_product、l2。我实际踩过的坑是一开始图省事选了 dot_product结果因为没做向量归一化评分被向量模长带偏长文本块的向量模长更大几乎永远排在前面。后来我统一改成 cosine并且在嵌入层强制对向量做 L2 归一化。这两个改动单独看都很小合在一起的效果是让排序从“谁向量更长谁赢”变成真正的“谁语义更近谁赢”。如果你坚持用 dot_product 冲性能那请务必保证所有向量已归一化否则出来的结果会让你怀疑模型质量。Elasticsearch 的余弦相似度和 dot_product 在数值表现上差异很大尤其当图文向量混在一个索引里时选错度量会让重排效果极不稳定。5.3 图文分块质量差该用 late chunking 的思路多模态检索最常见的问题是图文对生成得不对。比如一个页面有三张图解析器全把它们挂到第一段文字下面检索时就会被错误的图像向量干扰。这里我学到了 late chunking 思想的变体——先让整页在模型里整体编码再按文本块从完整编码中取出对应位置的向量而不是让每个小块独立编码。实现上本地模型可以用 token 级别编码后做池化窗口。在线接口如果不支持细粒度控制退而求其次的做法是切块时把窗口放宽让相邻块的图像上下文尽量重叠再在索引里同时保存块级向量和页级向量检索时两块打分加权。这个方法不用换模型也能明显改善跨页图文混淆的问题是性价比很高的一招。5.4 索引参数、批量写和性能调优的实测记录我对同一个十万文本块数据集做了三组对比默认参数、调优参数、极端参数。默认 mapping 下写入耗时最长把 refresh_interval 改为 -1 后写入速度提升约 3 倍把 number_of_shards 从 5 降为 2 后查询 P99 显著下降。这说明向量检索的核心瓶颈常在内存和分片调度而不是模型本身。另外HNSW 的 m 和 ef_construction 不是越大越好。我在实际项目中把 m 设为 16、ef_construction 设为 100 已经能满足召回要求再往上调索引构建时间和内存占用都翻倍召回提升却不到一个百分点。调优前最好先量化自己的召回基线而不是凭感觉堆参数。所谓基线就是用一组人工标注的 query-doc 配对去跑一遍当前的正确率每次改动参数都重新跑同一套测试集。6. 后续工作形态与扩展经验最后的经验写给打算把 demo 推到生产的读者。多模态 RAG 不只是一个算法问题更是一个工程治理问题越往后做越会发现系统的稳定性来自对数据流的控制。6.1 纯离线批量管道 vs 在线增量管道数据源不经常变动时离线批量管道就够用每天晚上定时全量重建当天的增量块减少增量更新引入的维护复杂度。数据源变动频繁时就需要在线增量管道。我的建议是二者共用一层抽象不写两套业务逻辑docs 表作为统一入口管道层监听变更事件并触发嵌入与写入。增量管道最容易翻车的是“文档改了一页但旧向量没删干净”这会导致同一条内容出现两个版本检索时旧版本还会占坑。所以增量写入时要按 doc_id 先做删除再补新不能让文档版本在索引里累积。Elasticsearch 的 delete_by_query 配合 doc_id 过滤可以在写入前做一次清理代价不算大。6.2 从“能搜到”到“答案像人话”二路重排要不要做只靠 kNN 召回Top-K 里往往混着噪音。我在一个测试集上观察过top5 精确率约 60%但 top10 里真正的命中项可能在第三四位。这时候如果直接拿 top5 去生成答案效果会波动。所以我在检索后加一个轻量重排层用交叉编码器对召回结果做一次精排再取前三名进提示词。这一步能明显提高答案的连贯性代价是额外的算力时间。对于知识库 QA通常值得做对于每秒几十次的线上检索需要仔细权衡。还有一种折中方案只在答案置信度低的时候触发重排平时直接走快速通道但会引入额外的链路判断逻辑上要复杂一些。6.3 最后一点个人体会多模态搜索的 RAG 落地技术难点从来不是模型有多先进而是“让检索结果和用户意图真正对齐”。向量、索引和生成模型都是手段真正值钱的是对业务的拆解和对数据形态的理解。我在这类项目上最大的体会是先跑通一条极简链路再逐段增加复杂度一开始就追求面面俱到往往连最简单的有效结果都拿不出来。Elasticsearch 加 JINA 这套组合帮我用最小的工程代价把图文知识库从“能搜文字”升级到了“能理解内容”的程度希望这篇记录也能让你少走几段我走过的弯路。