← 工程跃迁 / 第 09 章
架构定位

能力扩展

通过能力包、外部证据、持久状态和标准协议扩展 Agent 能做的事情。

如何为 Agent 增加 Skill、检索、记忆和外部协议能力?

概念校准

RAG

它是什么
在生成前检索外部证据,将经过选择的内容及来源注入上下文。
它不是什么
不等于 Memory,也不是关键词触发一次文件读取。

上下文

它是什么
一次模型调用可见的信息集合,包括指令、对话、工具 Schema 与结果、检索证据和当前状态。
它不是什么
不只是聊天记录,也不等于模型能够永久记住的全部信息。

验证与评测

它是什么
用测试、Schema、引用、状态和验收规则检查单次结果,并用数据集衡量系统表现。
它不是什么
不等于让模型对自己的输出再给一次意见;验证必须落到外部可检查证据。

RAG、检索与知识评测

从语料导入、切块、召回、重排和引用走到可评测知识系统。

本章只做一件事三个场景均生成带引用答案和检索质量报告。
开始前,Forge 已经具备已完成 Module 08 的“工具系统、沙箱与副作用控制”,其通过验收的 solution 是本章起点。
完成后,Forge 将能够让三个场景检索各自资料,并用测试集衡量引用和回答质量。
卡住时的最小恢复点使用小型本地语料和词法检索,先固定引用合同。
下载本章实验仓库Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock
阶段能力链
01规范化文档内容并生成稳定标识,同时保留来源与版本02按段落边界切块并保留受控重叠和文档偏移03用确定性本地向量建立可重复的语义相似度测试基线04按相似度召回 Top-K 片段,并在打分前应用来源元数据过滤05结合初始相似度和关键术语覆盖率重排候选证据06把回答声明绑定到已检索片段,并拒绝不存在或重复的引用07用测试集计算 Recall@K 和引用精确率,并汇总失败案例
每一步只增加一种可验证能力;后一步建立在前一步已经通过的代码和测试上。

概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n
\n\n### RAG rag\n\n- 它是什么: 在生成前检索外部证据,经过筛选或重排后把内容与来源注入上下文,再让模型据此回答。\n- 它不是什么: 它不等于 Memory,也不只是关键词触发一次文件读取;检索命中不代表证据正确或回答已被验证。\n- 与相邻概念的关系: Embedding 与索引支持召回,重排改善候选顺序,Context 承载证据,Evaluation 分别衡量检索、引用和回答质量。\n- 在 HeatStack Forge 中的位置: Forge 为三个场景保存 chunk_id、source、score 与引用位置,并用固定问题集区分“没找到”和“找到了但答错”。\n- 典型误用与修正: 误用是只看最终回答是否顺眼。修正方法是分层测试召回、重排、引用覆盖、答案正确性和无证据拒答。\n\n### 上下文 context\n\n- 它是什么: 一次模型调用实际可见的信息集合,包括指令、对话片段、工具 Schema 与结果、检索证据、当前状态和输出约束。\n- 它不是什么: 它不只是聊天记录,也不是数据库中的全部信息;只有经过选择并放入本次调用的信息才属于本次上下文。\n- 与相邻概念的关系: RAG 选择外部证据,Memory 保存跨步骤状态,Tool Schema 描述可用动作;三者都可供给上下文,但职责不同。\n- 在 HeatStack Forge 中的位置: Forge 的 context builder 按任务、权限、预算和来源优先级组装输入,并记录每段信息的来源与版本。\n- 典型误用与修正: 误用是把更多 Token 当成更完整的理解。修正方法是建立选择、截断、引用和敏感信息过滤规则,并用固定夹具验证。\n\n### 验证与评测 evaluation\n\n- 它是什么: Verification 用测试、Schema、引用、状态或验收规则检查一次结果;Evaluation 用数据集和指标衡量多次运行表现。\n- 它不是什么: 它不是让模型对自己的回答再给一次意见;通过与否必须落到外部可检查证据。\n- 与相邻概念的关系: Agent Loop 依赖 Verification 决定停止,RAG 需要分层评测,Planning 需要节点验收,Observability 提供运行记录。\n- 在 HeatStack Forge 中的位置: Forge 为单次运行保存合同测试与 evidence,再用固定场景集比较质量、延迟、成本、权限和恢复能力。\n- 典型误用与修正: 误用是把一个综合分数当成全部结论。修正方法是定义分层指标、失败样本和版本基线,并结合产品风险评审。\n\n

