内部的客服知识库问答系统上了生产,用的标准 RAG 架构,向量检索 + LLM 生成。上线前 demo 效果不错,上线第三天运营就来找我了:有个用户问"怎么取消自动续费",系统给了一套操作流程,用户照着做了,发现根本不对。
拉出来一查,知识库里确实有续费相关的文档,但那篇文档讲的是续费规则和计费标准,压根没有取消操作的内容。检索系统把它排在了 top-1,相似度 0.87,LLM 拿着这篇文档给用户编了一个[设置 → 账户管理 → 关闭自动续费]的步骤。
问题是我们的 prompt 里明确写了:如果提供的资料中没有相关信息,请直接告知用户你无法回答。写了也没用,LLM 照样编。
向量检索永远不会返回空结果
大家可能对 RAG 有一个错误的预期:如果用户问的问题不在知识库范围内,检索应该返回空。
但向量数据库不是这么工作的,你扔一个 query 进去,它做的事情是在向量空间里找距离最近的 K 个文档。不管这些文档跟 query 有没有关系,它都会给你返回 K 个结果。
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,处理方式应该不一样。
比如用户问"怎么取消自动续费,取消后剩余天数会不会退款",知识库里有退款政策但没有取消操作。这种情况下,把退款政策相关的内容给出来,同时告诉用户取消操作的部分你回答不了,比一刀切的我无法回答体验好得多。
最怕的是另一种误杀:文档里明明有答案,但相关性校验判成了 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 能提供一个额外的信号:向量说很相关,但关键词缺了核心动词,这次检索结果的置信度应该打折。
另一个信号是多路召回交叉验证,向量检索 和 BM25 各跑一遍,看两边 top-K 的重叠度。重叠度高说明两种检索策略一致认为这些文档相关,可信度高。重叠度低说明 semantic match 和 lexical match 之间分歧很大,结果不该被无条件信任。
说在最后
如果上线之后拒答率长期在 20% ~ 30%,说明两件事至少有一件成立:知识库覆盖有缺口,或者产品层面没让用户搞清楚这个系统能干什么,这两个问题调 prompt 解决不了。
拒答日志应该作为知识库迭代的输入源,高频被拒的问题定期拉出来看,哪些是知识库该覆盖但没覆盖的补进去;哪些是这个系统本来就不该回答的,在产品交互上做引导。