检索指标全绿,不代表回答可靠。RAG 上线后最常见的劣化不在"检索不到",而在"检索结果里混进了噪声":知识库膨胀后召回阈值放宽、embedding 模型升级、旧版本文档没下线,都会让 top-k 里混进跟问题相关但内容错误、或完全不相关的 chunk。LLM 对上下文里的具体数字和结论有很强的采信倾向,上下文混噪后它很少拒答,而是自信地把错的讲成对的——这是 RAG 最贵的故障模式,点赞类业务指标短期根本看不出来。噪声敏感度(noise sensitivity)就是量化这个风险的指标:控制变量地往上下文里注入噪声,看回答质量退化多少。Ragas 的噪声敏感度指标把噪声按性质分成两类——relevant(相关但错误/误导)和 irrelevant(完全无关),两类破坏机制不同,注入评测也要分开设计。这篇讲注入实验怎么设计、判定口径怎么定、怎么接成回归门禁,附一份可直接改的注入评测代码。
两类噪声,两种破坏机制
先把噪声分清楚,后面的实验设计都从这里长出来:
| irrelevant(完全无关) | 跨产品线文档混入、top-k 放宽 | 稀释注意力、占用上下文窗口;多数模型能忽略,但长上下文推理任务会被带偏 | 不能,命中率照常全绿 |
| relevant(相关但错误) | 旧版本本文档未下线、相似产品条款、同名实体不同客体 | 模型采信上下文中的数字和结论,产出"忠实于错误上下文"的回答 | 不能,相关度分甚至更高 |
relevant 噪声最危险,原因在检索原理本身:embedding 检索做的是语义相似,旧版本文档和新版本文档的相似度往往高过不相关内容。所以大部分噪声不是"混进来的",是被检索器请进来的。评测时注入的噪声必须模拟这个分布——纯随机拼一段话塞进去太假,模型识别后直接忽略,测不出真实退化。
注入实验设计:变体矩阵
评测集沿用上一篇的三层流水线产物(合成打底 + 日志校准 + 人工收口),取 200-300 条高价值 case。对每条 case 生成 5 个变体:
| v0 基线 | 真实检索 top-k | 干净检索下的忠实度基线 |
| v1 irrelevant | top-k + 3 条完全无关 chunk | 无关噪声耐受度 |
| v2 relevant-soft | top-k + 3 条同主题但无信息量/泛化表述 chunk | 低质量相关噪声 |
| v3 relevant-hard | top-k + 3 条与正确答案直接冲突的 chunk(旧版本、错误数字) | 矛盾噪声耐受度(最狠) |
| v4 全噪声 | 上下文全部替换为无关 chunk | 检索全挂时模型"知不知道"(拒答能力) |
控制变量的三条铁律:只动 context,不动 question 和 prompt 模板;同一条 case 的全部变体在同一轮内跑完(同一个 judge 模型、同一温度),避免 judge 漂移把噪声影响和评分波动混在一起;注入数量和位置做成显式参数,别散落在代码里。
代码:注入评测 harness
核心难点在噪声采样:irrelevant 噪声从语料库随机采样即可,relevant 噪声必须按语义相似度区间采样,模拟"被检索器请进来"的噪声。
import numpy as np
from dataclasses import dataclass, field
@dataclass
class NoisyVariant:
name: str
contexts: list[str]
def sample_noise_chunks(question: str, doc_store, embedder,
pool: list[str], sim_band=(0.70, 0.85), n: int = 3) -> list[str]:
"""从噪声候选池里按语义相似度区间采样 hard negative。
sim > 0.85 的基本就是正确答案,sim < 0.70 的模型一眼识破,都不合格。"""
q_vec = embedder.embed(question)
scored = []
for doc_id in pool:
s = cosine(q_vec, embedder.embed(doc_store.get(doc_id)))
if sim_band[0] <= s <= sim_band[1]:
scored.append((s, doc_id))
scored.sort(reverse=True)
return [doc_store.get(doc_id) for _, doc_id in
np.random.default_rng(42).choice(scored[: max(n * 3, 10)], n, replace=False)]
def build_variants(case, retriever, doc_store, embedder,
noise_pool, version_map, k_noise=3) -> list[NoisyVariant]:
base = [c.text for c in retriever.retrieve(case.question, top_k=4)]
return [
NoisyVariant("v0_base", base),
NoisyVariant("v1_irrelevant",
base + sample_noise_chunks(case.question, doc_store, embedder,
noise_pool, sim_band=(0.0, 0.4))),
NoisyVariant("v2_relevant_soft",
base + sample_noise_chunks(case.question, doc_store, embedder,
noise_pool, sim_band=(0.70, 0.85))),
NoisyVariant("v3_relevant_hard",
base + [doc_store.get(version_map[chunk.doc_id]) # 同主题旧版本
for chunk in retriever.retrieve(case.question, top_k=k_noise)]),
NoisyVariant("v4_all_noise",
sample_noise_chunks(case.question, doc_store, embedder,
noise_pool, sim_band=(0.0, 0.4), n=4)),
]
打分不要用"整体对不对"这种粗粒度 judge,噪声场景下它必然失真。用 keypoint 级核对:参考答案拆成可判据要点,逐条问 judge 两个独立问题——"该要点是否被上下文支持"(忠实度代理)和"回答是否覆盖该要点"(正确性代理)。两个问题分开问,模型混在一起答时倾向给自己找补:
FAITH_PROMPT = """判断【回答】中的每个要点是否被【上下文】支持。
只依据上下文,回答"支持/不支持/上下文未提及"。{context}\\n{answer}"""
def score_variant(variant: NoisyVariant, case, judge_llm) -> dict:
answer = run_rag_pipeline(case.question, variant.contexts)
keypoint_verdicts = [judge_llm(FAITH_PROMPT.format(
context="\\n".join(variant.contexts), answer=a)) for a in case.keypoints]
faithful = mean(v == "支持" for v in keypoint_verdicts) # 忠实度代理
correct = mean(v in ("支持", "上下文未提及但不矛盾") for v in keypoint_verdicts) # 宽松正确率
return {"faithful": faithful, "correct": correct}
def degradation(base: dict, noisy: dict) -> float:
return round((base["faithful"] – noisy["faithful"]) / max(base["faithful"], 1e-9), 3)
跑完对每个变体聚合 Δfaithful,输出退化矩阵,按类目和难度分桶。
判定口径与一组实测(示意)
退化分级建议按业务容忍度定,我们内部用的口径:
| ≤ 5% | 稳健 | 观察 |
| 5% – 15% | 需关注 | 查检索阈值与 rerank,下个迭代处理 |
| > 15% | 敏感 | 阻断发版,优先修 |
一组内部项目的实测形态(数据脱敏,量级可参考):单跳事实问答对 v1 几乎不退化(Δ≈2%),对 v3 矛盾噪声退化 8-15%;多跳整合和长尾实体类对 v3 退化 20-35%,且 v4 全噪声下正确率直接腰斩——说明多跳类的问题在检索失败时缺少"我不知道"的兜底。只报平均分会把多跳类的退化稀释掉,分桶报告才有决策价值。
踩坑记录
进阶用法:用噪声敏感度反推检索参数
把 v3 从"发版门禁"升级成"参数调优工具",是这套评测最划算的用法。检索链路有三个旋钮——top_k、相关性阈值、rerank 强度,它们的 trade-off 本质是同一件事:召回越多,噪声越多。拿同一份评测集在不同参数下跑 v0 + v3,能画出两条曲线:recall@k 随 top_k 上升而上升,v3 的 Δfaithful 也随 top_k 上升——两条曲线的交叉区域就是甜点区。调过的一个典型 case:top_k 从 4 放到 8,recall@k 涨了 11%,但 v3 Δfaithful 从 9% 跳到 19%——相关噪声 chunk 进入 top-8 的概率接近翻倍;把相关性阈值从 0.5 提到 0.6 后,recall 只掉 3%,Δfaithful 回到 11%,净收益为正。没有噪声敏感度这个维度,你只会看到"top_k=8 召回更好"就上线,把噪声风险全部留给用户。rerank 评测同理:rerank 模型换版后检索指标可能完全持平,但 v3 退化曲线能告诉你新 rerank 是更会去噪,还是更会把相似但错误的 chunk 排到前面。到这个阶段,评测集就从"测试资产"变成了"调参方向盘"。
回归门禁与线上监控
接门禁时给"关键类目"单独设阈值:用户问得最多、答错代价最高的 3-5 个类目,Δfaithful > 10% 直接阻断发版,其他类目放宽到 20%。线上侧加一个 canary 指标——对每次检索的结果算噪声率(相关性分低于阈值的 chunk 占比),噪声率趋势上涨通常先于业务指标恶化几周,是检索退化最早的信号。检索阈值、rerank、embedding 升级这类变更,发布前强制跑一遍 v3 集,比上线后看用户投诉便宜一个数量级。
RAG 的质量评测做到这层才算闭环:检索指标回答"能不能找到",噪声敏感度回答"找错了会不会一本正经地错下去"。进阶方向:把注入实验从文本推广到多模态文档、给 v4 全噪声场景加显式拒答判定、以及用噪声敏感度反推检索阈值和 rerank 参数——噪声敏感度不只是个测试指标,它能直接指导检索链路调优。


