
1. 为什么检索做完了答案还是不对做过企业级智能问答系统的人大概率都经历过这个阶段文档切好了向量库也灌进去了用户提问之后 Top-K 检索能召回一堆看起来相关的片段但把这些片段直接丢给大模型生成的答案要么答偏要么把同一个意思翻来覆去说三遍。你盯着那几条召回结果看明明有一条精准命中了问题核心但它排在第五位前面四条都是“沾边但不解决问题”的内容。这个问题的根源不在向量检索本身而在于向量检索的本质是“粗筛”。它把问题和文档分别编码成向量然后算余弦相似度或者内积。这个过程快是快但问题和文档之间从来没有真正“见过面”——它们是各自独立编码的交互只发生在最后那一次向量点积上。这就导致一个很尴尬的局面语义上大致相关的内容会被召回但真正能回答问题的那个片段未必排在前面。解决这个问题需要两步第一步用一个更强的模型对召回结果做精排Rerank把真正相关的片段顶上去第二步把精排后依然高度相似的片段做去冗余MMR避免大模型收到一堆重复信息。这两步做完你会发现同样的大模型、同样的提示词答案质量能上一个台阶。这一章就围绕Reranker MMR这条组合拳展开从原理到落地把每一步的参数选择、代码实现、踩坑经验都讲清楚。适合已经搭好了基础 RAG 流程、正在被“召回不准、答案啰嗦”困扰的开发者也适合刚开始接触检索增强生成、想搞清楚精排和去冗余到底怎么做的朋友。2. Reranker 与 MMR 的整体设计思路2.1 两阶段检索的标准架构企业级问答系统的检索环节现在基本已经形成了一套共识性的两阶段架构召回阶段用双塔模型Bi-Encoder精排阶段用交叉编码器Cross-Encoder。这两个阶段的分工非常明确。召回阶段面对的是百万级甚至千万级的文档库要求的是速度。双塔模型把查询和文档分别编码成向量离线把文档向量全部算好存进向量库线上只需要编码查询向量然后做一次近似最近邻搜索。这个过程的复杂度是可控的即使文档量很大也能在几十毫秒内返回 Top-100 候选。精排阶段面对的是召回出来的几十到几百条候选要求的是精度。Cross-Encoder 把查询和每一条候选文档拼在一起送进模型做一次完整的注意力计算最后输出一个相关性分数。因为查询和文档在模型内部发生了充分的交互所以它对相关性的判断比双塔模型准得多。但代价是它没法预计算每来一个查询都要把查询和所有候选逐条拼接、逐条推理。候选有 100 条就要推理 100 次。所以这套架构的核心逻辑就是用双塔模型把候选从百万级降到百级再用 Cross-Encoder 把百级精排到十级。前者保证不遗漏后者保证排序准。2.2 为什么不能只用 Cross-Encoder有人可能会想既然 Cross-Encoder 这么准为什么不直接用它做全量检索答案很简单算不过来。假设文档库有 50 万条文档每条文档平均 200 字。用 Cross-Encoder 逐条打分即使单条推理只要 10 毫秒50 万条就是 5000 秒一个多小时才能回答一个问题。这显然不可接受。而双塔模型的做法是离线阶段把 50 万条文档全部编码成向量存进 FAISS 或者 Milvus 这类向量库。线上阶段只编码一次查询然后做向量检索通常 10 到 50 毫秒就能返回 Top-100。这 100 条再用 Cross-Encoder 精排100 次推理按每次 10 毫秒算也就 1 秒左右。整体响应时间控制在 1 到 2 秒用户体验可以接受。注意Cross-Encoder 的推理耗时和候选数量是线性关系。如果你的候选集超过 200 条建议先做一次粗排截断或者用更小的 Reranker 模型否则响应时间会明显拉长。2.3 MMR 要解决的是什么问题精排之后你拿到了一组按相关性排序的片段。但这里有个隐患高度相关的片段往往也是高度相似的。比如用户问“年假怎么申请”检索出来的前三条可能都来自同一份《员工休假管理制度》内容分别是“年假申请流程”“年假天数规定”“年假审批权限”这三条确实都相关但把它们全部塞给大模型模型会收到大量重叠信息生成答案时容易重复表述甚至因为信息过载而抓不住重点。MMRMaximal Marginal Relevance最大边际相关性就是用来处理这个问题的。它的核心思想是在选择下一个片段时既要考虑它和查询的相关性也要考虑它和已选片段的差异性。用公式表示就是MMR argmax [ λ * Sim(doc, query) - (1 - λ) * max Sim(doc, selected_docs) ]其中 λ 是一个 0 到 1 之间的权重参数。λ 越接近 1越偏向相关性λ 越接近 0越偏向多样性。实际使用中λ 通常设在 0.5 到 0.7 之间。这个公式的直觉很好理解每次从候选池里挑一个片段时先看它和问题有多相关再看它和已经选中的片段有多相似。如果它既相关、又不和已选片段重复那它的 MMR 分数就高优先被选中。这样选出来的片段集合既覆盖了问题的不同方面又不会互相重复。2.4 组合使用的顺序与时机Reranker 和 MMR 的先后顺序不能乱。正确的流程是先 Rerank再 MMR。原因在于MMR 需要一个可靠的相关性分数作为输入。如果直接用向量检索的相似度做 MMR那个分数本身就不够准MMR 的筛选效果会大打折扣。而经过 Cross-Encoder 精排之后每条候选都有一个高质量的相关性分数这时候再做 MMR相关性和多样性的权衡才有意义。另外MMR 的输入候选数量不宜太少。如果精排后只保留 5 条再做 MMR 选出 3 条多样性调整的空间很小。通常的做法是精排后保留 20 到 50 条MMR 从中选出 5 到 10 条最后送给大模型。这样既有足够的挑选余地又不会让上下文过长。3. Cross-Encoder Reranker 的核心细节与实操要点3.1 Cross-Encoder 与 Bi-Encoder 的本质区别要理解 Reranker 为什么准得先搞清楚 Cross-Encoder 和 Bi-Encoder 在结构上的差异。Bi-Encoder 是双塔结构查询走一个编码器文档走另一个编码器通常共享参数各自输出一个向量最后算相似度。查询和文档在编码过程中没有任何交互交互只发生在最后的向量点积上。这种结构的优势是文档向量可以离线预计算线上只算查询向量速度快。劣势是交互太浅细粒度的语义匹配能力有限。Cross-Encoder 是单塔结构把查询和文档拼接成一个序列比如[CLS] 查询 [SEP] 文档 [SEP]送进同一个编码器。在 Transformer 的自注意力层里查询的每个 token 和文档的每个 token 都能直接计算注意力权重交互非常充分。最后用[CLS]位置的输出接一个分类头输出相关性分数。这种结构的优势是精度高劣势是没法预计算每条查询-文档对都要单独推理。用一个生活化的类比Bi-Encoder 像是两个人各自写了一份简历然后 HR 对比两份简历的关键词重合度Cross-Encoder 像是两个人坐在一起面试面试官直接观察他们的互动质量。后者显然更能判断匹配程度但成本也更高。3.2 Reranker 模型选型从 BGE 到 Cohere目前主流的 Reranker 模型有几类选择各有适用场景。BGE-Reranker 系列是开源方案里用得最多的。BGE-Reranker-base 和 BGE-Reranker-large 都是基于 XLM-RoBERTa 架构训练的 Cross-Encoder支持中英文。base 版本参数量约 2.78 亿large 版本约 5.6 亿。实测下来base 版本在大多数企业问答场景已经够用推理速度也更快。large 版本精度略高但显存占用和推理耗时都翻倍。Cohere Rerank是商业 API 方案精度很好支持多语言但需要调用外部接口有网络延迟和数据隐私方面的考量。如果企业对数据不出内网有硬性要求这个方案就不适合。Jina Reranker是另一个开源选择基于 JinaBERT 架构支持长文本对中文的支持也不错。选型的时候主要看三个维度语言支持、精度需求、推理成本。中文场景优先选 BGE-Reranker 或者 Jina Reranker如果追求极致精度且能接受 API 调用Cohere Rerank 可以考虑如果显存有限BGE-Reranker-base 是性价比最高的选择。模型参数量语言支持推理速度适用场景BGE-Reranker-base2.78亿中英文快大多数企业问答BGE-Reranker-large5.6亿中英文中等高精度要求Cohere Rerank未公开多语言依赖网络无内网限制Jina Reranker1.37亿多语言快长文本场景3.3 用 llama.cpp 加载 GGUF 格式的 Reranker这里要重点讲一个实际部署中很常见的需求用 llama.cpp 加载 GGUF 格式的 Reranker 模型。为什么会有这个需求因为很多企业的推理环境是 CPU 或者低显存的边缘设备用 PyTorch 加载原始模型太重而 GGUF 格式经过量化后模型体积大幅缩小llama.cpp 又能在 CPU 上高效推理。但这里有个坑llama.cpp 对 Cross-Encoder 架构的支持并不像对生成模型那么直接。标准的 llama.cpp 是为自回归生成模型设计的而 Reranker 是分类模型输出的是一个分数而不是 token 序列。如果你直接拿一个 GGUF 格式的 Reranker 模型去加载可能会遇到no lm runtime found for model format gguf!这类错误。这个错误的本质是llama.cpp 在加载模型时会检查模型的架构类型如果它不认识这个架构比如 BERT 类的 Cross-Encoder就会报这个错。解决办法有两条路第一条路是用支持 Reranker 的推理框架。比如llama-cpp-python的某些版本开始支持rerank接口或者用text-embeddings-inference这类专门为嵌入和重排序设计的推理服务。这些框架内部处理了 Cross-Encoder 的加载和推理逻辑你只需要把 GGUF 模型路径传进去就行。第二条路是把 Reranker 转成 ONNX 格式用 ONNX Runtime 推理。这个方案在 CPU 上的性能很好而且不依赖 llama.cpp 的架构支持。转换过程用optimum库就能完成转完之后用onnxruntime加载输入是input_ids和attention_mask输出是相关性分数。提示如果你在 Windows 7 上部署llama.cpp 的兼容性需要特别注意。较新版本的 llama.cpp 可能依赖更新的系统库Win7 上建议用较老的稳定版本或者直接用 ONNX Runtime 方案兼容性更好。3.4 批处理与显存优化Reranker 推理时候选文档是逐条打分的。如果候选有 50 条一条一条推理GPU 利用率很低。正确的做法是批处理把多条查询-文档对拼成一个 batch一次性送进模型。但批处理有个显存限制。BGE-Reranker-base 在 batch size 为 16、序列长度 512 的情况下显存占用大约 2 到 3 GB。如果显存不够可以减小 batch size或者缩短最大序列长度。实际使用中大多数问答场景的文档片段在 256 到 512 token 之间把max_length设成 512 基本够用。还有一个优化点是动态批处理。如果候选数量不固定可以按实际数量动态组 batch避免固定 batch size 造成的浪费。比如候选有 30 条batch size 设 16那就组两个 batch第二个 batch 只有 14 条而不是补零到 16 条。from transformers import AutoModelForSequenceClassification, AutoTokenizer import torch model_name BAAI/bge-reranker-base tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name) model.eval() def rerank(query, candidates, top_k10, batch_size16, max_length512): pairs [[query, doc] for doc in candidates] scores [] with torch.no_grad(): for i in range(0, len(pairs), batch_size): batch pairs[i:ibatch_size] inputs tokenizer( batch, paddingTrue, truncationTrue, max_lengthmax_length, return_tensorspt ) logits model(**inputs).logits.view(-1) scores.extend(logits.cpu().tolist()) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return ranked[:top_k]这段代码是 Reranker 推理的核心逻辑。几个关键点paddingTrue让同一个 batch 内的序列对齐truncationTrue配合max_length防止超长序列撑爆显存torch.no_grad()关闭梯度计算减少显存占用。实测下来这个实现在单张 8GB 显存的卡上处理 50 条候选、每条 512 token耗时大约 0.8 到 1.2 秒。4. MMR 去冗余的实现与参数调优4.1 MMR 算法的逐步拆解MMR 的算法逻辑可以用一句话概括每次从候选池里选一个和查询相关、且和已选集合不重复的片段直到选够为止。具体步骤是这样的计算每个候选片段和查询的相关性分数。这个分数可以来自 Reranker 的输出也可以来自向量相似度。推荐用 Reranker 的分数质量更高。选出相关性最高的那个片段放进已选集合。对于剩下的每个候选片段计算它和已选集合中所有片段的相似度取最大值作为“冗余度”。用公式λ * 相关性 - (1-λ) * 冗余度计算每个候选的 MMR 分数。选出 MMR 分数最高的片段放进已选集合。重复步骤 3 到 5直到选够 K 个片段。这个过程的计算量主要在第 3 步每选一个片段都要计算剩余候选和已选集合的相似度。如果候选有 50 条要选 10 条那总共要算 50×10 次相似度。这个量级用 numpy 或者 torch 都能很快算完不是瓶颈。4.2 相似度度量的选择MMR 里的“相似度”用什么度量直接影响去冗余的效果。常见的选择有余弦相似度和点积。余弦相似度对向量长度不敏感只看向量方向适合大多数场景。点积则受向量长度影响如果向量没有归一化长向量会占优势。实际使用中如果向量已经做了 L2 归一化余弦相似度和点积是等价的。对于文本片段还有一种选择是用 Reranker 的分数作为相似度。但 Reranker 的输入是查询-文档对没法直接算文档-文档的相似度。所以 MMR 里的相似度通常还是用向量相似度也就是用 Bi-Encoder 编码的向量来算。这里有个细节用于 MMR 的向量和用于召回阶段的向量可以是同一套。因为召回阶段已经把文档向量算好了直接复用就行不需要额外编码。这样 MMR 的计算成本就很低主要是向量相似度矩阵的运算。4.3 λ 参数的调优经验λ 是 MMR 里最关键的参数它控制相关性和多样性的权衡。λ 越大越偏向相关性选出来的片段可能比较相似λ 越小越偏向多样性选出来的片段覆盖面广但可能有些片段和问题的相关性不够强。实际调优的时候我一般从λ 0.6开始试。这个值在大多数场景下表现比较均衡。然后根据实际效果微调如果发现答案遗漏了某些方面的信息说明多样性不够把 λ 降到 0.5 或 0.4。如果发现答案里有一些不太相关的内容说明相关性权重太低把 λ 提到 0.7 或 0.8。还有一个经验是λ 的选择和候选数量有关。如果精排后候选很多比如 50 条λ 可以设低一点因为候选池大多样性调整的空间大如果候选只有 10 条λ 设太低可能导致选出来的片段相关性都不够这时候 λ 要设高一点。λ 值偏向适用场景0.3-0.4强多样性问题涉及多个方面需要广泛覆盖0.5-0.6均衡大多数通用问答场景0.7-0.8强相关性问题聚焦需要精准匹配0.9-1.0几乎只看相关性退化为纯相关性排序4.4 MMR 的代码实现import numpy as np def mmr(query_vector, candidate_vectors, candidate_scores, top_k5, lambda_param0.6): query_vector: 查询的向量表示shape (dim,) candidate_vectors: 候选片段的向量表示shape (n, dim) candidate_scores: 候选片段的相关性分数shape (n,) top_k: 最终选出的片段数量 lambda_param: 相关性与多样性的权衡参数 n len(candidate_scores) selected_indices [] remaining_indices list(range(n)) # 归一化向量方便算余弦相似度 candidate_vectors candidate_vectors / np.linalg.norm(candidate_vectors, axis1, keepdimsTrue) query_vector query_vector / np.linalg.norm(query_vector) # 先选相关性最高的 first_idx int(np.argmax(candidate_scores)) selected_indices.append(first_idx) remaining_indices.remove(first_idx) while len(selected_indices) top_k and remaining_indices: mmr_scores [] for idx in remaining_indices: relevance candidate_scores[idx] # 计算和已选片段的最大相似度 sim_to_selected [ np.dot(candidate_vectors[idx], candidate_vectors[sel]) for sel in selected_indices ] max_sim max(sim_to_selected) mmr_score lambda_param * relevance - (1 - lambda_param) * max_sim mmr_scores.append((idx, mmr_score)) # 选 MMR 分数最高的 best_idx max(mmr_scores, keylambda x: x[1])[0] selected_indices.append(best_idx) remaining_indices.remove(best_idx) return selected_indices这段代码里有个细节值得注意相关性分数和相似度分数的量纲要匹配。Reranker 输出的分数可能是 logits范围不确定而余弦相似度在 -1 到 1 之间。如果两者量纲差太多λ 的调节效果会失真。解决办法是把相关性分数做一次 min-max 归一化映射到 0 到 1 之间这样和余弦相似度的量纲就接近了。注意MMR 的第一步是选相关性最高的片段这个片段一定会被选中。如果你希望 MMR 完全从零开始按 MMR 分数选可以把第一步也纳入循环但实际使用中先选最高相关性片段更符合直觉。5. 完整实操流程从召回结果到最终上下文5.1 整体流程串联把 Reranker 和 MMR 串起来完整的检索后处理流程是这样的向量召回用 Bi-Encoder 编码查询从向量库检索 Top-N 候选N 通常设 50 到 100。Reranker 精排用 Cross-Encoder 对 N 条候选逐条打分按分数降序排列。截断取精排后的前 M 条M 通常设 20 到 30作为 MMR 的输入。MMR 去冗余从 M 条中选出 K 条K 通常设 5 到 10既相关又不重复。组装上下文把 K 条片段按相关性排序后拼接送给大模型生成答案。这个流程里N、M、K 三个数字的选择有讲究。N 太小召回阶段可能漏掉相关文档N 太大Reranker 推理时间线性增长。M 太小MMR 没有足够的挑选空间M 太大MMR 计算量增加而且可能引入一些相关性不够的片段。K 太小上下文信息不足K 太大上下文过长大模型可能抓不住重点。我的经验值是N50M20K5。这个配置在大多数企业问答场景下响应时间在 1.5 到 2 秒之间答案质量明显优于不做精排和去冗余的基线。5.2 参数计算与选择过程以 BGE-Reranker-base 为例算一下推理耗时。模型参数量 2.78 亿FP16 精度下显存占用约 550 MB。单条查询-文档对512 token在 V100 上的推理耗时约 8 到 12 毫秒。50 条候选batch size 16需要 4 个 batch总耗时约 50 到 80 毫秒。加上 tokenization 和数据传输的开销整体在 100 到 150 毫秒。MMR 的计算量20 条候选每条向量 768 维。计算相似度矩阵是 20×20 的矩阵乘法耗时可以忽略不计。选 5 条片段的循环总共算 20×5 次相似度也是微秒级。所以整个后处理流程的额外耗时大约 150 到 200 毫秒。相比不做精排的方案响应时间增加不多但答案质量提升明显。如果换成 BGE-Reranker-large参数量翻倍推理耗时也大约翻倍。50 条候选的推理耗时在 200 到 300 毫秒。如果对响应时间敏感可以用 base 版本如果对精度要求极高且能接受稍长的响应时间用 large 版本。5.3 实操现场记录一次完整的调优过程我拿一个实际的企业内部知识库问答场景做过调优。文档库有 3 万多条制度文档片段用户问题是“出差住宿标准是多少”。基线方案只用向量召回Top-5 直接送大模型召回的前 5 条里有 3 条来自《差旅管理制度》但分别是“出差申请流程”“差旅报销所需材料”“出差交通标准”没有一条直接回答住宿标准。大模型生成的答案含糊其辞说“具体标准请参考公司制度”。加 Reranker 后Top-50 召回Reranker 精排取 Top-5精排后的第一条就是“出差住宿标准一线城市每晚不超过 500 元二线城市不超过 400 元”第二条是“住宿费报销需提供发票”第三条是“超标部分自理”。答案准确了但第二条和第三条信息量不大而且和第一条有部分重叠。加 MMR 后Reranker 精排 Top-20MMR 选 Top-5λ0.6选出来的片段变成了“出差住宿标准一线城市每晚不超过 500 元”“二线城市住宿标准每晚不超过 400 元”“住宿费报销需提供发票”“超标部分自理”“特殊情况下可申请超标住宿”。信息覆盖更全面而且没有重复。大模型生成的答案结构清晰把不同城市的标凈、报销要求、超标处理都讲清楚了。这个调优过程让我确认了一点Reranker 解决“排序不准”MMR 解决“信息重复”两者缺一不可。5.4 与生成阶段的衔接MMR 选出的 K 条片段送给大模型之前还要做一步处理按相关性排序。MMR 选出的片段顺序是按选择顺序排列的不一定按相关性降序。而大模型对上下文里靠前的内容通常更关注所以把最相关的片段放在最前面有助于模型抓住重点。另外片段之间最好加一个分隔符比如---或者[文档片段 N]让模型清楚地区分不同来源的内容。如果片段来自不同文档还可以把文档标题带上给模型更多上下文信息。def build_context(selected_fragments, scores): # 按相关性降序排列 ranked sorted(zip(selected_fragments, scores), keylambda x: x[1], reverseTrue) context_parts [] for i, (frag, score) in enumerate(ranked): context_parts.append(f[片段 {i1}]\n{frag}) return \n\n---\n\n.join(context_parts)这个拼接方式看起来简单但实际效果比直接把片段用换行符连起来好很多。模型能清楚地知道每个片段的边界生成答案时引用信息也更准确。6. 常见问题与排查技巧实录6.1 Reranker 加载报错与排查问题一no lm runtime found for model format gguf!这个错误在用 llama.cpp 加载 GGUF 格式的 Reranker 模型时很常见。根本原因是 llama.cpp 的主线版本主要支持自回归生成模型对 Cross-Encoder 架构的支持有限。解决办法确认你用的推理框架是否支持 Reranker。llama-cpp-python需要较新版本且要调用专门的rerank接口而不是create_completion。如果框架不支持把模型转成 ONNX 格式用 ONNX Runtime 推理。转换命令optimum-cli export onnx --model BAAI/bge-reranker-base --task text-classification ./bge-reranker-onnx转换完成后用onnxruntime加载import onnxruntime as ort from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(BAAI/bge-reranker-base) session ort.InferenceSession(./bge-reranker-onnx/model.onnx) def rerank_onnx(query, candidates): pairs [[query, doc] for doc in candidates] inputs tokenizer(pairs, paddingTrue, truncationTrue, max_length512, return_tensorsnp) ort_inputs { input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64), } logits session.run(None, ort_inputs)[0] return logits.view(-1).tolist()问题二Reranker 分数全是负数或者范围异常BGE-Reranker 的输出是 logits没有经过 sigmoid 或者 softmax。所以分数可能是负数范围也不固定。这不影响排序因为排序只看相对大小。但如果你要把分数用于 MMR 的 λ 加权就需要先做归一化。用 min-max 归一化把分数映射到 0 到 1 之间def normalize_scores(scores): min_s, max_s min(scores), max(scores) if max_s min_s: return [1.0] * len(scores) return [(s - min_s) / (max_s - min_s) for s in scores]6.2 MMR 效果不理想的调优方向问题MMR 选出来的片段还是有很多重复可能的原因有三个λ 设得太高、相似度度量不合适、候选池太小。先检查 λ。如果 λ 是 0.8 或 0.9相关性权重太高多样性权重太低MMR 就退化成纯相关性排序了。把 λ 降到 0.5 到 0.6 试试。如果 λ 已经调低但还是重复检查相似度度量。如果用的是点积而不是余弦相似度且向量没有归一化相似度计算可能不准。确保向量做了 L2 归一化。如果候选池只有 10 条而且这 10 条本身就高度相似那 MMR 也巧妇难为无米之炊。这时候要回到召回阶段看看是不是召回策略太单一比如只用了向量召回没有结合关键词召回。混合召回能带来更多样化的候选。问题MMR 选出来的片段相关性不够这是 λ 设得太低的表现。多样性权重太高选出来的片段虽然不重复但和问题的相关性不够强。把 λ 提到 0.7 左右让相关性占主导。还有一个可能是 Reranker 的分数没有归一化导致 λ 加权时相关性分数的量纲和相似度分数不匹配。确保两者都在 0 到 1 之间。6.3 性能瓶颈与优化瓶颈一Reranker 推理慢如果候选有 100 条Reranker 推理可能要 200 到 300 毫秒。优化方向减少候选数量。召回阶段取 Top-50 而不是 Top-100精度损失不大但 Reranker 耗时减半。用更小的模型。BGE-Reranker-base 比 large 快一倍精度差距在大多数场景下可以接受。用 GPU 推理。CPU 推理 Cross-Encoder 会慢很多如果条件允许尽量用 GPU。用 ONNX Runtime 或者 TensorRT 加速。ONNX Runtime 在 CPU 上的性能比 PyTorch 好不少TensorRT 在 GPU 上更快。瓶颈二MMR 计算慢MMR 的计算量主要来自相似度矩阵。如果候选有 100 条要选 10 条相似度计算是 100×10 次向量点积。这个量级其实不大但如果向量维度很高比如 1024 维而且用 Python 循环实现可能会慢。优化方法是把相似度计算向量化# 向量化计算相似度矩阵 candidate_vectors candidate_vectors / np.linalg.norm(candidate_vectors, axis1, keepdimsTrue) sim_matrix np.dot(candidate_vectors, candidate_vectors.T)这样一次矩阵乘法就算完了所有候选之间的相似度比循环快很多。6.4 常见问题速查表问题现象可能原因排查方向解决办法Reranker 加载报错框架不支持 Cross-Encoder检查推理框架版本和接口转 ONNX 或用支持 Reranker 的框架Reranker 分数异常输出是 logits 未归一化检查分数范围min-max 归一化到 0-1MMR 结果仍重复λ 太高或候选池太小检查 λ 值和候选数量降低 λ扩大召回候选MMR 结果不相关λ 太低或分数未归一化检查 λ 和分数范围提高 λ归一化相关性分数响应时间过长候选太多或模型太大检查 N 和模型规格减少候选换 base 模型显存不足batch size 太大检查显存占用减小 batch size 或 max_length6.5 几个容易忽略的细节细节一Reranker 的 max_length 要和文档片段长度匹配。如果文档片段平均 300 tokenmax_length 设 512 够用。但如果有些片段超过 512 token会被截断可能丢失关键信息。建议在文档切分阶段就控制片段长度不要超过 Reranker 的最大输入长度。细节二MMR 的输入向量要和召回阶段一致。如果召回用的是 BGE 的向量MMR 也用 BGE 的向量这样相似度计算才准确。不要混用不同模型编码的向量。细节三Reranker 和 MMR 的 top_k 不要设成一样。Reranker 的 top_k 是精排后保留的数量通常设 20 到 30MMR 的 top_k 是最终送给大模型的数量通常设 5 到 10。两者不一样中间有一个筛选过程。细节四如果文档库更新频繁Reranker 不需要重新训练。Reranker 是通用的相关性模型不依赖具体文档库。文档库更新只需要更新向量库Reranker 照常使用。细节五MMR 的 λ 可以按查询类型动态调整。比如事实型查询“年假多少天”λ 设高一点偏向精准匹配开放型查询“公司有哪些福利”λ 设低一点偏向广泛覆盖。这个可以根据查询分类模型来做也可以简单点根据查询长度或者关键词来启发式判断。7. 扩展方向与个人经验这套 Reranker MMR 的方案落地之后还有几个可以继续深挖的方向。方向一用 LLM 做 Reranker。用大模型对候选片段做相关性打分精度可能比 Cross-Encoder 更高但成本也更高。适合对精度要求极高、且能接受较高延迟的场景。实现方式是用 prompt 让模型输出相关性分数或者用模型的 logprob 来算分数。方向二多路召回 Reranker。向量召回和关键词召回BM25各取 Top-30合并后用 Reranker 精排。这样召回阶段的覆盖面更广Reranker 的价值也更大。实测下来多路召回比单路召回的答案质量提升明显。方向三MMR 的变体。除了标准的 MMR还有一些变体比如考虑片段长度的加权 MMR或者用聚类先分组再选代表的方案。这些在特定场景下可能效果更好但实现复杂度也更高。我个人在实际操作中的体会是Reranker 的收益比 MMR 更直接、更明显。如果时间有限先把 Reranker 加上效果立竿见影。MMR 的收益取决于场景如果文档库本身冗余度不高MMR 的提升可能有限。但如果文档库里有大量相似内容比如多个版本的制度文档MMR 的去冗余效果就非常关键。最后分享一个小技巧Reranker 的分数可以用来做阈值过滤。如果精排后最高分低于某个阈值比如 0.3说明召回结果和问题都不太相关这时候可以直接返回“未找到相关信息”而不是硬让大模型生成一个可能胡编的答案。这个阈值需要根据实际数据调但加上之后系统的可靠性会明显提升。