STAGE 01

规范化文档内容并生成稳定标识,同时保留来源与版本

先看一个具体问题

同一文档的不同版本在系统中身份坍塌

在 RAG 系统中,检索质量的前提是每个文档片段具有可区分且不可变的身份。当前 forge/rag 包尚未创建,测试在导入 from forge.rag.documents import ingest_document 时直接抛出 ModuleNotFoundError,导致三个目标测试全部无法收集。

我们需要创建 forge/rag/documents.py 文件,实现 ingest_document 函数,使其接收来源路径、版本号、原始文本和可选元数据,对文本做空白规范化后生成文档标识。

一个常见的错误是仅基于来源和规范化内容生成 document_id,而把版本仅当作元数据存储。这样当同一来源的版本从 a 变为 b 但内容不变时,两个版本的文档会坍塌到同一个 ID,后续检索无法区分新旧版本,导致引用过时证据。

先做判断

ingest_document 中,如果 document_id 的计算公式是 sha256(f"{source}|{normalized}"),当调用 ingest_document("guide.md", "a", "body")ingest_document("guide.md", "b", "body") 时,两者的 document_id 会怎样?

  • 两者 document_id 不同,因为版本不同
  • 两者 document_id 相同,因为版本未参与哈希
  • 两者 document_id 不同,因为 Python 每次调用结果不同
  • 两者 document_id 相同,因为 source 和 text 完全一样
  • 第一个调用会抛出 ValueError

判断依据: 正确答案是第二个和第四个选项的合并:由于哈希输入只包含 sourcenormalized,版本 ab 不影响哈希结果,因此两个文档坍塌为同一个 document_id。这正是故障实验中 test_version_change_creates_a_new_document_identity 失败的根本原因。

它是什么

文档身份的构成要素

文档身份(document_id)是一个不可变的哈希摘要,它必须绑定来源(source)、版本(version)和规范化后的内容(normalized)三者。只有三者同时参与哈希计算,才能保证任何一个维度变化都会产生新的身份。

规范化内容通过对原始文本执行 " ".join(text.split()) 实现,它将所有连续空白(包括换行、制表符、多空格)压缩为单个空格,从而确保语义相同但排版不同的文本不会产生不同的身份。

Document 是一个冻结数据类(frozen=True),其字段在创建后不可修改,这从结构上保证了文档身份的不可变性。

它不是什么

文档身份不是什么

文档身份不是仅基于内容生成的哈希。如果只哈希 normalized 文本,那么不同来源的相同内容会坍塌为同一身份,丢失来源溯源能力。

文档身份也不是元数据的哈希。metadata 字典可能包含团队名、标签等可变信息,将其纳入身份会导致元数据更新时身份不稳定,违反不可变性原则。

版本不是单纯的展示标签。版本是身份的核心组成部分,同一来源同一内容的不同版本必须具有不同的 document_id,否则系统无法区分草稿与终稿。

它与相邻概念的关系

身份、规范化与验证的关系

规范化是身份稳定性的保障:它确保空白差异不产生新身份,从而使 test_document_id_is_stable_after_whitespace_normalization 通过。

版本绑定是身份可区分性的保障:它确保版本变化产生新身份,从而使 test_version_change_creates_a_new_document_identity 通过。

空内容校验是身份有效性的前置守卫:在生成身份之前,ingest_document 必须检查规范化后的内容非空,否则空白字符串也会产生一个看似合法的哈希,从而使 test_empty_content_is_rejected 通过。

本阶段的边界决定: 文档身份的边界是:来源、版本和规范化内容三者共同决定身份;空白规范化不改变身份,版本变化必须改变身份,空内容必须被拒绝而非生成空身份。

STAGE 02

按段落边界切块并保留受控重叠和文档偏移

先看一个具体问题

固定长度切块丢失边界上下文

