ARTICLE DETAIL

资讯详情

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

RAG精排与MMR去冗余:Cross-Encoder与llama.cpp GGUF实战

RAG精排与MMR去冗余:Cross-Encoder与llama.cpp GGUF实战 1. 为什么召回之后还需要一道“精排”工序很多人第一次搭问答系统时都会经历这样一个阶段向量库接好了embedding 模型也选了top-k 一调感觉效果还行于是就直接把召回结果丢给大模型去生成答案。结果上线之后发现用户问一个稍微具体点的问题返回的答案就开始“串味”——明明问的是 A 产品的退款政策模型却把 B 产品的售后条款混进来一起答了。这个问题的根源往往不在生成模型而在召回阶段本身。向量检索本质上是在做“粗筛”它用的是双塔结构Bi-Encoderquery 和文档分别编码成向量然后算余弦相似度。这种做法的好处是快可以提前把文档向量全部算好存进索引查询时只算 query 一次然后做近似最近邻搜索。但代价是query 和文档在编码阶段完全没有交互模型不知道这两个东西放在一起会是什么语义。这就是为什么我们需要 Reranker。Reranker 用的是 Cross-Encoder 结构把 query 和文档拼在一起送进模型让它们在注意力层里充分交互最后输出一个相关性分数。这个分数比向量余弦相似度准得多但代价是慢——你没法预计算每来一个 query都得把候选文档逐个跟它拼起来跑一遍模型。所以工业界的标准做法是两段式召回阶段用 Bi-Encoder 快速从百万级文档里捞出几十上百个候选精排阶段用 Cross-Encoder 对这几十个候选重新打分排序。这一步做完相关性通常会有肉眼可见的提升。但精排之后还有第二个问题冗余。top-k 里经常出现好几条内容高度相似的文档它们可能来自同一篇长文的不同段落或者是对同一个问题的重复表述。如果直接把这些都塞进上下文不仅浪费 token还会让生成模型被重复信息带偏。这时候就需要 MMRMaximal Marginal Relevance来做去冗余在“相关性”和“多样性”之间找一个平衡。这一章要解决的就是这两个问题怎么用 Reranker 把相关性做准怎么用 MMR 把冗余去掉。我会从原理讲到实操包括模型选型、llama.cpp GGUF 的部署方式、超时不等式的推导以及我在实际项目里踩过的坑。2. Reranker 的本质Cross-Encoder 到底比 Bi-Encoder 强在哪2.1 双塔结构的先天局限先把这个事情说透。Bi-Encoder 的工作方式是query_vec model.encode(query) doc_vec model.encode(doc) score cosine(query_vec, doc_vec)注意这里的关键model.encode(query)和model.encode(doc)是两次完全独立的调用。query 编码的时候模型根本不知道它要跟哪篇文档比doc 编码的时候也不知道会来什么 query。两边各自把语义压缩成一个固定维度的向量然后靠向量空间里的距离来衡量相关性。这个压缩过程是有损的。一篇 500 字的文档被压成一个 768 维的向量里面必然丢失了大量细节。更关键的是有些相关性是“条件性”的——比如 query 问“支持哪些支付方式”文档里写“我们接受微信、支付宝、银行卡”这两者在双塔里可能因为用词差异而距离较远但实际上高度相关。Cross-Encoder 因为能看到 query 和 doc 的完整文本就能捕捉到这种细粒度的匹配。2.2 Cross-Encoder 的交互机制Cross-Encoder 的输入形式是[CLS] query [SEP] doc [SEP]整个序列送进 Transformer每一层注意力里query 的 token 和 doc 的 token 都在互相看。最后取[CLS]位置的输出接一个线性层得到一个标量分数。这个分数直接就是“这两段文本有多相关”的预测。我实测下来同一个数据集上Bi-Encoder 的 top-1 准确率大概在 60% 出头换成 Cross-Encoder 精排之后能到 80% 以上。这个差距在问答场景里是决定性的——因为生成模型是“给什么料做什么饭”你喂进去的上下文不对它再怎么强也答不对。但 Cross-Encoder 的代价也很明显。假设你有 100 个候选文档每个文档平均 200 tokenquery 50 token那么每篇文档都要跑一次 250 token 的 forward pass。100 篇就是 100 次。如果用的是 7B 级别的模型这个延迟在 CPU 上基本不可接受在 GPU 上也要几百毫秒到一秒。所以这里有一个工程上的核心矛盾精度和延迟的权衡。我的经验是召回阶段可以放宽到 top-50 甚至 top-100但精排阶段一定要控制候选数量通常 top-20 到 top-30 是比较舒服的区间。再多了延迟上去了收益却递减。2.3 Reranker 模型的选型思路选 Reranker 模型我一般看三个维度语言支持、模型大小、部署方式。语言支持不用多说中文场景必须选中文或多语言训练过的模型。早期很多人直接拿英文的 ms-marco 模型来跑中文效果惨不忍睹因为 tokenizer 和语义空间都不匹配。模型大小方面Reranker 不需要像生成模型那么大。因为它的任务很简单——二分类式的相关性打分不需要生成能力。0.1B 到 1B 参数的模型通常就够用了。我试过 bge-reranker-base约 0.1B、bge-reranker-large约 0.3B以及一些 0.5B 级别的模型在中文问答场景下base 版本已经能带来大部分收益large 版本边际提升有限但延迟翻倍。部署方式上如果你的系统已经在用 llama.cpp 跑生成模型那么用 GGUF 格式的 Reranker 是最省事的——同一套推理框架不用额外维护 Python 服务。这也是为什么这一章我会重点讲 llama.cpp GGUF 的部署方式。3. 用 llama.cpp 部署 GGUF 版 Reranker 的完整流程3.1 为什么选 GGUF 而不是 PyTorch 服务先说选型理由。用 PyTorch 起一个 Reranker 服务你需要装 transformers、torch模型加载要占显存推理时还要处理 batch 和 padding。如果团队里已经有 llama.cpp 的推理链路再单独维护一个 PyTorch 服务运维成本是翻倍的。GGUF 的好处是单文件、量化后体积小、CPU 也能跑、和 llama.cpp 生态无缝衔接。一个 0.3B 的 Reranker 量化到 Q4_K_M文件大概 200MB 左右加载快内存占用低。在 CPU 上跑 20 个候选的精排延迟可以控制在 200-500ms对于大多数企业问答场景完全够用。当然GGUF 也有局限。它的量化会带来轻微精度损失而且 llama.cpp 对某些模型架构的支持可能滞后。但对于 Reranker 这种打分任务Q4 量化的精度损失基本可以忽略——我做过对比Q4_K_M 和 FP16 的排序结果一致性在 98% 以上。3.2 模型转换与量化假设你手上有一个 HuggingFace 格式的 Reranker 模型转 GGUF 的流程大致是# 1. 克隆 llama.cpp假设你已经有了 cd llama.cpp # 2. 安装 Python 依赖 pip install -r requirements.txt # 3. 转换模型为 GGUFFP16 python convert_hf_to_gguf.py /path/to/reranker-model \ --outfile reranker-fp16.gguf \ --outtype f16 # 4. 量化为 Q4_K_M ./llama-quantize reranker-fp16.gguf reranker-q4km.gguf Q4_K_M这里有个细节要注意不是所有 Reranker 模型都能顺利转换。llama.cpp 对模型架构有要求必须是它支持的架构比如 BERT、RoBERTa、XLM-R 等。如果你拿的是一个自定义架构的模型转换脚本可能会报错。我建议优先选那些社区已经验证过能转 GGUF 的模型比如 bge-reranker 系列。转换完成之后验证一下模型能不能正常加载./llama-cli -m reranker-q4km.gguf -p query: 退款政策 [SEP] doc: 我们支持7天无理由退款 --reranking注意--reranking这个参数它告诉 llama.cpp 这是一个 Reranker 模型输出的是相关性分数而不是生成的 token。3.3 服务化封装实际项目里你不会每次都调命令行。通常是用 llama-cpp-python 封装成一个服务from llama_cpp import Llama class Reranker: def __init__(self, model_path, n_ctx512, n_threads4): self.llm Llama( model_pathmodel_path, n_ctxn_ctx, n_threadsn_threads, embeddingFalse, rerankingTrue, ) def score(self, query, docs): scores [] for doc in docs: result self.llm.create_rerank( queryquery, documents[doc], ) scores.append(result[results][0][relevance_score]) return scores这里有个性能陷阱逐条打分效率很低。llama.cpp 的 rerank 接口其实支持一次传多个 documents内部会做 batch 处理。我建议这样写def score_batch(self, query, docs): result self.llm.create_rerank( queryquery, documentsdocs, ) return [r[relevance_score] for r in result[results]]实测下来batch 模式比逐条模式快 3-5 倍因为省去了重复的模型调用开销。4. 超时不等式精排延迟的数学边界4.1 这个不等式解决什么问题在企业级系统里延迟是有硬性预算的。假设你的问答接口 SLA 是 2 秒那么这 2 秒要分配给召回向量检索、精排Reranker、生成LLM、网络传输。如果精排吃掉了 1.5 秒生成就没时间了。所谓“超时不等式”就是用来判断在当前候选数量下精排会不会超时。它的形式很简单T_rerank N × t_per_doc t_overhead ≤ T_budget其中N是候选文档数量t_per_doc是单篇文档的打分耗时t_overhead是模型调用、tokenization 等固定开销T_budget是分配给精排的时间预算这个不等式看起来简单但实际用起来有几个坑。4.2 t_per_doc 不是常数很多人以为t_per_doc是固定的其实它跟文档长度强相关。Cross-Encoder 的输入是 query doc 拼接doc 越长序列越长注意力计算量是 O(n²) 增长的。我实测过一组数据用 Q4_K_M 的 0.3B Reranker在 4 核 CPU 上文档长度token单篇耗时ms5012100182003540078800210可以看到从 200 token 到 800 token长度翻了 4 倍耗时翻了 6 倍。所以如果你不控制文档长度t_per_doc会失控。我的做法是在精排之前对候选文档做截断。通常截到 256 或 512 token。因为 Reranker 只需要判断相关性不需要看完整文档。截断虽然会损失一点信息但换来的是延迟的可预测性。4.3 反推候选数量上限有了t_per_doc的实测数据就可以反推N的上限。假设T_budget 500mst_overhead 50ms文档平均 200 tokent_per_doc 35ms那么N ≤ (500 - 50) / 35 ≈ 12.8也就是说最多只能精排 12 篇。这个数字可能比很多人想象的要小。如果你召回 top-50然后全部精排延迟直接爆掉。所以工程上的做法是召回 top-50先用一个轻量级策略粗筛到 top-20再精排到 top-5。粗筛可以用向量分数阈值或者用一个更小的 Reranker。这样既保证了精度又控制了延迟。提示这个不等式里的数字都是示例实际项目里一定要自己实测。不同硬件、不同模型、不同文档长度结果差异很大。我建议在系统上线前专门跑一组压测把t_per_doc和文档长度的关系曲线画出来作为容量规划的输入。5. MMR 去冗余在相关性和多样性之间走钢丝5.1 冗余是怎么产生的精排之后你拿到一个按相关性排序的列表。但如果你仔细看会发现 top-5 里经常有 2-3 条讲的是同一件事。这种情况在长文档场景里特别常见。比如一篇 5000 字的产品手册被切成 20 个 chunk用户问“保修期多久”可能 chunk 3、chunk 7、chunk 12 都提到了保修期只是表述略有不同。精排模型给这三条都打了高分因为它们确实都相关。但如果把三条都塞进上下文生成模型会看到三遍几乎一样的信息不仅浪费 token还可能因为细微差异而产生混淆。另一种冗余来自多来源。同一个问题知识库里有官方文档、有客服话术、有用户手册三者内容重叠度高。精排只看相关性不看来源所以会把它们都排上来。5.2 MMR 的核心公式MMR 的思路很直观在选择下一条文档时不仅看它和 query 的相关性还要看它和已选文档的相似度。公式是MMR argmax [ λ × Sim(doc, query) - (1-λ) × max Sim(doc, selected_docs) ]其中λ是一个 0 到 1 之间的权重λ 1时退化成纯相关性排序不做去冗余λ 0时完全追求多样性可能选出不相关的文档实际项目里λ通常在 0.5 到 0.8 之间这个公式的直觉是一条文档如果跟 query 很相关第一项大但跟已选文档也很相似第二项大那么它的 MMR 分数会被第二项拉低从而让位给那些同样相关但内容不同的文档。5.3 相似度用什么算MMR 里的Sim可以用不同的度量。常见的有余弦相似度用 embedding 向量算快但需要额外维护一个 embedding 模型Jaccard 相似度基于词集合简单但对同义改写不敏感BM25 或 TF-IDF基于词频适合关键词重叠的场景我的经验是如果系统里已经有 embedding 模型召回阶段肯定有直接用余弦相似度最方便。因为文档向量在召回阶段可能已经算过了可以直接复用不用额外计算。但这里有个细节召回用的 embedding 和 MMR 用的 embedding 可以是同一个但要注意归一化。余弦相似度要求向量是归一化的否则点积不等于余弦。import numpy as np def mmr(query_vec, doc_vecs, doc_scores, lambda_param0.7, top_k5): query_vec: query 的 embeddingshape (d,) doc_vecs: 候选文档的 embeddingshape (n, d) doc_scores: 候选文档的相关性分数来自 Rerankershape (n,) # 归一化 query_vec query_vec / np.linalg.norm(query_vec) doc_vecs doc_vecs / np.linalg.norm(doc_vecs, axis1, keepdimsTrue) # query 和每个文档的相似度 query_sim doc_vecs query_vec selected [] candidates list(range(len(doc_vecs))) for _ in range(top_k): best_idx None best_score -np.inf for idx in candidates: # 相关性项 relevance lambda_param * query_sim[idx] # 冗余项跟已选文档的最大相似度 if selected: redundancy (1 - lambda_param) * max( doc_vecs[idx] doc_vecs[s] for s in selected ) else: redundancy 0 mmr_score relevance - redundancy if mmr_score best_score: best_score mmr_score best_idx idx selected.append(best_idx) candidates.remove(best_idx) return selected这段代码里doc_scores其实没用到因为 MMR 用的是 embedding 相似度而不是 Reranker 分数。如果你想让 Reranker 分数参与进来可以把query_sim替换成归一化后的 Reranker 分数。两种做法各有道理用 embedding 相似度更一致用 Reranker 分数更准。我一般倾向于用 Reranker 分数做相关性项用 embedding 相似度做冗余项这样各取所长。5.4 λ 怎么调λ的取值直接决定了最终上下文的质量。我的调参方法是先固定λ 0.7跑一批测试 query看 top-5 里有没有明显重复的内容如果还有重复降到 0.6 或 0.5如果发现选出来的文档开始跑题相关性下降说明 λ 太低了往上调这个过程没有捷径必须用真实数据调。我见过一些团队直接抄网上的λ 0.5结果在他们的场景里效果很差因为他们的文档冗余度本来就不高强行去冗余反而把相关文档挤掉了。注意MMR 是在精排之后做的所以它只能从已经排好序的候选里选。如果精排本身就没把相关文档排上来MMR 也救不了。所以顺序一定是召回 → 精排 → MMR。6. 把 Reranker 和 MMR 串进完整链路6.1 完整流程拆解现在把前面几块拼起来。一个完整的问答检索链路是这样的用户 query ↓ [1] 向量召回Bi-Encoder→ top-50 ↓ [2] 文档截断256 token ↓ [3] Reranker 精排Cross-Encoder→ top-20 ↓ [4] MMR 去冗余 → top-5 ↓ [5] 拼上下文 → 生成模型每一步都有它的作用缺一不可。我见过有人跳过第 3 步直接做 MMR结果 MMR 在低质量的候选集上做选择选出来的东西相关性很差。也见过有人跳过第 4 步结果上下文里全是重复内容。6.2 各阶段的参数配置把关键参数整理成一张表方便对照阶段参数建议值说明召回top_k50宁多勿少给精排留空间截断max_length256平衡信息量和延迟精排top_n20根据超时不等式反推精排batch_size8根据显存/内存调整MMRlambda0.6-0.8用真实数据调MMRtop_k5最终上下文条数这些数字不是金科玉律但可以作为起点。实际项目里我会先按这套配置跑通然后根据效果和延迟做微调。6.3 一个容易忽略的细节query 改写在召回之前其实还有一个可选步骤query 改写。用户的问题往往口语化、有指代、有省略。比如“它支持退款吗”里的“它”指什么如果直接拿去做向量检索效果会很差。我通常会用一个小模型做 query 改写把“它支持退款吗”改写成“XX产品支持退款吗”。这一步对召回质量的提升很明显而且成本很低。改写后的 query 同时用于召回、精排和 MMR保证三个阶段看到的是同一个 query。7. 实测中的坑与调优经验7.1 Reranker 分数不可比第一个坑不同 query 的 Reranker 分数不可比。Cross-Encoder 输出的是一个 logit它的绝对值没有意义只有相对排序有意义。你不能说“分数大于 0.5 就是相关”因为有的 query 整体分数都高有的整体都低。所以不要用固定阈值来过滤 Reranker 结果。正确的做法是永远取 top-n而不是取分数大于某个值的结果。如果非要过滤也要用相对阈值比如“取分数最高的 20%”。7.2 MMR 的 O(n²) 复杂度第二个坑MMR 的朴素实现是 O(n²) 的。每选一条文档都要跟已选文档算相似度。如果候选有 100 条选 5 条计算量是 100×5 次相似度计算还好。但如果候选有 1000 条就会明显变慢。优化方法是预计算文档之间的相似度矩阵。虽然矩阵本身是 O(n²) 的但可以用矩阵乘法一次性算完比 Python 循环快得多。如果 n 特别大还可以先用向量检索把候选降到 100 以内再做 MMR。7.3 截断位置的选择第三个坑截断文档时截头还是截尾。很多文档的关键信息在开头比如标题、摘要截尾比较安全。但有些文档是“总-分”结构关键结论在最后截尾就把结论截掉了。我的做法是优先保留开头和结尾中间截断。具体来说如果文档超过 256 token就取前 128 token 和后 128 token 拼起来。这样既能保留标题和开头信息又能保留结尾的结论。实测下来这比单纯截尾效果好。7.4 缓存 Reranker 结果第四个坑重复 query 的重复计算。企业问答场景里很多 query 是重复的或者高度相似的。如果每次都重新跑 Reranker浪费很大。我通常会在 Reranker 前面加一层缓存key 是 query 的哈希value 是精排后的结果。缓存命中率在真实场景里往往能到 30% 以上对降低平均延迟很有帮助。缓存可以用 Redis设置一个合理的过期时间比如 1 小时因为知识库更新不频繁。7.5 监控精排的“边际收益”最后一个经验要监控精排的边际收益。什么意思就是对比“精排前 top-5”和“精排后 top-5”的重合度。如果重合度很高比如 80% 以上说明召回质量已经很好精排带来的提升有限可以考虑降低精排的候选数量来省延迟。如果重合度很低说明精排很关键不能省。这个指标我一般会做成一个 dashboard持续观察。它比单纯看“精排后准确率”更有指导意义因为它直接告诉你精排这一步值不值得。8. 关于这套方案的一些个人体会写到这里Reranker 和 MMR 的核心内容基本讲完了。最后分享几点我在实际项目里的体会。第一不要一上来就上最复杂的方案。我见过一些团队召回还没调好就急着上 Reranker结果精排在一个低质量的候选集上做选择效果提升有限。正确的顺序是先把召回做好确保相关文档能进 top-50再上精排。召回是地基精排是装修。第二延迟预算要提前算。很多项目是上线之后才发现延迟超标然后回头砍候选数量效果就下来了。我的做法是在设计阶段就把超时不等式列出来反推每个阶段的预算这样后面调优有据可依。第三MMR 的 λ 一定要用真实数据调。网上的默认值只能作为起点不同场景差异很大。我一般会准备一批标注好的 query-doc 对然后网格搜索 λ看哪个值在“相关性”和“多样性”两个指标上综合最好。第四GGUF 部署虽然方便但要验证精度。量化会带来精度损失虽然通常很小但在某些边界 case 上可能会放大。我建议在切换量化版本之前跑一组回归测试对比排序结果的一致性。如果一致性低于 95%就要考虑用更高的量化精度。这套方案我在几个企业问答项目里都用过从客服机器人到内部知识库效果都比较稳定。核心思路就是召回负责“不漏”精排负责“准”MMR 负责“不重复”。三者各司其职配合起来才能把上下文质量做上去。
返回列表