首页 / 文章

RAG检索的调试:Recall@K、BM25、混合融合算法与重排序机制

在一个RAG流程上进行的小型实验中,正确的文本片段被排在第四位,该实验展示了文本分块、BM25、排名融合与重新排名如何影响Recall@3和MRR指标。

发布者:REACTAPP.TOP阅读约 11 分钟2556 词

来源: How to Fix a RAG System That Keeps Retrieving the Wrong Context

检索器答错的问题

使用一个简单的RAG流程,询问一个本应很简单的问题:CSV导出链接的有效期是多久?文档中已有答案——生成的CSV下载链接可正常使用7天。然而检索器将该段落排在第四位,而且只有排名前三的片段会被传递给语言模型,因此它根本无法获取到相关提示信息。

令人恼火的是,排名前三的结果看起来并没有问题。其中一个描述了CSV导出的处理流程,另一个涉及大容量导出,还有一个解释了如何取消导出。乍看之下似乎检索器表现正常,但实际上并非如此。它虽然找到了主题相关的段落,但这些段落中都没有包含问题所需的事实信息。

面对糟糕的RAG回答,人们通常会采取进一步措施:重写提示词、添加指令或责怪模型。调整提示词很有效,因为只需修改几行就能立即改变输出结果。但在这里这样做毫无作用——并非模型选择了那些文档,而是检索器负责的,而任何提示词都无法让从未进入上下文窗口的段落重新出现。

随着系统日益成熟,这一点愈发重要。过了演示阶段后,糟糕的检索表现会变成一种难以从最终答案中发现的故障。模型可能显得很有把握,上下文也可能看似有些关联,但真正的问题其实出在更上游的环节。更好的做法是区分故障原因:证据缺失、分块不合理、排序不佳或是生成环节出问题。如此一来,调试的顺序就明确了:

先给出错误答案,再检查检索结果,接着进行评估,随后进行修正,最后才考虑生成内容。

一个小型实验展示了早期阶段的重要性。

小到可以人工阅读的语料库

该实验使用了一套关于速率限制、备份、CSV导出、API错误、身份验证、监控及审计日志的虚构文档集。这套文档集并不出色,但这正是有意为之:规模小到足以让人阅读每一条检索到的内容,同时又足够复杂,以至于多个片段可能都与同一个问题相关。每个片段都被赋予了唯一的稳定标识:

chunks = [
    {
        "id": "audit_03",
        "source": "audit-logs",
        "text": (
            "Enterprise audit logs are retained for 365 days "
            "before automatic deletion."
        ),
    },
    {
        "id": "errors_07",
        "source": "api-errors",
        "text": (
            "NX-204 means the requested resource exists but is not "
            "available in the caller's current region."
        ),
    },
    {
        "id": "exports_01",
        "source": "csv-exports",
        "text": (
            "CSV exports run asynchronously and appear in the exports "
            "panel when processing completes."
        ),
    },
    {
        "id": "exports_04",
        "source": "csv-exports",
        "text": (
            "A completed CSV download link remains active for seven days."
        ),
    },
]

该标识看似用于记账,但实际上可以避免日后出现麻烦的简化处理。该套件还包含了12个评估查询,每个查询都标明了真正相关的部分:

eval_cases = [
    {
        "query": "How long are enterprise audit logs kept?",
        "relevant": {"audit_03"},
    },
    {
        "query": "What does error NX-204 mean?",
        "relevant": {"errors_07"},
    },
    {
        "query": "How long is a CSV export link usable?",
        "relevant": {"exports_04"},
    },
]

12个查询作为性能基准实在可笑,但这并非目标所在。真正的目的是拥有足够的测试案例,以便发现修复某个查询时无意中破坏了另一个查询的情况。即便只有极小的评估集,也能打破那种通过手工重新测试相同三个问题所带来的自信感。

刻意简化的基准

该基准采用了TF-IDF、截断SVD以及余弦相似度算法。实际项目中可能会选择现代的嵌入模型,但在此添加此类模型只会引入新的变量,而对解决当前问题并无帮助。检索系统无需过于复杂,只需是那种可以被破坏、被检测、可被量化的工具即可。

import numpy as np

from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import normalize