在检索增强生成系统中,长文档必须切分为较小的块以便进行向量检索,但如果仅按固定长度机械分割且不保留重叠,跨越块边界的关键信息会从检索结果中消失。

当前 forge/rag/chunks.py 文件尚不存在,测试在导入 chunk_document 时直接抛出 ModuleNotFoundError,你需要创建该文件并实现结构感知的切块逻辑。

本阶段的核心工程决策是:相邻块必须共享受控数量的词,使得边界两侧的上下文在任一块中均可被检索到,同时每个块必须携带可追溯的文档标识和顺序号。

先做判断

chunk_documentmax_words=4, overlap_words=2 切分七个词的文档时,如果下一个块的起始位置直接设为当前块的结束位置(即 start = end),第二个块的文本会是什么?

  • 第二个块文本会是 five six seven,因为起始位置没有回退导致丢失了重叠词
  • 第二个块文本会是 three four five six,因为起始位置回退了两个词保留了重叠
  • 第二个块文本会是 one two three four,因为起始位置没有变化导致重复生成第一个块
  • 第二个块文本会是 two three four five,因为起始位置只回退了一个词导致重叠不足

判断依据:start = end,则没有重叠,第二个块从索引 4 开始,结果为 ‘five six seven’。正确实现应将 start 设为 end - overlap_words,使第二个块从 ‘three’ 开始,得到 ‘three four five six’。

它是什么

受控重叠切块模型

受控重叠切块是指在按词列表切分文档时,每个新块的起始位置回退 overlap_words 个词,使得相邻块在边界处共享一段文本。

这种机制确保了即使一个关键事实恰好横跨两个块的理论分割点,它仍然会完整出现在至少一个块中,从而在向量检索时不会因为边界切割而丢失上下文。

块 ID 采用 文档ID:序号 的格式,ordinal 从 0 开始递增,使得每个块都可以追溯到其来源文档和在文档中的相对位置。

它不是什么

切块模型的边界

切块不是语义理解,它不判断词的含义或句子结构,仅按空白分割后的词列表进行索引操作。

重叠不是冗余存储的缺陷,而是有意为之的检索保障机制;它与简单的重复存储不同,其目的是弥补固定窗口在边界处的信息断裂。

切块不替代重排或引用验证,它只负责将文档拆分为可独立检索的文本单元,后续的检索质量评估仍需独立进行。

它与相邻概念的关系

切块与文档摄入的依赖关系

chunk_document 接收 ingest_document 返回的 Document 对象作为输入,依赖其 document_idtext 字段来生成带溯源信息的 Chunk 列表。

Chunk 是冻结的数据类,包含 chunk_iddocument_idordinaltext 四个字段,其中 document_id 直接从父文档继承以保持溯源链完整。

切块参数 max_wordsoverlap_words 之间存在约束:overlap_words 必须严格小于 max_words,否则回退操作会导致起始位置无法前进,形成无限循环。

本阶段的边界决定:overlap_words >= max_words 时,start = end - overlap_words 会导致 start 不大于原 start,循环无法终止,因此必须在进入循环前用 ValueError 拒绝此参数组合。

STAGE 03

用确定性本地向量建立可重复的语义相似度测试基线

先看一个具体问题

从零构建可重复的语义相似度测试基线

在检索增强生成(RAG)系统中,我们需要一种可重复的方法来衡量查询与文档片段之间的语义相似度。当前系统已存在 forge/rag/chunks.py,但尚未创建 forge/rag/vectors.py,导致测试因 ModuleNotFoundError 而中断收集。

本阶段要求不依赖任何外部预训练模型,仅用确定性本地代码将文本映射为固定维度的归一化向量,使共享词项的文本对获得高于不相关文本对的余弦相似度。

核心挑战在于:词袋哈希映射必须真实累加每个 token 的计数,且向量必须归一化,否则余弦相似度将退化为零或受文本长度干扰。

先做判断

embed 函数对文本进行向量化时,如果循环中每个 token 的贡献被设为 += 0.0 而非 += 1.0cosine(query, related)cosine(query, unrelated) 的断言结果会怎样?

  • 两者相似度均为0.0,断言失败
  • 相关文本相似度更高,断言通过
  • 不相关文本相似度更高,断言失败
  • 抛出维度不匹配异常

