RAG 不是万能药:电商客服 Agent 的检索质量门控实践

举报
yd_250554138 发表于 2026/10/23 00:29:22 2026/10/23
【摘要】 RAG 不是万能药:电商客服 Agent 的检索质量门控实践摘要:很多 RAG 项目死在"能跑通 Demo"和"敢上线"之间。真正拉开差距的不是模型选型,而是检索结果不可信时系统怎么处理。本文讲一个反直觉但有效的工程决策:给检索加一道门控,宁可拒答转人工,也不让模型拿着不相关的文档编答案。标签:RAG / AI Agent / 大模型应用 / LangGraph / 华为云 一、问题不在生...

RAG 不是万能药:电商客服 Agent 的检索质量门控实践

摘要:很多 RAG 项目死在"能跑通 Demo"和"敢上线"之间。真正拉开差距的不是模型选型,而是检索结果不可信时系统怎么处理。本文讲一个反直觉但有效的工程决策:给检索加一道门控,宁可拒答转人工,也不让模型拿着不相关的文档编答案。

标签:RAG / AI Agent / 大模型应用 / LangGraph / 华为云


一、问题不在生成,在检索

电商客服场景的 RAG 系统,最常见的线上事故不是"模型不会说话",而是模型很会说话,说的却是错的。

用户问"我这个订单能用运费险吗",知识库里有《退换货政策》《运费险规则》《发票开具说明》三篇文档。向量检索按语义相似度返回 Top-3,第一名是《发票开具说明》——因为它也提到"订单"“费用”。模型拿到这三篇,照样能输出一段语法完美、逻辑自洽、但完全答非所问的回复。

这类失败有个特点:它看起来不像失败。没有报错、没有空回复,只有用户投诉。而你在日志里看到的是一次"成功调用"。

我见过太多团队在这个阶段投入大量精力做 Prompt 调优、换更强的模型、加 few-shot,效果都不明显。因为问题出在更前面一层:检索召回了不相关的内容,生成端无论多强,都只能在不相关的信息里挑最好的胡说。

所以整个系统的关键指标不该是"回答了多少问题",而是"在检索不可信的时候,有没有如实说出来"。

二、先把"好坏"量化出来

在加任何门控之前,必须先有一个能反复跑的评测集,否则所有阈值都是拍脑袋。最小可用的评测集只需要两列:

用户问题 应命中的文档 ID
我的订单可以用运费险吗 doc_0312, doc_0317
发票抬头填错了怎么改 doc_0441
七天无理由从哪天开始算 doc_0102
这个手机支持以旧换新吗 (知识库无此信息,应拒答)

注意最后一行——负样本比正样本更重要。只有正样本的评测集,无法衡量系统"该拒答时是否拒答",而这恰恰是线上最影响体验的部分。建议正负样本比例控制在 3:1 到 2:1。

有了评测集,两个指标就够用了:

def recall_at_k(retrieved: list[str], gold: set[str], k: int) -> float:
    """Top-K 结果覆盖了多少标准答案文档"""
    if not gold:
        return 0.0
    return len(set(retrieved[:k]) & gold) / len(gold)


def reciprocal_rank(retrieved: list[str], gold: set[str]) -> float:
    """第一个命中文档的排名倒数,衡量排序质量"""
    for rank, doc_id in enumerate(retrieved, start=1):
        if doc_id in gold:
            return 1.0 / rank
    return 0.0

Recall@K 回答"找没找到",MRR 回答"排得对不对"。两个都要看:Recall 高但 MRR 低,说明答案在结果里但埋得太深,模型很可能忽略它;Recall 本身就低,说明切分策略或 embedding 模型不适合这个领域,这时候加门控也救不了,得先回去改索引。

这一步的产出是一个基线数字。 后面所有优化,都要能在这个评测集上复现出提升,否则就是自我感觉良好。

三、门控该放在哪里

很多人把门控理解成"检索完之后判断一下要不要答"。实际上它应该拆成三段,每段职责不同:

用户问题
   │
   ├─→ ① 查询改写 / 意图识别 ── 判断这是不是知识库能回答的类型
   │
   ├─→ ② 混合检索(向量 + 关键词)+ 重排
   │
   ├─→ ③ 检索质量门控 ── 决定 answer / clarify / handoff
   │
   └─→ ④ 生成(带引用来源)

① 意图层解决的是"问错了类型"。用户问"我的快递到哪了"——这是实时数据查询,不是知识库问题,走到检索层纯属浪费,应该直接走订单查询工具。这一层用规则或小模型分类即可,不必上大模型。

② 混合检索是为了互补。纯向量检索对"运费险""七天无理由"这类专有名词和数字型表述不敏感,BM25 恰好擅长;而 BM25 又处理不了"那个退货运费谁出"这种口语化表达。两路召回后做 RRF(倒数排名融合)合并,通常比任何单路都稳。

③ 门控才是决策点。这里有个设计原则:门控输出必须是一个有明确语义的动作,而不是一个布尔值。三态比二态实用得多:

from dataclasses import dataclass
from typing import Literal


@dataclass
class GateDecision:
    action: Literal["answer", "clarify", "handoff"]
    reason: str


