用负反馈改进 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,归因就变成一棵判定树:
- golden 文档在库里吗? 人工搜一下语料。不在 → 这是内容缺口,不是检索问题,转给内容维护。这类占比经常超出预期。
- 在库里,但没进 top-k? → 检索召回问题。看是词汇不匹配(查询用词和文档用词不同)还是分块问题(答案跨 chunk 被切断)。
- 进了 top-k 但排得很后? → 排序问题,reranker 或权重配置。
- 排进来了但模型没引用? → 生成侧问题,可能是上下文太长被淹没,或 prompt 没强制引用。
- 引用了但答案还是错的? → 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 条正常会话过一遍。这两条线互补,缺一不可。