判断依据: 由于所有 token 贡献被丢弃,向量全为零。归一化时零向量的范数为0,回退为1.0,最终返回全零向量。两个全零向量的余弦相似度为0.0,因此断言 0.0 > 0.0 失败。

它是什么

确定性嵌入与词袋哈希映射

确定性嵌入是指对相同输入文本始终生成完全相同的向量表示,不依赖随机种子或外部网络请求。本实现通过 hashlib.sha256 将每个 token 映射到固定维度的桶中,形成基于词袋特征的稀疏向量。

余弦相似度衡量两个向量方向的一致性,归一化后其值域为 [-1, 1]。在词袋模型中,共享词项越多,向量在对应桶上的分量越接近,余弦值越高。

它不是什么

确定性嵌入的边界与限制

确定性词袋嵌入不是预训练语义模型,它无法捕捉同义词或上下文语义。例如 ‘python’ 和 ‘snake’ 在此模型中毫无关联,因为它们是不同的 token 字符串。

它也不是关键词触发的一次性文件读取,而是将文本的词法特征编码为几何空间中的方向,使得词法重叠可被量化比较。

它与相邻概念的关系

嵌入、上下文与评测的因果关联

嵌入向量支持 RAG 的召回阶段,通过余弦相似度排序候选文档片段。选中的片段被注入上下文,供模型在生成回答时引用。

评测阶段分别衡量检索质量(相似度排序是否合理)和引用质量(回答是否基于注入的证据)。确定性嵌入保证了评测基线的可重复性,使每次测试运行结果一致。

本阶段的边界决定: 当两个向量的维度不一致时,cosine 函数必须抛出 ValueError,因为不同维度空间的向量方向不可比较,静默返回值会导致检索排序产生不可预测的错误。

STAGE 04

按相似度召回 Top-K 片段,并在打分前应用来源元数据过滤

先看一个具体问题

检索时未在打分前过滤来源导致越权召回

在当前代码状态下,forge/rag/retrieval.py 文件尚未创建,测试在导入 IndexedChunkretrieve 时直接抛出 ModuleNotFoundError,导致收集阶段中断。

本阶段需要新建该文件并实现 retrieve 函数,使其在计算余弦相似度之前,根据 filters 字典剔除所有不满足来源元数据约束的候选块。

如果先打分再过滤,来自 private 团队的片段可能因为文本高度相关而进入 Top-K 结果,从而在 engineering 专属查询中泄露越权数据。

先做判断

如果在 retrieve 中先对全部索引块计算相似度并排序,再按 filters 过滤,当 top_k=3 且过滤后只剩一个块时,最终返回的列表长度是多少?

  • 返回 1,因为过滤后只剩一个块
  • 返回 3,因为排序时已经取了前三个
  • 返回 0,因为过滤操作清空了已排序结果
  • 返回 2,因为排序和过滤互不影响

判断依据: 先排序后过滤意味着 sorted(hits)[:top_k] 已经截断了列表,此时再过滤可能得到少于 top_k 的结果,但更危险的是不满足过滤条件的块已经占用了 Top-K 名额,导致正确答案被挤出。正确做法是在打分前过滤,使候选集只包含合法来源的块。

它是什么

打分前过滤的检索管道

retrieve 函数接收查询字符串、索引列表、top_k 和可选的 filters,其核心管道分为三步:先按元数据过滤候选集,再对过滤后的候选计算余弦相似度,最后按分数降序取前 top_k 个。

IndexedChunkChunk、嵌入向量和 metadata 字典绑定在一起,metadata 中的键值对(如 team)是过滤的依据,只有所有过滤键值都匹配的候选才进入打分阶段。

SearchHit 是最终返回的命中结果,包含原始 Chunk 和相似度 score,排序时以 (-score, chunk_id) 为键确保分数相同时有确定性的次序。

它不是什么

过滤不是打分后的后处理

过滤不是在排序截断之后才执行的清理步骤,因为 [:top_k] 截断发生在排序之后,如果候选中混入了不合法来源的高分块,它们会占据名额并把合法的低分块挤出结果。

