首页 / 文章

对RAG检索中的过时数据块、漂移现象及噪声上下文进行审计。

针对RAG检索的Python审计:黄金查询、余弦相似度相关性检测、Kolmogorov-Smirnov漂移检验与上下文效率分析,以及如何解读报告。

发布者:REACTAPP.TOP阅读约 12 分钟2728 词

来源: I Built a RAG System That Audits Itself. Here’s How (With Code).

那种悄无声息的RAG故障

想象一下,某个客服机器人告诉客户退货期限为30天,而实际应为14天。相关政策文件是在1月修改的,嵌入索引则最后一次更新于10月。虽然没有人因此受到伤害,也没有引发诉讼,但令人担忧的是,没人能确定错误答案已经传播了多久——是一周?还是三个月?由于没有检测手段,根本无从知晓。

这才是值得关注的故障类型,它并非模型产生幻觉或胡言乱语的那种严重故障,而是那种悄无声息的故障。控制面板显示一切正常,而有少量用户数月来一直被误导,却没有任何警报发出。

大多数构建RAG的团队都在测量错误的内容。他们只关注最终答案是否听起来不错,却忽略了生成该答案的素材本身质量如何。这主要是因为没人给他们提供简单的检测工具。下面介绍一种这样的工具:一个基于开源库和测试查询JSON文件构建的Python脚本,可用于自动审计检索层,只需大约30分钟即可设置完成,且无需任何监控平台或统计学知识。

关注检索过程,而非模型本身

当RAG系统出现异常时,人们的本能反应是归咎于模型:提示词有误、温度参数不当、版本过旧。通过调整提示词或更换为更新版本的模型,问题可能会在几周内得到改善,但随后问题又会重现,因为真正的症结在于其他地方。实际生产系统中的处理流程通常如下:

文档虽然发生了变化,但嵌入索引并未更新。系统会检索到过时的数据块,大语言模型便依据这些过时的上下文自信地给出答案,结果却是错误的,而且没人能解释原因。

模型并没有说谎。它只是按照指令行事,即根据所提供的数据块生成答案。如果给它六个月前的数据块,它给出的答案也会是六个月前的内容,只不过表达流畅且语法完美。

问题在于常见的端到端评估方法:输入查询后,仅对输出答案进行评判。在较为广泛采用的评估框架中,RAGAS便是基于整个处理流程构建的,它能评估上下文精度、忠实度以及答案相关性(Es等人,2023年,EACL 2024)。这确实是一个不错的框架。不过它评估的是整个系统的表现,而非检索层本身,因此即便端到端的评分合格,也可能掩盖检索效率随时间下降的问题——毕竟模型有时会运气好而弥补这一缺陷。

在那个问题之下还有另一个问题。刘等人于2024年指出,当相关信息隐藏在较长上下文的中间时,语言模型的表现会明显变差,即便是专为处理长输入而训练的模型也是如此。因此,检索出五个片段且其中三个仅具有微弱相关性,实际上会损害系统性能,耗费在它们身上的计算资源也会降低答案质量。除非直接检查检索层,否则这两个问题都不会显现出来,而这正是目前的计划。

标准指标未能发现的三种故障模式

片段污染

“块污染”这一术语并非源自某篇论文。它指的是那些因为表述与查询有重叠而看似相关、因此在向量搜索中排名靠前,但实际上并不包含答案的块。常见的原因在于内容分割时将关键词放在一个块中,而实际内容放在另一个块中。富含关键词的块在余弦相似度评估中得分较高,而有用的内容则无法进入顶级结果,于是模型会拼凑出一个看似合理但实际上错误的回复。

过时内容

假设某个常见问题解答页面在1月更新了退款政策,但索引最后一次更新是在10月。旧的退款政策相关块仍然会在涉及退款的查询中排名靠前,并被模型视为“相关”内容。这样一来,用户会得到与客服团队说法相矛盾的答复,而各项指标却显示不出问题。

嵌入漂移

