Towards Data Science

An LLM as arbiter in RAG retrieval: picking the right candidate with reasons

8.5内容质量

TL;DR · AI 摘要

使用LLM作为仲裁者在RAG检索中选择最佳候选,提供理由并输出可审计的JSON结果。

核心要点

  • 使用LLM仲裁者可以对RAG检索结果进行排序并提供理由,提升可解释性。
  • 仲裁者在单次LLM调用中处理多个候选,输出结构化JSON。
  • 该方法适用于企业级文档智能系统,提高检索结果的可靠性。

结构提纲

按章节快速跳转。

  1. 本文是企业文档智能系列的一部分,介绍如何使用LLM作为仲裁者在RAG检索中选择最佳候选。

  2. RAG系统由四个模块组成:解析、问题解析、检索和生成,本文聚焦于检索模块的仲裁部分。

  3. LLM仲裁者在单次调用中处理多个候选,根据关键词匹配、嵌入匹配和上下文信息进行排序并提供理由。

  4. 仲裁者决定最终的检索结果,输出结构化的JSON,便于审计和后续处理。

  5. 仲裁者接收候选列表,根据多种因素进行排序,并输出最终的检索结果。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • LLM仲裁者在RAG检索中的应用
    • RAG系统构建
      • 解析
      • 问题解析
      • 检索
      • 生成
    • 仲裁者的作用
      • 处理候选列表
      • 排序并提供理由
      • 输出结构化JSON

金句 / Highlights

值得收藏与分享的关键句。

  • The arbiter sees keyword hits, embedding hits, and the section each candidate sits in, all in one structured brief, and writes a per-candidate verdict with a reason.

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • The output is one typed JSON an auditor can defend.

    第 2 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • This article is about that single LLM call. The arbiter. It sees keyword hits, embedding hits, and the section each candidate sits in, all in one structured brief, and writes a per-candidate verdict w

    第 4 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#RAG#LLM#企业文档智能#检索#JSON
打开原文

在 RAG 检索中使用 LLM 作为仲裁者:通过理由选择合适的候选者 | Towards Data Science

大型语言模型

在 RAG 检索中使用 LLM 作为仲裁者:通过理由选择合适的候选者