过滤也不是对查询向量的修改或对分数的惩罚,它是一个布尔级的集合筛选操作,在计算相似度之前就把不合法的候选从列表中移除。

retrieve 不负责重新嵌入文档或修改索引内容,它只读取 indexfilters 并返回一个新的 SearchHit 列表。

它与相邻概念的关系

过滤、打分与排序的因果顺序

过滤操作决定了打分的输入集合:如果过滤在打分前,打分只作用于合法候选;如果过滤在打分后,打分作用于全部候选,非法候选的分数可能高于合法候选。

排序的稳定性依赖于 chunk_id 作为次要排序键,当两个候选的 score 相同时,chunk_id 较小的排在前面,这保证了同一索引和查询的排序结果在多次运行间一致。

当过滤后候选集为空时,retrieve 返回空列表而不是报错,因为合法来源中确实没有匹配内容是一种正常的检索结果。

本阶段的边界决定: 过滤必须在打分前应用,以确保不相关来源的片段不会进入排序;Top-K 按分数降序返回,分数相同时按 chunk_id 升序排列以保证稳定性。

STAGE 05

结合初始相似度和关键术语覆盖率重排候选证据

先看一个具体问题

初始相似度最高的证据不一定是最佳答案依据

在上一阶段,你已经实现了基于向量相似度的检索,能够从文档库中返回一组候选证据。但检索结果往往只按相似度排序,而相似度高的片段可能只泛泛提及主题,没有覆盖用户真正关心的关键术语。

例如,用户查询“python retry policy”,一个片段“python retry overview”的相似度高达0.95,但它没有提到“permission”或“policy”这些关键术语;另一个片段“python retry permission failure policy”相似度只有0.75,却完整覆盖了这些术语。

如果只按初始相似度排序,第一个片段会排在前面,但用户可能更希望看到包含具体策略细节的第二个片段。因此,我们需要一种机制,在排序时同时考虑初始相似度和关键术语覆盖率,让更相关的证据排在前面。

本阶段你将创建 forge/rag/rerank.py,实现一个 rerank 函数,它接受查询、候选命中列表和可选的关键术语集合,返回重新排序后的列表。这个函数必须保持确定性:相同输入必须产生相同顺序,否则测试无法稳定通过。

先做判断

在实现重排函数之前,请预测:对于两个候选证据,一个初始相似度较高但覆盖关键术语较少,另一个初始相似度较低但覆盖关键术语较多,重排后哪个应该排在前面?

  • 初始相似度高的证据应该始终排在前面,因为相似度是检索的主要指标。
  • 覆盖关键术语更多的证据应该排在前面,即使其初始相似度较低。
  • 应该随机选择,因为两种因素都很重要。
  • 应该保持原始顺序,不做任何改变。

判断依据: 正确答案是第二个选项。重排的目的就是让覆盖更多关键术语的证据优先,即使其初始相似度较低。但要注意,这并不意味着完全忽略相似度,而是两者结合,且必须保证确定性。

它是什么

重排是结合相似度与术语覆盖率的确定性排序

重排函数接收一个查询字符串、一个 SearchHit 列表和一个可选的关键术语集合,返回一个新的列表,其中每个元素仍然是原来的 SearchHit 对象,但顺序可能改变。

排序的关键是计算每个命中的得分,这个得分是一个元组,包含两个部分:关键术语覆盖率(必需术语中出现在文本中的比例)和调整后的相似度(原始相似度加上查询词覆盖率乘以0.25)。

排序时使用 sorted 函数,并设置 reverse=True,这样得分高的命中会排在前面。由于得分元组中包含了 chunk_id 作为最后的比较项,当两个命中得分完全相同时,顺序是确定的。

它不是什么

重排不是简单的相似度排序,也不是随机调整

重排不是仅仅按初始相似度排序,那样就失去了意义;它必须考虑关键术语覆盖率,否则无法解决本阶段的问题。

重排也不是随机打乱顺序,它必须保持确定性,即相同输入必须产生相同输出,否则测试无法稳定通过。

重排不是修改原始列表,而是返回一个新的列表,原始列表保持不变,这符合函数式编程的惯例。

它与相邻概念的关系

重排与检索、上下文和评测的关系

