news 2026/9/7 6:01:16

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RAG检索系统如何判断“能不能答”:可回答性判断方案与实战

1. 检索系统的核心矛盾:相关并不等于可答

1.1 这个问题到底长什么样

在做 RAG(Retrieval-Augmented Generation,检索增强生成)项目落地时,我们经常遇到一种特别拧巴的情况:用户的问题进到系统里,检索模块明明召回了主题高度相关的文档,大模型也正儿八经地生成了一段答案,但用户看完后觉得需求完全没有被解决。问题不是模型能力不够,而是检索模块只负责“找相关文档”,从设计上就不负责判断“文档里到底有没有答案”。

这个现象对应的正是标题里的那句话:my retrieval can't tell a question it can answer from one it can't。我的检索系统分不清哪些问题它能回答、哪些问题它回答不了。如果这个问题不解决,知识库问答系统就会变成一个“一本正经地胡编”的生产工具:有相关的上下文就敢答,没有相关的上下文也敢答,反正生成模型总能把句子编得通顺。用户能明显感觉到系统“不懂装懂”,而这种体验对任何产品来说都是致命的。

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, threshold=0.55): query_vec = model.encode(query, normalize_embeddings=True) doc_vecs = model.encode(documents, normalize_embeddings=True) 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 方案三:答案抽取与验证

方案一和方案二本质上都在评估“文档跟问题是否相关”,而不是“文档里是否有答案”。方案三则直接绕过相关性,让模型从检索结果中抽取答案,如果抽不出来,就判定为不可回答。

核心思路是两步:

  1. 让生成模型基于检索到的上下文生成回答。
  2. 让验证模型判断“生成的回答能否在检索上下文中找到明确依据”。

下面是一个验证环节的 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.txt
  • vector_store.py:向量库构建和检索。
  • answerability.py:可回答性判断策略。
  • app.py:Flask HTTP 服务。
  • requirements.txt:依赖清单。

requirements.txt 内容如下:

sentence-transformers faiss-cpu flask numpy

4.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_name="paraphrase-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_embeddings=True) dim = vectors.shape[1] self.index = faiss.IndexFlatIP(dim) self.index.add(vectors.astype(np.float32)) return len(docs) def search(self, query, top_k=3): vec = self.model.encode([query], normalize_embeddings=True).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

这里有几个关键点需要解释:

  1. normalize_embeddings=True会对向量做 L2 归一化,配合IndexFlatIP计算内积,结果就等于余弦相似度。
  2. faiss 的IndexFlatIP是暴力检索索引,数据量小的时候简单高效;数据量大之后建议换成 IVF 或 HNSW 索引。
  3. search方法返回的结果里同时带上了分数和原始文本,方便后续判断和调试。

4.3 可回答性判断模块

接下来实现两种判断策略:一种是基于检索分数阈值的ScoreBasedJudge,一种是基于重排序模型的RerankJudge

# answerability.py from sentence_transformers import CrossEncoder class ScoreBasedJudge: """基于检索分数阈值的可回答性判断。""" def __init__(self, threshold=0.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_name="BAAI/bge-reranker-base"): self.reranker = CrossEncoder(model_name) def judge(self, query, candidates, threshold=0.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 "重排序分数低于阈值", }