def gate(dense_score: float, sparse_score: float, doc_count: int, top_gap: float,
         cfg) -> GateDecision:
    # 一路都没召回,直接转人工,不要让模型硬答
    if doc_count == 0:
        return GateDecision("handoff", "no_document")

    # 两路信号都很弱:不是知识库覆盖的问题
    if dense_score < cfg.min_dense and sparse_score < cfg.min_sparse:
        return GateDecision("handoff", "low_relevance")

    # 有候选但排名之间差距小,说明检索自己也拿不准
    if top_gap < cfg.min_gap:
        return GateDecision("clarify", "ambiguous_query")

    return GateDecision("answer", "ok")

三个分支分别对应三种线上情况:

  • handoff 转人工。宁可让用户等 30 秒,也不要给一个错误答案然后他去下单。
  • clarify 反问。检索拿不准往往是因为问题本身含糊(“这个能退吗”——哪个订单?什么原因?),反问一句成本最低。
  • answer 正常生成。

top_gap 是第一名和第二名的分数差,这个信号在实践中比绝对分数更稳——绝对分数受 embedding 模型、文档长度影响很大,而相对差距反映的是"检索自己有没有把握"。

四、阈值怎么定:别拍脑袋

门控里所有 cfg.* 参数都必须从评测集标定出来,流程是:

  1. 在评测集上跑一遍检索,记录每个问题的 Top-1 分数、分数差、是否命中;
  2. 把所有样本按 Top-1 分数排序,扫描不同阈值下的"误拒率"(本该回答却 handoff)和"误答率"(本该拒答却回答);
  3. 选一个保守点——在误答率可接受的前提下,尽量降低误拒率。

这里的方向性很重要:两类错误的代价不对称。误拒的代价是客服工作量上升,可量化、可消化;误答的代价是用户按错误信息操作、产生实际损失和投诉,不可逆。所以阈值要往"多拒一点"的方向偏,通常取 F1 最优点再往保守方向移 0.05 左右。

阈值还必须配置化存储,而不是硬编码在代码里:

# retrieval_gate.yaml
gate:
  min_dense: 0.62
  min_sparse: 3.5
  min_gap: 0.08
  clarify_fallback: "您好,为了准确回答,能否补充一下具体的订单号或问题类型?"

理由很实际:不同品类的知识库(3C、服饰、生鲜)分布完全不同,一套阈值套所有类目必然有一半不准。配置化之后,每个类目单独标定、单独上线,改阈值不需要发版。

五、上线之后:把门控变成可观测指标

做完上面这些,系统才算"能跑"。但线上真正的挑战是漂移:商品下架、政策更新、用户问法变化,都会让原来标定好的阈值逐渐失效。

所以要给门控本身加埋点,至少三个指标进监控面板:

  1. handoff 率。按类目看,如果某个类目突然从 8% 涨到 25%,大概率是知识库更新后文档切分出了问题,而不是用户变笨了。
  2. clarify 率与二次转化率。反问之后用户有没有补充信息、有没有继续问,直接反映反问话术的质量。
  3. answer 分支的抽样人工复核。系统认为"可信"的那些回答,每周抽 50 条人工看,这是唯一能发现"自信地答错"的手段。

这三条指标配合评测集做定期回归,就形成了一个闭环:线上发现异常 → 定位到类目和文档 → 补充评测样本 → 重新标定阈值 → 灰度上线。

六、部署时的几个工程细节

如果这套东西要落到云上,几个实践上的坑值得提前说:

索引与业务库分离。 文档元数据(类目、生效时间、上下架状态)放关系库,向量放检索服务。华为云的云搜索服务 CSS 提供向量检索能力,配合 OBS 存放原始文档,可以做到文档更新时只重建受影响的分片,而不是全量重建索引。全文重建在文档量大时是分钟级到小时级的中断,必须避免。

embedding 服务要独立部署、独立扩缩容。 它和生成模型的服务特征完全不同:embedding 是高频、短时、可批量,生成是低频、长时、流式。混在一起部署,结果就是检索被生成阻塞,整体延迟飙升。用 ModelArts 单独部署 embedding 服务,按 QPS 扩缩容。

门控逻辑放在应用层,不要塞进 Prompt。 我见过把"如果文档不相关就回答不知道"写进 system prompt 的做法,稳定性极差——模型对这类指令的遵从度随上下文长度增加而下降。用代码做确定性判断,只在生成阶段要求模型引用文档编号,方便事后核对。

留一条人工接管通道。 handoff 分支不能只是返回一句话就完事,要能带着会话上下文和检索到的 Top 文档一起进人工队列,客服才能快速接手。这条通道的设计质量,决定了门控敢不敢设得保守。

七、小结

回到开头那个判断:RAG 系统的瓶颈通常不在生成端,而在"系统对自己知道多少"这件事的判断能力上。

加门控看似让系统"能力变弱"了——它开始拒绝回答一些本来能答的问题。但实际效果相反:用户遇到一次编造的答案,对这个入口的信任就没了;而一次诚实的"我需要确认一下",换来的是继续使用。

如果让我给正在做 RAG 落地的团队一条建议,就是:先把评测集和负样本建起来,再谈模型和 Prompt。 没有负样本,你永远不知道自己的系统在什么情况下会撒谎。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。