这种变化缓慢且逐渐累积,是三种情况中最危险的。嵌入模型记录的是索引时的语料库状态。每一次文档修改、每一个新术语的出现以及用户查询方式的每一次变化,都会略微降低该快照的准确性。平均余弦相似度可能在六个月内从0.82下降到0.74。此时不会有任何系统故障或警报触发,只有当答案质量明显下降时才会显现出这种衰退,而那时系统早已落后数月。

上下文中的噪声也会带来负面影响。如刘等人所研究的,当窗口内有五个片段且其中三个无关时,资源会被浪费,质量也会下降,因为长上下文中的中间内容得到的关注度低于边缘内容。

审计的作用

有三项检查对应着这三种故障模式。

  1. 相关性与匹配度。 对测试集中的每个查询,脚本会获取排名前K的文本片段,并计算该查询的嵌入向量与每个片段嵌入向量之间的余弦相似度。同时还会检查是否包含答案的片段已被检索出来;如果没有,则该查询会被标记为未匹配。
  2. 与基线的偏差检测。 每次运行都会记录相似度分数的分布情况,后续运行会通过柯尔莫哥洛夫-斯米尔诺夫检验将新的分布与存储的基线分布进行比较。如果两者存在显著差异,就会触发偏差警报。无需了解具体数学原理:只要p值低于0.05,就说明情况发生了变化,需要进一步检查。
  • 上下文效率。该脚本会统计检索到的标记中有多少出现在模型的回答中。比例越高,说明检索结果越精准相关;比例越低,则意味着上下文中存在大量冗余信息。若该比例持续低于40%,就需要进一步调查。
  • 审计过程会生成结构化的JSON报告。每周执行一次定时任务即可,重点关注标记出现频率及数据漂移情况。大多数RAG系统此前都没有这类监控机制。

    黄金查询集

    在编写任何代码之前,首先需要一个黄金查询集:即一个包含典型查询的JSON文件,每个查询都会对应其结果中应出现的片段ID。

    [
      {
        "query": "What is the refund policy for digital products?",
        "expected_chunk_ids": ["faq_doc_chunk_12", "faq_doc_chunk_13"],
        "notes": "Customer FAQ, policy updated 2024-Q1"
      },
      {
        "query": "How do I reset my API key?",
        "expected_chunk_ids": ["api_docs_chunk_07"],
        "notes": "API documentation, stable"
      }
    ]
    

    数量很重要。起初20次查询似乎已经足够,但实际上并不够,因为漂移检测需要更多的数据点,20个数据点的分布过于稀疏,无法让人信赖。建议将最低数量设定为50次,而100次或更多时,漂移信号才具有实际意义。

    首先从应用日志中找出出现频率最高的用户查询。逐一手动执行检索,查看结果并记录哪些数据块ID位于最顶部。这项工作相当枯燥,需要数小时时间,但却是唯一无法自动完成的部分,其他所有步骤都依赖于它。以下一些习惯有助于提高效率:

    • 将查询分散到不同的主题类别中,而非集中在某一篇文档周围。
    • 加入一些本应不会返回任何结果的查询,因为负样本有助于识别误检的检索结果。
  • 仅在语料库发生重大变化时更新黄金集,而非持续更新。它是一个稳定的参考资料,而非实时数据集。
  • 项目结构与主脚本

    该仓库的结构如下:

    rag-retrieval-audit/
    ├── audit/
    │   ├── __init__.py           # Package exports
    │   ├── rag_audit.py          # Main audit logic — all three checks
    │   └── config.py             # All thresholds and model settings
    ├── examples/
    │   ├── golden_queries.json   # Sample golden set (10 queries)
    │   └── run_audit.py          # End-to-end demo — runs without an existing collection
    ├── tests/
    │   └── test_audit.py         # Unit tests for all scoring functions
    ├── requirements.txt          # sentence-transformers, chromadb, scipy, numpy
    └── README.md                 # Setup, usage, how to read the report
    

    rag_audit.py 的结构如下,该文件包含了全部三项检查功能。这只是用于了解结构的概要,并非完整实现代码。

    # rag_audit.py — structure overview
    # Full implementation: https://github.com/satyam671/rag-retrieval-audit
    
    # ── CONFIGURATION (tune to your pipeline)
    EMBEDDING_MODEL      = "all-MiniLM-L6-v2"  # must match your index
    TOP_K                = 5
    RELEVANCE_THRESHOLD  = 0.70
    DRIFT_P_THRESHOLD    = 0.05
    EFFICIENCY_THRESHOLD = 0.40
    
    # ── CHECK 1: Are the right chunks coming back?
    def score_relevance(query_embedding, chunk_embeddings, expected_ids, retrieved_ids):
        """Cosine similarity per chunk + expected chunk hit/miss against golden set."""
        ...
    # ── CHECK 2: Has retrieval quality shifted over time?
    def detect_drift(current_sims, baseline_path=None):
        """Two-sample KS test comparing current similarity distribution to baseline."""
        ...
    # ── CHECK 3: How much of the context window is signal?
    def score_efficiency(retrieved_docs, answer):
        """Token overlap between retrieved chunks and the LLM answer."""
        ...
    # ── RUNNER
    def run_audit(golden_set_path, collection_name, chroma_persist_dir,
                  baseline_path=None, answers_path=None, output_path="rag_audit_report.json"):
        """Runs all three checks, writes a structured JSON report, saves the baseline."""
        ...
    

    该脚本的README文档说明了如何克隆它、将其指向ChromaDB集合并运行它,还包括如何更换为其他向量存储库。

    逐一解析各项检查

    分块相关性评分

    相关性检查做了两件人们在自行实现时常常忽略的事情。

    首先,它直接从嵌入向量计算余弦相似度,而不使用ChromaDB返回的距离值。ChromaDB的默认度量标准是L2,而非余弦相似度,两者不可互换。将它们混用虽然得到的分数看似合理,但实际上衡量的是其他指标。

    其次,它证实了黄金数据集预期的分块ID确实出现在前K个结果中,而这正是暴露分块污染现象的关键。0.75的平均相似度看似不错,但当你得知本应回答查询的分块得分仅为0.62且排在第六位——刚好超出阈值一位时,情况就变了。模型始终没有得到正确答案,而相似度数值却仍显得合理。

    在代码中,启用门控机制与不启用门控机制的实际差异十分明显。

    # WITHOUT the relevance gate — standard approach most teams use
    def build_context_naive(query, collection, top_k=5):
        results = collection.query(
            query_embeddings=[embed(query)],
            n_results=top_k,
            include=["documents"]
        )
        # Pass everything back, no quality check
        return "\n\n".join(results["documents"][0])
    
    # WITH the relevance gate - what the audit tells you to build
    def build_context_gated(query, collection, model, top_k=5, threshold=0.70):
        q_emb   = model.encode([query], normalize_embeddings=True)[0]
        results = collection.query(
            query_embeddings=[q_emb.tolist()],
            n_results=top_k,
            include=["documents", "embeddings", "ids"]
        )
        passed_chunks = []
        for i, chunk_emb in enumerate(results["embeddings"][0]):
            sim = cosine_sim(q_emb, np.array(chunk_emb))
            if sim >= threshold:
                passed_chunks.append(results["documents"][0][i])
        # Empty context is better than wrong context
        return "\n\n".join(passed_chunks)
    

    在简单版本中,排名靠前的片段无论质量如何都会被使用。而在受限版本中,那些尚未获得使用资格的片段会被排除。在空上下文情况下,给出“我缺乏足够信息来回答”这样的回复,总比基于错误信息给出的自信但错误的答案要好。

    嵌入漂移检测

    漂移检测依赖于柯尔莫哥洛夫-斯米尔诺夫检验。它将当前的相似度分布形态与保存的基准值进行比较,一旦发现显著差异就会发出警报。为何要使用分布而非平均分?因为单个数字会掩盖太多信息。平均分可能始终保持在0.78,但其背后的分布却可能变得平坦且分散,这意味着某些查询的得分会大幅下降,而其他查询则能起到补偿作用。柯尔莫哥洛夫-斯米尔诺夫检验能够捕捉到平均分无法发现的分布变化。

    该示例图表展示了在未重新建立索引的情况下,语料库更新六个月后生产环境中的RAG系统出现的嵌入漂移情况:KS统计值为0.21,p值为0.003。

    需要注意一点:该测试需要足够的数据才能保证结果的可靠性。仅有15次查询时,p值会波动不定,从而导致错误的信心,而50次及以上的查询才能给出稳定的信号。这也正是黄金数据集规模比表面上看更为重要的另一个原因。

    上下文窗口效率

    这是三项检测中最不精确的,存在实际限制。检索到的文本片段与模型答案之间的单词级重叠度仅能作为一种参考。当模型像GPT-4这类模型那样进行大量改写时,重叠度会低估实际被使用的上下文量。

    尽管如此,它仍能发现系统性问题。如果效率得分持续低于40%,则说明上游存在问题:数据块过大、top-K值过高,或是检索结果中包含了与查询主题相关但并未回应查询内容的文本。这种检测虽不精确,但方向是正确的,足以作为展开调查的依据。

    阅读报告

    输出结果为JSON文件,以下字段值得关注:

    • flag_rate表示触发失败条件的查询比例。若该数值超过20%,则说明检索环节需要改进;如果一周为8%,下一周升至22%,则表明有变化发生。
    • drift_analysis.drift_detected是需重点关注的布尔值。若其为true,则需查看mean_delta以判断变化趋势。负值表示相似度正在下降,这是重新建立索引的信号。
  • drift_analysis.ks_statistic 的值会随着数据分布偏移程度的增大而上升。低于0.10时很可能是噪声;高于0.20时则无论p值如何都表明存在实际问题。
  • query_results[i].relevance.expected_chunk_hit 表示每个查询的基准值。false表示本应回答该查询的数据块未进入前K名。如果这种情况在黄金数据集中出现的比例超过15%,则需重新建立索引。
  • 该示例可通过此处所示的单一命令启动。

    python -m examples.run_audit
    

    这份速查表将审计结果浓缩为一张图表。

    每周运行一次并记录所有基准值。多次运行之间的趋势比单次运行更能反映问题。

    关于样本量的经验

    对包含15个查询的黄金数据集进行漂移检测时,得到的p值毫无意义。虽然测试在技术上得以运行,但结果不可靠,人们很容易在怀疑统计实现方式上浪费两天时间,却没意识到问题其实出在数据量太少。

    当查询数量少于50个时,所得的漂移数据不可信,因此应先构建数据集,然后再进行漂移检测。对于涵盖多个不同主题领域的语料库,每个领域都需要一个独立的黄金数据集,并分别进行审计,因为混合语料库中的整体分布可能会掩盖某个领域内的下降趋势,而该趋势在单独查看该领域数据时则会十分明显。

    这种方法的局限性

    该脚本仅用于验证是否获取了正确的片段,不会判断片段边界是否设置得当、片段大小是否适合相应的查询类型,也不会考虑是否应采用语义分割替代固定大小分割。

    这些问题其实很重要。NVIDIA在五个数据集上进行的基准测试结果显示,最佳片段大小因查询类型而异:事实类查询的理想片段大小为256到512个标记,而分析类查询则需要1024个标记或更多。任何单一的片段大小都只是折中方案,而试图同时兼顾两种查询类型的理想方案最终仍会导致基本的不匹配。直接测试分割策略,并用实际数据对比语义分割与固定大小分割的效果,则是另一项独立的任务。

    参考文献

    • 关于检索增强生成自动评估的RAGAS论文(arXiv 2309.15217,2023年;EACL 2024年)。
    • 2024年发表在《TACL》第12卷上关于语言模型长上下文行为的“中间缺失”研究。
    • 2024年NVIDIA针对不同文档类型所进行的分块策略应用研究。