两种判断器返回的结构保持一致,都有answerablescorereason三个字段。这样上层服务可以灵活切换策略,而不用改动接口逻辑。实际项目中,你可以先用阈值策略快速上线,攒一段时间日志后,再把重排序模型作为第二道判断叠加进来。

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, encoding="utf-8") as f: return [line.strip() for line in f if line.strip()] store.build(load_docs()) judge = ScoreBasedJudge(threshold=THRESHOLD) @app.post("/ask") def ask(): data = request.get_json(force=True) question = (data.get("question") or "").strip() if not question: return jsonify({"error": "question 参数不能为空"}), 400 results = store.search(question, top_k=TOP_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(host="0.0.0.0", port=8000)

这段代码只实现了检索和判断,没有接大模型生成回答。如果你希望把回答也集成进来,可以在判断为可回答之后,把retrieval_results中的文档拼成上下文,再调用大模型接口生成最终答案。这里用一个 OpenAI 兼容接口做示例,实际使用时请替换为自己的模型服务:

# optional_llm.py import os from openai import OpenAI def generate_answer(question, context): client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL"), ) resp = client.chat.completions.create( model=os.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?useSSL=false&allowPublicKeyRetrieval=true

但这里必须强调生产环境的安全边界:allowPublicKeyRetrieval=true会把公钥获取行为打开,在不可信网络上有中间人风险。生产建议优先使用 SSL 连接,或者通过安全网络环境访问数据库,不要把开发环境的参数直接搬到线上。

5.2 常见问题对照表

问题现象常见原因解决思路
启动服务时 JDBC 连接 MySQL 8.0 报 Public Key Retrieval is not allowedcaching_sha2_password 认证 + 未开启 SSL,驱动默认禁止获取公钥JDBC URL 加 allowPublicKeyRetrieval=true,或启用 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 用多级判断链代替单一阈值

单靠一个阈值很难同时满足“低误拒”和“低误答”两个目标。更实用的方案是搭一条分级判断链:

  1. 向量检索取 Top-K。
  2. 分数明显高于高阈值:直接放行。
  3. 分数处于灰色区间:触发重排序二次判断。
  4. 重排序后仍然存疑:触发大模型答案验证。
  5. 最终仍无法确认:返回“知识库暂未覆盖”。

这套链路的好处是把昂贵的判断留给少数模糊case,大部分请求走轻量路径,成本和效果都能兼顾。

6.2 把“拒答”当成产品能力,而不是失败

很多团队在做知识库问答时,把“回答率”当成唯一指标,结果就是系统宁可编答案也不肯承认不会。实际上,一个诚实的“我不知道”比一段胡编乱造的回答更有价值。建议把不可答的问题记录下来,定期导出分析,反哺知识库建设。你可以给每个不可答结果加一个反馈按钮,让用户确认“这个问题确实该有答案”,这就是迭代知识库的最直接信号。

6.3 日志里一定要留原始证据

检索系统的日志至少要记录四类信息:

  • query 原文。
  • 召回的文档 ID 和分数。
  • 是否通过可回答性判断。
  • 判断依据(阈值、重排序分数、验证结论)。

有了这些日志,遇到用户投诉时才能快速定位是检索问题、判断问题还是生成问题。否则线上出了幻觉,光看生成结果很难判断锅到底在哪个环节。

6.4 安全与性能边界

生产环境需要注意几个原则:

  • 数据库账号、模型 API Key 等敏感信息使用环境变量或配置中心管理,不要写死在代码和仓库里。
  • 向量索引不要无条件全部加载到单机内存,数据量大时选择 Milvus、Elasticsearch、Qdrant 等专用组件。
  • 重排序和答案验证都是计算密集型操作,接口需要限流和超时控制。
  • 文档更新要做版本管理,防止旧版本内容被当作最新答案返回。

7. 总结与下一步学习建议

现在回顾一下,这个标题背后真正的问题,不是“检索系统没有召回到文档”,而是“检索系统无法区分相关性和可回答性”。本文从这个问题出发,讲了四种判断方案,并给出了一套可以直接运行的 Flask 示例代码。掌握了阈值判断、重排序、答案验证这三个层次,你就有能力让系统在不确定的时候选择“不说”,而不是硬编。

下一步建议从三件事开始:第一,拿你自己的业务文档构建一个几十条的评估集,先量一量当前检索系统的误答率和拒答率;第二,把“拒答日志”接入日常监控,让不可答问题变成知识库迭代的输入;第三,在灰色地带逐步引入重排序和答案验证,而不是一上来就上最贵的方案。

如果你在调检索可回答性时遇到过更奇怪的 badcase,欢迎在评论区分享,评论区里互相踩坑往往比看文档有效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 12:43:04

基于SpringBoot+Vue的校园竞赛管理系统设计与实现

简介:本资源是一套面向计算机专业本科生及Java初学者的毕业设计级实战项目,聚焦校园竞赛全流程数字化管理,解决高校竞赛信息发布滞后、报名分散、成绩统计低效等实际问题。压缩包为RAR格式,共27.36MB,包含Spring Boot后…

作者头像 李华
网站建设 2026/9/7 6:01:12

无线信道质量预测深度学习实战:CNN+LSTM模型代码解析与训练指南

简介:本资源是一个面向通信工程、无线网络与人工智能交叉领域研究者的深度学习实践项目,聚焦于无线信道质量(如信号强度、SNR等)的时序预测问题,适用于高校研究生、通信算法工程师及AI落地开发者。压缩包共22个文件&am…

作者头像 李华
网站建设 2026/9/4 8:52:38

珊瑚礁物种目标检测数据集实战:从解压到YOLOv8训练全流程

简介:本资源是面向生态环境AI监测与海洋生物学研究的专用目标检测数据集,聚焦珊瑚礁生态保护场景,为算法工程师、生态科研人员及水下机器人开发者提供高质量水下物种识别训练基础。数据集包含1188张实地采集的水下生态图像(含1095…

作者头像 李华
网站建设 2026/9/5 14:33:34

MATLAB实现三维A*与RRT避障路径规划全解析

简介:本资源是一套面向机器人导航、无人机路径规划等领域的MATLAB三维避障路径生成实现方案,适用于具备基础编程与几何建模能力的本科生、研究生及算法工程师。资源聚焦三维空间下A 与RRT两类主流算法的工程化落地,涵盖障碍物建模&#xff0…

作者头像 李华
网站建设 2026/9/5 0:48:31

MATLAB与STK联合仿真:轨道数据导出Excel全流程指南

简介:本资源是一套面向航天工程学习者与遥感数据分析初学者的MATLAB-STK协同开发实践方案,聚焦卫星轨道数据自动化提取、处理与Excel标准化导出这一典型跨平台需求。资源共38个文件,主体为30个MATLAB函数(.m)&#xff…

作者头像 李华
网站建设 2026/9/6 7:12:05

Subagent工作流持久化与可追踪实战:从一次性脚本到可观测

没有把“子代理工作流”做成持久化、可追踪之前,我的项目基本是跑一次看一次,任务多了之后根本说不清某个子任务到底卡在哪、是重试过还是彻底失败。最近团队把普通 subagent 工作流转成了带持久化状态和事件追踪的方式之后,才真正解决了“跑…

作者头像 李华