Reranker、RRF、断崖截取、动态 TopK:RAG 检索结果别再一刀切

📅 2026/7/22 2:30:25
Reranker、RRF、断崖截取、动态 TopK:RAG 检索结果别再一刀切
Day19|Reranker、RRF、断崖截取、动态 TopKRAG 检索结果别再一刀切前言召回越多答案为什么反而更差有个读者前两天问了我一个问题我看完第一反应是这不就是很多 RAG 项目的真实现场吗他说“我把 TopK 从 5 调到 20本来以为召回更多资料答案会更稳。结果模型开始胡说八道甚至把不相关文档里的内容也揉进答案里。”这个坑我太熟了。很多人刚做 RAG 的时候都有一个朴素想法检索结果越多模型知道得越多答案就越好。但生产里刚好相反。模型上下文不是垃圾桶你塞进去的每一段噪声最后都可能变成答案里的一个坑。今天这篇我们把 RAG 检索后半段讲透Reranker 负责精排RRF 负责多路融合断崖截取负责砍掉尾巴动态 TopK 负责按问题决定喂多少。PART 01Reranker 不是召回器它是最后一公里的裁判先说 Reranker。很多人把 Reranker 当成“更强的向量检索”这个理解不太准。向量检索和 BM25 更像海选。它们负责从几万、几十万、几百万段文本里快速捞出一批可能相关的候选。Reranker 像决赛裁判。它不负责从全库里找人而是对候选结果重新打分把真正贴近问题的文档排到前面。差别在哪里Embedding 检索通常是先把 query 和 doc 各自编码成向量再算相似度。速度快适合大规模粗筛。Reranker 常见做法是 Cross-Encoder把query doc放在一起读直接判断“这段文档能不能回答这个问题”。它更慢但更准。工程上我一般不建议一上来就 rerank 全库。更常见的组合是先用 BM25 / 向量召回 30~100 条再用 Reranker 重排最后只拿前 3~10 条给大模型你可以先把接口抽象成这样后面换真实模型也不影响主流程from dataclasses import dataclass dataclass class Doc: doc_id: str text: str score: float 0.0 def simple_rerank(query: str, docs: list[Doc]) - list[Doc]: query_terms set(query.lower().split()) ranked [] for doc in docs: text_terms set(doc.text.lower().split()) overlap len(query_terms text_terms) doc.score overlap / max(len(query_terms), 1) ranked.append(doc) return sorted(ranked, keylambda x: x.score, reverseTrue)这个simple_rerank只是示意。真实项目里你可以换成 bge-reranker、Cohere Rerank或者你们内部的 cross-encoder。关键不是这段代码有多强而是你要把“召回”和“精排”拆开。RAG 检索最怕一步到位。一步到位往往意味着一步到坑里。PART 02RRF 解决多路召回融合别手写权重猜玄学再说 RRF。RAG 里很少只靠一路召回。你可能会同时用BM25擅长精确词、型号、错误码向量检索擅长语义相似标题字段文档标题比正文更短、更准历史点击用户过去常看的文档GraphRAG实体关系和全局线索问题来了这些召回器的分数不在一个尺度上。BM25 分数可能是 12.7向量相似度可能是 0.83标题匹配可能是一个布尔值。你硬把它们加权相加很容易变成拍脑袋调参。RRFReciprocal Rank Fusion聪明的地方在于它不太关心原始分数只关心排名。排名越靠前加分越多多个榜单都靠前的文档最终自然会浮上来。一个最小可运行版本如下from collections import defaultdict def rrf_fusion(rank_lists: list[list[str]], k: int 60) - list[tuple[str, float]]: scores defaultdict(float) for rank_list in rank_lists: for rank, doc_id in enumerate(rank_list, start1): scores[doc_id] 1.0 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue) bm25_results [doc3, doc1, doc9, doc2] vector_results [doc1, doc8, doc3, doc5] print(rrf_fusion([bm25_results, vector_results]))你会发现doc1和doc3因为在两路结果里都靠前融合后会排得更稳。RRF 的好处是简单、鲁棒、少玄学。它特别适合做多路召回的第一层融合先把不同来源候选合并成一个靠谱候选池再交给 Reranker 精排。顺序别反了。先 RRF 融合多路召回再 Reranker 精排候选这条链路会比单路 TopK 稳很多。PART 03断崖截取 动态 TopK把噪声挡在上下文外最后讲两个经常被忽略的小动作断崖截取和动态 TopK。固定 TopK 很方便但也很粗暴。有的问题只需要 2 段上下文比如“某个错误码是什么意思”。有的问题需要 8 段比如“对比三套方案的优缺点”。你一律取 Top10就像不管感冒还是骨折都开同一副药。断崖截取看的是 rerank 分数曲线。如果前 4 条分数都很高第 5 条突然明显下降那第 5 条之后很可能就是噪声尾巴。动态 TopK 则更进一步它不只看分数还看 query 类型和 token 预算。比如精确查询少取避免噪声对比分析多取保证覆盖不同角度总结归纳按 token budget 多取但要控长度分数断崖明显提前截断来一个可以直接改的函数def dynamic_topk( scores: list[float], token_counts: list[int], min_k: int 3, max_k: int 10, gap_threshold: float 0.18, token_budget: int 3000, ) - int: if not scores: return 0 k min(max_k, len(scores)) for i in range(1, min(k, len(scores))): gap scores[i - 1] - scores[i] if i min_k and gap gap_threshold: k i break total_tokens 0 budget_k 0 for tokens in token_counts[:k]: if total_tokens tokens token_budget: break total_tokens tokens budget_k 1 return max(min_k, budget_k) scores [0.92, 0.88, 0.84, 0.80, 0.51, 0.49, 0.45] tokens [420, 530, 480, 510, 700, 650, 600] print(dynamic_topk(scores, tokens)) # 4这段逻辑不复杂但很有用。它会先看分数有没有“断崖”再看上下文 token 预算够不够。最后选出来的 K不是拍脑袋的 5也不是一刀切的 10而是跟问题和结果分布有关。真实项目里你还可以把 query 分类加进去exact_lookupmax_k4comparisonmax_k8summarymax_k12这样 RAG 的上下文就不再是“固定塞几段”而是“该多给时多给该收手时收手”。结尾检索不是多拿检索是会筛我们把今天这条链路收一下多路召回BM25、向量、标题、图谱各自捞候选RRF 融合按排名融合减少手写权重玄学Reranker 精排让 query 和 doc 一起读判断谁更相关断崖截取分数明显掉下去时别硬塞后面的噪声动态 TopK按问题类型、分数分布、token budget 决定喂多少很多 RAG 系统答不好不是因为模型太弱而是因为你把检索结果当成一盘“能塞多少塞多少”的自助餐。可模型不是来帮你挑垃圾的。你越早把无关材料挡在上下文外答案就越少被噪声拖下水。RAG 的检索质量不取决于你塞给模型多少材料而取决于你敢不敢把噪声挡在门外。互动时间你现在的 RAG 系统是固定 TopK还是已经做了 rerank / RRF / 动态截取评论区留一个配置我可以帮你看看有没有优化空间。下一篇预告我们继续拆 RAG 工程里的“上下文拼装”聊聊 chunk 合并、引用来源和答案可追溯到底怎么做。— END —小刘檀木 · 帮普通人把 AI 学进简历