class LsaRetriever:
    def __init__(self, chunks, dims=16):
        self.chunks = chunks

        self.tfidf = TfidfVectorizer(
            stop_words="english",
            ngram_range=(1, 2),
            sublinear_tf=True,
        )

        term_matrix = self.tfidf.fit_transform(
            chunk["text"] for chunk in chunks
        )

        # The corpus is tiny. SVD doesn't need dimensions it cannot use.
        dims = min(
            dims,
            term_matrix.shape[0] - 1,
            term_matrix.shape[1] - 1,
        )

        if dims < 1:
            raise ValueError("Need more text to build the LSA index.")

        self.svd = TruncatedSVD(
            n_components=dims,
            random_state=0,
        )

        self.index = normalize(
            self.svd.fit_transform(term_matrix)
        )

    def search(self, query, limit=None):
        query_vec = self.tfidf.transform([query])
        query_vec = normalize(self.svd.transform(query_vec))

        similarity = cosine_similarity(
            query_vec,
            self.index,
        )[0]

        ranked = np.argsort(similarity)[::-1]

        if limit is not None:
            ranked = ranked[:limit]

        return [
            (self.chunks[i], float(similarity[i]))
            for i in ranked
        ]

第一轮分块处理是按句将文档分割开。随后提出CSV相关问题后得到了如下结果:

1. 0.879  exports_01
   CSV exports run asynchronously and appear in the exports panel...

2. 0.843  exports_02
   Large exports are split into multiple compressed files.

3. 0.830  exports_03
   Users can cancel an export while it is still queued...

4. 0.772  exports_04
   A completed CSV download link remains active for seven days.

事后看来,0.879的最高分简直可笑,因为看起来系统似乎完全知道自己在做什么。但当你查看具体内容时,它却没有任何回应。这时就应该停止将相似度分数当作可靠的依据。

问题发生在生成之前——检索器选择了看似合理但实际上毫无用处的上下文,而大语言模型只能看到通过top-k筛选后的内容。

打印返回结果

在调整模型、分块大小或检索策略之前,先使用一个简单的工具来打印检索器返回的内容,这样就能避免很多问题。

def show_hits(retriever, query, limit=5):
    print(f"\n{query}\n")

    for position, (chunk, score) in enumerate(
        retriever.search(query, limit),
        start=1,
    ):
        print(
            f"{position:>2}. {score:.3f}  "
            f"{chunk['id']} ({chunk['source']})"
        )
        print(f"    {chunk['text']}\n")

在实验中的所有元素中,这个辅助工具的使用频率最高。一旦内容块显示在屏幕上,错误就会被分类。可能会出现正确的文档,但显示的是错误的部分;或者有两三个内容块几乎是完全相同的。还有些情况下,查询内容与段落在词汇上高度重合,得分很高,但该段落却无法作为有效证据。除非有人仔细阅读文本,否则这些情况都会被简单的数字掩盖。

它还强调了容易被忽视的一点:检索器会做出具有决定性意义的判断。经过这一阶段筛选后的内容将成为模型所认知的现实,这一过程值得比通常更深入的审视。

按ID评估,而非字符串匹配

早期的评估草案中有一个后来被舍弃的快捷方法,那就是在内容块文本中查找特定提示字符串:

if answer_hint in chunk["text"]:
    ...

虽然方便,但并不可靠。片段边界稍有变动、标点符号改变或表述方式不同,都可能导致原本正确的检索结果出现错误。更糟糕的是,这种评估方式依赖于合成答案的措辞,而非真正重要的相关性判断。正因如此,这些片段才被预先赋予了编号,以便评估者能够直接进行核对:

def evaluate_retriever(retriever, cases, k=3):
    recall_scores = []
    reciprocal_ranks = []

    for case in cases:
        hits = retriever.search(case["query"])
        relevant = case["relevant"]

        relevant_positions = [
            position
            for position, (chunk, _) in enumerate(hits, start=1)
            if chunk["id"] in relevant
        ]

        found_in_top_k = sum(
            position <= k
            for position in relevant_positions
        )

        recall_scores.append(
            found_in_top_k / len(relevant)
        )

        reciprocal_ranks.append(
            1 / relevant_positions[0]
            if relevant_positions
            else 0.0
        )

    return {
        f"recall@{k}": float(np.mean(recall_scores)),
        "mrr": float(np.mean(reciprocal_ranks)),
    }

