ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ES+Milvus双引擎RAG:图文混排知识库的检索融合实战

ES+Milvus双引擎RAG:图文混排知识库的检索融合实战 先抛个结论纯向量检索解决不了所有RAG问题纯ES更是会被口语化查询打到自闭。我把这个结论的来龙去脉理清楚是在一个真实的生产级图文混排知识库项目里验证过的。项目本身不复杂企业产品手册、维修指南、FAQ文本和配图混在一起用户用自然语言提问系统要能从海量内容里准确召回最相关的段落。最初大家觉得RAG嘛文档切一切、embedding灌进向量库、问一句搜一下不就完了。等真跑到线上各种问题就浮出来了——用户问显示接口匹配不到DP接口问便宜的4K屏匹配不到任何带价格语义的内容有时候明明答案是几十页前的插图检索结果却把不相关的纯文本顶在最前面。这篇文章主要拆解我是怎么把ES和Milvus组成双引擎RAG并最终落到生产环境的。核心解决三件事为什么必须双引擎而不是二选一、BM25分数和向量分数怎么归一化到同一把尺子、召回结果如何做重排融合才能让图文混排的命中率真正可用。适合正在做企业知识库RAG、或者想给现有检索系统加语义能力的团队参考尤其适合图文混合内容的场景。1. 为什么双引擎不是炫技单一检索路线在图文场景下的致命短板1.1 纯向量库在精确匹配上的失控Milvus这类向量数据库核心优势在语义召回。你把文本和图片分别过一遍CLIP或者SigLIP映射到同一个向量空间然后拿用户query的向量去算近邻它确实能理解支持DP接口的显卡和DisplayPort之间的关系。问题在于向量检索对精确这件事非常迟钝。举一个我们线上真实踩过的例子产品手册里有一条内容是型号GTX-4090-Ultra接口HDMI 2.1 / DP 1.4功耗450W。用户问GTX 4090 Ultra功耗多少向量检索能把这条召回来。但用户问的是功耗低于500W的显卡有哪些整个知识库根本没有低于这个概念的精确表达向量检索就是把分数最高的几条返回给你它不会因为450W在数值上满足小于500W就把这条排在前面它只认语义相似。更麻烦的是型号和编号。产品库里有大量形如ABC-100-X23这样的SKU编码向量模型经常把数字位搞混X23和X24在语义空间里几乎重合但业务上这是完全不同的两个东西。这种场景下纯向量库里你一点办法都没有只能靠过滤条件硬筛或者接受糟糕的准确率。1.2 纯ES的字符串匹配救不了口语化查询既然向量搞不定精确匹配那把系统整个换成ES行不行也不行。ES的BM25全文检索在关键词命中这件事上非常强型号、术语、精确短句都是它的舒适区。但用户不会总是用精确术语提问。比如用户问你们有没有带DP口、价格还便宜点的显卡ES拿到这个query分词之后可能匹配到显卡这个通用词然后被大量包含显卡二字的页面淹没。它不理解DP口等于DisplayPort也不理解便宜点是一种价格排序的意图更不理解用户可能还想看到对应的显卡图片。知识库里的内容是图文混排的单靠ES根本没法把图片相关的内容纳入召回体系图片没有文本词频不参与BM25打分。1.3 双引擎的正确分工精确与语义各司其职所以双引擎不是为了架构好看是两种检索模式的能力互补太明显丢掉任何一个都会漏掉大量有效结果。ES负责精确检索、结构化过滤和元数据约束产品型号、SKU编码、接口类型、维护日期、文档分类这些该用倒排索引解决的交给BM25和term查询。Milvus负责语义召回和跨模态图文匹配文本query去匹配图片的语义向量文本query去匹配口语化改写后的文本向量让DP口和DisplayPort这种非字面匹配成为可能。两条召回通道在并行阶段互不干扰各自返回一批候选结果进入融合层统一打分排序。这个架构思路本身不复杂真正复杂的是后面要说的分数归一化和融合策略。2. 图文混排场景下的数据建模与索引落库段落粒度的核心设计2.1 以段落为最小检索单元的图文统一模型图文混排最难处理的不是向量模型选哪个而是数据怎么切、怎么建模。我的做法是不管原文是一页PDF、一张流程图还是一个多级标题下的章节统一切到段落粒度一个段落就是一条独立的检索记录。段落分三类纯文本段落、纯图片段落、图文混合段落。纯图片段落没有文本内容只有图片URL、OCR出来的文字、以及图片的语义向量图文混合段落则是文本和图片一起出现文本以markdown格式保留图片引用位置。{ paragraph_id: para_102938, doc_id: doc_0042, doc_title: GTX系列显卡安装指南, category: installation_guide, product_model: GTX-4090-Ultra, text_content: 将显卡插入PCIe x16插槽连接8pin供电线..., image_url: https://cdn.example.com/gtx-pcie-slot.png, text_vector: [0.023, -0.015, ...], image_vector: [0.102, -0.086, ...], publish_date: 2025-03-14 }这里有个很容易被忽视的点一条段落记录同时保留text_vector和image_vector。原因在于一个图文混合段落被召回后前端要同时展示文本和图片才能给用户完整答案。如果只存文本向量图片内容会全部漏检如果只存图片向量纯文本问题的语义匹配又变差。两个向量字段并存才能保证无论用户从文本角度还是图片角度提问都能命中同一条段落。2.2 ES侧索引设计元数据约束和BM25的配合ES索引的设计重点是该过滤的提前过滤该分词的仔细分词。字段上除了text_content用标准的IK分词器或者你定制的行业词典其他元数据字段全部走keyword或数值类型这样在检索阶段可以拿product_model、category、publish_date做精确过滤。实际项目里我强烈建议在检索阶段先用元数据过滤缩小范围再跑BM25。比如用户只看安装指南类目下的内容就先把category限定住用户提到了具体型号就把product_model作为filter条件。这个习惯能显著减少BM25在高基数文档集合下的误匹配也能给后面的分数归一化省掉很多麻烦。ES侧我的索引mapping大致这样{ mappings: { properties: { paragraph_id: { type: keyword }, doc_id: { type: keyword }, doc_title: { type: text, analyzer: ik_max_word }, category: { type: keyword }, product_model: { type: keyword }, text_content: { type: text, analyzer: ik_max_word }, publish_date: { type: date } } } }keyword字段一律不做全文检索避免模糊匹配把范围搞乱。text_content和doc_title才走全文分词并且我对doc_title执行了更高的boost标题命中的段落比正文命中的优先级更高——这是从用户点击行为里总结出来的规律标题相关性和用户最终想要的答案之间高度相关。2.3 Milvus侧Collection设计多向量字段与索引参数选择Milvus这边的设计取决于你的版本。Milvus 2.4之后的版本支持在同一个Collection里挂多个向量字段我最新的项目就这么做一个Collection里同时放text_vector和image_vector两个字段用不同的索引类型和参数。如果你的版本还停留在旧版那就开两个Collection用paragraph_id做外键关联效果一样只是检索的时候需要并行查两个Collection再合并ID。我给图片向量用的是CLIP系列模型文本向量用的是配套的text encoder这样文本和图片天然落在同一个语义空间里文本query可以直接检索图片向量。索引参数上我测试下来比较稳妥的一套配置是参数值说明index_typeHNSW内存索引查询性能稳定metric_typeIP内积配合归一化后的向量M16每个节点的最大连接数越大召回越高内存越大efConstruction200建索引时的搜索宽度200够用再高收益不明显ef128查询时的搜索宽度线上稳定在128附近这里有个坑如果你选的是IP内积向量入库之前一定要做归一化否则内积结果会被向量模长严重干扰长文本的向量模长天然偏大导致检索结果无脑偏向长文本。归一化之后内积等价于余弦相似度语义可比性才有保障。检索时Milvus会返回很多命中的段落但速度相对ES会慢一些所以Milvus侧召回数量不要贪多我一般控制在top 100以内。再多的话重排阶段的计算压力会指数上升而收益趋近于零。3. 分数归一化让BM25和向量分数真正同台竞技3.1 两种分数的原生分布差多远双引擎召回之后你会拿到两套完全不同的分数体系。ES的BM25分数理论上范围是0到正无穷实际运行中受文档长度、词频、字段boost影响非常大。有的query命中的文档BM25得分能到20多有的query只有3、4分。这个分数本身没有绝对含义只能在同一批检索结果里做相对排序。Milvus这边如果你向量归一化后用的是IP内积分数范围会落在[-1, 1]如果是欧氏距离那是越小越相似跟BM25的方向正好相反如果用了某些embedding模型的原始cosine分数范围可能是[0.6, 0.95]这种局部区间。直接把两边的分数加在一起排序是我见过最多的低级错误。你想想一个BM25得10分的精确匹配和一个cosine得0.85的语义匹配谁更重要数值上10比0.85大得多但0.85的语义匹配可能才是用户真正想要的。反过来如果把BM25缩放成0到1两者的权重又无从谈起——到底语义占比高还是关键词占比高根本没有依据。所以第一步必须做归一化让两边分数落在可比的尺子上第二步才能考虑权重和融合。3.2 min-max、百分位与滑动窗口校准三种生产可用的归一化方案我把生产上验证过的归一化方案按推荐程度列一下方案一min-max归一化最直接但最不稳def min_max_norm(score, min_score, max_score): if max_score min_score: return 1.0 if score max_score else 0.0 return (score - min_score) / (max_score - min_score)用当前这轮检索结果里的最小值和最大值做映射。优点是零成本、实时计算。缺点也很明显每一轮query的分数分布都不一样可能这轮query的BM25最高分是8下一轮最高分是20min-max归一化后8和20都会变成1.0跨query之间完全不可比。如果你的融合策略需要根据语义权重和关键词权重做全局权衡这个方案会让权重永远调不准。方案二百分位映射用离线分布做校准函数离线准备几百条代表性query分别记录ES和Milvus的原始分数分布算出5分位、50分位、95分位。线上查询时用分位数做线性插值把原始分数映射到0到1的百分位区间。# 预先统计出的百分位实际值按你的数据分布来 percentiles_es {5: 1.2, 50: 3.8, 95: 15.7} percentiles_milvus {5: 0.55, 50: 0.73, 95: 0.91} def percentile_norm(score, percentiles): if score percentiles[5]: return 0.0 if score percentiles[95]: return 1.0 # 取50分位和95分位做线性映射也可以多段映射 return (score - percentiles[5]) / (percentiles[95] - percentiles[5])这个方案的优点是跨query打分稳定缺点是离线分位数分布可能随时间漂移。线上内容更新频繁的话你得定期重算分位数。方案三滑动窗口自适应归一化生产环境最推荐用最近N次检索的分数来动态估算min和max相当于给min-max加了一个滑动窗口。实现上可以用一个带遗忘因子的在线统计器每一轮查询结束把当轮的分数分布更新进去。class SlidingScoreNormalizer: def __init__(self, window_size200, decay0.98): self.window [] self.window_size window_size self.decay decay self.ema_min None self.ema_max None def update(self, scores): self.window.extend(scores) if len(self.window) self.window_size: self.window self.window[-self.window_size:] current_min min(self.window) current_max max(self.window) if self.ema_min is None: self.ema_min current_min self.ema_max current_max else: self.ema_min self.decay * self.ema_min (1 - self.decay) * current_min self.ema_max self.decay * self.ema_max (1 - self.decay) * current_max def norm(self, score): if self.ema_max self.ema_min: return 1.0 return (score - self.ema_min) / (self.ema_max - self.ema_min)滑动窗口的好处是能缓慢跟踪线上数据分布的变化不会因为某一次奇怪的query导致整个归一化失效。线上我用的就是这个方案效果比固定min-max稳很多。3.3 归一化之后还需要做的三件小事归一化不是结束后面还有三个工程细节少了任何一个效果都会打折扣。第一给每个引擎单独设置可信分阈值。归一化之后ES低于0.15的结果基本是噪声Milvus低于0.3的结果大概率语义不相关。融合之前先过滤掉这些低分结果能显著降低后面重排阶段的压力。阈值不能拍脑袋定要拿着线上真实query的标注数据去调宁可多召回一点给重排也不要一开始就错杀。第二两边归一化后的分数分布可能还是不对齐。比如ES归一化后经常集中在0.4到0.9Milvus集中在0.1到0.6。这时候要通过一个引擎置信权重来对齐不是简单乘个系数而是对分数做一次幂变换或者log变换让两边的分布形状接近。我在ES侧用过score ^ 0.8这样轻微的压缩在Milvus侧用过score / (score 0.2)这种饱和函数具体参数从验证集上回归出来。第三归一化的输入要按业务类型分开。FAQ问答和产品参数查询的分数分布是完全不同的。FAQ语义匹配通常是Milvus分数高产品参数查询经常是ES分数高。如果你把所有检索混在一个归一化器里最后一定是两边打架。我的做法是按doc类型建了两套归一化器和融合权重这样效果才真正稳定。4. 从召回融合到精排为什么RRF比加权求和稳重排模型解决什么问题4.1 加权求和的三宗罪归一化做完了很多人会顺手做加权融合score 0.5 * norm_es 0.5 * norm_milvus这个公式看起来合理实际上有三个坑。第一个坑是权重难以标定。0.5和0.5看起来公平但不同query下两边的靠谱程度完全不一样。有的query明显是精确型号查询ES权重应该拉高有的query是口语化描述Milvus权重应该拉高。固定权重等于假设所有query的分布一致这个假设在真实场景里基本不成立。第二个坑是分数经过归一化后仍然保留了绝对分数的部分误导信息。一个ES分数0.9的结果可能是因为这一个文档特别短、BM25统计上占便宜而不是它真的相关。加权求和没法区分高分的可靠和高分的侥幸。第三个坑是可解释性差线上调参全靠玄学。你说0.5不行改0.6为什么改0.6拿不出依据。一旦出问题你根本不知道是归一化偏了还是权重偏了。4.2 RRF融合用排名替代分数屏蔽分布干扰RRFReciprocal Rank Fusion的思路是彻底不看分数只看排名def rrf_fusion(es_results, milvus_results, k60): fused_scores {} for rank, doc_id in enumerate(es_results, start1): fused_scores[doc_id] fused_scores.get(doc_id, 0) 1.0 / (k rank) for rank, doc_id in enumerate(milvus_results, start1): fused_scores[doc_id] fused_scores.get(doc_id, 0) 1.0 / (k rank) return sorted(fused_scores.items(), keylambda x: x[1], reverseTrue)k值取60是RRF原始论文里的经验值在实际项目中我也测过20、50、10060确实是综合效果最好的。k越小排名靠前的结果权重越高融越发激进k越大排名的影响越平滑相当于容忍更多长尾结果。60这个值在多数场景下能达到平衡。RRF的好处在于它绕开了分数大小不一致的核心问题只保留谁排前面这个最稳定的信息所以对归一化误差不敏感对引擎差异也不敏感。ES召回的前3名和Milvus召回的前3名哪怕两者分数分布差一个数量级RRF也能公平地都给到高分。如果同一个doc在两个引擎的召回列表里都出现在前10它的融合分会被自然放大这正是多重证据加持的体现。在双引擎RAG里我的做法是ES取top 50Milvus取top 100两者取并集后用RRF融合再截断到top 50交给后面的精排。这里有个细节——ES召回数量小于Milvus是因为ES的BM25对精确匹配的精度更高前面50条已经覆盖了大部分有效结果Milvus语义召回范围广、噪声多需要多捞一些给后面精排去筛选。4.3 引入Cross-Encoder精排RRF解决召回重排解决准RRF之后的结果排序逻辑是多引擎共同认可的程度还不是和用户query真实相关的程度。比如一个段落虽然被两个引擎都排到前10但它可能只包含query里的一个侧面整体上并不算一个好答案。这时候需要引入Cross-Encoder重排模型对query和候选段落做一次真正的深度语义交互评分。Cross-Encoder和向量检索用的Bi-Encoder原理不同。Bi-Encoder是把query和段落分别编码成向量再算相似度速度快、可以离线建索引但语义交互不足。Cross-Encoder是把query和段落拼成一个序列直接送进模型让模型同时看两边的token做深度交互效果更好但速度慢得多所以只能用于精排阶段。我用的是bge-reranker-v2-m3重排的输入是query 段落文本。对纯图片段落输入是query OCR文本 图片向量对应的标签文本没有OCR的话就用图片的alt文本。一定要重排的还是比RRF融合后的top 50而不是全量召回集不然延迟扛不住。Cross-Encoder跑50条每条大概几十毫秒总延迟能接受。精排输出的分数才是最终排序依据。最后再在精排分数上叠加一个业务兜底逻辑如果某个段落是精确型号匹配比如SKU完全相等给它加一个强加成如果段落是配图段落且用户问题带图字或长什么样给它提权。这些业务规则属于锦上添花但确实能解决不少bad case。重排模型对纯向量召回和纯ES召回的提升幅度我们做了个对比实验方案Recall10MRR10平均响应延迟纯ES61.2%0.5235ms纯Milvus67.8%0.5880msESMilvus RRF78.4%0.6790msESMilvus RRFCrossEncoder89.6%0.79240ms数据说明RRF已经把召回率抬上去了但MRR排序质量还是不够重排模型是最后一步把正确答案排到最前面的关键杠杆。240ms的延迟在内部知识库场景完全可接受如果需要降到200ms以内可以把重排范围从top 50缩到top 30MRR损失很小。5. 生产环境落地的几条实测避坑经验双写一致性、延迟预算和监控指标5.1 双写一致性ES和Milvus数据不同步是怎么排查出来的双引擎架构绕不开的一个问题是同一份文档既要写ES又要写Milvus两边数据怎么保持同步。我们的初始方案是用Canal监听业务数据库的binlog然后异步写ES和Milvus。上线头几天一切正常直到有一天用户反馈昨天刚更新的手册搜不到。一查ES里有这条数据Milvus里没有再过一会儿Milvus来了ES的refresh interval还没到。两边写入链路的速度天然不一致加上异步重试必然产生一个可见的时间窗口在这个窗口里检索会漏数据。排查链路是这样的确认ES里有没有GET /index/_search按doc_id查到数据在。确认Milvus里有没有按主键query数据确实缺失。检查消费进度发现binlog消费程序在写Milvus时因为向量模型推理超时抛异常后进入了重试队列。重试队列是串行的前面的任务堵住后面的任务全部延迟。修复方案是两件事。第一写入端改为并行独立重试ES和Milvus分别维护各自的重试队列失败互不阻塞重试带上幂等ID保证重复消费不会产生脏数据。第二删除操作要双删ES和Milvus主键一致删除时必须两个引擎都删只删一个会造成幽灵数据。Milvus按主键删除走expression过滤注意确认删除实际生效Milvus对删除的可见性有时延。5.2 延迟预算分配从用户点击到答案渲染的每一毫秒都花在哪了生产级RAG必须算清楚延迟预算。用户感知到的延迟一半以上来自检索链路所以我把一次完整检索的延迟分成四个阶段阶段时间预算说明用户query解析和意图分类10ms判断是否需要元数据过滤ES BM25召回30ms倒排索引走完top 50Milvus向量召回80msHNSW检索top 100RRF融合CrossEncoder重排120ms50条候选拼序列推理总预算240ms线上p99实测在290ms左右主要波动来自Milvus的集群负载和重排模型的GPU推理排队。如果你们的业务对响应时间更敏感我有几个亲测有效的降延迟手段向量检索的ef降到64延迟能砍掉20%召回率只损失不到1%。CrossEncoder改成小模型比如ms-marco-MiniLM-L-6-v2单条推理从40ms降到15msMRR降幅在3%以内。ES侧把refresh interval从1s调到5s写入压力降了三分之二查询侧基本无感。5.3 监控什么才能知道检索质量在退化RAG上线只是起点检索质量会随着知识库内容变化、用户query分布变化而悄悄退化。只盯着系统延迟和CPU使用率远远不够得监控业务层面的指标。我维护的监控面板里最重要的三个业务指标无结果率每天有多少query最终一个段落都没返回。无结果率突然升高大概率是embedding模型或向量索引出问题了。Top1命中率抽样看返回的第一条结果是否为正确答案这个指标和用户满意度直接挂钩。反馈闭环用户点了有帮助还是没帮助这个信号可以回流到重排模型的训练集里做周期性微调。技术上还要监控ES的慢查询、Milvus的segment状态、消费goroutine积压数量、跨引擎数据一致性差异条数。一致性差异条数这个指标尤其重要我就是靠它发现过一次ES写入成功但Milvus丢失的老大难问题后来在两边各加了一个校验任务定期核对ID集合差异超过阈值就报警。最后分享一个经验细节上线新embedding模型或新重排模型之前一定要拿着线上真实query的历史记录做离线评测否则模型换完你都不知道效果是变好了还是变差了。我们离线评测的测试集就是线上用户真实query和对应的正确答案段落每周更新一次任何模型替换都必须过这一关。我见过太多项目模型换了半天靠直觉说好像变准了一跑离线测试发现MRR掉了8个点白白回滚一次。这种哑巴亏吃一次就够了。
返回列表