跳至主要內容

程序员小富大约 9 分钟

大家好,我是小富~

《十万个why》系列持续更新中

上周内部的客服知识库问答系统上了生产,用的标准 RAG 架构,向量检索 + LLM 生成。上线前 demo 效果不错,上线第三天运营就来找我了:有个用户问"怎么取消自动续费",系统给了一套操作流程,用户照着做了,发现根本不对。

拉出来一查,知识库里确实有续费相关的文档,但那篇文档讲的是续费规则和计费标准,压根没有取消操作的内容。检索系统把它排在了 top-1,相似度 0.87,LLM 拿着这篇文档给用户编了一个"设置 → 账户管理 → 关闭自动续费"的步骤。

问题是我们的 prompt 里明确写了:如果提供的资料中没有相关信息,请直接告知用户你无法回答。写了也没用,LLM 照样编。

为什么?

向量检索永远不会返回空结果

很多人对 RAG 有一个错误的预期:如果用户问的问题不在知识库范围内,检索应该返回空。

但向量数据库不是这么工作的。你扔一个 query 进去,它做的事情是在向量空间里找距离最近的 K 个文档。不管这些文档跟 query 有没有关系,它一定会给你返回 K 个结果。

用户 query: "怎么取消自动续费?"

向量检索 top-3 结果:
1. 《自动续费规则说明》        cosine_similarity: 0.87
2. 《会员套餐与计费标准》      cosine_similarity: 0.82
3. 《支付方式绑定指南》        cosine_similarity: 0.79

0.87 的相似度看起来很高对吧?但这三篇文档没有一篇能回答"怎么取消"。它们只是跟"续费"这个词在语义空间里距离很近而已。

这就是第一个坑:embedding 模型分不清"同一主题"和"能回答这个问题"之间的差别。 它看到的是"续费"这个概念在两边都出现了,语义向量距离就近了。但"续费规则"和"取消续费操作"是两件完全不同的事情。

相似度阈值为什么不靠谱

直觉上的解法是设一个阈值,比如低于 0.7 就不采纳。跑几天就知道不行。

同一个 embedding 模型,不同领域的 query,相似度分布差异极大。我们内部测过一组数据:客服场景下强相关文档的相似度集中在 0.82 ~ 0.92,但换到技术文档场景,强相关文档的相似度掉到 0.68 ~ 0.78。

客服知识库:
  强相关 query-doc 对: cosine_similarity 分布 [0.82, 0.92]
  弱相关 query-doc 对: cosine_similarity 分布 [0.75, 0.85]
  不相关 query-doc 对: cosine_similarity 分布 [0.65, 0.78]

技术文档知识库:
  强相关 query-doc 对: cosine_similarity 分布 [0.68, 0.78]
  不相关 query-doc 对: cosine_similarity 分布 [0.55, 0.70]

客服场景里 0.78 可能是不相关的噪声,技术文档场景里 0.78 已经是最强相关了。一个全局阈值根本覆盖不了这个差异。

更要命的是,同一个知识库里,不同类型的问题相似度分布也不一样。短问题("退款流程")和长问题("我上个月买的年度会员,用了三个月想退剩下的钱怎么操作")召回同一篇文档的相似度可以差 0.1 以上。

LLM 为什么"不听话"

好,检索层拦不住,那让 LLM 自己判断行不行?prompt 里写清楚"没有相关信息就说不知道"。

问题在于 LLM 在有 context 注入的情况下,默认行为就是基于 context 组织答案,而不是先去质疑 context 是否真的相关。

你发给 LLM 的 prompt:

系统角色: 你是一个客服助手,基于以下资料回答用户问题。
         如果资料中没有相关信息,请直接告知用户你无法回答。

资料:
"""
《自动续费规则说明》
自动续费将在会员到期前 7 天自动扣款...
扣款金额按当前套餐价格执行...
续费成功后会员有效期自动延长...
"""

用户问题: 怎么取消自动续费?

LLM 看到了什么?它看到 context 里有"自动续费"四个字,跟用户的问题高度相关。它不会像人类一样先停下来想"等等,这篇文档讲的是续费规则,不是取消操作"。它的推理过程更接近于:用户问续费 → context 里有续费相关内容 → 基于这些内容组织答案。

至于答案里的具体操作步骤是从哪来的?是 LLM 的参数记忆里的通用知识。它见过太多"设置 → 账户管理 → 关闭 XXX"这种操作路径,context 给了它一个续费的主题锚点,它就顺着自己的参数知识编了一套看起来合理的步骤。

这就是为什么 prompt 里写"不知道就说不知道"效果很差。LLM 不觉得自己不知道,它觉得 context 里有相关内容,只是内容不够详细,所以它自动补全了。

真正有效的方案:生成前加一道相关性校验

检索结果拿到之后,别直接塞给 LLM 生成答案。先用 LLM 做一步判断:这些文档能不能用来回答这个问题。

第一步 prompt(相关性判断):

