
在做检索增强生成Retrieval-Augmented GenerationRAG项目时最让人头疼的问题往往不是检索不到内容而是系统分不清哪些问题能回答、哪些问题不能回答。很多问答系统上线后表现像“硬答模式”知识库里根本没有答案它也能基于几个高相似度片段编出一个看似合理的回复。这个问题的本质是检索链路只做了相关性召回没有做可回答性判断。这里的 retrieval 不是 MySQL 连接配置里 Public Key Retrieval is not allowed 这种数据库检索报错而是 RAG 场景下的信息召回和答案可用性判断。下面会从根因开始逐步讲清楚怎么让检索系统学会“有把握才答、没把握就拒”并给出一套最小可运行的 Python 示例和排查方法。1. 检索分数高不代表问题可回答先理解可回答性1.1 一个看起来“相关”但根本答不了的典型现象假设你的知识库里有这样一条 FAQ“如何申请发票在订单详情页点击申请发票填写抬头和税号。”用户实际的问题是“发票抬头填错了怎么办”。从语义上看“发票”和“抬头”都有重叠向量检索很可能把这条 FAQ 排在第一位相似度甚至超过 0.8。但知识库里并没有“修改发票抬头”的流程系统如果直接根据这段内容生成答案很容易给出“去订单详情页申请发票”这种牛头不对马嘴的回复。这种感觉就是项目标题里说的My retrieval cant tell a question it can answer from one it cant。检索系统知道“这段文字和问题相关”但它不知道“这段文字是否能支撑最终答案”。下面用一个表格说明这种情况查询召回到的最高分段相似度是否包含完整答案常见系统行为发票抬头填错了怎么办“如何申请发票填写抬头和税号”0.82否错误回答“请填写抬头”忘记密码如何找回“如何重置密码点击忘记密码”0.55是有时因阈值设置过高而拒答今天天气怎么样发货时效相关片段0.31否可能硬答或胡编这里的核心问题是相似度只衡量“语义相关”不衡量“信息充分”。一个查询可以和一个片段高度相关但片段里只有部分信息甚至只是一句话里的几个关键词重合。1.2 召回和可回答性是两件不同的事信息检索里有两种目标经常被混在一起召回Retrieval找到与查询语义相关的文档或片段。可回答性Answerability判断召回的片段是否足以回答当前问题。召回的目标是“不能漏”可回答性的目标是“不能错”。一个系统可以很好地完成召回但仍然无法判断该不该回答。比如搜索“红烧肉怎么做”可以召回很多菜谱但如果用户问的是“红烧肉糖色怎么熬”而知识库里的菜谱只写了“加糖上色”没有具体熬糖温度和时间那么相关片段虽然召回了答案仍然不成立。可回答性还和生成模型的能力有关。同一个片段能力强的模型可以通过多步推理得到答案能力弱的模型则不行。所以在 RAG 系统里可回答性判断不能只看检索层还要看生成层能不能真正从上下文里推出答案。实际项目中必须把这两类信号放在一起做决策。1.3 常见错误设计为什么都不稳很多团队第一版 RAG 会采用以下三种粗暴方案它们各有明显缺陷。第一种是只返回相似度最高的片段完全不做可回答性判断。系统把所有问题都当成“可回答”即使没有正确答案也会让大模型基于不相关内容生成内容这是幻觉最直接的来源。第二种是设置一个全局相似度阈值比如相似度大于 0.7 才回答。问题在于不同领域、不同问题表述下的相似度分布完全不一样。有的知识库片段长、关键词重合多阈值 0.7 会大量漏拒有的知识库片段短、语义表达差异大0.7 又会导致大量误拒。第三种是在提示词里写“如果不知道就说不知道”。这种做法有一定作用但并不可靠。大模型是生成式模型它倾向于补全文本尤其在上下文里存在相关信息时它很容易忽略“不知道”的指令继续生成一个顺滑但错误的答案。正确的做法是把可回答性当成一个独立的决策模块来处理。下面从最小可运行案例开始实现一个能够同时输出“检索结果”和“可回答性判断”的系统。2. 最小可运行案例用 Python 搭一个“能召回也能判定”的检索器2.1 技术选型和数据准备为了不依赖外部 API同时又能讲清原理这里使用 TF-IDF 作为文本向量化方式。TF-IDF 和 Embedding 检索在流程上没有本质区别都是把文本转成向量再计算查询向量和文档向量的相似度。生产环境可以换成向量数据库加 Embedding 模型判定逻辑保持一致。准备一个极简知识库包含几条业务文档。示例文档使用中文贴近日常开发场景docs { faq_0: 如何重置密码登录页点击忘记密码输入注册邮箱系统发送重置链接点击链接后设置新密码。, faq_1: 订单发货后多久能到普通物流3到5个工作日偏远地区可能需要7天节假日顺延。, faq_2: 如何申请发票在订单详情页点击申请发票填写抬头和税号提交后3个工作日内开具电子发票。, faq_3: 如何修改收货地址订单未发货前在订单详情页点击修改地址选择新地址并保存。, }这里故意让文档之间有一定关键词重合比如“订单详情页”出现在 faq_2 和 faq_3 中方便后面观察可回答性判断为什么不能只看相似度。2.2 环境准备建议使用 Python 3.9 及以上版本。核心依赖只有 scikit-learn 和 numpy它们用来做向量化和相似度计算。先创建虚拟环境再安装python -m venv .venv source .venv/bin/activate pip install scikit-learn numpy如果使用 Windows激活命令是.venv\Scripts\activate。这里不锁定 scikit-learn 的具体版本因为本文示例比较基础主版本可用即可。实际项目落地前需要确认依赖版本与部署环境一致。2.3 创建索引并实现检索函数先实现文本索引和 TopK 检索。检索逻辑分为三步将文档列表转成 TF-IDF 向量矩阵。把查询文本转成同一个向量空间中的向量。计算查询向量与每个文档向量的余弦相似度按分数从高到低返回。示例代码如下import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity class TfidfRetriever: def __init__(self, docs, k3): self.docs docs self.k k self.vectorizer TfidfVectorizer( analyzerchar_wb, ngram_range(1, 2), min_df1 ) self.doc_ids list(docs.keys()) self.doc_vectors self.vectorizer.fit_transform(list(docs.values())) def retrieve(self, query): query_vector self.vectorizer.transform([query]) sims cosine_similarity(query_vector, self.doc_vectors).flatten() top_indices np.argsort(sims)[::-1][: self.k] results [] for idx in top_indices: results.append({ doc_id: self.doc_ids[idx], score: float(sims[idx]), text: self.docs[self.doc_ids[idx]], }) return sims, results这里有两个细节需要解释。analyzerchar_wb表示使用字符级别的 n-gram。对中文文本来说按字粒度建模通常比默认的英文词元切分更稳定因为中文没有天然空格分词。ngram_range(1, 2)表示同时使用单个字和相邻两个字组合作为特征可以缓解同义词改写带来的稀疏问题。检索函数返回两个值sims是查询与所有文档的相似度数组用于后续信号提取results是 TopK 结果列表包含文档 ID、相似度分数和原文。2.4 先跑通“检索”这一步写一个简单的验证脚本观察检索结果。比如查询“订单发票一直没有收到”预期能召回到 faq_2 申请发票相关的文档但仅仅凭这个结果还不足以决定能不能回答。验证代码retriever TfidfRetriever(docs) queries [ 如何重置密码, 忘记密码怎么找回, 发票抬头填错了怎么办, 今天的天气怎么样, ] for q in queries: sims, results retriever.retrieve(q) print(查询:, q) for r in results: print(r[doc_id], round(r[score], 4), r[text][:30]) print(- * 20)运行后会看到“忘记密码怎么找回”这条查询虽然语义上能答但 TF-IDF 相似度可能并不高而“发票抬头填错了怎么办”可能因为包含“发票”“抬头”召回到申请发票文档但它无法回答用户真实问题。这就是下一步要引入可回答性判定的原因。3. 用检索层信号和生成层信号给“可回答性”打分3.1 检索层信号要想让系统判断一个问题能不能答不能只看 Top1 分数至少要从相似度分布里提取几个信号。常见信号如下信号计算方式它反映什么使用建议top1_score最高相似度查询与最相关内容的相关程度不能单独作为判定依据topk_avg_scoreTopK 分数均值召回整体是否稳定分数普遍低时更可能是不可回答marginTop1 与 Top2 的差值候选之间是否有明显区分度margin 过小时答案可能不确定hit_count相似度大于低阈值的片段数上下文信息量是否足够过少说明可用信息不足coverage召回到的片段是否覆盖问题关键实体信息充分性缺失关键实体时应提高拒答概率下面实现一个信号提取函数把这些指标统一封装起来def extract_retrieval_signals(sims, query, top_results, low_threshold0.2): sorted_sims np.sort(sims)[::-1] top1 sorted_sims[0] if len(sorted_sims) 0 else 0.0 top2 sorted_sims[1] if len(sorted_sims) 1 else 0.0 margin top1 - top2 topk_avg float(np.mean(sorted_sims[:3])) hit_count int(np.sum(sims low_threshold)) return { top1_score: float(top1), top2_score: float(top2), margin: float(margin), topk_avg_score: topk_avg, hit_count: hit_count, }margin是一个很有价值的信号。如果 Top1 和 Top2 分数非常接近说明系统无法确定哪段内容才是真正的答案来源此时即使 Top1 分数不低也应该降低可回答置信度。hit_count则表示有多少片段与查询有基础关联它反映的是上下文信息量。3.2 生成层信号检索层信号只回答“相关程度”生成层信号要回答“大模型是否真的能从上下文里生成答案”。最直接的方法是在提示词里要求模型输出结构化 JSON同时给出它自己的可回答性判断和置信度。示例提示词模板如下你是一个问答助手。请根据【背景资料】回答问题。 【背景资料】 {context} 【用户问题】 {query} 请按以下 JSON 格式输出 { answerable: true 或 false, confidence: 0到1之间的小数, answer: 如果 answerable 为 true则给出答案否则填空字符串 }在真实项目中这里可以调用 OpenAI、本地 vLLM、或者公司内部部署的模型。为了让示例不依赖外部服务下面用一个模拟函数代替大模型调用重点展示后处理逻辑def mock_llm_generate(query, context): # 生产环境中替换为真实模型调用。 # 这里模拟一个“看到了发票和抬头但没看到修改流程”的模型输出。 if 抬头 in query and 修改 in query: return { answerable: True, confidence: 0.6, answer: 你可以到订单详情页申请发票并重新填写抬头。, } return { answerable: False, confidence: 0.1, answer: , }这里模拟的是一个非常关键的失败场景模型认为可以回答但答案本身并不正确。所以生成层信号不能单独作为最终判断标准还需要和检索层信号融合。可以再加一个一致性信号检查模型生成的答案和召回片段之间是否存在足够的字面覆盖。如果模型给出的答案里很多关键名词根本不在召回的文档里说明答案可能来自模型内部记忆而不是来自知识库。一个轻量实现如下def lexical_overlap(answer, context): if not answer: return 0.0 answer_tokens set(answer) context_tokens set(context) if not answer_tokens: return 0.0 return len(answer_tokens context_tokens) / len(answer_tokens)这个指标很粗糙但能在后处理阶段拦截明显的幻觉。生产环境可以换成 ROUGE-L 或 embedding 向量一致性原理一致答案必须能被检索上下文支撑。3.3 综合判定规则把检索层信号、生成层信号和一致性信号放在一个决策函数里。先定义一个可回答性得分计算方法然后用规则判定def compute_answerability_score(retrieval_signals, llm_output, overlap_score): score 0.0 score retrieval_signals[top1_score] * 0.3 score min(retrieval_signals[topk_avg_score], 1.0) * 0.2 score min(retrieval_signals[margin] * 2, 1.0) * 0.1 score llm_output.get(confidence, 0.0) * 0.2 score overlap_score * 0.2 return score def decide_answerable(score, answerable_flag, min_score0.45): if answerable_flag is False: return False, llm_rejected if score min_score: return False, low_score return True, ok这里的权重是示例值目的是演示多信号融合的思路。不同项目的权重应该通过对历史样本的回归或网格搜索确定。注意一个大方向不要把规则的最终输出只依赖单一指标尤其不能让LLM 返回的 answerable字段成为最终裁决因为模型可能过度自信。4. 拒答策略设计与代码落地4.1 三种拒答策略判定“不可回答”后系统不是简单甩一句“我不知道”就结束。根据业务场景有三种常见策略策略适用场景示例硬拒答明确超出知识库范围“该问题不在知识库范围内”分级响应存在部分相关信息但信息不足推荐相近 FAQ并提示信息不完整澄清后重试问题本身含糊、缺少关键实体“请提供订单号才能查询发货状态”硬拒答适合“今天天气怎么样”这种明显越界的问题。分级响应适合“发票抬头填错了怎么办”这种表面相关但上下文不足的问题。澄清后重试适合用户问题缺少必要条件的情况比如“订单到哪里了”但没有订单号。4.2 拒答话术模板拒绝话术也需要模板化避免模型每次生成不同风格。可以设计一个简单的话术函数class RefusalGenerator: staticmethod def refuse_with_faq(query, faq_title): return f暂时没有找到关于“{query}”的完整信息。你可能需要参考{faq_title}。 staticmethod def ask_clarification(missing_field): return f为了帮你找到准确答案请补充以下信息{missing_field}。 staticmethod def transfer_to_human(): return 该问题需要人工处理已为你转接人工客服。实际产品中话术可以使用配置中心下发让运营同学不需要改代码就能调整语气。4.3 拒答后的补偿机制拒答之后系统应该尝试补偿而不是直接结束对话。补偿链路可以设计成一个责任链如果是因为信息不足先输出澄清问题。如果是因为 TopK 召回片段不够改用更宽的召回范围重试一次。如果是因为问题跨领域返回相近 FAQ 列表。如果仍然无法回答再转人工或记录日志。示例伪代码def handle_unanswerable(query, retriever, decision_reason): if decision_reason low_score: retriever.k 5 sims, results retriever.retrieve(query) if results and sims[0] 0.25: return {type: faq, items: [r[doc_id] for r in results[:2]]} if 缺少 in query or not query.strip(): return {type: clarification, field: 订单号或用户账号} return {type: human}补偿机制的价值在于即使系统拒绝回答用户仍然能得到下一步行动指引而不是陷入“问了也白问”的挫败感。5. 用一份测试集验证“该答的答不该答的拒”5.1 构造测试集要验证系统是否真的学会了可回答性判断需要有带标签的测试集。测试集至少包含四类数据问题期望动作说明如何重置密码回答知识库存在忘记密码怎么找回回答语义改写需要泛化发票抬头填错了怎么办拒答或澄清上下文不足今天天气怎么样拒答完全越界订单没发货可以改地址吗回答需要跨片段推理测试集规模不用大但必须覆盖“可回答”“不可回答”“语义相似但不可回答”三种情况否则评估结果很容易虚高。5.2 评估脚本和输出定义几个指标可回答问题召回率应该回答的问题里系统回答出来的比例。不可回答问题召回率应该拒绝的问题里系统拒绝的比例。综合准确率全部样本中判断正确的比例。评估代码可以用最简单的混淆矩阵from sklearn.metrics import confusion_matrix, accuracy_score y_true [1, 1, 0, 0, 1] # 1 表示可回答 y_pred [1, 1, 0, 1, 0] # 1 表示系统选择回答 print(confusion_matrix(y_true, y_pred)) print(accuracy:, accuracy_score(y_true, y_pred))运行结果里第一类错误是“把不该答的答了”这会产生幻觉第二类错误是“把该答的拒了”这会影响用户满意度。项目指标设计必须同时关注这两类错误不能只看 accuracy因为如果 90% 样本都是可回答的系统全部回答就能拿到 90% 准确率但剩下的不可回答样本会变成严重的体验事故。5.3 根据评估结果调阈值阈值调优不要拍脑袋。把测试集跑一遍遍历不同的min_score观察两类错误的变化趋势。示例调参代码scores [] labels [] # 模拟每个样本的 answerability score 和真实标签 for sample in test_samples: score sample[score] label sample[answerable] scores.append(score) labels.append(label) best_metric 0 best_threshold 0 for threshold in np.arange(0.2, 0.9, 0.05): preds [1 if s threshold else 0 for s in scores] tn, fp, fn, tp confusion_matrix(labels, preds).ravel() unanswerable_recall tn / (tn fp) answerable_recall tp / (tp fn) # 根据业务定义综合指标这里按平均分示例 metric (unanswerable_recall answerable_recall) / 2 if metric best_metric: best_metric metric best_threshold threshold print(best threshold:, best_threshold)实际调阈值时不一定要追求平均分最高。如果产品更怕幻觉就优先保证不可回答问题召回率高如果产品怕误拒用户就优先保证可回答问题的体验。6. 这些问题最常出现在哪里从现象到根因的排查链路6.1 输入检查和向量化一致性系统上线后最常见的现象是“离线测试很好线上表现很差”。排错不要先怀疑模型先从输入开始检查。检查项检查方法查询文本是否有前后缀干扰打印 query 原文确认没有拼接脏数据检索向量化是否与索引一致确认查询和文档使用同一个 vectorizer 或 embedding 模型文档是否真的进入了索引按 doc_id 查询索引是否存在切分是否破坏了语义查看召回片段是否在句子中间被截断如果使用训练好的 Embedding 模型尤其要注意部署环境里模型版本和索引阶段是否一致。模型一旦更新离线索引的向量空间可能变化线上查询用新模型编码检索结果会完全错乱。6.2 检索结果为空或碎片化现象系统经常拒答但人工看知识库里明明有答案。常见原因有三个TopK 设置太小比如 k1正确答案被排在第二位但没被召回。文档切分太碎一个完整流程被切成多段单段包含信息量不足。召回分数整体偏低因为 embedding 模型对领域术语不敏感。解决方案是增大 TopK、对长文档做重叠切分、加入 BM25 混合检索让稀疏检索和向量检索互补。6.3 LLM 不遵守拒答指令现象提示词明确写了“不可回答时输出 false”模型仍然输出一段答案。可能原因上下文里存在相似表述模型被“带偏”。prompt 中拒答指令放在后面模型生成长文本时注意力偏移。温度参数过高生成随机性变大。模型本身没有经过拒答指令微调通用模型容易忽略不回答指令。处理方式分两层。第一层在生成层强制要求输出 JSON并在代码里校验answerable字段如果解析失败或不符合 schema直接当作不可回答处理。第二层在后处理时再次用检索层信号校验即使模型说可回答只要检索信号不足仍然要拒绝。JSON 解析示例import json def safe_parse_llm_output(text): try: data json.loads(text) return data except json.JSONDecodeError: return { answerable: False, confidence: 0.0, answer: , }这是兜底设计避免生成格式错误导致系统崩溃。6.4 推荐日志字段和问题定位顺序排查可回答性问题最怕没有日志。生产环境建议把每次查询的完整链路都写入结构化日志{ query: 发票抬头填错了怎么办, signals: { top1_score: 0.72, margin: 0.08, hit_count: 2, llm_confidence: 0.6, overlap_score: 0.4 }, decision: refuse, reason: low_score, context_doc_ids: [faq_2], latency_ms: 312, timestamp: 2025-01-01T10:00:00Z }定位问题的顺序应该是看decision和reason确认是检索层拒了还是生成层拒了。再看signals确认哪个信号拉低了可回答性得分。再看context_doc_ids确认召回的文档是否真的相关。最后看latency_ms排除是否是超时导致没走完整链路。7. 最佳实践与生产化扩展建议7.1 不要把“可回答性”完全交给阈值阈值只是最后的护栏不是核心能力。真正可靠的可回答性判断需要同时具备三样东西可靠的检索召回保证相关知识没有被切碎或漏掉。结构化的生成输出保证模型明确回答“可回答”或“不可回答”。后处理校验保证答案内容能被检索上下文支撑。三者缺一不可。只要其中一个环节失效其他环节就要能够兜底。7.2 生产环境需要补齐的组件组件作用建议混合检索提升召回率向量检索 BM25弥补单一检索盲区重排序模型提升 TopK 精排质量在打分阶段加入 reranker再提取信号配置中心管理阈值和话术阈值、提示词模板不下发到代码里日志平台收集拒绝样本记录完整请求链路和决策理由监控指标观察误拒和漏拒趋势每天统计两类错误率超过阈值告警人工反馈通道修正错误决策用户对“拒绝”点踩或反馈后进入定期评估集7.3 常见坑和正确做法常见坑错误表现正确做法只依赖 Top1 相似度分数高就回答分数低就拒答使用多信号融合至少看 topk_avg 和 margin信任 LLM 的自评分模型自信地给 0.95但答案错误加入答案与检索片段的一致性校验提示词要求拒答但无兜底模型偶尔遵守偶尔不遵守强制 JSON 解析解析失败视为拒答只用准确率评估可回答样本多时指标虚高分别统计可回答问题召回率和不可回答问题召回率7.4 下一步扩展方向如果项目已经跑通最小可用版本可以从以下方向继续完善。第一个方向是训练独立可回答性分类器。用历史日志里的“可回答/不可回答”样本训练一个二分类模型替代当前手工规则效果通常更稳定。第二个方向是对模型做拒答指令微调。让模型学习在信息不足时主动输出特殊 token而不是依赖用户写死的提示词。第三个方向是引入自一致性检查。让模型对同一个问题生成多次答案如果多个答案之间差异很大说明模型自己也不确定此时应该拒答。第四个方向是构建人工反馈闭环。用户对错误答案和错误拒答的反馈定期进入测试集和训练集让系统逐步贴近真实业务分布。如果你目前只想先做一件事我建议把查询日志里的拒绝样本收集起来每周看一次。可回答性不是一次性规则而是一套持续校准的机制。只有把“哪些问题不能答”的数据沉淀下来检索系统才能真正分清一个问题它能回答还是不能回答。