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 方案三:答案抽取与验证
方案一和方案二本质上都在评估“文档跟问题是否相关”,而不是“文档里是否有答案”。方案三则直接绕过相关性,让模型从检索结果中抽取答案,如果抽不出来,就判定为不可回答。
核心思路是两步:
- 让生成模型基于检索到的上下文生成回答。
- 让验证模型判断“生成的回答能否在检索上下文中找到明确依据”。
下面是一个验证环节的 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 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_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这里有几个关键点需要解释:
normalize_embeddings=True会对向量做 L2 归一化,配合IndexFlatIP计算内积,结果就等于余弦相似度。- faiss 的
IndexFlatIP是暴力检索索引,数据量小的时候简单高效;数据量大之后建议换成 IVF 或 HNSW 索引。 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 "重排序分数低于阈值", }两种判断器返回的结构保持一致,都有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, 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 allowed | caching_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 用多级判断链代替单一阈值
单靠一个阈值很难同时满足“低误拒”和“低误答”两个目标。更实用的方案是搭一条分级判断链:
- 向量检索取 Top-K。
- 分数明显高于高阈值:直接放行。
- 分数处于灰色区间:触发重排序二次判断。
- 重排序后仍然存疑:触发大模型答案验证。
- 最终仍无法确认:返回“知识库暂未覆盖”。
这套链路的好处是把昂贵的判断留给少数模糊case,大部分请求走轻量路径,成本和效果都能兼顾。
6.2 把“拒答”当成产品能力,而不是失败
很多团队在做知识库问答时,把“回答率”当成唯一指标,结果就是系统宁可编答案也不肯承认不会。实际上,一个诚实的“我不知道”比一段胡编乱造的回答更有价值。建议把不可答的问题记录下来,定期导出分析,反哺知识库建设。你可以给每个不可答结果加一个反馈按钮,让用户确认“这个问题确实该有答案”,这就是迭代知识库的最直接信号。
6.3 日志里一定要留原始证据
检索系统的日志至少要记录四类信息:
- query 原文。
- 召回的文档 ID 和分数。
- 是否通过可回答性判断。
- 判断依据(阈值、重排序分数、验证结论)。
有了这些日志,遇到用户投诉时才能快速定位是检索问题、判断问题还是生成问题。否则线上出了幻觉,光看生成结果很难判断锅到底在哪个环节。
6.4 安全与性能边界
生产环境需要注意几个原则:
- 数据库账号、模型 API Key 等敏感信息使用环境变量或配置中心管理,不要写死在代码和仓库里。
- 向量索引不要无条件全部加载到单机内存,数据量大时选择 Milvus、Elasticsearch、Qdrant 等专用组件。
- 重排序和答案验证都是计算密集型操作,接口需要限流和超时控制。
- 文档更新要做版本管理,防止旧版本内容被当作最新答案返回。
7. 总结与下一步学习建议
现在回顾一下,这个标题背后真正的问题,不是“检索系统没有召回到文档”,而是“检索系统无法区分相关性和可回答性”。本文从这个问题出发,讲了四种判断方案,并给出了一套可以直接运行的 Flask 示例代码。掌握了阈值判断、重排序、答案验证这三个层次,你就有能力让系统在不确定的时候选择“不说”,而不是硬编。
下一步建议从三件事开始:第一,拿你自己的业务文档构建一个几十条的评估集,先量一量当前检索系统的误答率和拒答率;第二,把“拒答日志”接入日常监控,让不可答问题变成知识库迭代的输入;第三,在灰色地带逐步引入重排序和答案验证,而不是一上来就上最贵的方案。
如果你在调检索可回答性时遇到过更奇怪的 badcase,欢迎在评论区分享,评论区里互相踩坑往往比看文档有效得多。