你是一个文档相关性评估器。判断以下每条文档是否能直接用来回答用户的问题。

注意: "相关"的定义不是"文档和问题属于同一主题",
      而是"文档内容能直接用来回答用户的具体问题"。

对每条文档输出 JSON:
{
  "doc_id": "文档编号",
  "relevance": "relevant | partially_relevant | irrelevant",
  "reason": "一句话说明判断理由"
}

用户问题: 怎么取消自动续费?

文档1: 《自动续费规则说明》...
文档2: 《会员套餐与计费标准》...
文档3: 《支付方式绑定指南》...
LLM 输出:

{"doc_id": "1", "relevance": "irrelevant", 
 "reason": "文档讲的是续费规则和扣款机制,没有取消操作的说明"}
{"doc_id": "2", "relevance": "irrelevant", 
 "reason": "文档讲的是套餐价格,与取消续费无关"}
{"doc_id": "3", "relevance": "irrelevant", 
 "reason": "文档讲的是支付方式绑定,不涉及取消续费"}

三条全部 irrelevant,这时候系统就可以直接拒答,告诉用户:"这个问题超出了我的知识范围,建议联系人工客服。"

这里有几个关键细节:

逐条打标,不要笼统问。 不要问"这些文档和问题相关吗",LLM 面对一堆文档时倾向于给模糊评价。逐条判断 + 给理由,准确率高很多。

"相关"的定义要严格限定。 不是"属于同一主题",而是"能直接回答问题"。这一句话的差异直接影响拒答的准确率。前者会把续费规则判为相关,后者不会。

用 structured output 约束输出格式。 不约束的话 LLM 容易跑偏,一会给你写一段分析,一会给你改格式。JSON schema 约束住,输出稳定,后续代码也好解析。

拒答要分层,不要一刀切

全部不相关就直接拒答,这没问题。但如果有一条 partially_relevant,处理方式应该不一样。

拒答策略的处理流程:

query 进来 → 向量检索 top-K → 相关性校验
                                    ↓
                    全部 irrelevant → 直接拒答,给出能力边界提示
                    有 partially_relevant → 回答已有部分,说明缺失部分
                    有 relevant → 正常生成答案

比如用户问"怎么取消自动续费,取消后剩余天数会不会退款",知识库里有退款政策但没有取消操作。这种情况下,把退款政策相关的内容给出来,同时告诉用户取消操作的部分你回答不了,比一刀切的"我无法回答"体验好得多。

最怕的是另一种误杀:文档里明明有答案,但相关性校验判成了 irrelevant,用户问什么都说不知道。拒答率一旦超过 20%,用户反馈里一半在骂"问什么都说不知道",产品口碑直接崩。

检索层也不是完全没招

相关性校验靠 LLM,意味着多一次 API 调用,延迟和成本都上去了。检索层能做的粗筛越多,LLM 那一步的压力就越小。

一个低成本的信号是 token overlap。query 里的关键词和检索回来的文档做词级别的 overlap 检查。

def token_overlap_ratio(query_tokens, doc_tokens):
    """计算 query 和 doc 之间的关键词重叠率"""
    query_set = set(query_tokens)
    doc_set = set(doc_tokens)
    overlap = query_set & doc_set
    return len(overlap) / len(query_set) if query_set else 0

# 示例
query = "怎么取消自动续费"
doc = "自动续费将在会员到期前7天自动扣款..."

# 分词后
query_tokens = ["取消", "自动", "续费"]
doc_tokens = ["自动", "续费", "会员", "到期", "扣款"]

ratio = token_overlap_ratio(query_tokens, doc_tokens)
# ratio = 2/3 = 0.67  "取消"没出现在文档里

"取消"这个核心动作词在文档里完全没出现,但向量相似度 0.87。token overlap 能提供一个额外的信号:向量说很相关,但关键词缺了核心动词,这次检索结果的置信度应该打折。

另一个信号是多路召回交叉验证。dense retrieval(向量检索)和 sparse retrieval(BM25)各跑一遍,看两边 top-K 的重叠度。重叠度高说明两种检索策略一致认为这些文档相关,可信度高。重叠度低说明 semantic match 和 lexical match 之间分歧很大,结果不该被无条件信任。

说在最后

拒答本身不是目的,是兜底。如果上线之后拒答率长期在 20% ~ 30%,说明两件事至少有一件成立:知识库覆盖有缺口,或者产品层面没让用户搞清楚这个系统能干什么。这两个问题调 prompt 解决不了。

拒答日志应该作为知识库迭代的输入源,高频被拒的问题定期拉出来看,哪些是知识库该覆盖但没覆盖的,补进去。哪些是这个系统本来就不该回答的,在产品交互上做引导。

RAG 系统的检索结果永远不会为空,LLM 看到 context 就倾向于用它来回答。光靠 prompt 里一句"不知道就说不知道"拦不住,必须在生成前加一道显式的相关性校验,让 LLM 先判断"这些文档能不能答这个问题",再决定要不要答。


我是小富,下期见。

上次编辑于: