ARTICLE DETAIL

资讯详情

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

072、多路召回与融合排序

072、多路召回与融合排序 072、多路召回与融合排序昨天夜里线上告警某个Agent在回答用户关于“某型号工业网关的Modbus寄存器地址映射”时给出了一堆完全不相关的内容。我翻日志发现召回阶段只用了向量检索Top K直接抽了20条文本片段。问题就出在这儿——用户的query里包含“寄存器地址”“映射表”这种强结构化的词向量检索按语义相似度拉回来的几乎都是说明书里泛泛而谈的“网关支持Modbus协议”这类句子。你跟我讲语义用户想要的是寄存器偏移量和数据类型。这种场景不是个例。Agent要能稳定输出检索链路的召回质量是地基。单路召回无论你选向量、BM25还是基于知识图谱的查询总有盲区。向量擅长语义匹配但对精确数字、代码标识、专业术语的字符串匹配毫无脾气BM25这类稀疏检索对关键词命中敏感但换个说法比如用户问“这个寄存器能不能写”你文档里写的是“支持读写操作”它就抓瞎。所以多路召回不是炫技是现实逼着你这么干。我这个项目里最后是四条路并行向量召回、BM25召回、倒排索引精确匹配、还有基于规则模板的“实体-属性”抽取召回。向量用了bge-m3BM25用的是ES里的标准实现精确匹配那路是专门针对型号、寄存器地址这类pattern做的规则模板则是维护了一张领域词典和几十条正则。你可能觉得重但每一路都是为了堵住另外几路的漏洞。代码结构大概长这样defrecall(query,topk20):# 各路召回结果都放在一个列表里统一塞进一个结构all_results[]# 向量召回vec_docsvector_search(query,topk)# 这里给每路结果打上来源tag后面融合和调试都用得上all_results.append(pack(vec_docs,tagvector))# BM25召回bm25_docsbm25_search(query,topk)all_results.append(pack(bm25_docs,tagbm25))# 精确匹配专门处理型号/寄存器地址这种带特殊字符的exact_docsexact_pattern_search(query)all_results.append(pack(exact_docs,tagexact))# 规则召回查领域词典rule_docsrule_based_entity_recall(query)all_results.append(pack(rule_docs,tagrule))returnall_results注意exact_search那一路返回的doc可能不到topk没关系后面融合的时候要处理长度不均。我这里直接用list包起来没有把各路拉平因为融合阶段需要知道每个候选的来源。召回拿到手重头戏是融合排序。别直接拼一拼去重就扔给LLM那样的话如果某一路上来一堆低相关的反而会把真正有用的结果挤下去。我常用的是RRFReciprocal Rank Fusion简单粗暴但非常有效。核心思路每个候选根据它在各路排序里的名次算一个分数名次越靠前分数越高然后把所有路上的分数加起来。具体公式score Σ 1 / (k rank_i)k是个常数经验上设个60左右。这个公式的好处是不需要归一化各路分数因为只依赖排名。各路返回的数量不一样也没关系排名是相对的。实现起来几行代码defrrf_fusion(results_per_source,k60,topk20):# 对每个来源的列表给每个doc按名次打分scores{}forsource_resultsinresults_per_source:# 注意有的source可能返回空别跳过但要处理forrank,docinenumerate(source_results):doc_iddoc[id]scores[doc_id]scores.get(doc_id,0.0)1.0/(krank1)# 按分数排序拿topkrankedsorted(scores.items(),keylambdax:x[1],reverseTrue)return[doc_idfordoc_id,scoreinranked[:topk]]这里踩过一个坑enumerate从0开始而公式里的rank通常从1开始所以分母是krank1。刚开始我直接写krank结果第一名比其他路的第一名分数偏高虽然不影响最终排序但严格来说不符合RRF定义。别在意那点偏差但为了后面调参能复现还是统一从1算。RRF吃的是名次不关系分数本身。但也有一个问题如果某一路特别靠谱比如精确匹配明明应该优先RRF却因为其他三路里面这个文档排名靠后导致总分被拉低。所以后来我在RRF的基础上加了一个加权项针对每一路根据历史日志统计的准确率分配一个权重α_i加权RRFscore Σ α_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,topk5):# 精确匹配的doc永远优先因为这是硬需求priority[dfordinranked_docsifd.idinexact_ids]rest[dfordinranked_docsifd.idnotinexact_ids]# 然后按原顺序填充剩余位置finalpriorityrestreturnfinal[:topk]这段代码注释少但你要明白为什么——不是所有场景都能用。我见过有人把所有精确匹配结果一股脑塞给LLM结果上下文爆炸且那些“精确”匹配因为只是字符串包含其实并不精确。所以这里的exact_ids必须是经过严格校验的比如寄存器地址要符合“%04x”格式或者产品型号在官方库里能查到。否则宁可不加这个优先级。再说回调试。多路召回融合排序最麻烦的是定位问题到底出在哪一路。我的做法是日志里给每个候选doc打上tag然后每次Agent回答完把召回列表和最终答案一起存到MongoDB里。出问题时直接查日志看是召回阶段就没找到还是融合把好结果排后面了。另外我给每路recall单独加了一个超时和熔断——如果BM25那边的ES集群响应慢不能让它拖累整体延迟。异步并发调用每一路等所有路返回或者超时再进融合。超时的那路按空列表处理别等它。你问并发怎么搞用asyncio.gather()注意给每一路包一层try-except别让一个异常挂了整个召回。我写过一版没加异常处理结果其中一个向量库临时抖动整个Agent直接报错用户那边给出的回答是“抱歉我暂时无法回答”。后来改成except以后记录一条warning继续跑其他路用户体验正常了只是偶尔没有向量结果而已。说回那个线上问题我后来在召回阶段加了精确匹配和规则模板融合权重也调了。现在用户问“寄存器地址映射”精确匹配那路直接把文档里所有包含“Modbus寄存器地址映射”的小节全部捞出来权重给到1.8RRF后这些结果排前几LLM就能基于这些准确内容回答。再测试之前那些failed query基本都过了。当然新问题又冒出来比如用户问“网关的DI/DO口配置”规则模板里没有“DI/DO”的实体又得去扩充词典。这就是做基础设施的日常。经验说几句。第一多路召回不是越多越好每加一路维护成本、延迟、融合复杂度都上去了。先分析bad case看看单路失败的真实原因是什么再决定加哪一路。第二融合排序选RRF起步简单可控上线后根据bad case再迭代。别一上来就搞模型你连各路召回都还没稳定模型就是个黑盒出了问题不知道甩锅给谁。第三每路召回的结果数量不定融合时要处理空结果我的经验是宁可空着也别用随机padding除非你想让模型学偏。第四给每一路打tag从第一天就做别偷懒。你永远不知道什么时候线上出问题需要回溯到时候没有tag几百个候选混在一起哭都来不及。最后LLM对输入顺序敏感融合排序输出top5后人工重排一下把硬约束相关的提到前面虽然看起来粗暴但实际效果提升明显。别迷信算法多看看LLM到底吃了什么。
返回列表