应用工程

用负反馈改进 RAG 检索:把用户的"没帮上忙"变成可执行修复

RAG 系统上线后,最有价值的改进信号不在评测集里,而在用户放弃的那一刻。离线评测能告诉你”在我们设想的问题上表现如何”,只有线上负反馈能告诉你”真实用户问的问题里,哪些我们根本答不了”。

问题是大多数团队的反馈机制止步于一个点踩按钮:数据收了,没人看,看了也不知道该改什么。本文讲的是怎么把负反馈做成一条能产出具体工单的流水线。

一、显式反馈太稀疏,隐式信号才是主力

点踩的采集率通常在 1%–3% 量级(不同产品差异很大,此处为大致观察)。靠它驱动改进,样本量根本不够。真正有用的是隐式信号:

信号提取方式指向的问题
立即重问(30s 内改写同义问题)会话内相邻消息相似度答案没解决问题,多为检索没召回
检索返回但答案含”未找到相关信息”生成侧的兜底话术打点召回到了但不相关,或 chunk 太碎
用户追问”具体在哪""有链接吗”关键词匹配引用缺失或引用不可验证
会话中途放弃(无后续、无复制行为)会话终止 + 无交互泛化的不满意,需人工看
复制答案后又回来问复制事件 + 后续消息答案部分可用但不完整

这五类里,立即重问的信噪比最高,也最容易自动提取。它几乎一定意味着上一轮失败了,且用户还有耐心——这正是最值得修的那批问题。

可操作的结论:如果你只做一件事,就做”立即重问”检测。在会话日志里对相邻用户消息算一次语义相似度,超过阈值且间隔小于 60 秒的,直接进待分析队列。

二、负反馈不能直接当工单,必须先归因

拿到一条失败样本,“检索不好”是个没法执行的结论。必须把它归到链路的某一环。归因需要在日志里留够现场:

# 每次问答落一条可回溯的 trace,这是归因的前提
trace = {
    "query_id": qid,
    "raw_query": q,
    "rewritten_query": rq,          # 若有改写
    "retrieved": [                   # 保留分数,不只是 id
        {"chunk_id": c.id, "score": c.score, "doc_id": c.doc_id}
        for c in hits
    ],
    "reranked": [c.id for c in reranked],
    "context_tokens": n_tokens,
    "answer": ans,
    "cited_chunks": cited,           # 模型实际引用了哪几个
    "fallback_triggered": is_fallback,
}

有了 trace,归因就变成一棵判定树:

  1. golden 文档在库里吗? 人工搜一下语料。不在 → 这是内容缺口,不是检索问题,转给内容维护。这类占比经常超出预期。
  2. 在库里,但没进 top-k? → 检索召回问题。看是词汇不匹配(查询用词和文档用词不同)还是分块问题(答案跨 chunk 被切断)。
  3. 进了 top-k 但排得很后? → 排序问题,reranker 或权重配置。
  4. 排进来了但模型没引用? → 生成侧问题,可能是上下文太长被淹没,或 prompt 没强制引用。
  5. 引用了但答案还是错的? → chunk 内容本身过时或有误,转 RAG 内容新鲜度监控 那条线。

这五类的修复动作完全不同。不做归因就动手调参,改一个指标坏另一个是常态。这套排查顺序和 RAG 质量问题排查路径 是同一个框架,区别在于这里的输入是线上真实失败样本而非人工构造的 case。

三、归因可以半自动,但别全自动

归因步骤 1(文档在不在库里)是最耗人力的,可以用模型辅助:拿失败查询和全量文档做一次高召回检索(k 取 50),让模型判断”这批候选里有没有能回答该问题的”。

JUDGE_PROMPT = """判断以下候选片段中,是否存在足以回答用户问题的信息。

用户问题:{query}

候选片段:
{candidates}

只输出 JSON:{{"answerable": true/false, "chunk_ids": [...], "reason": "一句话"}}
若信息分散在多个片段中且合起来足够,answerable 为 true 并列出全部相关 id。"""

这一步能把”内容缺口”和”检索失效”自动分开,人力只需要复核 answerable: true 那部分。但不要让模型直接输出修复建议——它会给出一堆”优化 chunk 策略”之类的正确废话。模型的职责是分类,判断和修复留给人。

实践中这个 judge 本身也需要校准:先人工标 50 条,看 judge 的一致性,不到 85% 就调 prompt 或换更强的模型。校准方法和 RAG 评测框架 里的评委校准是一套东西。

四、修复动作的优先级排法

归因完成后,按”影响面 × 修复成本”排:

归因类别典型修复成本影响面
内容缺口补文档 / 补 FAQ 条目中通常只影响该问题簇
词汇不匹配索引侧加别名、同义词映射低广,一次改善一批
跨 chunk 切断调分块策略或加重叠高(要重建索引)广但风险大
排序靠后调 reranker 或混合检索权重低广
上下文淹没减少 top-k,强化引用指令低广

判断原则:优先做索引侧的低成本广覆盖修复(同义词映射、别名),这类改动风险小、见效快;分块策略调整放到最后,因为它要全量重建且会让历史评测结果失效。

还有一条纪律:每次修复前,先把这批失败样本加进回归集。否则你会在三个月后重新”发现”同一个问题。这批从线上捞出来的真实失败样本,质量比人工构造的评测集高得多——它们是 评测黄金集 最好的增量来源。

五、闭环的节奏:每周一小时

这条流水线不需要专职人力,但需要固定节奏。一个可持续的做法:

  • 每天自动跑:提取立即重问 + 兜底触发 + 点踩,跑 judge 分类,写入待办表
  • 每周一小时人工过:只看聚类后的簇(相似查询归一),按上表排优先级,产出 3–5 个具体工单
  • 每两周一次回归:把新增样本跑一遍,确认上个周期的修复没被冲掉

关键在聚类。不聚类的话你面对的是几百条零散抱怨,聚类后通常会发现 60% 的失败集中在 5–10 个主题簇上。聚类方式不必复杂,对失败查询做 embedding 后跑一次简单聚类就够。

# 把失败查询聚成簇,按簇大小排序,只看头部
from sklearn.cluster import AgglomerativeClustering

vecs = embed([f["raw_query"] for f in failures])
labels = AgglomerativeClustering(
    n_clusters=None, distance_threshold=0.35, metric="cosine", linkage="average"
).fit_predict(vecs)

clusters = defaultdict(list)
for f, l in zip(failures, labels):
    clusters[l].append(f)
for l, items in sorted(clusters.items(), key=lambda kv: -len(kv[1]))[:10]:
    print(len(items), items[0]["raw_query"])   # 簇大小 + 代表查询

六、两个容易踩的坑

坑一:把负反馈当成用户在标注数据。 用户点踩的理由五花八门——答案对但语气不好、格式难读、回答太长。直接把点踩样本当作检索失败会引入大量噪声。所以归因这一步不能省,尤其对显式反馈。

坑二:只看负反馈,不看沉默的大多数。 负反馈天然偏向”愿意反馈的用户”和”明显失败的场景”。有一类问题它抓不到:答案看起来合理但实际是错的,用户直接信了。这类只能靠抽样人工审计发现,建议每周随机抽 20 条正常会话过一遍。这两条线互补,缺一不可。

七、相关阅读