共监测了两项指标。Recall@K用于判断所需证据是否进入上下文窗口,而Mean Reciprocal Rank则用于区分位于第一位的正确片段与位于第八位的被掩埋片段。基准测试结果为:Recall@1为0.750,Recall@3为0.917,MRR为0.854。

Recall@3这个数值令人担忧。由于预设的上下文长度为三个片段,有些问题在生成过程开始之前就已经丢失了得到正确答案所需的信息。此时再调整系统提示语纯属徒劳。

片段长度:部分原因

句子级片段曾是主要嫌疑对象,确实也有一定责任。当采用两句组成的片段时,Recall@1从0.750上升至0.917,Recall@3达到1.000,MRR则升至0.958。三句组成的片段结果几乎相同。整页内容则能获得满分,这种感觉大概只能持续五秒钟左右。

问题在于这些页面的规模实在太小。如果返回整页内容,就几乎不可能错过答案,因此检索效果有所提升,部分原因在于每个单元包含的文本量更大了。但这也有其代价——过大的文本块会占用过多上下文,包含无关句子,从而减少单独证据的存储空间;而那种总是返回整本手册的检索系统,在召回率方面也会表现得极为出色。

因此,得到的教训并非存在某种理想的文本块大小。而是最初的按句子分割方式过于激进,虽然结论较为平淡,但仍然具有实用价值。这也让人对类似的设置产生了怀疑:

chunk_size = 500
chunk_overlap = 50

这些数字本身并没有问题。只是因为有一篇教程恰好使用了它们,所以才不应被用于实际生产环境。分块处理是检索过程的一部分,和其他所有内容一样,它也需要相应的依据。

精确的术语才能承载意义

CSV示例中还隐藏着另一条线索:所有排名靠前的段落都在语义上与“出口”相关,这说明检索系统并没有误解领域含义,只是未能充分区分该领域内的不同段落。技术文档则能改善这一问题,因为精确的字符串承载着大量的意义。像NX-204这样的错误代码并非仅仅在语义上与邻近的代码相似,customer_id、接口地址、版本号或函数名也是如此。

BM25在Recall@1、Recall@3和MRR指标上的得分均为1.000。它不仅超越了语义基线模型,表现也超出了所有人的预期,更令人惊讶的是,在这么小的数据集上依然毫无瑕疵。虽然“嵌入模型已死”这样的标题很吸引人,但数据并不支持这一观点。数据表明,对于精确的术语匹配而言,这个评估集具有极大的价值;而如果使用大量改写后的问题,结果可能会不同。由此可见,词汇匹配在该语料库中的重要性,这也是构建评估集的初衷。

结合排名融合的混合检索

目前放弃语义检索似乎为时过早。这两种方法的失效方式不同,因此采用互反排名融合技术将它们的排名结果结合起来。有一个实现细节很重要:每个检索器仅能提供有限数量的候选项。若对庞大的语料库进行两次排序,再在 Python 中将所有结果合并,这种设计实在难以复制。

from collections import defaultdict


def fuse_rankings(vector_hits, bm25_hits, rrf_k=60):
    fused = defaultdict(float)
    chunks_by_id = {}

    for hits in (vector_hits, bm25_hits):
        for rank, (chunk, _) in enumerate(hits, start=1):
            chunk_id = chunk["id"]
            chunks_by_id[chunk_id] = chunk
            fused[chunk_id] += 1 / (rrf_k + rank)

    ranked_ids = sorted(
        fused,
        key=fused.get,
        reverse=True,
    )

    return [
        (chunks_by_id[chunk_id], fused[chunk_id])
        for chunk_id in ranked_ids
    ]

其包装非常简单:

def hybrid_search(query, lsa, bm25, candidate_k=20):
    vector_hits = lsa.search(query, limit=candidate_k)
    bm25_hits = bm25.search(query, limit=candidate_k)

    return fuse_rankings(vector_hits, bm25_hits)

混合检索方法将MRR提升至0.958,并解决了原有的Recall@3缺失问题。但它仍然输给了BM25。由于那样做会违背实验初衷,因此并未对实验进行过多调整,直到更复杂的架构取得优势为止。更多的组件并不必然意味着更高的相关性。

一种廉价的重新排序器

