ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

RAG检索系统如何判断“能不能答”:可回答性判断方案与实战

RAG检索系统如何判断“能不能答”:可回答性判断方案与实战 1. 检索系统的核心矛盾相关并不等于可答1.1 这个问题到底长什么样在做 RAGRetrieval-Augmented Generation检索增强生成项目落地时我们经常遇到一种特别拧巴的情况用户的问题进到系统里检索模块明明召回了主题高度相关的文档大模型也正儿八经地生成了一段答案但用户看完后觉得需求完全没有被解决。问题不是模型能力不够而是检索模块只负责“找相关文档”从设计上就不负责判断“文档里到底有没有答案”。这个现象对应的正是标题里的那句话my retrieval cant tell a question it can answer from one it cant。我的检索系统分不清哪些问题它能回答、哪些问题它回答不了。如果这个问题不解决知识库问答系统就会变成一个“一本正经地胡编”的生产工具有相关的上下文就敢答没有相关的上下文也敢答反正生成模型总能把句子编得通顺。用户能明显感觉到系统“不懂装懂”而这种体验对任何产品来说都是致命的。1.2 相关但不可答的典型场景为了更清楚地理解问题我们先列出几种最常见的“检索分数很高、但根本不能回答”的场景场景问题示例被召回的高相关文档答非所问某公司今天的股价是多少一篇介绍股票基础概念的文章信息过时今年最新的退税政策是什么两年前的旧政策文档细节缺失这个接口的 timeout 默认值是多少一篇只讲接口设计思路的设计文档多跳问题A 系统和 B 系统的权限模型有什么区别一篇只介绍 A 系统权限模型的文档这些场景有一个共同点query 和 document 在语义上很接近但文档里并不包含足以支撑最终回答的事实片段。检索模型只能感知到“话题相关”它并不知道“这句话有没有直接回答这个问题”。从工程角度看这就是检索阶段与回答阶段之间的裂缝检索结果质量高不意味着生成答案有依据。1.3 为什么检索系统天然缺少这项能力从实现原理上看主流向量检索系统做的事情是用 embedding 模型把 query 和 document 都编码成稠密向量然后计算余弦相似度或内积取出分数最高的 Top-K 文档。这个分数的含义是“语义向量在向量空间里的距离”不是“文档对问题的可回答程度”。这里有一个关键事实embedding 模型的训练目标通常是“语义相似的文本在向量空间中靠近”并不是“验证某个文档是否包含某问题的答案”。两个任务高度相关但并不是一回事。你在搜索“MySQL 连接失败”时系统召回一篇 MySQL 认证机制的文章打分可能很高但文章里未必写了“Access denied”的排查步骤。所以把相似度分数直接当成置信度使用是很多检索项目翻车的根源。想要让系统分清“能答”和“不能答”必须在检索后增加一道独立的“可回答性判断”。下面就来拆解实现方案。2. 判断“能不能答”的四种主流方案2.1 方案一检索分数阈值最简单直观的做法是给检索分数设一个阈值最高分低于阈值就认为知识库没有覆盖这个问题直接拒绝回答。from sentence_transformers import SentenceTransformer, util model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) documents [ MySQL 8.0 默认使用 caching_sha2_password 认证插件。, Spring Boot 3.x 需要 JDK 17 及以上版本才能正常运行。, Redis 提供了字符串、哈希、列表、集合、有序集合五种数据类型。, ] def answerable(query, threshold0.55): query_vec model.encode(query, normalize_embeddingsTrue) doc_vecs model.encode(documents, normalize_embeddingsTrue) scores util.cos_sim(query_vec, doc_vecs)[0] top_score float(scores.max()) return top_score threshold, top_score print(answerable(Spring Boot 3.x 运行需要什么版本)) print(answerable(北京明天会下雨吗))这个方案的优点是实现成本几乎为零不需要额外模型也不增加接口延迟。缺点也很明显阈值和知识库的数据分布强相关。不同的 embedding 模型、不同的文档领域、不同的 query 写法分数分布差别很大。同一个阈值在 A 项目里能挡住所有坏问题在 B 项目里可能把所有好问题都挡住了。所以阈值方案适合作为第一道防线适合对准确性要求没那么高、或者 badcase 容忍度较高的内部工具。2.2 方案二重排序模型比阈值更进一步可以使用 cross-encoder 重排序模型。与 embedding 双塔结构不同cross-encoder 会把 query 和 document 拼成一个序列送入模型让模型在词级别上做交互因此对“这段文本到底相不相关”的判断更细粒度。from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-base) query Spring Boot 3.x 运行需要什么版本 candidates [ MySQL 8.0 默认使用 caching_sha2_password 认证插件。, Spring Boot 3.x 需要 JDK 17 及以上版本才能正常运行。, Redis 提供了五种基本数据类型。, ] pairs [(query, doc) for doc in candidates] scores reranker.predict(pairs) print(scores)在真实项目中重排序通常放在向量召回之后先用轻量的向量检索快速筛选出 Top-50再用重排序模型把候选压缩到 Top-3同时输出一个新的相关性分数。这个分数比向量相似度更能反映“文档是否真的和问题相关”因此用来做可回答性判断也更稳。需要注意重排序模型需要和业务语言匹配。英文场景可以用cross-encoder/ms-marco-MiniLM-L-6-v2中文场景可以考虑BAAI/bge-reranker-base等支持中文的模型。这个方案的主要代价是性能和成本cross-encoder 推理比 embedding 相似度计算慢一到两个数量级对线上延迟敏感的服务需要加缓存和限流。2.3 方案三答案抽取与验证方案一和方案二本质上都在评估“文档跟问题是否相关”而不是“文档里是否有答案”。方案三则直接绕过相关性让模型从检索结果中抽取答案如果抽不出来就判定为不可回答。核心思路是两步让生成模型基于检索到的上下文生成回答。让验证模型判断“生成的回答能否在检索上下文中找到明确依据”。下面是一个验证环节的 Prompt 示例请判断以下回答是否可以直接从【参考资料】中找到依据。 问题{question} 回答{answer} 参考资料 {context} 如果回答中的所有关键事实都能在参考资料中找到依据输出 1 如果无法找到依据或回答中包含参考资料之外的推断输出 0。这个方案直接对“证据缺失”建模副作用最小。但它会让系统多一次大模型调用成本和延迟都会上升。生产中一般只对最高分不足、处于灰色地带的问题触发验证而不是对所有问题都做。2.4 方案四大模型自反思自反思方案是让大模型自己评估自己的回答是否靠谱本质上是把“可回答性判断”交给生成模型完成。可以让模型在生成回答前先判断检索上下文是否足够也可以在生成回答后再做一次合规检查。自反思方案的优点是省事不需要额外训练或加载新模型缺点是模型偶尔会“自我洗白”明明资料里没有答案它还是会觉得自己推理得很合理。所以自反思更适合作为辅助信号不建议单独作为生产环境的判定依据。更稳妥的做法是和方案一、方案二组合先过阈值再做重排序最后对高风险问题用自反思兜底。3. 环境准备与版本说明3.1 开发环境本文实战部分使用 Python 编写代码在 Windows、macOS、Linux 上都可以运行。示例基于 Python 3.10开发者使用 3.9 及以上版本基本没有障碍。包版本变化较快这里不固定具体版本号安装时以 PyPI 上的最新稳定版为准重点演示实现思路。3.2 依赖安装需要安装以下依赖pip install sentence-transformers faiss-cpu flask numpy说明sentence-transformers 负责文本向量化和重排序模型加载。faiss-cpu 负责向量索引的构建和检索。flask 用于提供对外 HTTP 接口。numpy 是向量计算的底层依赖。首次运行会自动下载模型权重到本地缓存目录。如果网络环境不允许直接访问 HuggingFace可以提前在可联网环境下载模型再把代码中的模型名称改成本地目录路径例如SentenceTransformer(./models/bge-m3)。3.3 准备演示数据集在项目目录下创建数据文件data/documents.txt写入几行与技术主题相关的文本MySQL 8.0 默认使用 caching_sha2_password 认证插件安全性比 mysql_native_password 更高。 Spring Boot 3.x 需要 JDK 17 及以上版本才能正常运行。 Redis 提供了字符串、哈希、列表、集合、有序集合五种基本数据类型。 Docker 容器通过镜像启动镜像采用分层存储容器运行时的修改不会影响镜像本身。 Python 的 GIL 限制了多线程对多核 CPU 的利用多进程方案可以绕开这个限制。 Elasticsearch 使用倒排索引实现全文检索是日志检索场景的核心数据结构。这个数据集很小跑起来非常快也方便我们直观对比“能答的问题”和“不能答的问题”之间的差异。4. 完整实战给检索系统装上“可回答性判断”4.1 项目结构先规划项目结构方便后续维护retrieval-answerability/ ├── app.py ├── vector_store.py ├── answerability.py ├── requirements.txt └── data/ └── documents.txtvector_store.py向量库构建和检索。answerability.py可回答性判断策略。app.pyFlask HTTP 服务。requirements.txt依赖清单。requirements.txt 内容如下sentence-transformers faiss-cpu flask numpy4.2 构建向量库与检索模块先来看 vector_store.py这个模块把文档编码成向量并用 faiss 建立索引。# vector_store.py import numpy as np import faiss from sentence_transformers import SentenceTransformer class VectorStore: 极简向量检索库便于演示可回答性判断。 def __init__(self, model_nameparaphrase-multilingual-MiniLM-L12-v2): self.model SentenceTransformer(model_name) self.docs [] self.index None def build(self, docs): self.docs docs vectors self.model.encode(docs, normalize_embeddingsTrue) dim vectors.shape[1] self.index faiss.IndexFlatIP(dim) self.index.add(vectors.astype(np.float32)) return len(docs) def search(self, query, top_k3): vec self.model.encode([query], normalize_embeddingsTrue).astype(np.float32) scores, idxs self.index.search(vec, top_k) results [] for score, idx in zip(scores[0], idxs[0]): results.append({ index: int(idx), score: round(float(score), 4), text: self.docs[idx], }) return results这里有几个关键点需要解释normalize_embeddingsTrue会对向量做 L2 归一化配合IndexFlatIP计算内积结果就等于余弦相似度。faiss 的IndexFlatIP是暴力检索索引数据量小的时候简单高效数据量大之后建议换成 IVF 或 HNSW 索引。search方法返回的结果里同时带上了分数和原始文本方便后续判断和调试。4.3 可回答性判断模块接下来实现两种判断策略一种是基于检索分数阈值的ScoreBasedJudge一种是基于重排序模型的RerankJudge。# answerability.py from sentence_transformers import CrossEncoder class ScoreBasedJudge: 基于检索分数阈值的可回答性判断。 def __init__(self, threshold0.55): self.threshold threshold def judge(self, top_result): score top_result[score] answerable score self.threshold return { answerable: answerable, score: score, reason: 检索分数达到阈值 if answerable else 检索分数低于阈值, } class RerankJudge: 基于 cross-encoder 重排序的可回答性判断。 def __init__(self, model_nameBAAI/bge-reranker-base): self.reranker CrossEncoder(model_name) def judge(self, query, candidates, threshold0.5): pairs [(query, item[text]) for item in candidates] scores self.reranker.predict(pairs) best_idx int(scores.argmax()) best_score float(scores[best_idx]) return { answerable: best_score threshold, score: best_score, best_doc: candidates[best_idx][text], reason: 重排序分数达到阈值 if best_score threshold else 重排序分数低于阈值, }两种判断器返回的结构保持一致都有answerable、score、reason三个字段。这样上层服务可以灵活切换策略而不用改动接口逻辑。实际项目中你可以先用阈值策略快速上线攒一段时间日志后再把重排序模型作为第二道判断叠加进来。4.4 封装 Flask 接口app.py 负责读取文档、构建检索库、接收请求并返回判断结果。# app.py from flask import Flask, jsonify, request from vector_store import VectorStore from answerability import ScoreBasedJudge app Flask(__name__) DATA_FILE data/documents.txt THRESHOLD 0.55 TOP_K 3 store VectorStore() def load_docs(): with open(DATA_FILE, encodingutf-8) as f: return [line.strip() for line in f if line.strip()] store.build(load_docs()) judge ScoreBasedJudge(thresholdTHRESHOLD) app.post(/ask) def ask(): data request.get_json(forceTrue) question (data.get(question) or ).strip() if not question: return jsonify({error: question 参数不能为空}), 400 results store.search(question, top_kTOP_K) judgment judge.judge(results[0]) return jsonify({ question: question, answerable: judgment[answerable], top_score: judgment[score], reason: judgment[reason], retrieval_results: results, }) if __name__ __main__: app.run(host0.0.0.0, port8000)这段代码只实现了检索和判断没有接大模型生成回答。如果你希望把回答也集成进来可以在判断为可回答之后把retrieval_results中的文档拼成上下文再调用大模型接口生成最终答案。这里用一个 OpenAI 兼容接口做示例实际使用时请替换为自己的模型服务# optional_llm.py import os from openai import OpenAI def generate_answer(question, context): client OpenAI( api_keyos.getenv(LLM_API_KEY), base_urlos.getenv(LLM_BASE_URL), ) resp client.chat.completions.create( modelos.getenv(LLM_MODEL, qwen-plus), messages[ {role: system, content: 你是一个严谨的问答助手请严格依据参考资料回答。}, {role: user, content: f问题{question}\n参考资料{context}}, ], ) return resp.choices[0].message.content需要特别注意的是大模型生成回答的能力和“是否有依据”是两回事。哪怕上下文里没有答案大模型也能编出通顺的话。所以回答生成必须放在可回答性判断之后判断为不可答时直接返回“知识库暂未覆盖该问题”不要走生成链路。4.5 运行与验证启动服务python app.py然后在另一个终端调用接口。先测试一个知识库能回答的问题curl -X POST http://localhost:8000/ask \ -H Content-Type: application/json \ -d {question: Spring Boot 3.x 运行需要什么版本}返回结果类似下面这样分数仅供示意实际数值取决于模型和数据但结构一致{ question: Spring Boot 3.x 运行需要什么版本, answerable: true, top_score: 0.73, reason: 检索分数达到阈值, retrieval_results: [ { index: 1, score: 0.73, text: Spring Boot 3.x 需要 JDK 17 及以上版本才能正常运行。 }, { index: 0, score: 0.31, text: MySQL 8.0 默认使用 caching_sha2_password 认证插件安全性比 mysql_native_password 更高。 }, { index: 2, score: 0.25, text: Redis 提供了字符串、哈希、列表、集合、有序集合五种基本数据类型。 } ] }再测试一个知识库完全没覆盖的问题curl -X POST http://localhost:8000/ask \ -H Content-Type: application/json \ -d {question: 北京明天会下雨吗}返回结果里answerable为 false理由是“检索分数低于阈值”。到这里我们就实现了一个最简版本的可回答性判断检索系统分数够才让下游生成回答分数不够就诚实地告诉用户不知道。这一步看似简单但能挡住大量“不懂装懂”的翻车case。5. 常见问题与排查思路5.1 Public Key Retrieval is not allowed在实际部署检索系统时还有一个高频报错值得单独说明当检索系统的元数据存储在 MySQL 8.0 中而 Java 侧的服务用 JDBC 连接数据库时常常会看到Public Key Retrieval is not allowed这个错误。这个报错的根因是MySQL 8.0 默认使用caching_sha2_password认证插件当客户端没有启用 SSL 时密码传输需要用服务器的 RSA 公钥加密JDBC 驱动出于安全考虑默认不允许客户端自动从服务器获取公钥。于是连接直接被拒绝。开发环境常用的解决方式是在 JDBC URL 中增加两个参数jdbc:mysql://localhost:3306/rag_meta?useSSLfalseallowPublicKeyRetrievaltrue但这里必须强调生产环境的安全边界allowPublicKeyRetrievaltrue会把公钥获取行为打开在不可信网络上有中间人风险。生产建议优先使用 SSL 连接或者通过安全网络环境访问数据库不要把开发环境的参数直接搬到线上。5.2 常见问题对照表问题现象常见原因解决思路启动服务时 JDBC 连接 MySQL 8.0 报 Public Key Retrieval is not allowedcaching_sha2_password 认证 未开启 SSL驱动默认禁止获取公钥JDBC URL 加 allowPublicKeyRetrievaltrue或启用 SSL或调整认证插件生产建议 SSL所有查询的检索分数都偏高阈值形同虚设embedding 模型对短文本区分度不足或文档质量参差换用更适合中文的 embedding 模型先做文档清洗和分块所有查询的检索分数都偏低系统几乎不回答embedding 模型与业务领域不匹配或 query 与文档表述差异过大对比多个模型在小样本集上的效果必要时增加查询改写模块明显能回答的问题被判为不可答阈值设置过高或多跳类问题单篇文档无法支撑降低阈值搭配重排序或支持多文档拼接后再判断明显不能回答的问题被判为可答检索召回内容相关但事实缺失增加答案验证环节对生成回答做“是否有依据”检查5.3 阈值到底怎么选阈值从来不是一个“抄作业”的参数。同一个模型、同一个阈值在 A 知识库效果好到 B 知识库可能完全不适用。建议的做法是从历史日志中挑一批“确实可答”和“确实不可答”的问题各 50 到 100 条构建一个小型评估集然后遍历阈值选择准确率和覆盖率平衡最好的值。def best_threshold(samples, labels): # samples: list of top1 score # labels: list of 0/1 best (0, None) for threshold in [i / 100 for i in range(20, 85)]: preds [1 if s threshold else 0 for s in samples] acc sum(p l for p, l in zip(preds, labels)) / len(labels) if acc best[0]: best (acc, threshold) return best评估集要持续维护。每上线一个版本把线上 badcase 补充进去再重新验证阈值和模型效果。没有评估集就调阈值本质上是在凭感觉做优化。6. 最佳实践与工程建议6.1 用多级判断链代替单一阈值单靠一个阈值很难同时满足“低误拒”和“低误答”两个目标。更实用的方案是搭一条分级判断链向量检索取 Top-K。分数明显高于高阈值直接放行。分数处于灰色区间触发重排序二次判断。重排序后仍然存疑触发大模型答案验证。最终仍无法确认返回“知识库暂未覆盖”。这套链路的好处是把昂贵的判断留给少数模糊case大部分请求走轻量路径成本和效果都能兼顾。6.2 把“拒答”当成产品能力而不是失败很多团队在做知识库问答时把“回答率”当成唯一指标结果就是系统宁可编答案也不肯承认不会。实际上一个诚实的“我不知道”比一段胡编乱造的回答更有价值。建议把不可答的问题记录下来定期导出分析反哺知识库建设。你可以给每个不可答结果加一个反馈按钮让用户确认“这个问题确实该有答案”这就是迭代知识库的最直接信号。6.3 日志里一定要留原始证据检索系统的日志至少要记录四类信息query 原文。召回的文档 ID 和分数。是否通过可回答性判断。判断依据阈值、重排序分数、验证结论。有了这些日志遇到用户投诉时才能快速定位是检索问题、判断问题还是生成问题。否则线上出了幻觉光看生成结果很难判断锅到底在哪个环节。6.4 安全与性能边界生产环境需要注意几个原则数据库账号、模型 API Key 等敏感信息使用环境变量或配置中心管理不要写死在代码和仓库里。向量索引不要无条件全部加载到单机内存数据量大时选择 Milvus、Elasticsearch、Qdrant 等专用组件。重排序和答案验证都是计算密集型操作接口需要限流和超时控制。文档更新要做版本管理防止旧版本内容被当作最新答案返回。7. 总结与下一步学习建议现在回顾一下这个标题背后真正的问题不是“检索系统没有召回到文档”而是“检索系统无法区分相关性和可回答性”。本文从这个问题出发讲了四种判断方案并给出了一套可以直接运行的 Flask 示例代码。掌握了阈值判断、重排序、答案验证这三个层次你就有能力让系统在不确定的时候选择“不说”而不是硬编。下一步建议从三件事开始第一拿你自己的业务文档构建一个几十条的评估集先量一量当前检索系统的误答率和拒答率第二把“拒答日志”接入日常监控让不可答问题变成知识库迭代的输入第三在灰色地带逐步引入重排序和答案验证而不是一上来就上最贵的方案。如果你在调检索可回答性时遇到过更奇怪的 badcase欢迎在评论区分享评论区里互相踩坑往往比看文档有效得多。
返回列表