重排是检索流程的后续步骤:retrieve 返回候选命中,rerank 调整顺序,然后才将排序后的证据注入上下文供模型使用。

重排的质量直接影响最终回答的质量,因为模型只能看到重排后的证据,如果关键证据被排在后面,可能被截断或忽略。

评测阶段会检查重排是否有效,例如测试 test_required_term_coverage_can_promote_better_evidence 验证覆盖关键术语的证据是否排在前面。

本阶段的边界决定: 重排必须保持确定性,因此排序键必须包含一个唯一标识符(如 chunk_id)作为最后的比较项,以避免相同得分时出现不稳定顺序。

STAGE 06

把回答声明绑定到已检索片段,并拒绝不存在或重复的引用

先看一个具体问题

回答声明引用了检索结果中不存在的片段 ID

在 RAG 系统中,模型生成的回答声明必须绑定到实际检索到的证据片段,否则回答将失去可验证的事实基础。当前 forge/rag/citations.py 文件尚未创建,测试在导入 Claimbuild_grounded_answer 时直接抛出 ModuleNotFoundError

学习者需要从零创建该模块,定义 ClaimGroundedAnswer 数据类,并实现 build_grounded_answer 函数。该函数不仅要构建证据映射,还必须验证每个声明引用的 ID 确实存在于检索结果中。

如果引用 ID 仅在语法上存在但未出现在检索结果中,系统将无法追溯声明的来源,导致幻觉风险。因此,未知引用和未引用声明都必须被显式拒绝。

先做判断

build_grounded_answer 接收到一个引用了 c-9 的声明,但检索结果 hits 中只包含 c-1 时,函数应该表现出什么行为?

  • 直接接受该声明,因为 c-9 是一个合法的字符串 ID
  • 抛出 ValueError,因为 c-9 不在检索到的证据映射中
  • 忽略 c-9 引用并继续构建答案
  • 返回一个空的 GroundedAnswer 对象

判断依据: 正确答案是抛出 ValueError。引用 ID 必须在检索结果集合中具有可追溯的证据来源,任何无法映射到实际检索片段的引用都属于未知引用,必须被拒绝。

它是什么

引用溯源是声明与检索证据的绑定契约

引用溯源要求回答中的每个声明都通过 citation_ids 显式绑定到检索结果中的 SearchHit 片段。build_grounded_answer 函数首先从 hits 构建证据映射,然后逐一检查声明的引用 ID 是否在该映射中存在。

这种绑定机制确保了回答的可验证性:任何声明都可以通过 answer.evidence 字典追溯到原始检索文本,从而防止模型编造看似合理但无证据支持的声明。

它不是什么

引用溯源不是单纯的语法格式检查

引用验证不等于检查 citation_ids 是否为非空元组或字符串格式是否正确。即使引用 ID 是一个合法的字符串,如果它未出现在检索结果中,仍然属于无效引用。

引用溯源也不是对回答内容本身的事实性判断,系统不会评估声明文本是否正确,只验证该声明是否有对应的检索证据支撑。

它与相邻概念的关系

证据映射、声明引用与拒绝逻辑的依赖关系

SearchHit 提供了检索片段的 chunk_idtextbuild_grounded_answer 将其转化为 evidence 字典。Claim 通过 citation_ids 引用这些键,函数通过集合差集运算检测未知引用。

citation_ids 为空元组时,声明缺乏引用,触发 requires 错误;当存在重复 ID 时触发 duplicate 错误;当 ID 不在 evidence 中时触发 unknown 错误。这三层检查共同构成了引用验证的完整链路。

本阶段的边界决定: 引用验证的边界在于:系统只验证引用 ID 是否存在于检索结果的证据映射中,不评估检索片段本身的内容质量或事实准确性,也不对声明文本进行语义级别的真伪判断。

STAGE 07

用测试集计算 Recall@K 和引用精确率,并汇总失败案例

先看一个具体问题

从零创建 RAG 评测模块并修复引用精确率分母

在上一阶段中,forge/rag/citations.pybuild_grounded_answer 已经能生成带引用的回答,但目前没有任何模块能量化检索和引用的质量。测试文件 tests/test_stage.py 试图从 forge.rag.evaluation 导入 EvaluationCaseevaluate,但由于 forge/rag/evaluation.py 尚不存在,测试收集阶段直接抛出 ModuleNotFoundError

本阶段需要从零创建 forge/rag/evaluation.py,定义 EvaluationCase 数据类来封装每个测试用例的 case_idrelevant_chunk_idsretrieved_chunk_idscited_chunk_ids,并实现 evaluate 函数来计算 recall_at_kcitation_precision 以及 failed_case_ids

在实现引用精确率时,一个隐蔽但致命的错误是使用案例数量作为分母,而不是引用总数。这会导致系统在存在无效引用时仍然报告 1.0 的完美精确率,从而掩盖 RAG 系统捏造证据的风险。

先做判断

evaluate 函数中,当计算引用精确率时,如果直接用有效引用数除以案例总数,在包含两个案例且总共产生三个引用(其中两个有效)的测试集下,精确率会返回什么值?

  • 返回 0.6666666666666666,即两除以三得到的三分之二
  • 返回 1.0,因为两个有效引用除以两个案例等于一
  • 返回 0.5,因为只有一半的案例完全正确
  • 抛出除零异常,因为分母计算方式不正确

判断依据: 如果分母是案例总数 2,则 2 除以 2 等于 1.0。这错误地掩盖了存在一个无效引用的事实。正确的分母必须是所有案例的引用总数 3,这样才能得到 2/3。

它是什么

RAG 分层评测的数学模型

Recall@K 衡量的是检索阶段的能力,计算方式是每个案例中检索到的相关片段数量除以该案例的相关片段总数,最后对所有案例取算术平均值。如果某个案例没有相关片段,则通过 max(1, len(relevant_chunk_ids)) 避免除零并将其召回率记为 0。

引用精确率衡量的是生成阶段的证据忠实度,计算方式是所有案例中指向真实相关片段的引用总数除以所有案例生成的引用总数。当没有任何引用时,精确率定义为 1.0,因为系统没有捏造任何证据。

failed_case_ids 收集所有召回率小于 1.0 的案例标识,用于后续针对检索失败的案例进行改进分析。这三个指标共同构成了 RAG 系统的基础评测报告。

它不是什么

评测指标的单位混淆

引用精确率不是案例级别的通过率。不能因为一个案例中有一条有效引用就记为 1,另一个案例中有一条无效引用就记为 0,然后取平均值。这种做法会丢失引用级别的粒度信息。

评测不是让模型对自己的回答再给一次意见。通过与否必须落到外部可检查的数学计算上,即测试集中预先标注的 relevant_chunk_ids 与系统实际输出的交集。

Recall@K 不等于引用精确率。检索到了相关片段不代表模型在生成时正确引用了它,两者必须独立计算。

它与相邻概念的关系

评测模块与已有 RAG 组件的依赖

forge/rag/evaluation.py 依赖于 forge/rag/citations.py 中定义的 Claimbuild_grounded_answer 的输出结构,但评测本身是独立运行的离线计算过程,不修改检索和生成的运行时行为。

EvaluationCaseforge/rag/retrieval.py 的检索结果和 forge/rag/citations.py 的引用结果封装为可计算的静态数据,使得评测可以脱离实际的模型调用,在确定性 Mock 模式下快速运行。

EvaluationReport 输出的 failed_case_ids 为后续的 Observability 和 Planning 模块提供了需要重点关注的失败节点,形成从评测到改进的闭环。

本阶段的边界决定:cited_chunk_ids 为空元组时,citation_count 为 0,此时精确率必须返回 1.0 而非抛出异常,因为无引用不等于捏造证据。

完成本章

让三个场景检索各自资料,并用测试集衡量引用和回答质量。

FORGE / LOCAL CHECK

本地实验自检

  1. 未开始
  2. 2阅读中
  3. 3实验已下载
  4. 4测试结果已读取
  5. 5本地自检通过

verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。

概念校准与一周复习

三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。

  1. 1哪一项最能证明你真正完成了「RAG、检索与知识评测」?
  2. 2关于「RAG」,哪一种理解最准确?
  3. 3实验卡住时,哪个动作是本章建议的最小恢复点?