去年我们把内部知识库从“能搜到文本”升级成“真能回答图文混排问题”的 RAG 系统,整个过程最折腾的不是大模型生成,而是检索侧的 ES + Milvus 双引擎协同。我们的数据源是几百份产品手册、技术白皮书和售后工单,PDF、Word、PPT 什么格式都有,打开一看,真正的信息量有一大半藏在图片里:结构图、拓扑图、截图、表格转图。文本链路倒还好,麻烦的是用户问一句“这块板卡在拓扑图里的位置”,老检索系统直接懵了。就是这个场景,逼着我们解决了两件事:跨引擎分数归一化,以及多路召回后的重排融合。这篇就把完整方案、踩过的坑、以及可以直接抄的参数配置都写出来。
1. 为什么生产级 RAG 需要双引擎
1.1 单一向量检索的短板
很多团队一上来就选纯向量检索方案,把文档切块后全部扔进 Milvus,查询时 top-k 召回灌给大模型。demo 阶段确实跑得通,但生产环境很快就露馅:用户提问往往是带着明确型号、编号、规格的,比如“CL-2000 板卡的最大功耗是多少”。这种查询对语义相似度并不敏感,它需要的是精确关键词匹配。你问“最大功耗”,向量检索可能召回一堆“功率”“能耗”相关的段落,但“CL-2000”这个精确型号如果没被语义模型理解透,就会排在很后面,最终被 top-k 截断丢掉。
反过来,纯 BM25 全文检索也不行。用户用口语化问法“这块板子发热严重怎么办”时,文档里用的是“温度过载保护机制”,字面完全不重合,BM25 排不出来。这就是 ES 和 Milvus 互补的根本原因:一个擅长精确词匹配和结构化过滤,一个擅长语义近似召回。生产级 RAG 不能只押一边,双路召回是底线。
1.2 ES 与 Milvus 的职责边界
先说结论,ES 负责“记得住”,Milvus 负责“看得懂”。
| 维度 | Elasticsearch | Milvus |
|---|---|---|
| 核心能力 | 倒排索引、全文检索、聚合分析 | 高维向量索引、近似最近邻检索 |
| 擅长场景 | 关键词匹配、型号/编号精确命中、过滤条件 | 语义相似、同义改写、跨语言召回 |
| 查询语言 | DSL,支持 bool/filter/match | 向量相似度搜索,支持 scalar filter |
| 分数含义 | BM25 相关度,数值范围不固定 | 余弦相似度 / 内积,数值范围依赖向量分布 |
| 索引类型 | inverted index + rank_features | HNSW、IVF_FLAT、IVF_PQ |
| 运维成本 | 依赖 JVM,堆内存调优 | 依赖 etcd + minio,组件较多 |
在图文混排场景里,我还会让 ES 承担“图片上下文文本”的检索:把图片识别出来的文字、图片所在章节的标题、图片的 caption 拼成一段文本,写入同一个 chunck 的 content 字段。这样用户即使只输入文字,ES 也能通过文本通道召回图片相关内容,而不是非得依赖图片向量。
1.3 整体数据流与查询流
简单描述下架构,非代码层面:
数据入库阶段:原始文档经解析和 OCR 后,按版面拆成多个 chunk,每个 chunk 分别生成文本向量、图片向量、并保留纯文本和元数据。文本向量写入 Milvus,文本内容写入 ES,同时 Milvus 和 ES 的文档都带上同一个 doc_id 和 chunk_id。
查询阶段:用户 query 进来后,先并行发给 ES 和 Milvus。ES 用 BM25 出候选集,Milvus 用向量相似度出候选集。两道结果做分数归一化,再按融合策略合成一份候选表,最后用 cross-encoder 重排,取 top-k 交给大模型生成回答。
这套流程看着简单,但每一环都有坑。下面按数据链路、分数归一化、重排融合、性能优化、问题排查的顺序展开。
2. 数据链路:从原始文档到双索引
2.1 先把文档“拆开”:OCR 与版面分析
图文混排检索的第一步不是向量化,而是把图片里的信息“挖”出来。我们的文档来源杂乱,有原生 PDF,也有扫描件,还有从网页导出的 HTML。一开始直接按整篇文本切块,结果图片里的型号、参数全丢了,检索效果惨不忍睹。
后来我们加了版面分析层,流程是三段式:
- 文本型 PDF:用 PyMuPDF 提取文字和坐标,同时标注页面中每个图片的位置;
- 扫描型 PDF:先走 OCR,整页识别后用坐标还原段落和图片位置;
- 表格区域:如果表格被转成图片,先调用表格结构识别模型,把表格还原成 Markdown 文本,而不是当成一张普通图丢给视觉模型。
OCR 工具选型上,中文场景我建议优先看 PaddleOCR 或 RapidOCR,接口简单,CPU 也能跑。如果文档量不大,直接用 PaddleOCR 的 PP-OCRv4 就够用,识别精度在中文印刷体上表现很稳。对清晰度极差的扫描件,再考虑调用商用 OCR 服务,因为自建服务在这种场景的投入产出比不高。
图片信息提取这里有个容易犯的错:只提取图片,不提取图片上下文。我们的做法是每个图片块记录三部分:图片本身做视觉向量、图片周围最近的一级标题、图片在正文中被引用的句子。三者拼成一个“图片语义单元”。
2.2 分块策略与元数据设计
分块决定了检索效果的上限。我们试过固定 256 字、512 字、1024 字,也试过按段落切,最终选择“层级切分 + 滑动窗口”组合方案:
- 先按章节标题切出大块,避免把两个不同小节的内容混在一个 chunk 里;
- 每个大块再按句子边界切成 512 字左右的子块,相邻子块之间重叠 50 个字,这 50 个字是为了避免关键句正好落在切分边界上;
- 表格和图片各自独立成块,不强行塞进相邻文本块中。
分块后的元数据必须齐全。以我们 ES mapping 为例,简化的字段设计如下:
{ "chunk_id": "keyword", "doc_id": "keyword", "doc_type": "keyword", "page_no": "integer", "chunk_type": "keyword", "content_text": "text", "content_vector": "dense_vector", "image_caption": "text", "source": "keyword", "updated_at": "date" }Milvus 集合字段类似,但多一个高维向量字段。元数据看似不起眼,实际用途很大:doc_type 可以只搜索某类文档,page_no 可以在生成答案时附带引用页码,chunk_type 可以让我们知道召回的是文本还是图片。
2.3 Embedding 选型:文本和图片要对齐
这是图文混排场景的核心。如果你把文本用 BGE-M3 生成向量,把图片单独用别的模型生成向量,两个向量处在不同语义空间,即使都归一化到同一长度,相似度比较也没有意义。你检索“拓扑图中电源位置”时,文本向量会偏向纯文本语义,图片向量和它根本不匹配。
我们的做法是分两条路:
- 纯文本 chunk 用 BGE-M3 生成 dense 向量,维度 1024,中文和英文效果都稳定;
- 图片 chunk 同时生成两类向量:一类是用视觉语言模型先给图片生成一段描述文本,再把这个描述文本用 BGE-M3 生成向量;另一类是用 CLIP 的 image encoder 生成图片本身的视觉向量。
查询时,query 文本先转换出两个向量:一个 BGE-M3 文本向量去跟所有 chunk 的 BGE-M3 向量做相似度,一个 CLIP 文本向量去跟图片的 CLIP 视觉向量做相似度。这样图片既能被语义检索到,又能在文本语义空间里和其他文本 chunk 比较。真正做向量检索时,我们优先用 BGE-M3 通道,CLIP 通道只在明确识别出图片检索意图时才加大权重。
如果你不想维护两套向量通道,还有一个省事方案:所有图片在入库时只生成描述文本,后续检索不碰视觉向量。这个方案能让系统结构简单很多,但代价是图片描述模型的幻觉会让细节丢失,比如“型号”可能描述错。我们最终保留视觉向量,就是为了防止描述模型把关键字符看错。
2.4 Milvus 索引与集合设计
Milvus 侧的集合设计,按我们的体量(几百万级向量)做了这样的配置:
- 集合名:knowledge_chunk
- 向量字段:embedding,维度 1024
- 标量字段:doc_id, chunk_id, chunk_type, page_no, doc_type
- 向量索引类型:HNSW,M=16,efConstruction=200
- 相似度度量:IP,但入库前对向量做了 L2 归一化,这样 IP 等价于余弦相似度
HNSW 是当前性价比最高的索引类型,内存占用比 IVF 系列大一点,但查询延迟低很多,召回率也稳。M 和 efConstruction 是建索引时的参数,M 越大,图连接越稠密,召回越好但内存和建索引时间越高;efConstruction 控制构建时的搜索广度。生产环境我们跑过 8/16/32 三档,16 是折中点。
efSearch 是查询时参数,控制搜索深度。这个参数可以在每次查询时动态设置,线上我们设 64,如果延迟还有余量,可以试试 128,召回率会再涨一点但查询耗时会明显上升。
集合按 doc_type 建分区不是必须的,除非你有很强的隔离需求。我们直接全量放在一个集合里,靠标量过滤就够了。
2.5 ES 索引设计
ES 侧不只是存文本,还承担关键词精确匹配和过滤。我们采用了两个关键技术点:
第一,文本检索字段使用 BM25,参数保持 ES 默认的 k1=1.2、b=0.75。除非你的文档长度分布极不均匀,否则不需要动这两个参数。我们试过调 b 到 0.3,短文档分数被拉得过高,把长文档的准确信息挤掉了,最后又改回来。
第二,如果要进一步强化关键词,可以用 Elasticsearch 的 rank_features 字段存稀疏词向量。BGE-M3 会输出 lexical 权重,把权重最高的前 64 个词存入 rank_features,然后用 rank_feature 查询做“稀疏检索”。这个做法在精确匹配场景下很管用,尤其适合型号、名词的精确召回。不过 rank_features 要求字段值是正整数权重,BGE-M3 输出的权重需要做一次缩放取整。
ES 索引 mapping 中,content_text 用 ik_max_word 分词器的效果比 standard 好,中文场景建议加上 IK 分词。如果不想引入插件,也可以用 standard + lowercase,但召回率会差一些。
2.6 写入管道的可靠性
入库链路有几个工程细节必须提前设计,不然后期补数据特别痛苦。
首先是异步写入。文档解析、OCR、向量化都是耗时操作,不能放在接口请求链路里同步做。我们用的是 Java 后端,处理流程是:上传文档 → 写入任务表 → 后台 Worker 消费任务 → 解析并向量化 → 并行写入 ES 和 Milvus → 更新任务状态。
ES 写这块,用 BulkProcessor 或者新版的 BulkRequest 加监听器,批量 500 条或者每 5 秒 flush 一次。这里有个大坑:如果批量提交后回调里没有捕获失败,索引不存在或 mapping 冲突会导致整批写入失败,但任务状态已经标记成成功。必须为每个文档记录写入日志,并设置重试机制。
Milvus 写入用 insert 接口,理论上单次可插上万条,但我们控制在 4096 条一批,避免负载过高。Milvus 的写入是异步落盘的,如果任务刚写完马上查询,可能会出现查不到的情况,需要确认 flush 状态或等几秒。
幂等性上,我们以 chunk_id 作为唯一键。ES 里用 doc_id 指定 _id,Milvus 里用主键。重复跑任务时直接覆盖写,不会产生重复数据。
3. 检索链路:分数归一化是第一步
3.1 两套分数体系不可比
第一次做融合时,我们的实现简单粗暴:ES 返回 BM25 分数,Milvus 返回余弦相似度,两个分数直接加权相加。效果一言难尽。
BM25 分数受词频、文档长度、平均文档长度影响,数值范围非常大,常见情况是 0 到 30 不等,极端情况下能到几十。而 Milvus 用余弦相似度,数据经过 L2 归一化后,分数基本落在 0.65 到 0.95 之间。直接相加,向量相似度对最终排序的影响几乎可以忽略,等于退化成纯 ES 检索。
你必须先搞清楚:这两套分数不是同一个量纲,不能直接加。
3.2 归一化方法的对比
归一化有几种常见方法,我都试过:
min-max 归一化:把当前候选集的分数区间映射到 [0, 1]。公式是 (score - min) / (max - min)。这个方案的问题在于,min 和 max 来自候选集自身,如果候选集里有一个离群高分,其他分数都会被压到很低的区域,排序关系虽然不变,但和其他引擎加权时就失真了。
z-score 归一化:对候选集算均值和标准差,然后 (score - mean) / std。它比 min-max 稳定一些,但要求候选集分布接近正态,实际场景不满足。
softmax 归一化:先对分数乘一个温度系数,再做 softmax。温度和分数分布强相关,需要频繁调参,做起来很重。
基于排序的归一化:不关心绝对分数,只关心排名。比如 score_rank = 1 - rank / topK,或者直接用 RRF 的倒数排名。这个方法鲁棒性最好,因为排名的稳定性远高于绝对分数。生产环境我们最终选了这个方向。
3.3 比例加权也会引入新问题
也有团队采用“离线校准比例”的方法:先分析同一批 query 在两个引擎上的分数分布,找到 95% 分位数,然后按比例映射到同一区间。这个方案能工作,但推广性差。因为不同 query 的类型差异极大:有的 query 精确型号命中率高,BM25 分数整体很高;有的 query 是语义发散型,BM25 分数很低。如果比例固定,遇到分布偏移的 query 又会出现一边压倒另一边。
3.4 我们最终采用的方案
在生产环境里,我们保留了两层分数处理:
第一层,计算出 min-max 归一化分数,但只用于日志记录和人工排查,不参与最终排序。第二层,用排名作为融合依据。具体做法是:对 ES 召回的 top 50 条,每条按排名给一个 rank 值(第 1 名 rank=1,第 50 名 rank=50);对 Milvus 召回的 top 50 条也做同样处理。然后用 RRF 公式合并,后面第 4 节详细说。
为什么最终放弃绝对分数?因为 query 分布变化太大,绝对分数跨 query 不可比。你在这条 query 里看到的 BM25 高分,在另一条 query 里就是中分。为了一个不可比的数值去调权重,怎么调都只在当前测试集上有效。
4. 重排融合:从多路召回到最终排序
4.1 候选集合成与去重
双路召回后,第一步是合并候选集。ES 和 Milvus 很可能召回到同一个 chunk,如果不做去重,融合时会重复计算,正确性出问题。我们的去重键是 chunk_id。
合并后常见候选集大小在 70 到 90 条左右,因为两路 top 50 有重叠,去掉重复后就是这个规模。这个数量不能直接丢给 cross-encoder 重排,也不能直接丢给大模型,需要在这里做第一层融合排序。
4.2 第一层融合:RRF 公式
RRF(Reciprocal Rank Fusion)是经典的多路召回融合算法,公式如下:
def rrf_score(rank, k=60): return 1.0 / (k + rank)每一条候选 chunk,在 ES 结果里有一个 rank,在 Milvus 结果里也有一个 rank,合并后的总分为两个 RRF 分数之和。k 是平滑参数,控制排名对分数的贡献曲线。k 越小,排名越靠前的影响越大;k 越大,排名差异的影响越小。经验值是 60,这也是很多论文和比赛中推荐的默认值。
有人会问,既然归一化之后可以直接加权,为什么要用 RRF?因为 RRF 不依赖绝对分数,只依赖两路分别给出的排名。排名的含义比分数稳定得多。ES 的 BM25 可能在这条 query 给出 0 到 30 的分数,但不能保证那条 query 也是这个分布;而排名是相对的,永远不会因为整体分数偏移而失真。
4.3 第二层重排:Cross-Encoder
RRF 只是解决了“两套分数可比”的问题,并没有解决“候选是否和 query 真的相关”的问题。这时候需要 cross-encoder 模型做最后一道排序。
双塔向量模型(如 BGE-M3 的 dense 向量)的问题在于,query 和 passage 是独立编码后才算相似度,交互信息被压缩在一个固定向量里。cross-encoder 则把 query 和 passage 拼在一起输入模型,直接输出相关性分数,语义交互更充分,精度明显更好。
我们用的是 bge-reranker-v2-m3,主要看重它对中文和多语言的支持。你也可以选 bge-reranker-base 或更大规模的模型,看显存和延迟预算。线上用量不大,单张 16G 显存足够,批量重排 50 条候选,延迟控制在 100ms 到 200ms 之间。
重排代码逻辑大概是:
query = "这块板卡在拓扑图里的位置" passages = [candidate["content_text"] for candidate in merged_list] scores = reranker.compute_score([(query, p) for p in passages], batch_size=16) for cand, score in zip(merged_list, scores): cand["rerank_score"] = score reranked = sorted(merged_list, key=lambda x: x["rerank_score"], reverse=True) final_topk = reranked[:10]注意一点:cross-encoder 的输入不要超长。bge-reranker-v2-m3 对超过 512 token 的长文本会截断,截断可能让关键信息丢失。我们的分块是 512 字左右,基本不会超限,但如果你用了更大的分块,重排前要再截断一次。
4.4 图文 chunk 的重排拼接
重排图片 chunk 时,不能只把图片路径丢给模型,模型看不懂图片路径。我们的做法是把预先生成的“图片描述文本 + 图片所在章节标题 + 图片引用句子”拼成一段伪文本,再喂给 cross-encoder。伪文本格式如下:
图片内容描述:图中显示电源模块位于板卡左下角,有 12V 输入接口。 所在章节:XXXX 板卡接口说明 引用上下文:下图为板卡的电源接口位置示意图。这样 cross-encoder 才能判断这张图是否与 query 相关。这一步看起来很简单,但如果不做,图片类 chunk 的重排分数会普遍偏低,最终全部被排到后面,图片检索等于失效。
4.5 融合权重与调参
如果只依赖 RRF,两路召回的权重是完全对等的。但实际场景中,不同 query 对两路的依赖不同。我们后续加入了 query 级动态权重:
- 检测到 query 包含精确型号、编号、且长度较短,偏向 ES 权重;
- query 是口语化长句、含同义改写,偏向 Milvus 权重;
- 其余情况保持默认 0.5/0.5。
实现上不复杂,用一组 if-else 规则就能覆盖大部分场景。更精细的做法是用一个意图分类模型预测权重,但对多数知识库场景,规则够用。
融合公式最终是:
final_sort_score = alpha * rrf_es + (1 - alpha) * rrf_milvus这里的 rrf_es 和 rrf_milvus 是同一个候选分别在两路结果里的 RRF 分数再归一化到 [0, 1] 后的值。alpha 由 query 类型决定。这个 final_sort_score 只用来决定重排模型的输入顺序,重排模型输出的 rerank_score 才是最终排序依据。
4.6 阈值与兜底策略
重排分数不能直接当“置信度”用,因为不同 query 的分数分布不一样。我们设置了两道兜底:
第一道,取重排第一名的分数做归一化,如果第一名分数与第 k 名的差距过小,说明候选间区分度不足,宁可减少返回条数,也不要把不相关内容喂给大模型。
第二道,如果两个引擎召回结果交集为空,且重排后最高分低于预设阈值,直接让 RAG 返回“知识库中没有找到相关内容,请尝试换一种问法”。这个兜底很重要,能防止大模型硬编答案。
5. 查询执行与性能优化
5.1 使用并发调用的双路召回
线上查询请求进来后,ES 查询和 Milvus 查询必须并行执行,不能串行,否则总延迟是两倍。Java 里用 CompletableFuture.allOf,Python 里用 asyncio.gather。
我们给两路都设了超时:ES 300ms,Milvus 300ms。超时后放弃该路结果,用另一路的结果继续往下走,避免单个引擎抖动拖慢整个接口。设置超时后,慢查询率从 5% 降到了 0.5% 以下。
5.2 Milvus 查询参数调优
Milvus 查询有三个参数直接影响延迟:topk、efSearch、nprobe。HNSW 索引时主要调 efSearch,线上设 64;如果使用 IVF_FLAT,nprobe 一般设为 16 到 32。grep 日志看延迟,P99 超过 300ms 时先检查这两项,再看服务端资源和 segment 状态。
Milvus 还存在一个“搜得慢但不知道为什么”的情况:刚写入的数据还在 growing segment 中,未参与索引,查询时是暴力扫描,延迟自然高。解决方法是触发 compaction,或者配置自动 compaction 策略。我们后来把 segment 的 max_size 调大,减少碎片,问题明显缓解。
5.3 ES 查询优化
ES 查询里尽量用 filter 上下文做元数据过滤,不要参与相关性打分。比如 doc_type、page_no 这些都是 filter,BM25 只跑 content_text 字段。这样能利用 ES 的缓存机制,减轻计算压力。
另外要控制返回字段。默认不返回 _source,只返回需要重排的字段(content_text、chunk_id、doc_type、page_no)。ES 经常在数据量一大之后返回体暴增,影响网络耗时。
5.4 缓存与预计算
知识库场景的 query 重复度很高,尤其是售后客服这类场景,前 500 个高频 query 可能覆盖 50% 以上的请求。我们为这些 query 做了结果缓存:
- 第一级:Redis 缓存最终 top-k 内容和引用信息,TTL 设为 24 小时;
- 第二级:重排结果缓存,key 是 query 的 embedding 向量做哈希,命中后跳过 ES/Milvus 召回和 cross-encoder 重排,直接出答案。
实测缓存命中率大约 40%,接口 P99 延迟从 900ms 降到 400ms 左右。
5.5 部署形态与资源估算
Milvus 生产部署,如果你说的是几百万到几千万向量的规模,并且不追求多租户隔离,单机 docker compose 就够了。核心组件是 etcd、minio、proxy、querynode、datanode。如果使用外部 minio,注意配置 bucket 和 access key,Milvus 才能正常持久化数据。我们曾经因为外部 minio 的 bucket 权限不对,导致写入后重启数据丢失,排查了很久。
ES 部署时 JVM 堆内存不要超过物理内存的一半,给操作系统和 page cache 留空间。堆内存超过 32GB 后,对象指针压缩会失效,反而浪费内存,所以单节点 32GB 是性价比最高的配置。
重排和向量化建议单独部署 GPU 服务,避免和 ES、Milvus 抢 CPU 资源。我们用的是 16G 显存的卡,embedding 和 rerank 共用,batch 化后完全够用。
6. 生产环境常见问题与排查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| Milvus 查询偶尔耗时飙到 2s+ | segment 未合并、索引未构建、查询节点资源竞争 | 开启自动 compaction,预构建索引,增加 querynode 副本 |
| ES 查询分数出现 NaN | script_score 里除零或稀疏字段权重为空 | 对 script 加保护判断,空权重直接置 0 |
| 图片类 query 检索不到 | 图片未生成描述文本,或视觉向量与文本向量不在同一空间 | 补充图像描述 pipeline,使用同一文本模型对描述编码 |
| cross-encoder 重排后结果变差 | 候选集本身就缺失正确答案,或重排模型输入被截断 | 扩大召回 topK,检查输入长度,必要时截断策略调整 |
| 不同环境的向量维度不一致 | embedding 模型版本不一致 | 锁定模型版本,记录向量维度到配置中心 |
| Java 异步写 ES 丢数据 | bulk 回调里没有捕获失败异常 | 回调中记录失败明细,失败数据进入重试队列 |
| 双路融合后结果偏向某一引擎 | 归一化方式选择不当或权重固定 | 改为 rank-based 融合,或引入 query 级动态权重 |
| Milvus 写入后立刻查不到 | 数据尚未 flush 完成 | 检查 flush 状态,或设置强一致读取策略 |
这些坑里,最值得展开说的是 Java 异步写 ES 丢数据。使用 BulkProcessor 时,很多人只写了成功回调,失败时默认丢弃。我们的处理是:批量失败时,把请求里的 doc_id 列表写入重试队列,最多重试三次,三次后进入死信队列,再人工排查。这个机制上线后,数据完整率从 99.2% 提升到 99.98%。
还有一次线上事故,是 Milvus 版本升级后外部 minio 的存储格式不兼容,导致历史数据读不出来。升级前一定要先备份 etcd 数据,并且对 minio 里的对象做一次完整校验。单机部署时,稳妥做法是先停写,全量 flush,再做版本升级。
7. 效果评估与调优
7.1 离线评估集构建
做检索系统的评估,不能只看感觉。我们整理了一份评估集,包含 120 条真实 query,覆盖文本检索、图片检索、混合检索三类场景。每条 query 由人工标注 5 到 10 条相关 chunk。评估指标用三个:Recall@10、MRR@10、NDCG@5。
对比了三套方案:
| 方案 | Recall@10 | MRR@10 | NDCG@5 |
|---|---|---|---|
| 仅 Milvus 向量召回 | 0.64 | 0.41 | 0.44 |
| 仅 ES BM25 + rank_features | 0.58 | 0.38 | 0.40 |
| ES + Milvus + RRF + cross-encoder 重排 | 0.86 | 0.68 | 0.72 |
双引擎融合加重排后,Recall@10 提升非常明显。主要原因就是很多 query 同时触发了关键词和语义两路召回,RRF 保住了两边的优势答案,重排又把真正的相关文档顶到了最前面。
7.2 线上效果
线上真实场景下,最能感知的变化是“找不到答案”的比例。过去纯向量检索时,售后同学经常反馈“这个内容库搜不到”,现在这类反馈少了大概 60%。另一个明显变化是答案引用的页码准确了,因为图文 chunck 都带了 page_no,且重排能保证命中的 chunk 是真正和 query 相关的。
还有一个细节:重构后的大模型生成不再经常“胡编”。原因是 RAG 喂给大模型的 top-k 内容里混入了太多无关文本,模型被迫从错误上下文里编答案。重排之后,top-k 的精度高了,幻觉概率自然下降。
7.3 后续扩展方向
这套双引擎架构可以继续往两个方向走。
一个是 Agentic RAG,也就是让大模型自己决定什么时候查 ES、什么时候查 Milvus、什么时候做多跳检索。我们的实践是从 query 改写开始的,先让大模型把复杂问题拆成 2 到 3 个子查询,每个子查询走双路召回,最后汇总答案。这个方案对“对比两个型号差异”这类问题效果很好。
另一个是 Ontology RAG,把领域本体约束加进检索链路。比如明确“板卡”和“电源模块”的关系后,查询可以自动带上过滤条件,缩小召回范围。这个方向适合垂直领域知识库,前期需要梳理本体,成本较高,但效果上限也高。
我个人在实际操作中的体会是:ES + Milvus 双引擎的价值不在“多一个引擎”,而在“让两种不同性质的匹配方式各司其职”。分数归一化和重排融合才是真正决定系统上限的部分。想在生产环境稳定落地,别在绝对分数上死磕,早点转向基于排名的融合方案,把 cross-encoder 重排加上,图文混排问题会解决得更干净。最后再分享一个小技巧:线上每次升级融合策略前,回放过去的日志,用评估集跑一遍离线对比,稳定了再上线。这个习惯能帮你避免很多“感觉变好了但其实只是测试数据恰好合适”的坑。