企业文档智能 [第1卷 #7C] – 一次 LLM 调用通过理由对候选者进行排序。输出是一个可被审计者辩护的类型对象

angela shi

2026年6月25日

31分钟阅读

分享

照片由 Fatih Güney 提供,来源:Pexels。

本文是企业文档智能系列的一部分,该系列通过四个模块构建企业级 RAG 系统:解析、问题解析、检索和生成。本文完成了该模块的三部分。前一部分,文章7B(锚点检测)生成了排序后的候选者;这一部分则进行仲裁:一次 LLM 调用通过理由对它们进行排序,输出是一个可被审计者辩护的类型 JSON。

本文在系列中的位置:第7篇文章(检索),仲裁部分,位于第二部分(四个模块)中 – 图片由作者提供

检索是在 line_df 和 toc_df 上进行过滤,锚点与上下文之间的对比:这是来自文章7A(检索作为过滤)的心理模型。锚点本身来自一个三阶段的检测流程(文章7B,锚点检测):关键词与嵌入向量并行处理,聚合到一个结构单元,最后进行一次 LLM 调用。本文讨论的是最后那个调用。

本文讨论的是那个单一的 LLM 调用。仲裁者。它在一个结构化的摘要中看到关键词匹配、嵌入匹配以及每个候选者所在的章节,并为每个候选者写出一个带有理由的判断。一句话概括模式:探测器提出建议,仲裁者做出决定。在一次调用中。

本文还涵盖了仲裁者周围的内容:确定性调度器,它根据问题选择运行哪些探测器,“未找到”路径(一个可靠的系统必须能够拒绝),以及统一的 JSON 合同(RetrievalResult),检索将这个合同传递给生成。

在本文中,我们以单个文档为例进行操作,该文档是《Attention Is All You Need》(Vaswani 等人,2017年,15页;arXiv 非独占分发许可,声明在 arXiv 摘要页面上)。该文档在 PDF 的大纲中包含一个干净的原生目录(22个条目,3层深),其内容对任何接触 RAG 的工程师来说都是熟悉的领域:编码器、解码器、注意力、查询、键、值。这使得关注点集中在检索方法上,而不是解析特定领域的语料库。本文还假设文档自带目录;从原始文本中恢复目录留待后续工作。

本文中的每种方法都从 line_df 和 toc_df 开始 – 图片由作者提供

1. LLM 仲裁者:最后的单次 LLM 调用

这是文章7B(锚点检测)在阶段3放置的 LLM 调用。那篇文章从每个探测器生成了候选者;这一部分是它们接下来的处理过程。候选者是一个探测器返回的单个段落,包括其锚点(匹配的位置)、聚合单元(章节、页面或块)以及周围上下文的片段。仲裁者在一个调用中看到所有这些候选者,根据理由对它们进行排序,并输出最终列表。

本部分涵盖的三个要点:

  • 为什么融合评分(RRF 及其相关方法)会丢失探测器已经提供的信号。
  • 应该提供什么样的结构化摘要给仲裁者,以便它能根据理由进行排序。
  • 应该记录每个候选者的哪些信息,以便任何审计者都能重建决策。

一句话概括模式:探测器提出建议,仲裁者做出决定,一次调用完成。

1.1 分数融合是错误的直觉

当多种方法返回候选结果时,第一反应是将它们的分数进行融合。这些方法返回的分数处于不同的尺度上:余弦相似度、无限制的 BM25 分数、以及共现的整数计数。将它们相加是没有意义的。将每种方法的分数进行归一化处理也无济于事,因为 0.9 的余弦相似度和 0.9 的归一化 BM25 分数并不表示同一个候选对象具有相同的意义。

经典的解决方案是倒数排名融合(RRF)。它通过忽略分数并使用排名来规避校准问题:

RRF:基于排名的融合方法,忽略原始分数;k = 60 – 图片由作者提供

通常使用 k=20。多个方法对候选对象进行排名时,它们的贡献会累积;只被一种方法看到的候选对象则只会得到一个较小的项。RRF 是许多向量数据库(如 Pinecone、Weaviate、Elastic)的默认方法,并且在大多数情况下无需调整即可正常工作。

但 RRF 会忽略实际信号。一个方法为何将某个候选对象排名靠前是有意义的。TOC 将其排名靠前是因为有一个章节标题匹配。关键词将其排名靠前是因为“premium”和“€”出现在同一行。嵌入向量将其排名靠前是因为页面附近的内容在向量空间中大致接近。RRF 将所有这些信息压缩为一个排名索引。方法之间的一致性变成一个数字,而这种一致性的原因却消失了。

这正是专家从屏幕上读取的内容:章节标题、匹配的关键词、匹配周围的行。我们认为,如果以结构化的方式提供相同的信息,一个小型的 LLM 调用将比任何分数融合方法都能更好地进行排名。

当周围工具默认使用 RRF 时,我们仍然会记录 RRF;当候选对象池太大,无法放入一个 LLM 调用中时,我们也会使用 RRF 作为廉价的预过滤器(通过 RRF 选择前 200 名,然后对剩下的对象进行 LLM 处理)。但排名决策应属于 LLM,而不是某个分数公式。

1.2 为 LLM 提供结构化的简报

LLM 是进行排名的层级。它的输入不是“这里有五个段落,请选择最好的一个”。它的输入是一个结构化的简报,每一行对应一个候选对象,列出每种检索方法找到的内容:

  • candidate_id:稳定的引用(页面 + 行范围,或章节 + 行偏移)。
  • methods:显示此候选对象的检索方法(TOC、关键词、嵌入向量)。
  • section:候选对象所在的 TOC 章节,从 toc_df 中提取。
  • matched_keywords:从解析后的问题中匹配到的关键词。
  • snippet:周围上下文的三到五行,从 line_df 中提取。

简报就是 LLM 所读取的内容。它看起来就像专家在屏幕上看到的内容:顶部是章节标题,正文是匹配的关键词,匹配周围是相关行。这比余弦相似度的排名列表要接近得多。LLM 对候选对象进行排名,并为每个保留的候选对象写下一个理由,该理由直接进入审计跟踪。每个候选对象将被分配以下四个角色之一:

  • primary:承载答案。
  • supporting:提供使答案有意义的上下文。
  • tangential:相关但优先级较低。
  • discarded:LLM 排除的候选对象,记录原因以供审计。
python
class CandidateBrief(BaseModel):
    candidate_id: str
    methods: list[str]
    section: str
    matched_keywords: list[str]
    snippet: str

class CandidateRanking(BaseModel):
    candidate_id: str
    role: Literal["primary", "supporting", "tangential", "discarded"]
    reason: str
python
def llm_rank(
    question: str,
    briefs: list[CandidateBrief],
    client,
) -> list[CandidateRanking]:
    """读取结构化的摘要,为每个候选人返回一个排名。"""
    ...

一个最小的可运行仲裁器。一次LLM调用,一次性处理整个候选人列表,结构化输出返回。

python
def llm_rank(question: str, briefs: list[CandidateBrief], client) -> list[CandidateRanking]:
    """将结构化的摘要交给LLM,为每个候选人分配一个角色和一个原因。"""
    briefs_text = "\\n".join(
        f"[{b.candidate_id}] section={b.section!r}, methods={b.methods}, "
        f"matched={b.matched_keywords}, snippet={b.snippet!r}"
        for b in briefs
    )
    prompt = (
        f"Question: {question}\\n\\nCandidates:\\n{briefs_text}\\n\\n"
        "For each candidate, assign a role (primary, supporting, tangential, "
        "discarded) and a one-line reason. Use the candidate_id verbatim."
    )
    return client.responses.parse(
        model=model_chat,
        input=prompt,
        text_format=ArbiterOutput,
    ).output_parsed.rankings

为什么这比分数融合更胜一筹:

  • LLM能看到每个方法为何对候选人进行了排名。高余弦匹配但没有关键词重叠可能只是主题噪声。目录加关键词一致是真正的结构信号。RRF将两者都转换成相同的排名数字。
  • LLM可以标记候选人,不仅仅是保留或删除:主要、支持、相关、删除。当答案有主要部分和支持背景时非常有用。
  • LLM可以标记矛盾:两个段落对同一问题有不同的说法。常见于有修订的合同和监管文件中。
  • 理由是纯文本,直接进入审计跟踪。不需要解释给合规部门的“rrf_score = 0.0327”这样的行。

成本:一次LLM调用(对于前10名候选人池大约需要一秒钟)。比对整个文档运行嵌入更便宜。比生产中错误答案的成本低得多。

一篇正在运行的论文上的具体摘要。问题:“论文使用了哪种位置编码?”目录匹配(直接命中3.5位置编码)加上整个文档的关键词产生了四个候选人。每个候选人变成摘要中的一行。

章节标题、匹配的关键词、片段行:与专家阅读的信息相同 – 图片由作者提供

LLM阅读这四个摘要和问题,返回:

python
{
  "rankings": [
    {
      "candidate_id": "page_5_sinusoidal",
      "role": "primary",
      "reason": "Defines the sinusoidal positional encoding used in the paper"
    },
    {
      "candidate_id": "page_6_learned",
      "role": "primary",
      "reason": "States the alternative (learned embeddings) the paper compared"
    },
    {
      "candidate_id": "page_2_intro",
      "role": "discarded",
      "reason": "Mentions the motivation for positional encoding, but no options"
    },
    {
      "candidate_id": "page_3_encoder",
      "role": "discarded",
      "reason": "Single mention in a different context, off-topic"
    }
  ]
}

四个候选人中保留了两个。被删除的两个在任何分数融合方案下都可能被包含进来:它们有合法的关键词命中。但片段看起来像上下文,而不是答案。LLM根据片段做出了判断,并写下了进入审计跟踪的原因。

对相同的四个摘要进行实时调用。

实时LLM调用:每个候选人一个角色和一个原因,主要或相关 – 图片由作者提供

1.3 冲突和审计跟踪

当方法之间出现分歧时,LLM 必须做出选择。以下三条经验法则可以帮助决策:

  • 信任目录(TOC)在标题完全匹配时的判断。作者写下了这个标题。“3.5 位置编码”与运行问题中的关键词直接匹配。没有任何统计方法应该推翻这一点。
  • 信任具有强信号的共现关键词。一条包含主关键词和次关键词(位置 + 正弦波,或位置 + 学习)的行几乎肯定相关,即使它位于 TOC 精确范围之外。
  • 对仅由嵌入匹配的结果保持怀疑:一个仅通过嵌入被发现的候选内容,没有 TOC 或关键词支持,需要仔细检查。有时可能是关键词方法遗漏的真实同义词。但更多时候是主题噪声。LLM 在阅读摘要后,通常能判断是哪一种。

结构化的摘要使 LLM 能够一致地应用这些规则。分数融合将每种方法对候选内容的排名压缩为一个单一的排名索引,并将规则一同丢弃。

每个候选内容都必须有充分的依据。为什么向用户展示这段内容?答案不能是“嵌入相似度为 0.78”。它必须是一个清晰的链条:哪些方法发现了这个候选内容,向 LLM 展示了什么样的摘要,LLM 被分配了什么角色,LLM 写下了什么理由。每个候选内容都从检索到生成都携带了这条轨迹。

python
class CandidateProvenance(BaseModel):
    candidate_id: str
    methods_that_found_it: list[str]
    rank_per_method: dict[str, int]
    structural_context: CandidateBrief
    llm_role: Literal["primary", "supporting",
                      "tangential", "discarded"]
    llm_reason: str

class AuditedRetrievalResult(BaseModel):
    candidates: list[dict]
    provenances: list[CandidateProvenance]
    methods_run: list[str]
    methods_skipped: list[str]
    skipped_reasons: dict[str, str]

对于每个到达用户处的候选内容,系统可以回答:哪些方法为它投票了,它在每种方法中的排名是多少,LLM 看到了什么,LLM 分配了什么角色以及为什么,是否有方法被跳过以及为什么。

以上示例中第 5 页候选内容的一个具体条目如下:

python
provenances = [
    CandidateProvenance(
        candidate_id="page_5_sinusoidal",
        methods_that_found_it=["toc", "keywords"],
        rank_per_method={"toc": 1, "keywords": 1},
        structural_context=CandidateBrief(
            candidate_id="page_5_sinusoidal",
            methods=["toc", "keywords"],
            section="3.5 Positional Encoding",
            matched_keywords=["positional", "encoding", "sinusoidal"],
            snippet="We use sine and cosine functions of different frequencies...",
        ),
        llm_role="primary",
        llm_reason="Defines the sinusoidal positional encoding used in the paper",
    ),
    # ... 其他保留候选内容的结构相同
]
methods_skipped = ["embeddings"]
skipped_reasons = {
    "embeddings": "TOC and keywords returned strong reinforcing signals"
}

这不仅仅是为了调试。在合规、法律和审计的背景下,这条轨迹是证明系统输出的文档。没有它,检索管道就是一个黑箱,而黑箱无法通过监管审查。

2. 选择方法

2.1 为什么嵌入方法排在最后

大多数 RAG 教程、框架和会议演讲都将基于嵌入的检索作为系统的基础。隐含的假设是嵌入是默认方法;唯一的问题是使用哪种嵌入模型。

在遍历网格后,这一系列的位置变得具体:嵌入在两个表格中都扮演着支持性角色(在 toc_df 上进行标题嵌入匹配,在 line_df 上进行块嵌入匹配),但这两个都不是大多数企业文档的推荐默认选项。这并不是对嵌入的否定,而是基于什么有效而进行的重新优先排序。

原因一,嵌入会稀释高信号标记。将“保险条款第 L131-1 条是否适用?”进行嵌入,向量是“文章”、“保险”、“条款”、“适用”、“L131-1”这些词的平均。标记“L131-1”是整个查询,但它只是七个信号中的一个。正确段落的向量可能并不是最接近的匹配。内容关键词匹配可以立即找到它。

我们在最小的 RAG 管道中看到过这种现象,尤其是在著名的 epsilon 标签平滑失败案例中:包含答案的页面甚至没有进入余弦相似度前 3 名。胜出的页面包含更多与问题视觉风格相似的内容,而不是其具体信息内容。

原因二,嵌入无法区分相关但不同的概念。“保费”和“免赔额”的向量是接近的。两者都是保险合同中的金额,也出现在相似的上下文中。对“保费”的查询使用余弦相似度,会返回提及“免赔额”的段落和提及“保费”的段落。如果基于嵌入的检索主要返回免赔额段落,下游的 LLM 就无法进行区分。

否定、时间引用以及任何依赖于小词变化的语义区别都存在同样的问题。嵌入模糊了这些区别;这既是它们的优势,也是它们的弱点。

原因三,嵌入对文档结构没有理解。向量数据库是一组扁平的块集合。它没有“这个块属于定义部分”或“这个块属于附录”的概念。当文档被分块时,所有这些结构都被丢弃了。

对于像“保修条款对洪水损坏说了什么?”这样的问题,嵌入搜索会返回提及“保修”和“洪水”的块,可能来自定义或排除部分,而这都不是用户想要的。目录推理可以在一步中选择保修部分。

在运行的论文中具体体现:用户问“图 2 显示了什么?”这个问题被一个高信号标记(图 2)主导;其余部分都是填充内容。嵌入会平均所有内容;正则表达式可以立即锁定确切的标记。

嵌入稀释了信号标记;正则表达式可以一次性提取所有精确匹配的行 – 图片由作者提供

嵌入根据查询措辞的视觉相似性对页面进行排序,可能根本不会将实际提到图 2 的内容放在前五名中。正则表达式可以确定地找到包含确切标记的行,位于正确的页面上。七个信号中的一个,在平均中丢失;通过正确的工具提取出一个信号。

2.2 嵌入何时胜出

嵌入在以下三种情况下表现突出:

  • 词汇不匹配但重写有帮助的情况。“提前退出”在重写后与“提前终止条款”嵌入接近。标题关键词匹配和内容关键词匹配会错过这一点;块嵌入匹配可以捕捉到这一点。(目录推理也可以捕捉到这一点,但原因不同:LLM 理解“提前退出”通过暗示与“终止”相关,而不是相似性。)
  • 概念性或模糊性问题:“责任上限是否合理?”:没有特定的关键词可供搜索。嵌入可以揭示具有正确概念形态的段落。
  • 没有目录且没有专业术语的文档。备忘录、电子邮件、博客文章。没有结构可供导航;没有专家词典可供应用。

在这三种情况下,嵌入并不是单独使用,而是与关键词和目录方法(混合嵌入组合)结合使用,以弥补其缺乏的结构意识。

在实际应用中:在某个生产系统中,我们通过逐一禁用每种检索方法,以每种失败模式进行评估,测量了每种检索方法的贡献:仅使用嵌入的基线:71% 的准确率。在适用的情况下添加基于目录的检索:84%。添加使用专家词典的共现关键词检索:91%。添加调度器根据问题选择方法:94%。仅使用嵌入与所有方法在确定性调度下的23个百分点差距,就是更优检索带来的提升。我们尝试的任何嵌入模型升级都无法接近这一差距。

HyDE 是这一原则的一个特例。HyDE 会为问题生成一个假设答案,将其嵌入,然后与文档片段进行余弦相似度计算。它之所以有效:假设答案包含了问题中缺失的文档领域术语。“如何提前退出合同?”在大语言模型的草稿中会变成“提前终止、通知期、退出费用、书面通知……”,这与嵌入空间中的真实段落匹配。

在这个框架中,相同的逻辑在问题解析阶段更早地被捕捉,并被编码到专家关键词词典中。HyDE 是“基于嵌入的关键词扩展,按查询隐式完成”;而这一系列方法则更倾向于“显式关键词扩展,一次性完成并重复使用”。收益相同,但有三个操作上的优势:

  • 关键词集可审计:专家可以看到匹配的内容及原因
  • 成本只需支付一次,而不是每次查询都支付
  • 它构建了一个永久资产(词典),而不是每次生成一个一次性假设

在没有领域专家且没有审计要求的开放领域消费场景中,HyDE 仍保持微小优势;而在本系列方法所针对的企业环境中,显式扩展在实践中更优。问题解析模块详细分解了 HyDE 的机制,并解释了为什么在文档词汇有限的情况下,关键词和同义词扩展方法占主导地位。

2.3 决策树

调度器如何决定为给定问题启用哪些单元格的组合?

code
def choose_methods(intent, keywords, doc_has_toc,
                   has_exact_codes=False,
                   vocabulary_diverges=False):
    methods = []
    if doc_has_toc and intent in ("qa", "summarization"):
        methods.append("toc")
    if keywords:
        methods.append("co_occurrence")
    if has_exact_codes:
        methods.append("bm25")
    if not methods or vocabulary_diverges:
        methods.append("embedding")
    return methods
  • 如果文档有清晰的目录,运行标题关键词匹配(始终执行)和标题嵌入匹配(可选)作为目录侧的检测器。仲裁者将在其单次调用中使用目录来推理细微的标题匹配。
  • 在 line_df 上,使用解析后问题的关键词和共现增强,运行内容关键词匹配。这是主要的检测器;其匹配结果会直接提供给仲裁者。
  • 当预期出现词汇不匹配或问题属于概念性时,以并行方式在 line_df 上添加嵌入。可选,当关键字信号已经干净时可以跳过。
  • LLM 仲裁者(第 1 节)一次看到所有内容,根据理由进行排序。这是将检测器命中结果转换为最终列表的单个 LLM 调用。
  • 对于非常大的文档,当候选池太大,无法在一次仲裁者调用中容纳时,使用“先推理后匹配”作为预筛选技巧:在 toc_df 上进行一次额外的 LLM 调用,选择相关部分,然后在内容中运行关键字匹配,最后仲裁者对幸存者进行排序。总共两次 LLM 调用,仅在规模要求时使用。

在生产环境中,大多数问题会激活关键字(可选地)加上嵌入检测器,并最终由仲裁者处理。下面的策略代码使用了较旧的“toc_reasoning” / “reason_then_match”标签以保持向后兼容性;从概念上讲,它们分别对应于“仲裁者进行推理”和“在仲裁者之前进行筛选”。后续的集成流程将围绕调度器进行协调。

常见错误:为所有问题硬编码相同的检索策略。一个总是运行嵌入检索的流程,即使文档有干净的目录且问题有特定关键字,也会产生比简单方法更嘈杂的候选结果,这会做多余的工作。调度器应该做出选择;如果它总是选择相同的方式,那么你已经消除了选择。

python
def choose_retrieval_strategy(
    intent: str,
    keywords: list[str],
    doc_has_toc: bool,
    has_exact_codes: bool = False,
    vocabulary_diverges: bool = False,
) -> list[str]:
    strategy: list[str] = []
    if doc_has_toc and intent in ("qa", "summarization"):
        strategy.append("toc_reasoning")
        strategy.append("title_keyword_match")
    if keywords:
        if "toc_reasoning" in strategy:
            strategy.append("reason_then_match")
        else:
            strategy.append("content_keyword_match")
    if has_exact_codes:
        strategy.append("bm25")
    if not strategy or vocabulary_diverges:
        strategy.append("chunk_embedding_match" if not doc_has_toc else "hybrid_embedding")
    return strategy
# 四个真实读者问题,一个研究论文的人可能会问,由策略选择器调度。
doc_has_toc = len(toc_df) > 3
questions = [
    {"q": "How does multi-head attention work?",
     "intent": "qa", "keywords": ["multi-head", "attention"], "exact": False, "diverges": False},
    {"q": "What does Figure 2 show?",
     "intent": "qa", "keywords": ["Figure 2"], "exact": True, "diverges": False},
    {"q": "Why does this model train faster than RNNs?",
     "intent": "qa", "keywords": [], "exact": False, "diverges": True},
    {"q": "Summarize the Training section.",
     "intent": "summarization", "keywords": ["Training"], "exact": False, "diverges": False},
]
for q in questions:
    s = choose_retrieval_strategy(
        q["intent"], q["keywords"], doc_has_toc,
        has_exact_codes=q["exact"], vocabulary_diverges=q["diverges"],
    )
    print(f"{q['q']}\\n  strategy: {s}\\n")

在四个不同形状的示例问题上运行:一个词汇丰富的问答、一个指向特定图表的问题、一个没有关键字的概念性问题,以及一个摘要请求:

同一个调度器,四个问题,四种不同的策略 – 图片由作者提供

3. 可靠地表示“未找到”

一个可靠的系统必须能够说“不”。这听起来似乎简单,但实际上却更加困难,因为大语言模型(LLMs)是被训练用来生成响应,而不是拒绝生成响应。

架构必须在每个阶段都支持“未找到”这一情况:

  • 元数据过滤返回零个文档。不执行任何检索。系统告知用户没有文档匹配该范围。
  • 目录(TOC)匹配没有找到相关章节。流程将传递到下一个方法。
  • 所有检索方法返回空或置信度低的候选结果。流程报告“在可用文档中未找到答案”,并解释所搜索的内容。
  • 候选结果存在,但大语言模型在生成过程中无法提取答案。这种情况在生成阶段处理,但检索阶段必须传递足够的元数据,以确保生成过程保持保守。

3.1 为什么关键词可以证明缺失,而嵌入向量不能

检索方法在处理缺失时存在一种深层次的不对称性。嵌入向量检索总是返回一个top-k列表,并带有连续的相似性评分。关于保费的页面对关于排除条款的问题返回0.78的相似度;关于排除条款的页面对同一问题返回0.81的相似度。你如何在“找到”和“未找到”之间划出界限?嵌入向量空间中没有任何东西会说“这不是正确的答案”,它只会说“这比另一个更近或更远”。你可以设定一个阈值,但这个阈值是任意的;在不同的文档或不同的问题上,这个阈值可能会失效。

关键词检索则恰恰相反。如果你的“排除”专家词典涵盖了主题在语料库中可能出现的所有形式(如“exclusion”、“exclu de”、“non couvert”、“hors champ”、“ne couvre pas”),并且文档中没有包含这些关键词,那么你就可以做出有说服力的结论:这个主题不在该文档中。同样适用于金额、日期和代码。当正则表达式“€”没有匹配到任何内容时,就没有任何内容可以匹配。缺失本身就是证据,而不是不确定性。

这正是业务用户一直以来使用Ctrl+F所做的事情。他们搜索他们预期的术语,当没有结果出现时,他们得出结论:文档中没有涵盖该内容。这个结论成立,因为他们知道要查找哪些术语。三十年的专业文档工作都是基于这种方法进行的。一个检索流程如果编码了相同的专家词汇,也将继承这一特性:在全面的关键词集合中没有匹配项,将返回“未找到”并附带一个明确且可审计的原因。

在企业环境中(如法律、合规、金融),这之间的区别在于“我不确定”(模糊)和“这些特定术语在文档中都没有出现,如果答案在这里,它们应该会出现”(可辩护的搜索)。前者对合规官员毫无用处,而后者才是答案。

这个论点也是为什么在问题解析时构建专家词典值得投资的原因。一个能够全面覆盖主题的词典,不仅仅是用来在答案存在时找到答案的方法;它也是让系统在答案不存在时能够证明答案确实不存在的机制。

code
class RetrievalResult(BaseModel):
    candidates: list[dict]
    methods_used: list[str]
    not_found: bool
    not_found_reason: str | None = None
    confidence: float

当not_found为True时,生成阶段将知道生成“答案不在可用文档中”并附带原因,而不是猜测。生成模块会回到这一点。

code
class RetrievalResult(BaseModel):
    candidates: list[dict]
    methods_used: list[str]
    not_found: bool
    not_found_reason: str | None = None
    confidence: float
# 一个读者的问题,其答案不在本文中:GDPR合规性与Transformer架构无关。以下关键词具有高度区分性——在论文中验证为0次命中。
absent_question = "本文对GDPR合规性和个人数据保护说了什么?"
absent_keywords = ["gdpr", "personal data", "compliance", "data protection officer"]
# 关键词方面:全面搜索返回0次命中 -> 可辩护的“未找到”。
title_hits = match_titles(toc_df, absent_keywords)
content_hits = line_df[line_df["text"].str.lower().apply(
    lambda t: any(kw in t for kw in absent_keywords)
)]
keyword_envelope = RetrievalResult(
    candidates=[],
    methods_used=["title_keyword_match", "content_keyword_match"],
    not_found=title_hits.empty and content_hits.empty,
    not_found_reason="no_keyword_match" if title_hits.empty and content_hits.empty else None,
    confidence=0.0 if title_hits.empty and content_hits.empty else 0.8,
)
print("在缺失主题上的关键词结果:")
print(f"  not_found = {keyword_envelope.not_found}, reason = {keyword_envelope.not_found_reason}")
# 嵌入方面:仍然返回非零相似度的top-k。没有阈值告诉我们“不在文档中”。
emb_hits, _ = retrieve_pages_by_similarity(page_df, line_df, absent_question, top_k=3, client=client)
print(f"\\n在相同缺失主题上的嵌入结果:")
for _, r in emb_hits.iterrows():
    print(f"  page {int(r['page_num']):>2}  sim={r['similarity']:.3f}  (将作为候选返回)")
print("  -> 嵌入无法说明‘未找到’:它总是返回具有连续得分的top-k。")

在缺失主题上,这两种方法之间的对比使该模式变得有价值。向注意力论文询问GDPR合规性(一个它从未涉及的主题),关键词检索可以清晰地标记not_found=True,而嵌入检索仍然返回三个具有非零余弦得分的页面:

相同缺失主题:关键词表示否定,嵌入仍然将三个页面排名高于零 – 图片由作者提供

3.2 没有答案胜过错误答案

在消费者聊天机器人中,错误答案令人讨厌。在企业文档问答系统中,错误答案代价高昂。

一些例子:

  • 合规官询问合同是否有竞业禁止条款。系统根据错误的段落返回“是的,持续3年”。合规官在会议中依赖这个信息。条款实际上为1年。决策是基于错误信息做出的。
  • 财务团队询问供应商合同中的责任上限。系统返回了供应商的保险上限(一个不同的数字,在不同的部分中),因为检索混淆了这两个内容。谈判基于错误的假设进行。错误在一个月后被发现。
  • 法律团队询问合同是否涵盖数据泄露。系统返回了一段关于一般损害的段落,其中仅间接提到了数据。团队假设存在覆盖。公司面临未投保的事件。

在所有这些情况下,没有答案比错误答案更好。没有答案会促使用户深入挖掘、询问同事、阅读文档。带有自信语气的错误答案会中断这一过程,并产生虚假的安全感。

一个知道如何说“我没有找到任何内容”的检索管道,比一个虽然召回率更高但缺乏谦逊的管道更有价值。这不是一个技术偏好,而是一个业务需求。

4. 检索输出:一个生成阶段就绪的 JSON

本文中提到的所有内容都只有一个目的:生成正确的候选内容,供生成阶段使用。方法、LLM 裁决者、调度器、“未找到”处理等,所有内容都汇聚成一个单一的类型对象。

本节明确命名了这个对象。检索为每对(文档,问题)生成一个 RetrievalResult。生成模块读取它,生成答案,无需再向检索请求任何内容。如果生成需要更多上下文,后续的编排循环可以扩展相同的 RetrievalResult,但合同的结构保持不变。

4.1 一个候选,两个范围

根据文章 7A 中的锚点和上下文框架(检索作为过滤),每个候选都包含这两个部分。锚点是匹配的位置:特定页面上的一个狭窄的行范围,以及它所属的章节。上下文是检索决定传递给生成的扩展窗口:一个段落、一个章节或 N 行的窗口,同样以一个带有页面边界的行范围表示。

为什么需要两者?生成需要锚点来进行引用(在 PDF 上突出显示匹配的行,将答案链接回精确的行),需要上下文进行支撑(阅读答案所在的段落或章节)。只存储上下文会丢失精确的引用。只存储锚点会丢失使答案可解释的周围证据。

约定:上下文的范围始终包含锚点的范围。上下文的宽度来自问题解析,由解析器标记的两个正交轴驱动:answer_shape(基数)和 answer_type(值),以及 StructuralHints 上的 answer_context(需要读取多少上下文文本)。一个带有 answer_context=line 的(单个,数量)问题会获得一个狭窄的上下文(锚点加上少量的边距行)。一个带有 answer_context=section 的(列表,文本)问题会获得整个章节。

4.2 统一的模式

python
class CandidateScope(BaseModel):
    """line_df 中的一个范围:页面和行边界,以及所属的章节。"""
    page_start: int
    page_end: int
    line_start: int
    line_end: int
    section_id: str | None = None

class RetrievedCandidate(BaseModel):
    """一个作为可能答案来源检索出的段落。"""
    candidate_id: str
    anchor: CandidateScope             # 匹配的位置(狭窄)
    context: CandidateScope            # 传递给生成的上下文(扩展)
    snippet: str                       # 简短,用于 LLM 裁决者快速查看
    text: str                          # 完整上下文文本,用于生成
    methods: list[str]                 # 哪些方法发现了它
    matched_keywords: list[str]        # 哪些关键词匹配到了锚点
    section_title: str | None = None
    role: Literal["primary", "supporting", "tangential"] | None = None
    reason: str | None = None          # LLM 裁决者的理由

class RetrievalResult(BaseModel):
    """完整的检索输出,作为单个 JSON 传递给生成。"""
    doc_id: str
    question: str
    candidates: list[RetrievedCandidate]   # 有序:主候选优先
    methods_used: list[str]
    not_found: bool
    not_found_reason: str | None = None
    confidence: float

顶部的 doc_idquestion 是使结果可重放的关键。持久化保存这个对象后,你可以在相同的检索基础上稍后重新运行生成过程,而无需再次支付检索费用。这是审计追踪(第 1.3 节)、批量评估以及任何需要在相同检索证据上比较两个生成提示的编排器的基础。

4.3 运行中的论文上的实时 JSON

我们使用在第 1 节中用于演示 LLM 裁决器的相同问题:“论文使用了哪种位置编码?” 有两个主要候选答案,均基于第 3.5 节“位置编码”,上下文内容扩展到该节的全文。

code
{
  "doc_id": "1706.03762v7",
  "question": "What positional encoding does the paper use?",
  "candidates": [
    {
      "candidate_id": "p5_l12_l14",
      "anchor": {
        "page_start": 5,
        "page_end": 5,
        "line_start": 12,
        "line_end": 14,
        "section_id": "11"
      },
      "context": {
        "page_start": 5,
        "page_end": 6,
        "line_start": 1,
        "line_end": 40,
        "section_id": "11"
      },
      "snippet": "We use sine and cosine functions of different frequencies...",
      "text": "3.5 Positional Encoding [... full section body, 40 lines, elided for the print ...]",
      "methods": [
        "title_keyword_match",
        "content_keyword_match"
      ],
      "matched_keywords": [
        "positional",
        "encoding",
        "sinusoidal"
      ],
      "section_title": "3.5 Positional Encoding",
      "role": "primary",
      "reason": "Defines the sinusoidal positional encoding used by the paper."
    },
    {
      "candidate_id": "p6_l4_l5",
      "anchor": {
        "page_start": 6,
        "page_end": 6,
        "line_start": 4,
        "line_end": 5,
        "section_id": "11"
      },
      "context": {
        "page_start": 5,
        "page_end": 6,
        "line_start": 1,
        "line_end": 40,
        "section_id": "11"
      },
      "snippet": "We also experimented with learned positional embeddings...",
      "text": "3.5 Positional Encoding [... same full section body as candidate 1, shared context ...]",
      "methods": [
        "title_keyword_match",
        "content_keyword_match"
      ],
      "matched_keywords": [
        "positional",
        "encoding",
        "learned"
      ],
      "section_title": "3.5 Positional Encoding",
      "role": "primary",
      "reason": "Mentions the learned variant the paper also tested."
    }
  ],
  "methods_used": [
    "title_keyword_match",
    "content_keyword_match",
    "llm_arbiter"
  ],
  "not_found": false,
  "not_found_reason": null,
  "confidence": 0.95
}

当你将检索构建为服务或批量步骤时,这就是被存储到数据库中、进行版本控制并支持重放的工件。这也是审计人员在询问为什么某段文字被展示给用户时所读取的内容。

与解析模块安装的缓存约定相同。save_retrieved_pages(pdf_path, question, retrieved_pages_df) 将页面级别的结果(保留 match_countmatched_keywords 列)写入 output/<subdir>/<stem>/questions/<question_slug>/retrieved_pages.xlsxfiltered_line_df 故意未被缓存。生成过程会实时从 line_df 和页面编号重新推导出它。持久化页面 ID 是廉价的默认方式;持久化这个较重的过滤后的数据框会导致状态重复。这保持了每个模块边界清晰:检索模块写入页面选择,生成模块读取它们,两者都不需要额外信息。

5. 结论

检索不是搜索。它是对结构化表格的过滤。一旦解析生成了 line_dftoc_df,每种检索方法都是一种从一个或两个表中选取行的方式。锚点检测分为三个阶段:在 line_dftoc_df 上进行关键词检测(始终进行,免费)以及并行的可选嵌入相似度计算,然后聚合到结构单元(如章节、页面、块),最后通过一次 LLM 仲裁调用对候选结果进行排序并给出理由。关键词始终启用,因为它们可审计,并能提供可辩护的“未找到”结果;嵌入是可选的,对词汇不匹配情况有用;BM25 在企业问题上表现不如业务编码的关键词过滤。锚点小(如行、标题),扩展上下文(如段落、章节);将两种粒度合并是常见的流水线错误。

文章第 8 部分(生成)从这里开始,是第四个模块。在正确粒度上检索到合适的候选内容后,LLM 仍需进行实质性工作:提取答案、格式化、引用、在答案不存在时拒绝虚构。这一步必须作为受控执行,而不是自由形式的预测。

本文是企业文档智能系列的一部分。最小的 RAG 流水线展示了检索输出如何端到端地馈送到生成模块,基于真实 PDF。

6. 来源与进一步阅读

文章所依赖的 BM25 基础是 Robertson 和 Zaragoza(《The Probabilistic Relevance Framework: BM25 and Beyond》,FnT IR 2009)。当结合关键词和嵌入评分器时,使用的排名融合机制是 Reciprocal Rank Fusion(Cormack 等,SIGIR 2009)。文章中关于 BM25 在分布外情况下仍是一个强基线的实证主张,由 Thakur 等(BEIR,NeurIPS 2021)支持。文章的方向与 Anthropic 的 Contextual Retrieval(2024 年 9 月)、Elastic 和 Vespa 现在支持的混合搜索共识一致。最接近 TOC-as-retriever 模式的已发表等价物是 RAPTOR(Sarthi 等,ICLR 2024);文章利用文档现有的 TOC,而不是聚类自己的 TOC。

与文章方向一致的资料包括:

  • Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond, FnT IR 2009. 经典的 BM25 参考文献;文章中关于 BM25 测量频率而业务检索需要共现的主张基于此。
  • Cormack, Clarke, Buettcher, Reciprocal Rank Fusion outperforms Condorcet, SIGIR 2009. 文章在结合关键词和嵌入评分器时使用的融合机制。
  • Thakur et al., BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models, NeurIPS 2021 (arXiv:2104.08663). 为文章中关于 BM25 是一个强基线且密集检索器并不总是能击败它的主张提供实证支持。
  • Sarthi 等人,RAPTOR: 用于树状组织检索的递归抽象处理,ICLR 2024(arXiv:2401.18059)。分层检索;目前发表的与 TOC-as-retriever 模式最接近的实现。RAPTOR 将文档聚类为一棵树;本文使用文档本身已声明的目录。
  • Anthropic,上下文检索(2024 年 9 月工程文章)。混合搜索共识,文章的方向与此一致。

不同角度,不同背景:

  • Karpukhin 等人,用于开放域问答的密集段落检索(DPR),EMNLP 2020(arXiv:2004.04906)。密集检索作为生产环境的默认方式。背景是领域内开放问答;本文在企业语料库中优先使用 BM25 / 关键词,最后使用嵌入。
  • Khattab & Zaharia,ColBERT:通过 BERT 上的上下文化晚期交互实现高效且有效的段落搜索,SIGIR 2020(arXiv:2004.12832)。以词粒度的晚期交互作为检索的基本单位。与本文的锚点与上下文分离的背景不同。
  • Izacard 等人,使用对比学习的无监督密集信息检索(Contriever),2022(arXiv:2112.09118)。无监督密集检索达到 BM25 级别的分布外性能。与本文在企业语料库中优先使用 BM25 的立场形成有用对比。
  • Izacard 等人,Atlas:使用检索增强语言模型的少样本学习,2022(arXiv:2208.03299)。大规模密集检索 + RAG 研究;对以嵌入优先为特点的方法有帮助的背景。

系列中更早的内容:

  • 文档智能:系列介绍。系列是如何逐步构建的,以及构建的顺序。
  • 基线企业 RAG,从 PDF 到高亮答案。四步流程端到端:PDF 输入,高亮答案输出。
  • 嵌入不是魔法:RAG 检索的可预测失败模式。嵌入相似性在哪些情况下有效(同义词、拼写错误、改写),在哪些情况下可预测地失效(未知术语、否定、术语与答案的相关性),以及如何在这些情况下仍然使用它。
  • 重排序器也不是魔法:何时值得使用交叉编码器层。交叉编码器相对于双编码器嵌入添加了什么,以及何时值得承担延迟成本。
  • RAG 不是机器学习,而机器学习工具箱解决的是错误的问题。为什么块大小调整和微调优化了错误的目标;应根据问题类型进行路由。
  • 从正则表达式到视觉模型:哪种 RAG 技术适合哪种问题。两个维度,文档复杂度和问题控制,用于为每种情况选择合适的技术。
  • 我们在生产中反复看到的 10 个常见 RAG 错误。10 个生产错误,按步骤分类,每个错误都有对应的修复方法。
  • 超出 extract_text:驱动 RAG 质量的 PDF 的两个层次。解析步骤的第一部分:文档的性质、信号和摘要。
  • 停止从 PDF 返回扁平文本:RAG 所需的关系结构。解析步骤的第二部分:每个下游步骤读取的关系表。

撰写者

查看 angela shi 的所有文章

文档处理

,

大语言模型

大语言模型应用

RAG

检索

分享本文

  • 在 Facebook 上分享
  • 在 LinkedIn 上分享
  • 在 X 上分享

Towards Data Science 是一个社区出版物。提交你的见解,以触达全球受众,并通过 TDS 作者支付计划获得报酬。

更新为你的实际提交 URL

为 TDS 撰写文章

✦ 结束 CTA ✦