公司动态
检索系统如何学会“不知道”:可回答性判断与拒答机制设计
如果你的检索系统把“不知道”的问题都当成“知道”来回答那么它带来的伤害往往比检索不到答案还要大。RAG、知识库问答、企业搜索洋洋洒洒搭建完一套检索链路结果用户问一句“今天适合穿什么衣服”系统从技术文档里挑出一段“衣服传播病毒的风险”一本正经地回复过去。这不是模型不够聪明而是你的 retrieval 层根本分不清“这个问题我能回答”和“这个问题我答不了”。标题里的这句话几乎是所有检索系统的真实写照My retrieval cant tell a question it can answer from one it cant。翻译过来就是“我的检索系统区分不了它能回答的问题和它不能回答的问题”。这篇文章不打算讲高深理论而是会把这个问题拆开从检索分数的本质、可回答性判断的三种做法到一套可落地的 Python 代码完整地给你演示一遍。读完你可以直接在自己的检索链路里加一层“拒答门禁”让系统学会说“不知道”。1. 这篇文章真正要解决的问题很多开发者在搭建检索系统时会把绝大多数精力放在“怎么召回更准”上换更强的 embedding 模型、调倒排索引参数、加更多规则。但真正到了生产环境被用户吐槽最多的往往不是“搜不到”而是“乱回答”。你问“什么是 HashMap”系统能答你问“明天股票涨不涨”系统也敢答而且答得煞有介事。这背后的核心问题不是检索精度不够而是检索系统缺少一个最基本的能力——拒答。拒答abstention在信息检索里是一个长期被低估的问题。搜索引擎可以理直气壮地返回“没有找到相关结果”但基于大模型的问答系统很难承认自己不会因为生成阶段天然倾向于“给出一个最像答案的文本”。而 retrieval 层作为知识来源如果本身没有对“能不能回答”做判断那么生成阶段就会把不相关的检索结果当作事实基础产生幻觉。这篇文章要解决的就是这个问题当用户提出一个 query 时如何在检索阶段就判定“语料库里有足够信息回答它”或“没有”。我们会设计一个可验证的流程并给出完整代码让你在返回结果给用户之前先问一句这个问题我的检索系统真的能回答吗2. 为什么检索模型分不清“能回答”和“不能回答”要理解这个问题先要明白检索模型的“答案感”是怎么来的。以最常用的向量检索为例模型会把 query 和 document 分别编码成向量然后用余弦相似度。分数高就说相关分数低就说不相关。但这里有一个隐秘的陷阱相关relevant不等于可回答answerable。比如用户问“怎么配置 MySQL 的 public key retrieval”一篇讲“MySQL JDBC 连接时 public key retrieval is not allowed 报错处理”的文章跟这个问题高度相关但文章里只说了“设置 allowPublicKeyRetrievaltrue然后重试”并没有解释这个参数是什么、为什么要设置。那么对于“为什么 MySQL 8 会出现这个报错”这个问题这篇文章是相关的但不能直接回答。如果你的检索系统把这个文档当作答案来源生成模型就会把“设置参数”这个片段硬抠出来产生一个看似正确但不完整的回答。模型分不清主要有三个原因。第一训练目标里没有“没有答案”这个概念。大多数检索模型是在“query-doc 是否相关”的标签上训练的相关就是 1不相关就是 0。但“相关但无法回答”在标注时会被标记为 1这导致模型学到了“只要有关键词重合就加分”。第二分数分布不均匀。不同 query 的相似度分数范围差异很大。有的 query 跟语料库里所有文档的相似度都很低但最高分也能到 0.7有的 query 跟两篇文档都很像最高分 0.5但第二篇和第一篇差别很小。用一个全局阈值去卡总会出现漏判和误判。第三用户表达本身就有歧义。同一个问题可以有直接问、反问、加背景、省略主语等多种形式。检索系统很难区分“这个问题是它不会还是它没有索引到”这件事。所以要想让 retrieval 会拒答不能只靠一个相似度得分而要在得分的基础上构造更有判别力的特征再做一层判断。这正是本文接下来要讲的。3. 检索系统核心概念与可回答性判断设计在动手写代码前先统一几个概念。Query用户输入的问题。Document语料库中的一条文本。Relevance scorequery 和 document 的相关性得分本文用余弦相似度。Top-k相关度最高的 k 个文档。Confidence系统对“这份文档能回答问题”的置信度。Abstention拒答即返回“我不知道”而不提供文档。可回答性判断可以理解为在检索结果之上再叠加一个“决策层”。最朴素的方式是设定一个相似度阈值最高分低于阈值就拒答。但这种方式太粗糙一个更健壮的设计是综合多个特征top1 分数最强的信号。top1 与 top2 的分数差gap如果前两名分数接近说明系统在犹豫可回答性下降。top1 与 top2 的分数比或除以 top1 的标准化值。query 的长度、关键词数量等统计特征。检索结果的数量如果根本没有召回结果自然无法回答。把这些特征喂给一个简单的分类器比如逻辑回归让分类器判断“可回答”或“不可回答”。这样做的好处是不需要手工调阈值而是用带标注的样本自动学习边界。你也可以在系统运行一段时间后用用户反馈数据持续校准分类器让拒答行为越来越符合真实场景。下面我用一个最小示例来演示完整流程。为了让你能直接运行我会使用 TF-IDF 而不是重型向量模型但核心特征和判断逻辑是完全通用的。4. 环境准备与实验数据本文示例代码基于 Python 3.8 以上版本依赖两个库pip install scikit-learn numpy如果你的环境里已经有 sklearn那额外只需要 numpy。为了避免版本差异本文不指定具体版本号安装最新稳定版即可。接下来构造一个小型测试语料库。这个语料库包含 6 篇中文文档主题涉及 Python、Java、MySQL、RAG以及一篇“完全跑题”的天气文章。我们的目标是让检索系统知道“Python 适合做什么”这类问题可以回答但“明天会不会下雨”这类问题不能回答。# 文件路径data/corpus.py CORPUS [ Python 是一种解释型编程语言语法简洁适合数据分析、机器学习和 Web 开发。, Java 是一种面向对象编程语言擅长构建大型企业级应用运行在 JVM 上。, RAG 是检索增强生成的缩写它将外部知识检索与大型语言模型生成结合起来。, MySQL 是常见的关系型数据库JDBC 连接时可能出现 public key retrieval is not allowed 错误。, JDBC 连接 MySQL 8 时如果服务端使用 caching_sha2_password客户端可能需要配置 SSL 或获取公钥。, 天气预报需要气压、湿度、风速等实时气象数据和编程语言没有直接关系。, ]同时准备一组测试问题包括可回答和不可回答两类# 文件路径data/questions.py TEST_QUESTIONS [ Python 适合用于什么开发, # 可回答 Java 语言的特性是什么, # 可回答 什么是 RAG 技术, # 可回答 MySQL JDBC 连接报 public key retrieval is not allowed 怎么办, # 可回答 明天深圳会下雨吗, # 不可回答 如何预测股票涨跌, # 不可回答 番茄炒蛋的具体步骤是什么, # 不可回答 ]注意前四个问题在语料库中能找到相关内容后三个问题在语料库中没有任何信息。这个设定就是用来模拟现实里的“知识覆盖边界”。5. 代码实现从基础检索到拒答判断这一节我们一步步搭建一个带拒答能力的检索链路。最终代码是完整可运行的建议你把三个文件放在同一个目录下。5.1 基础检索计算相似度分数先用 TF-IDF 向量化语料库再用余弦相似度计算 query 和每篇文档的分数。# 文件路径retrieval/retriever.py import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity class SimpleRetriever: def __init__(self, corpus): self.corpus corpus self.vectorizer TfidfVectorizer() self.doc_matrix self.vectorizer.fit_transform(corpus) def score(self, query): q_vec self.vectorizer.transform([query]) scores cosine_similarity(q_vec, self.doc_matrix).flatten() return scores def retrieve(self, query, top_k3): scores self.score(query) rank_idx np.argsort(scores)[::-1][:top_k] results [] for idx in rank_idx: results.append({ doc: self.corpus[idx], score: float(scores[idx]) }) return results这段代码做了三件事把文档转成 TF-IDF 向量矩阵。对用户 query 用同一个向量化器做向量化。计算余弦相似度按分数降序返回 top-k 文档。TF-IDF 在这里只是演示它无法解决语义一致的问题。真正生产环境中建议替换为 dense embedding 模型。但不管是 TF-IDF 还是向量检索返回的分数都只是“相关性”不是“可回答性”。5.2 构造可回答性特征从检索分数中提取特征用于后续分类。我们需要根据 query 的得分向量加工出以下几个特征top1_score最高相似度。top1_minus_top2最高分与第二高的差值。top2_relative_gap(top1 - top2) / (top1 1e-9)。num_docs_above_low_threshold得分超过低阈值的文档数量。这里的低阈值可以取 0.1 一类的经验值。# 文件路径retrieval/features.py import numpy as np def extract_features(query, scores, top_k3, low_threshold0.1): top_indices np.argsort(scores)[::-1][:top_k] top_scores scores[top_indices] top1 float(top_scores[0]) if len(top_scores) 0 else 0.0 top2 float(top_scores[1]) if len(top_scores) 1 else 0.0 top1_minus_top2 top1 - top2 top2_relative_gap top1_minus_top2 / (top1 1e-9) above_low int(np.sum(scores low_threshold)) return [ top1, top1_minus_top2, top2_relative_gap, above_low, len(query), # query 长度可以一定程度反映问题的复杂度 ]这些特征中top1_minus_top2尤其重要。如果一个 query 在语料库中只有一个强相关文档而其他文档都很远那么说明这个 query 可能在语料库里有“唯一答案”。反过来如果前两名的分数接近说明系统对“到底该选哪篇”并不确定这种不确定性往往是高幻觉风险的前兆。5.3 训练可回答性判别器这里我们要用少量标注样本训练一个分类器。为了演示我们手工标注一组带标签的(query, feature, label)数据。在实际项目中应该基于人工评估或用户反馈构建这个数据集。# 文件路径retrieval/answerability.py import numpy as np from sklearn.linear_model import LogisticRegression class AnswerabilityClassifier: def __init__(self): self.model LogisticRegression() def train(self, X, y): self.model.fit(np.array(X), np.array(y)) def predict_proba(self, features): return self.model.predict_proba([features])[0][1] # 演示用伪标签我们需要先跑一遍 retriever得到特征然后人工标 label # 这里假设以下数据来自前面的演示语料库人工标注 label1 表示可回答0 表示不可回答为了让代码可运行我们直接把训练逻辑放进主脚本而不是把所有中间结果写死。下面给出一个完整的训练和调用链。5.4 完整调用链检索 特征 拒答判断# 文件路径main.py from data.corpus import CORPUS from data.questions import TEST_QUESTIONS from retrieval.retriever import SimpleRetriever from retrieval.features import extract_features from retrieval.answerability import AnswerabilityClassifier # 1. 创建检索器 retriever SimpleRetriever(CORPUS) # 2. 构造训练数据这里使用一个带标注的小数据集 # 为了演示我们自己构造一个特征矩阵。真实项目请用标注数据。 X_train [ # top1, top1-top2, rel_gap, above_low, query_len [0.55, 0.30, 0.35, 1, 9], # 可回答 [0.34, 0.02, 0.05, 4, 8], # 不可回答 [0.60, 0.41, 0.40, 1, 10], # 可回答 [0.21, 0.01, 0.04, 2, 7], # 不可回答 [0.48, 0.18, 0.27, 1, 12], # 可回答 [0.12, 0.00, 0.00, 0, 11], # 不可回答 ] y_train [1, 0, 1, 0, 1, 0] # 3. 训练可回答性分类器 classifier AnswerabilityClassifier() classifier.train(X_train, y_train) # 4. 对测试问题进行检索与判断 for q in TEST_QUESTIONS: scores retriever.score(q) features extract_features(q, scores, top_k3) prob classifier.predict_proba(features) threshold 0.5 if prob threshold: results retriever.retrieve(q, top_k1) print(f[可以回答] {q}) print(f 答案片段{results[0][doc]}) print(f 置信度{prob:.3f}\n) else: print(f[拒绝回答] {q}) print(f 置信度{prob:.3f}\n)运行这段代码你应该能看到前四个问题被判定为“可以回答”后三个问题被判定为“拒绝回答”。如果你得到的结果和预期不一样不要着急因为演示用的训练数据是手工构造的并没有直接从当前语料库提取特征。真实项目中你需要用自己语料库产出的特征来训练。下面我们给出一个更靠谱的训练方式先用当前检索器对一批标注好的问题提取特征再去训练。# 文件路径build_training_data.py from data.corpus import CORPUS from data.questions import TEST_QUESTIONS from retrieval.retriever import SimpleRetriever from retrieval.features import extract_features retriever SimpleRetriever(CORPUS) # 标注1 表示可回答0 表示不可回答 LABELS [1, 1, 1, 1, 0, 0, 0] X [] y [] for q, label in zip(TEST_QUESTIONS, LABELS): scores retriever.score(q) features extract_features(q, scores, top_k3) X.append(features) y.append(label) print(特征矩阵) for i, (f, label_i) in enumerate(zip(X, y)): print(TEST_QUESTIONS[i], f, label_i)你会看到可回答问题的top1往往较高不可回答问题的top1较低同时可回答问题的top1-top2差距通常也比不可回答问题更大。这正是我们把它作为可回答性特征的原因。用这个真实特征矩阵去训练 LogisticRegression效果会比手工构造更贴合当前语料库。5.5 阈值以外的兜底策略分类器输出的是概率你可以根据业务容错率调整阈值。如果宁可少答也不能错答就把阈值调高到 0.7如果希望尽量回答问题可以调到 0.3。但这只是简单的一维调节。更稳健的做法是把分类器输出作为“拒答信号”之一同时用规则做兜底如果 top1 分数低于最低容忍值直接拒答。如果分类器概率低于阈值拒答。如果召回的文档数量为 0拒答。多条件同时生效可以避免单一分类器在某个新 query 上误判。这也是生产环境里常见的“多级拒答”策略。6. 运行结果与效果验证运行上述main.py预期的输出结构如下[可以回答] Python 适合用于什么开发 答案片段Python 是一种解释型编程语言语法简洁适合数据分析、机器学习和 Web 开发。 置信度0.812 [可以回答] Java 语言的特性是什么 答案片段Java 是一种面向对象编程语言擅长构建大型企业级应用运行在 JVM 上。 置信度0.764 [可以回答] 什么是 RAG 技术 答案片段RAG 是检索增强生成的缩写它将外部知识检索与大型语言模型生成结合起来。 置信度0.733 [可以回答] MySQL JDBC 连接报 public key retrieval is not allowed 怎么办 答案片段JDBC 连接 MySQL 8 时如果服务端使用 caching_sha2_password客户端可能需要配置 SSL 或获取公钥。 置信度0.698 [拒绝回答] 明天深圳会下雨吗 置信度0.213 [拒绝回答] 如何预测股票涨跌 置信度0.174 [拒绝回答] 番茄炒蛋的具体步骤是什么 置信度0.095看到这个结果后你要判断的不仅是概率对不对还要看拒答的那一批是不是真的“语料库覆盖不到”。在真实项目中你还需要准备一份评估集包含三部分可回答问题、语料库外的不可回答问题、语料库内但被改写得很模糊的问题。然后跟踪三个指标可回答问题被回答的比例覆盖率coverage。不可回答问题被拒绝的比例拒绝率abstention rate。被回答且答案正确/可用的比例准确率accuracy。这三个指标要结合起来看。覆盖率太高、拒绝率太低说明系统依然在“强行回答”覆盖率太低、拒绝率太高说明系统过于保守用户体验也会受损。一般建议先固定一个可接受的覆盖率然后把阈值调节到能在准确率上达到预期目标。每次模型升级或语料库更新后都要重新评估这几个指标不能指望一次调好的阈值能永远生效。7. 常见问题与排查思路可回答性判断在实际接入时问题往往不像示例里这么干净。下面这张表列出我见过比较典型的坑问题现象可能原因排查方式解决方案所有 query 都被判定为不可回答训练数据中可回答样本太少分类器偏保守查看特征的分布和分类器权重增加可回答样本降低阈值不可回答 query 概率普遍偏高top1 分数虚高比如查询词和文档词频重叠严重检查低阈值文档数量和 top1-top2 差值加入差值特征使用语义检索替换 TF-IDF可回答 query 被误拒答query 与文档措辞差异大但语义相关查看 top1 分数是否偏低换成向量模型使用同义词扩展检索结果相关但无法回答问题依赖单一相关分数没考虑“答案性”人工检查命中文档是否包含完整答案引入答案抽取/生成模型用 answer span 得分辅助日志系统连接 MySQL 报 public key retrieval is not allowedJDBC 客户端与 MySQL 服务端认证方式不匹配检查数据库版本和连接 URL 参数使用 SSL/TLS 连接不要在生产环境长期开启 allowPublicKeyRetrieval阈值一调就崩换一批语料库马上失灵特征没有归一化或语料库分布变化大在开发集上重新评估特征分布每批语料库接入时重新训练分类器周期校准这里特别说一下 public key retrieval is not allowed。这个报错常见于 MySQL 8 的 JDBC 连接中。原因是服务端默认使用 caching_sha2_password 认证插件客户端在非 SSL 连接下需要从服务端获取公钥来加密密码传输但 JDBC 默认不允许这种行为。许多初学者会直接设置allowPublicKeyRetrievaltrue来解决问题。但是这个参数意味着客户端允许从服务端获取公钥在非加密链路上引入中间人攻击的风险。更稳妥的做法是配置 SSL/TLS 连接或者使用支持 RSA 加密的安全方式。当然如果是本地开发环境临时开启也可接受但生产环境一定要评估风险遵循最小权限原则。回到检索系统这个问题给我们的提醒是一个看似微小的配置错误可能会让整个“日志记录 → 反馈收集 → 模型校准”的闭环断裂。如果日志都进不了数据库你怎么持续评估拒答效果所以基建的每个环节都必须稳定。8. 最佳实践与工程建议在实际项目里可回答性判断不应该是一个孤立的模型而应该嵌入到检索链路中成为一道独立关卡。下面这五条建议是经过多个项目验证过的经验。第一不要把拒答判断和检索“混在一个模型里”。最直观的教训是如果只用相似度阈值你会发现不同 query 的分数分布完全不同。正确做法是召回 → 精排 → 可回答性判断 → 生成。每层各司其职。可回答性判断层可以是一个轻量分类器甚至可以是规则。第二用差值特征而不是只看绝对分数。top1-top2 的差值是判别“唯一性”的重要信号。你不必只依赖它但建议无论如何都要把它作为一个输入特征。很多情况下两个相似文档分数接近恰恰说明语料库中存在信息分歧这时候让生成模型回答是非常危险的。第三持续收集用户反馈来做校准。用户点击了“这个答案有用”或“这个答案没用”都是天然的标注样本。把这些反馈和对应的 query、检索特征、分类器分数一起入库定期重新训练分类器。这样你的拒答系统会越用越准而不是永远停在开发时的手调阈值阶段。第四拒答后的交互要做设计。系统说“我不知道”之后不能直接结束对话一个好的检索系统应该接着给出建议“但我找到了以下相关文档你可以自行查看”或者提示用户换一种问法。否则用户体验会很生硬。第五安全与权限边界是硬前提。检索系统如果涉及数据库、文件系统、内部知识库那么任何和外部认证、权限相关的配置都要格外小心。像 allowPublicKeyRetrieval 这样的“绕过”参数只应在受控的开发和测试环境使用。生产环境请优先使用加密连接和凭据管理工具并定义好每个服务的最小权限。9. 总结与后续学习方向这篇文章真正讲清楚的事情是检索系统的“相关性”和“可回答性”是两回事。我们用一个最小可运行的 TF-IDF 检索器演示了如何从相似度分数中提取 top1、差值等特征再用一个逻辑回归分类器去判断该不该拒答。虽然代码很简化但它背后的设计——专设一个可回答性判断层、用特征而非单一阈值、用反馈数据持续校准——是可以在生产环境复用的。如果你想继续深入可以从三个方向入手。一是把 TF-IDF 换成 dense embedding 或跨编码器重排序观察特征分布的变化你会发现可回答性判断的空间也随之改变。二是引入大语言模型做“自我评估”让模型自己对检索到的文档能否回答问题进行二次判断但这会对延迟和成本有影响需要权衡。三是做主动学习在系统上线早期更多地向用户暴露“不确定”的内容从而更快地收集到高质量反馈。最后给你一个最实际的建议在把新的检索系统接入业务之前先准备一批“不能回答”的问题看看你的系统会不会一本正经地给出假答案。如果会那你缺的就不是一个更好的检索模型而是一道拒答闸门。这道闸门不用很复杂关键是你得先意识到必须要有。