Reranker与MMR实战:企业级问答系统精排去冗余指南

📅 发布时间:2026/10/5 9:29:09
Reranker与MMR实战:企业级问答系统精排去冗余指南
1. 为什么你的问答系统搜出来的东西总是“差点意思”做过企业级问答系统的人多半都经历过这个阶段向量检索跑通了Top-K 也召回了但把结果丢给大模型之后回答要么答非所问要么把同一段话翻来覆去说三遍。用户问“报销流程要多久”系统返回五条文档三条都在讲“报销单填写规范”真正讲时效的那条排在第四位模型一看前面全是重复内容直接开始胡编。这个问题的根子不在向量模型而在召回之后的排序与筛选环节。向量检索本质上是“粗筛”它用的是双塔结构Bi-Encoderquery 和 document 分别编码成向量再算余弦相似度。这种做法的好处是快可以在一毫秒级别从百万级文档里捞出几百条候选坏处是 query 和 document 之间没有交互语义匹配的精度天然受限。你拿“报销流程要多久”去检索它可能把“报销流程”这四个字匹配度最高的文档排前面但那条文档讲的是流程步骤不是时效。所以工业界的标准做法是两段式第一阶段用向量检索或 BM25 做召回第二阶段用 Reranker 做精排。Reranker 用的是 Cross-Encoder 结构把 query 和 document 拼在一起送进模型让两者在注意力层里充分交互打分精度比双塔高出一大截。但 Cross-Encoder 的代价是慢——它没法预计算每来一个 query 都要和所有候选文档重新跑一遍推理所以只能用在 Top-50 到 Top-100 这个量级的精排上。精排解决了“排序不准”的问题但没解决“内容重复”的问题。Top-5 里可能有三条讲的是同一件事只是措辞不同。这时候就需要MMRMaximal Marginal Relevance最大边际相关性出场它在“相关性”和“多样性”之间做权衡把那些既相关又不像的文档挑出来去掉冗余。这一章要聊的就是这两个环节Reranker 怎么选、怎么部署、怎么调参以及MMR 怎么实现、λ 怎么设、和 Reranker 怎么配合。整套方案我会用 llama.cpp GGUF 格式的 Reranker 模型来落地因为这是目前本地部署成本最低、兼容性最好的路线一台没有独立显卡的普通服务器就能跑起来。如果你正在搭建企业知识库问答、客服机器人、文档助手这类系统这篇内容可以直接抄作业。2. Reranker 与 MMR 的整体设计思路2.1 两段式检索架构到底解决了什么问题先把整体链路捋清楚。一个完整的企业级问答检索流程从用户输入到最终送给大模型的上下文中间要经过这么几道关Query 预处理改写、扩展、意图识别把“报销要多久”补全成“员工差旅报销流程的处理时效是几个工作日”。粗召回向量检索Embedding 向量库和关键词检索BM25各召回一批通常各取 Top-50合并去重后得到 80 到 100 条候选。精排Reranker用 Cross-Encoder 对这 100 条逐条打分按分数重排取 Top-10 到 Top-20。去冗余MMR在精排结果上做多样性筛选去掉内容高度重叠的文档最终留下 3 到 5 条。上下文组装把筛选后的文档拼成 prompt送给生成模型。粗召回和精排的分工可以用招聘来类比。粗召回像 HR 筛简历看关键词匹配度速度快但不够准精排像业务主管面试逐个人深入聊判断真实匹配度但一天只能面几个人。你不可能让业务主管去看一万份简历也不可能只靠 HR 筛简历就发 offer。两段式架构的本质就是用低成本手段把候选集缩小到高成本手段能承受的范围。MMR 则是在精排之后的“查重”环节。它不改变相关性排序的逻辑而是在已经相关的文档里挑出信息量最大的组合。举个例子精排 Top-5 是排名文档内容概要Reranker 分数1报销单需填写发票号、金额、事由0.922报销单填写规范发票号必填0.893差旅报销时效为 5 个工作日0.854报销单填写注意事项0.835报销审批流程提交后 3 天初审0.80前两条和第四条讲的是同一件事MMR 会把它们压掉保留第 1、3、5 条这样送给大模型的上下文里既有填写要求又有时效信息还有审批流程信息密度最高。2.2 为什么选 Cross-Encoder 而不是继续用双塔有人会问既然双塔精度不够为什么不直接换一个更强的 Embedding 模型答案是结构决定的精度上限不同。双塔模型里query 和 document 是分别编码的两个向量在最后一刻才做点积或余弦。这意味着模型在编码 document 的时候根本不知道 query 是什么它只能把 document 的“通用语义”压缩进一个固定维度的向量里。而 Cross-Encoder 是把[query, document]拼成一个序列送进 Transformer注意力机制可以让 query 里的每个词和 document 里的每个词直接交互“报销”这个词会去关注 document 里的“时效”“工作日”“天”这些词匹配信号强得多。代价也很明显。双塔可以离线把所有 document 的向量算好存进向量库查询时只算 query 向量再做 ANN 搜索复杂度是 O(1) 级别的。Cross-Encoder 必须在线推理100 条候选就是 100 次前向传播延迟直接和候选数量成正比。所以 Cross-Encoder 只能用在精排不能用在召回。实测数据上同一个数据集双塔召回 Top-10 的命中率大概在 70% 到 80%加上 Cross-Encoder 精排之后Top-3 命中率能拉到 90% 以上。这个提升在问答场景里是决定性的因为大模型对上下文的信噪比非常敏感前面塞两条不相关的它就开始跑偏。2.3 MMR 的数学直觉相关性减冗余MMR 的公式看起来吓人其实逻辑很朴素MMR argmax [ λ · Sim(di, q) - (1-λ) · max Sim(di, dj) ] di∈R\S j∈S翻译成人话从候选集 R 里还没被选中的文档中挑一个出来让它满足两个条件——和 query 的相关性尽量高第一项和已经选中的文档尽量不像第二项。λ 是权重λ1 时退化成纯相关性排序λ0 时只追求多样性不管相关性。实践中 λ 一般设在 0.5 到 0.7 之间。这里有个容易踩的坑第二项的max Sim(di, dj)是拿候选文档和已选集合里的每一条比取最大相似度。也就是说只要候选文档和已选集合里任意一条很像它就会被惩罚。这个设计是对的因为你要避免的是“新选的这条和已有的某一条重复”而不是“和所有已有的都重复”。相似度用什么算可以用 Embedding 向量算余弦相似度也可以用 Reranker 的分数做归一化。我一般用 Embedding 余弦因为 MMR 阶段文档数量已经很少10 到 20 条算相似度开销可以忽略而且 Embedding 向量在去冗余这个任务上比 Cross-Encoder 分数更稳定——Cross-Encoder 分数是 query 相关的两条文档分数接近不代表它们内容像。3. Reranker 模型选型与 GGUF 部署实操3.1 主流 Reranker 模型横向对比选 Reranker 模型核心看三个指标精度NDCG10、延迟100 条候选的总耗时、显存占用。下面是我实测过的几个主流方案测试环境是单张 RTX 3090候选集 100 条文档平均长度 256 token。模型参数量语言支持NDCG10100条耗时显存占用部署难度bge-reranker-base278M中英0.821.2s1.1GB低bge-reranker-large560M中英0.872.8s2.2GB低bge-reranker-v2-m3568M多语言0.893.1s2.3GB低Cohere Rerank v3闭源多语言0.91API无极低jina-reranker-v2278M多语言0.861.5s1.2GB中mxbai-rerank-large435M英文0.882.1s1.8GB中如果预算充足且能接受 API 调用Cohere Rerank 是省事的选择。但企业级场景往往有数据不出域的要求本地部署是刚需。本地方案里bge-reranker-v2-m3是综合最优解多语言支持好中文场景精度高模型大小适中社区生态成熟。但这里有个现实问题很多企业的服务器没有 GPU或者 GPU 被其他任务占着。这时候就需要llama.cpp GGUF这条路——把 Reranker 模型转成 GGUF 格式用 llama.cpp 在 CPU 上跑量化推理。3.2 GGUF 格式与 llama.cpp 的适配逻辑GGUF 是 llama.cpp 团队推出的模型格式全称 GPT-Generated Unified Format。它的核心价值是把模型权重和元数据打包成一个文件支持多种量化级别并且能在 CPU 上高效推理。相比 PyTorch 的.bin或.safetensorsGGUF 的优势在于量化友好支持 Q2_K 到 Q8_0 多种量化Q4_K_M 在精度损失很小的情况下把模型压到原大小的 1/4。内存映射llama.cpp 用 mmap 加载 GGUF多个进程可以共享同一份权重内存适合多实例部署。跨平台Windows、Linux、macOS 都能跑甚至能在树莓派上跑小模型。但 GGUF 生态有个历史遗留问题早期 llama.cpp 只支持生成式模型LLaMA、Qwen 这类不支持 Cross-Encoder 结构的 Reranker。你直接拿一个 bge-reranker 的 GGUF 文件去加载会报no lm runtime found for model format gguf!这个错误。原因是 Reranker 的输出是单个分数不是 token 序列llama.cpp 的推理管线默认走的是自回归生成逻辑对不上。解决办法有两条路。第一条是等社区适配目前 llama.cpp 已经通过--reranking参数支持了一部分 Reranker 模型但支持列表有限。第二条是自己写推理脚本用 llama.cpp 的 Python bindingllama-cpp-python手动构造输入、取 logits、算分数。我走的是第二条路因为可控性更强下面详细讲。3.3 从 HuggingFace 到 GGUF 的完整转换流程假设你选定了bge-reranker-v2-m3第一步是把它转成 GGUF。llama.cpp 仓库里有个convert_hf_to_gguf.py脚本但直接跑会失败因为 Reranker 的模型结构和生成式模型不同。你需要先确认模型是否在支持列表里。# 克隆 llama.cpp 仓库 git clone https://github.com/ggerganov/llama.cpp cd llama.cpp # 安装 Python 依赖 pip install -r requirements.txt # 尝试转换以 bge-reranker-base 为例 python convert_hf_to_gguf.py /path/to/bge-reranker-base \ --outfile bge-reranker-base-f16.gguf \ --outtype f16如果脚本报Model type not supported说明这个模型架构还没被适配。这时候有两个选择换一个已经被适配的 Reranker 模型或者手动改转换脚本。我建议优先换模型因为改脚本涉及架构映射容易出错。目前社区验证过能成功转换的 Reranker 包括bge-reranker-base、bge-reranker-large的部分版本以及jina-reranker-v1-tiny。转换成功后得到的是 F16 精度的 GGUF文件大概 500MB 到 1GB。接下来做量化# 编译 llama.cpp如果还没编译 make # 量化到 Q4_K_M ./llama-quantize bge-reranker-base-f16.gguf \ bge-reranker-base-q4km.gguf Q4_K_MQ4_K_M 是我最推荐的量化级别它在精度和体积之间平衡得最好。Q4_0 体积更小但精度掉得明显Q8_0 精度接近 F16 但体积翻倍。对于 Reranker 这种只需要输出一个分数的任务Q4_K_M 的精度损失基本可以忽略实测 NDCG 下降不到 0.5 个百分点。3.4 用 llama-cpp-python 写 Reranker 推理服务转换完模型接下来写推理代码。核心思路是把[query, document]拼成模型期望的输入格式送进 llama.cpp取最后一个 token 位置的 logits经过 sigmoid 或 softmax 得到相关性分数。from llama_cpp import Llama import numpy as np class GGUFReRanker: def __init__(self, model_path, n_ctx512, n_threads8): self.llm Llama( model_pathmodel_path, n_ctxn_ctx, n_threadsn_threads, embeddingFalse, logits_allFalse, verboseFalse ) self.n_ctx n_ctx def _build_input(self, query, doc): # bge-reranker 的输入模板 return fquery: {query} document: {doc} def score(self, query, doc): text self._build_input(query, doc) # 截断到最大长度 tokens self.llm.tokenize(text.encode(utf-8)) if len(tokens) self.n_ctx - 2: tokens tokens[:self.n_ctx - 2] # 调用底层 eval取 logits self.llm.reset() self.llm.eval(tokens) logits self.llm.scores[self.llm.n_tokens - 1] # bge-reranker 的输出是二分类取正类 logit # 具体索引取决于模型通常是最后一个维度 score logits[-1] return float(1 / (1 np.exp(-score))) # sigmoid def rerank(self, query, docs, top_k10): scored [(self.score(query, d), i, d) for i, d in enumerate(docs)] scored.sort(keylambda x: x[0], reverseTrue) return scored[:top_k]这段代码有几个关键点需要说明。第一n_ctx要设得足够大因为 query 加 document 拼起来可能超过 512 token截断策略是保留前面的部分因为 bge-reranker 的训练数据里关键信息通常在前半段。第二logits[-1]这个索引不是通用的不同模型的输出维度不同你需要先打印logits.shape确认。第三sigmoid 之后得到的是 0 到 1 的分数可以直接用来排序。实测下来在 8 核 CPU 上Q4_K_M 量化的 bge-reranker-base 处理 100 条候选平均 200 token大概需要 8 到 12 秒。这个延迟对于离线批处理可以接受但在线问答场景就太慢了。优化方向有两个一是减少候选数量粗召回只取 Top-30二是用多进程并行把 100 条分给 4 个进程同时打分。4. MMR 去冗余的工程实现与参数调优4.1 从零实现一个可用的 MMR 选择器MMR 的实现本身不复杂难的是工程细节。下面是一个生产可用的版本import numpy as np from sklearn.metrics.pairwise import cosine_similarity class MMRSelector: def __init__(self, lambda_param0.6, top_k5): self.lambda_param lambda_param self.top_k top_k def select(self, query_embedding, doc_embeddings, doc_scores): query_embedding: (1, d) query 向量 doc_embeddings: (n, d) 文档向量 doc_scores: (n,) reranker 分数已归一化到 0-1 n len(doc_scores) selected [] candidates list(range(n)) # 预计算文档间相似度矩阵 sim_matrix cosine_similarity(doc_embeddings) # 第一轮选分数最高的 first max(candidates, keylambda i: doc_scores[i]) selected.append(first) candidates.remove(first) while len(selected) self.top_k and candidates: best_idx None best_mmr -np.inf for i in candidates: # 相关性项 relevance doc_scores[i] # 冗余项和已选文档的最大相似度 max_sim max(sim_matrix[i][j] for j in selected) # MMR 分数 mmr self.lambda_param * relevance - \ (1 - self.lambda_param) * max_sim if mmr best_mmr: best_mmr mmr best_idx i selected.append(best_idx) candidates.remove(best_idx) return selected这段代码里有个细节值得展开第一轮直接选分数最高的不参与 MMR 计算。原因是 MMR 的冗余项需要和已选集合比较第一轮已选集合是空的没法算。所以标准做法是先选一个种子再从第二轮开始跑 MMR。种子选分数最高的保证至少有一条最相关的文档在结果里。另一个细节是doc_scores的归一化。Reranker 输出的分数范围不确定有的模型输出 logits可能是负数有的输出概率0 到 1。MMR 公式里相关性项和冗余项需要可比所以要把分数归一化到 0 到 1。最简单的方法是 min-max 归一化def normalize_scores(scores): scores np.array(scores) return (scores - scores.min()) / (scores.max() - scores.min() 1e-8)4.2 λ 参数怎么设不同场景的取值策略λ 是 MMR 里最关键的参数它直接决定了“相关性”和“多样性”的权衡。我按场景整理了一张对照表场景推荐 λ理由事实型问答如“报销要多久”0.7 - 0.8答案通常集中在一两条文档里多样性需求低综述型问答如“介绍一下报销制度”0.5 - 0.6需要覆盖多个方面多样性重要客服机器人0.6 - 0.7平衡既要准确又要避免重复法律/医疗等严谨场景0.8 - 0.9宁可重复也不能漏掉关键信息头脑风暴/创意生成0.3 - 0.5追求多样性鼓励不同角度λ 的调优没有理论最优值只能靠评测集。我的做法是构造一个 50 到 100 条的评测集每条标注“理想上下文应该包含哪些信息点”然后网格搜索 λ 从 0.3 到 0.9步长 0.1看哪个值下最终回答的准确率最高。实测下来大多数企业问答场景 λ0.65 是个不错的起点。还有个动态 λ 的思路根据 query 类型自动调整。如果 query 里包含“哪些”“分别”“对比”这类词说明用户要的是多角度信息λ 调低如果 query 是“是什么”“多少”“多久”这类事实型问题λ 调高。这个逻辑可以用一个简单的规则引擎实现不需要额外训练模型。4.3 Reranker 和 MMR 的配合顺序与性能权衡这里有个工程上的取舍MMR 是在 Reranker 之前做还是之后做方案 A先 MMR 再 Reranker。从粗召回的 100 条里先做多样性筛选选出 20 条再送 Reranker 精排。优点是 Reranker 的输入少了延迟降低缺点是 MMR 用的是 Embedding 相似度精度不如 Reranker可能把真正相关的文档提前筛掉。方案 B先 Reranker 再 MMR。100 条全部过 Reranker取 Top-20再做 MMR 选出 5 条。优点是相关性判断更准缺点是 Reranker 要处理全部 100 条延迟高。我推荐方案 B理由是 Reranker 的精度提升对最终结果影响更大而 MMR 阶段文档数量少开销可以忽略。如果延迟实在扛不住可以折中粗召回只取 Top-50Reranker 处理 50 条再 MMR。实测 50 条和 100 条的召回率差距在 3 个百分点以内但延迟减半。还有一个优化点MMR 的相似度计算可以复用 Reranker 的中间层输出。Cross-Encoder 在算分数的时候其实已经编码了 query 和 document 的交互信息理论上可以用最后一层的 [CLS] 向量做相似度。但 llama.cpp 的接口不暴露中间层所以这条路在 GGUF 方案里走不通还是老老实实用 Embedding 算。5. 常见问题与排查技巧实录5.1 GGUF 加载报错与模型兼容性排查no lm runtime found for model format gguf!这个错误我踩过至少三次每次原因都不一样。整理成排查表报错信息可能原因解决方法no lm runtime found for model format gguf!模型架构不被 llama.cpp 支持换模型或升级 llama.cpp 版本unknown model architectureGGUF 元数据里的 architecture 字段不对检查转换脚本是否匹配模型类型failed to load model量化级别和 llama.cpp 版本不兼容用同版本 llama.cpp 重新量化context length exceededn_ctx 设太小增大 n_ctx 或截断输入tensor not found模型文件损坏或转换中断重新下载原模型并转换重点说第一个。这个报错的本质是 llama.cpp 在 GGUF 文件里读到了一个它不认识的 architecture 标识。生成式模型的 architecture 是llama、qwen2这类Reranker 的 architecture 可能是bert或xlm-roberta。llama.cpp 早期只实现了生成式架构的推理管线遇到bert就直接报这个错。解决办法是升级到较新的 llama.cpp 版本社区已经陆续适配了 BERT 类架构。如果升级后还是报错说明这个具体模型还没被支持只能换模型。我建议在选型阶段就先拿一个小文件测试加载别等部署到生产环境才发现跑不起来。5.2 Reranker 分数异常与输入格式陷阱Reranker 分数异常通常表现为两种所有分数都接近 0.5或者分数和相关性完全对不上。前者多半是输入格式不对后者多半是模型选错了。bge-reranker 系列对输入格式很敏感。它的训练数据用的是query: {query} document: {doc}这个模板如果你直接拼{query} {doc}模型也能跑但分数分布会变得很平区分度下降。我实测过用错模板的情况下Top-1 和 Top-10 的分数差距从 0.3 缩小到 0.05基本没法排序。另一个陷阱是文档截断位置。Cross-Encoder 的输入长度有限通常 512 tokenquery 加 document 超长时要截断。截 query 还是截 document我的经验是优先保 querydocument 从尾部截。因为 query 通常很短截了会影响匹配信号document 的关键信息一般在前半段尾部截掉影响较小。但如果是法律条文这类关键信息可能在末尾的文档就要反过来或者用滑动窗口分段打分取最大值。还有个隐蔽的坑tokenizer 不一致。GGUF 转换的时候tokenizer 会被打包进文件但如果你用的 llama.cpp 版本和转换时的版本不一致tokenizer 行为可能有细微差异导致分数偏移。解决办法是转换和推理用同一个版本的 llama.cpp。5.3 MMR 选出来的结果还是重复怎么办MMR 失效的常见原因有三个。第一Embedding 模型不适合去冗余。如果你用的 Embedding 模型是在短文本上训练的长文档的向量会趋同余弦相似度都在 0.9 以上MMR 的冗余项区分不出来。解决办法是换一个在长文本上表现好的 Embedding 模型或者对文档分段编码再取平均。第二λ 设得太高。λ0.9 的时候冗余项的权重只有 0.1基本不起作用。如果你发现 MMR 选出来的还是重复先把 λ 降到 0.5 试试。第三相似度阈值没设。标准 MMR 只做相对比较不设绝对阈值。如果所有文档都高度相似MMR 也只能矮子里拔将军。这时候需要加一个硬阈值如果候选文档和已选文档的相似度超过 0.95直接跳过不管 MMR 分数多高。# 加硬阈值的 MMR if max_sim 0.95: continue # 直接跳过这条这个阈值 0.95 是经验值你可以根据实际数据调整。设太低会误杀相关文档设太高起不到过滤作用。5.4 性能优化从 10 秒到 1 秒的实操记录CPU 上跑 Reranker延迟是最大的痛点。我记录了一次完整的优化过程从初始的 12 秒降到 1.2 秒。第一轮优化减少候选数量。粗召回从 Top-100 降到 Top-30延迟从 12 秒降到 4 秒。代价是召回率下降约 2 个百分点但精排后 Top-3 命中率只降了 0.5 个百分点可以接受。第二轮优化多进程并行。把 30 条候选分给 4 个进程每个进程处理 7 到 8 条延迟从 4 秒降到 1.5 秒。注意 llama.cpp 的模型加载是内存映射的多进程共享同一份权重不会额外占内存。第三轮优化批处理。llama.cpp 支持 batch 推理把多条候选拼成一个 batch 送进去比逐条推理快 30% 左右。但 batch 大小受 n_batch 参数限制设太大反而会变慢我实测 8 是最优值。第四轮优化量化级别调整。从 Q4_K_M 换到 Q4_0延迟再降 20%但 NDCG 掉了 1.5 个百分点。这个取舍看场景如果对精度要求不高可以用 Q4_0。最终配置Top-30 候选4 进程并行batch8Q4_K_M 量化单次精排延迟 1.2 秒。这个延迟对于在线问答可以接受用户感知不明显。5.5 评测集怎么建没有标注数据时的冷启动方案Reranker 和 MMR 的调优都依赖评测集但很多团队一开始没有标注数据。我的冷启动方案是用大模型生成伪标注。具体做法从真实用户 query 里采样 100 条对每条 query用向量检索召回 Top-20然后让 GPT-4 或 Claude 对每条文档打相关性分数0 到 3 分。这个标注质量不如人工但足够用来调参。等系统上线后收集用户的点击和反馈数据逐步替换成真实标注。评测指标用 NDCG5 和 MRR。NDCG 衡量排序质量MRR 衡量第一条相关文档的位置。这两个指标对 Reranker 的调优最敏感。MMR 的评测则要看“信息覆盖率”可以人工检查 Top-5 里是否覆盖了所有关键信息点。还有个偷懒的办法A/B 测试。直接把 λ0.5 和 λ0.7 两个版本同时上线看哪个版本的最终回答满意度高。这个方法的缺点是周期长但结果最真实。6. 几个我踩过的坑和最后的小技巧Reranker 模型的选择上我一开始迷信大模型用了 bge-reranker-large精度确实高但延迟是 base 的两倍多。后来发现对于大多数企业问答场景base 版本的精度已经够用省下来的延迟预算可以多召回一些候选整体效果反而更好。模型不是越大越好要看整体链路的瓶颈在哪。GGUF 转换的时候一定要保留原始 HuggingFace 模型别转完就删。因为 GGUF 转换是有损的如果发现精度下降太多需要回退到原始模型重新调量化参数。我有一次手贱删了原模型结果发现 Q4_K_M 精度不够想换 Q5_K_M 都换不了只能重新下载。MMR 的 λ 参数我建议做成可配置的不要硬编码。不同业务线的需求不一样客服团队可能希望 λ0.7市场团队做竞品分析可能希望 λ0.4。把 λ 暴露成 API 参数让调用方自己决定比你在代码里写死要灵活得多。最后分享一个提升 Reranker 效果的小技巧在 query 前面加指令前缀。比如把“报销要多久”改成“检索与以下问题最相关的文档报销要多久”。这个前缀会让 Cross-Encoder 的注意力更集中在“相关性判断”这个任务上实测 NDCG 能提升 1 到 2 个百分点。前缀的具体措辞可以调但核心是给模型一个明确的任务信号。这套 Reranker MMR 的方案我从去年开始在三四个项目里落地过最复杂的场景是百万级文档的企业知识库最轻量的场景是几千条 FAQ 的客服机器人。核心逻辑是一样的粗召回保召回率精排保准确率MMR 保信息密度。三个环节各司其职缺一不可。