检索需要广泛搜索,因此采用低成本方案是合理的。一旦有10到20个看似可行的候选项,再进行更有针对性的相关性检测就变得可行了。在本次本地实验中,这种检测工具就是字符n-gram评分器。它只是一种廉价的实验用重新排序器,并非可用于实际推荐的方案。在真实系统中,应考虑使用经过训练的交叉编码器。

def cheap_local_rerank(query, candidates, limit=5):
    """
    Good enough for this experiment.
    I'd use a learned reranker for a real deployment.
    """
    candidate_text = [
        chunk["text"]
        for chunk, _ in candidates
    ]

    tfidf = TfidfVectorizer(
        analyzer="char_wb",
        ngram_range=(3, 5),
        min_df=1,
    )

    matrix = tfidf.fit_transform(
        [query, *candidate_text]
    )

    relevance = cosine_similarity(
        matrix[0],
        matrix[1:],
    )[0]

    reranked = sorted(
        zip(candidates, relevance),
        key=lambda row: row[1],
        reverse=True,
    )

    return [
        (chunk, float(score))
        for ((chunk, _), score) in reranked[:limit]
    ]

该流程最终形成简单的两阶段结构。

其流程为首先获取大量的候选集,然后再对它们进行重新排序,只让最优质的片段传递给大语言模型。重新排序后,小型测试集在Recall@1、Recall@3和MRR指标上的得分均为1.000,堪称完美。不过这一数据并不适合用在宣称检索问题已解决的演示文稿中,因为12个查询虽然有助于开发,但并不能充分证明其通用可靠性。尽管如此,这项实验仍达到了预期目的。以下是各阶段的对比情况:

如果在该实验之前就草拟好了方案,每行的表现都会呈规律性提升。但实际情况并非如此。最简单的选项BM25与最终方案的成绩相当。混合搜索在初始的语义基准上有所改进,但未能夺得第一名。更大的文本块虽然解决了一个问题,却又引发了关于上下文资源分配的新问题。这种不规律性反而让结果更具可信度,同时也揭示了这项任务的一个关键点:精确的术语使用至关重要。这远比赢得架构流行度竞赛更有价值。

最初的问题是:

How long is a CSV export link usable?

在更改之前,排名情况如下:

1. CSV exports run asynchronously...
2. Large exports are split...
3. Users can cancel an export...
4. A completed CSV download link remains active for seven days.

之后:

1. A completed CSV download link remains active for seven days.
2. Users can cancel an export while it is still queued...
3. CSV exports run asynchronously...

原始文档和问题都没有变化,因此也不需要更智能的大型语言模型。变化的是证据。

处理错误RAG回答的调试步骤

这种差异决定了当RAG响应出错时应如何应对。如果所需证据从未进入上下文,那就需要检查检索环节;如果证据明明存在,模型仍然输出无意义内容,这时才需要深入分析生成过程、提示词设计、上下文顺序以及模型行为。还存在第三种故障情况:正确答案虽然进入了上下文,但却被大量关联度较低的文本掩盖。这是排序或上下文构建问题,而将其归类为“幻觉”则会掩盖这一问题。

实际流程首先会检查检索到的证据,然后判断故障出在检索环节还是生成环节。这个决策点至关重要。当Recall@3指标较差时,花费一小时用六种越来越严格的方式重新表述“仅使用提供的上下文来作答”是毫无意义的,因为上下文本身就已经有误。

提示词调整仍然显得更快。你稍作修改、重新运行,就能立即看到新的答案。而检索工作则更为缓慢且效果不佳:需要逐段阅读、标注相关性、检查排序结果、处理段落边界问题,有时甚至会发现传统的词汇检索器比复杂的系统表现更好。不过,前者所传授的方法更为可靠。

最有帮助的问题并非大语言模型为何会给出错误答案,而是它是否获取到了所需的信息。这听起来似乎过于显而易见而不值得关注,但实际上却常常被忽视。

相关阅读

  • 针对过时数据块、偏差及噪声上下文的RAG检索审计 — 一款用于RAG检索的Python审计脚本:包含黄金查询、余弦相关性过滤、Kolmogorov-Smirnov偏差检测及上下文效率分析功能,同时介绍如何解读其生成的报告。
  • RAG系统虽能正常运行,仍可能将错误证据排在首位 — 密集型与混合型模型在5%召回率指标下均达到完美表现;多查询评估法则揭示了单次查询时RRF评分会下降的问题。为何逐查询检查比依赖平均值更可靠。