072、多路召回与融合排序
2026/9/17 1:51:15 网站建设 项目流程

072、多路召回与融合排序

昨天夜里线上告警,某个Agent在回答用户关于“某型号工业网关的Modbus寄存器地址映射”时,给出了一堆完全不相关的内容。我翻日志,发现召回阶段只用了向量检索,Top K直接抽了20条文本片段。问题就出在这儿——用户的query里包含“寄存器地址”“映射表”这种强结构化的词,向量检索按语义相似度拉回来的几乎都是说明书里泛泛而谈的“网关支持Modbus协议”这类句子。你跟我讲语义,用户想要的是寄存器偏移量和数据类型。

这种场景不是个例。Agent要能稳定输出,检索链路的召回质量是地基。单路召回,无论你选向量、BM25还是基于知识图谱的查询,总有盲区。向量擅长语义匹配,但对精确数字、代码标识、专业术语的字符串匹配毫无脾气;BM25这类稀疏检索对关键词命中敏感,但换个说法,比如用户问“这个寄存器能不能写”,你文档里写的是“支持读写操作”,它就抓瞎。所以多路召回不是炫技,是现实逼着你这么干。

我这个项目里最后是四条路并行:向量召回、BM25召回、倒排索引精确匹配、还有基于规则模板的“实体-属性”抽取召回。向量用了bge-m3,BM25用的是ES里的标准实现,精确匹配那路是专门针对型号、寄存器地址这类pattern做的,规则模板则是维护了一张领域词典和几十条正则。你可能觉得重,但每一路都是为了堵住另外几路的漏洞。

代码结构大概长这样:

defrecall(query,topk=20):# 各路召回结果都放在一个列表里,统一塞进一个结构all_results=[]# 向量召回vec_docs=vector_search(query,topk)# 这里给每路结果打上来源tag,后面融合和调试都用得上all_results.append(pack(vec_docs,tag="vector"))# BM25召回bm25_docs=bm25_search(query,topk)all_results.append(pack(bm25_docs,tag="bm25"))# 精确匹配,专门处理型号/寄存器地址这种带特殊字符的exact_docs=exact_pattern_search(query)all_results.append(pack(exact_docs,tag="exact"))# 规则召回,查领域词典rule_docs=rule_based_entity_recall(query)all_results.append(pack(rule_docs,tag="rule"))returnall_results

注意exact_search那一路,返回的doc可能不到topk,没关系,后面融合的时候要处理长度不均。我这里直接用list包起来,没有把各路拉平,因为融合阶段需要知道每个候选的来源。

召回拿到手,重头戏是融合排序。别直接拼一拼去重就扔给LLM,那样的话,如果某一路上来一堆低相关的,反而会把真正有用的结果挤下去。我常用的是RRF(Reciprocal Rank Fusion),简单粗暴但非常有效。核心思路:每个候选根据它在各路排序里的名次算一个分数,名次越靠前分数越高,然后把所有路上的分数加起来。具体公式:

score = Σ 1 / (k + rank_i)

k是个常数,经验上设个60左右。这个公式的好处是不需要归一化各路分数,因为只依赖排名。各路返回的数量不一样也没关系,排名是相对的。

实现起来几行代码:

defrrf_fusion(results_per_source,k=60,topk=20):# 对每个来源的列表,给每个doc按名次打分scores={}forsource_resultsinresults_per_source:# 注意有的source可能返回空,别跳过,但要处理forrank,docinenumerate(source_results):doc_id=doc['id']scores[doc_id]=scores.get(doc_id,0.0)+1.0/(k+rank+1)# 按分数排序拿topkranked=sorted(scores.items(),key=lambdax:x[1],reverse=True)return[doc_idfordoc_id,scoreinranked[:topk]]

这里踩过一个坑:enumerate从0开始,而公式里的rank通常从1开始,所以分母是k+rank+1。刚开始我直接写k+rank,结果第一名比其他路的第一名分数偏高,虽然不影响最终排序,但严格来说不符合RRF定义。别在意那点偏差,但为了后面调参能复现,还是统一从1算。

RRF吃的是名次,不关系分数本身。但也有一个问题:如果某一路特别靠谱,比如精确匹配明明应该优先,RRF却因为其他三路里面这个文档排名靠后,导致总分被拉低。所以后来我在RRF的基础上加了一个加权项,针对每一路根据历史日志统计的准确率分配一个权重α_i,加权RRF:

score = Σ α_i / (k + rank_i)

α_i可以用线上反馈调,简单做法是每天解析一次用户对回答的点赞/点踩,然后按来源tag统计贡献度。这一步我一开始没做,导致精确匹配那路几乎等于白干——它在RRF下跟向量路权重一样,排位靠前也没优势。后来加了权重,效果立刻上去了。

再深度一点,如果你想用机器学习模型做融合排序,那输入特征可以包括:每个候选在各路的排名、原始分数(归一化)、是否被多路同时召回、文档长度、与query的编辑距离等等。用LightGBM或者简单的逻辑回归就能跑。我试过,特征工程麻烦,但收益比RRF能高3~5个点,看场景。如果你们的Agent是面向开放域的,样本量足够,可以上模型。如果是一个垂直场景、数据量不大,RRF加手动权重完全够用。

还有个被忽略的点:融合后的候选顺序不是最终交给LLM的顺序。你要考虑上下文长度限制,以及LLM对信息位置的敏感性。一般做法是取融合后的top5~10,再按某种规则做一次微调。比如,把包含精确匹配命中结果的文档排在最前面,哪怕它在RRF总分里只排第七,因为这是用户明确指定的entity。我这里有个简单的重排函数:

defrerank_for_llm(ranked_docs,exact_ids,topk=5):# 精确匹配的doc永远优先,因为这是硬需求priority=[dfordinranked_docsifd.idinexact_ids]rest=[dfordinranked_docsifd.idnotinexact_ids]# 然后按原顺序填充剩余位置final=priority+restreturnfinal[:topk]

这段代码注释少,但你要明白为什么——不是所有场景都能用。我见过有人把所有精确匹配结果一股脑塞给LLM,结果上下文爆炸,且那些“精确”匹配因为只是字符串包含,其实并不精确。所以这里的exact_ids必须是经过严格校验的,比如寄存器地址要符合“%04x”格式,或者产品型号在官方库里能查到。否则宁可不加这个优先级。

再说回调试。多路召回+融合排序最麻烦的是定位问题到底出在哪一路。我的做法是日志里给每个候选doc打上tag,然后每次Agent回答完,把召回列表和最终答案一起存到MongoDB里。出问题时,直接查日志看是召回阶段就没找到还是融合把好结果排后面了。另外,我给每路recall单独加了一个超时和熔断——如果BM25那边的ES集群响应慢,不能让它拖累整体延迟。异步并发调用每一路,等所有路返回或者超时,再进融合。超时的那路按空列表处理,别等它。

你问并发怎么搞?用asyncio.gather(),注意给每一路包一层try-except,别让一个异常挂了整个召回。我写过一版没加异常处理,结果其中一个向量库临时抖动,整个Agent直接报错,用户那边给出的回答是“抱歉我暂时无法回答”。后来改成except以后记录一条warning,继续跑其他路,用户体验正常了,只是偶尔没有向量结果而已。

说回那个线上问题,我后来在召回阶段加了精确匹配和规则模板,融合权重也调了。现在用户问“寄存器地址映射”,精确匹配那路直接把文档里所有包含“Modbus寄存器地址映射”的小节全部捞出来,权重给到1.8,RRF后这些结果排前几,LLM就能基于这些准确内容回答。再测试之前那些failed query,基本都过了。当然,新问题又冒出来,比如用户问“网关的DI/DO口配置”,规则模板里没有“DI/DO”的实体,又得去扩充词典。这就是做基础设施的日常。

经验说几句。第一,多路召回不是越多越好,每加一路,维护成本、延迟、融合复杂度都上去了。先分析bad case,看看单路失败的真实原因是什么,再决定加哪一路。第二,融合排序选RRF起步,简单可控,上线后根据bad case再迭代。别一上来就搞模型,你连各路召回都还没稳定,模型就是个黑盒,出了问题不知道甩锅给谁。第三,每路召回的结果数量不定,融合时要处理空结果,我的经验是宁可空着也别用随机padding,除非你想让模型学偏。第四,给每一路打tag,从第一天就做,别偷懒。你永远不知道什么时候线上出问题需要回溯,到时候没有tag,几百个候选混在一起,哭都来不及。最后,LLM对输入顺序敏感,融合排序输出top5后,人工重排一下,把硬约束相关的提到前面,虽然看起来粗暴,但实际效果提升明显。别迷信算法,多看看LLM到底吃了什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询