项目:离线可引用问答

项目目标

运行一条完全离线、可追踪、可故障注入的教学管线:

稳定 query fixture → 可信执行上下文 → tenant/ACL/半开有效期过滤 → 字符召回
                 → topic 规则重排 → canonical 去重与预算
                 → 抽取式 claim → citation/revision 校验 → 公共响应
                                              ↘ 受保护审计轨迹 → 分层 eval artifact

项目解决的是“RAG 的每层契约如何被验证”,不是“如何用字符规则做高质量语义搜索”。它故意不使用 Embedding、ANN、LLM、网络或 API key。

项目文件

文件内容
rag-fixture.json10 篇版本化文档、8 个 query、风险切片与 eval policy
offline_cited_qa.py严格 JSON、公共/审计双 schema、证据管线、评测报告与 CLI
test_offline_cited_qa.py73 项 schema、UTF-8/数值资源边界、非干扰、权限、审计绑定、故障、grounding、评测与 CLI 测试

环境

  • Windows 11 与 PowerShell 7;
  • Python 3.11 已验证,代码只依赖标准库;
  • 不需要安装依赖或提供密钥;
  • 通过 -B 和 PYTHONDONTWRITEBYTECODE 避免生成 __pycache__。

若你需要隔离 Python,可把 venv 建在 vault 外,例如 $env:TEMP\rag-lab-venv;不要把 .venv、缓存或真实凭据放进知识库。

第一步:运行全部案例

从项目根目录(同时包含 docs/ 与 .website/)执行:

$env:PYTHONDONTWRITEBYTECODE = '1'  # 禁止写入 __pycache__,使本地教学项目保持干净。
$env:PYTHONIOENCODING = 'utf-8'  # 强制终端 JSON 使用 UTF-8,避免中文结果显示异常。
$script = '.\docs\RAG\examples\offline_cited_qa.py'  # 保存主脚本路径,后续阶段复用同一可执行文件。
$fixture = '.\docs\RAG\examples\rag-fixture.json'  # 保存被严格加载和哈希绑定的教学 fixture 路径。
 
python -B -W error $script --fixture $fixture demo  # 一次运行全部内置场景,观察不同业务状态与引用边界。

预期业务状态:

Query ID场景状态
Q-refund当前退款规则,旧版已过期answered
Q-duplicate重复扣款处理answered
Q-phone-guest普通用户询问内部电话insufficient_evidence,且不泄露 S3
Q-phone-oncall有 oncall 组权限answered
Q-conflict两个当前住宿标准冲突conflict
Q-mars语料无答案insufficient_evidence
Q-untrusted-content有权读取但含伪指令的外部内容answered,控制字段不随正文改变
Q-order-live实时订单状态tool_required

第二步:比较公共响应与受保护审计轨迹

公共命令:

python -B -W error $script --fixture $fixture ask --query-id Q-refund  # 输出退款规则的公共 answered 投影。

公共 CLI 的教学 envelope 只包含 mode 和 public result:后者只含稳定状态、由 claims 渲染的答案、授权后引用和教学 trace_id,不包含候选、过滤计数、内部版本或调用方传入的 fixture 本机路径。本例 ID 由 pipeline revision 与 query ID 确定性计算,只为测试复算,不是生产随机 opaque ID,也不能用作身份或授权。需要本地教学诊断时显式运行:

python -B -W error $script --fixture $fixture inspect --query-id Q-refund --operator-view  # 在本地教学中显式请求同一案例的受保护审计轨迹。

--operator-view 只是防止误把内部结构当公共响应,不提供真实身份认证。检查受保护轨迹:

  1. 本项目的固定 enum 是 visibility=privileged_audit,中文统一称“受保护审计”;它与第 9 课的 protected_audit 是两个不同 schema;
  2. 轨迹绑定 authorization_revision;
  3. filter_summary、候选和 dropped reason 只存在于内部轨迹;
  4. retrieved、reranked、selected 分开保存;
  5. filter summary、rank/score、selected chars/context chars、阶段转换及 degraded/fallback 会按可信输入重算;
  6. dropped 能看到 canonical duplicate 或预算原因;
  7. citations 同时记录 document、fact 和 source revision,claim 中每条引用都必须逐字支持该 claim;
  8. trace_id 和公共响应会从可信 runtime 输入及各阶段结果重新计算,不能在 audit envelope 中任意替换;
  9. runtime validator 不读取离线 expected_* oracle。

字符/bigram 分数只在本 query 内排序,不是概率,也不能与真实向量分数比较。

第三步:比较权限

python -B $script --fixture $fixture ask --query-id Q-phone-guest  # 用普通 public 组执行,同文本不应泄露内部电话。
python -B $script --fixture $fixture ask --query-id Q-phone-oncall  # 用包含 oncall 的可信 fixture 身份执行,允许得到授权事实。

两次 query 文本相同,可信身份层解析出的 subject_groups 不同;公共访问也显式携带 public 组,不以空组或 magic bypass 表示。观察普通用户公共结果中:

  • 没有内部电话;
  • 没有 S3;
  • 没有私有标题;
  • 没有任何过滤原因或计数。

内部 inspect 可以区分原因;公共响应对新增未授权文档保持逐字节不变,避免通过计数探测私有资源。

第四步:观察冲突与工具路由

python -B $script --fixture $fixture ask --query-id Q-conflict  # 查看有效来源互相矛盾时的 conflict 状态。
python -B $script --fixture $fixture ask --query-id Q-order-live  # 查看实时问题被路由为 tool_required,而非伪装成知识答案。

Q-conflict 保留两个互相矛盾的有效 fact,不取平均、不凭 authority 随意选一个。Q-order-live 在检索前短路,因为知识快照不能回答当前订单状态。

第五步:模拟依赖故障

python -B $script --fixture $fixture inspect --query-id Q-refund --failure retrieval_error --operator-view  # 注入检索失败,观察 dependency_unavailable 与审计原因。
python -B $script --fixture $fixture inspect --query-id Q-refund --failure reranker_error --operator-view  # 注入重排失败,观察是否复用相同安全候选集。
python -B $script --fixture $fixture inspect --query-id Q-refund --failure generation_error --operator-view  # 注入生成失败,观察是否拒绝输出未经验证 claim。
故障行为
retrieval_error停止并返回 dependency_unavailable
reranker_error在相同安全候选集上保留 retrieval 顺序,标记 degraded
generation_error保留检索 trace,但不输出未经验证 claim

第六步:运行四种解释器模式回归

$env:PYTHONDONTWRITEBYTECODE = '1'  # 不让四轮测试留下字节码缓存。
$env:PYTHONIOENCODING = 'utf-8'  # 让失败信息和 JSON 断言的中文输出保持 UTF-8。
$tests = '.\docs\RAG\examples'  # 保存 unittest discovery 的测试目录。
 
python -B -m unittest discover -s $tests -p 'test_offline_cited_qa.py' -v  # 正常模式详细运行 73 项离线问答测试。
python -O -B -m unittest discover -s $tests -p 'test_offline_cited_qa.py'  # 优化模式复跑,检验不依赖裸 assert。
python -B -W error -m unittest discover -s $tests -p 'test_offline_cited_qa.py'  # 严格 warning 模式复跑,避免忽略运行期告警。
python -O -B -W error -m unittest discover -s $tests -p 'test_offline_cited_qa.py'  # 同时覆盖优化与严格 warning 的组合模式。

-O 会移除 Python 的裸 assert。四种模式都通过,说明关键校验没有依赖会被优化掉的断言,组合模式也没有忽略 warning;它仍不等于证明所有生产风险已覆盖。

第七步:生成分层评测证据

python -B -W error $script --fixture $fixture evaluate  # 生成正常路径的分层评测 artifact,预期 PASS。
python -B -W error $script --fixture $fixture evaluate --failure retrieval_error  # 注入检索故障,预期得到阻断发布的 BLOCK artifact。

正常报告为 PASS、退出码 0;故障注入为 BLOCK、退出码 1。artifact 同时报告 case/critical pass rate、status accuracy、retrieval/context/citation fact recall、non-disclosure 违规数、pipeline revisions、fixture/evidence 完整 SHA-256。当前按 fact 计数的统计与严格审计验证由 offline-rag-harness-v3 标识;改变指标或验证语义时必须继续升版。case 只输出非敏感 failure code,不回显私有 canary。

Fixture 契约

严格加载器会在解析前限制 fixture 为 2,000,000 UTF-8 bytes、JSON 容器深度为 64,并限制文档、query、每文档 fact、列表、阶段候选与上下文字符预算。JSON 转义出的孤立 surrogate 会在进入 hash/检索前拒绝;需要转换为有限数值的超大整数也会成为 schema 错误,而不是泄漏 OverflowError。读取失败只报告错误类型,不回显本机路径。这些是教学防资源耗尽边界,不是吞吐压测结论;生产系统仍需在入口、队列、解析器和各检索阶段分别设置预算。

文档

每篇文档包含:

  • stable/canonical ID、title、text;
  • tenant、ACL、status、有效期;
  • source revision 与教学 authority;
  • 一个或多个 fact:topic、逐字 statement、value 与 unit。

statement 必须逐字存在于 text,让引用支持可确定验证。真实系统通常使用 source span,而不是预先结构化 fact。

Query

每个 query case 包含两类字段:

  • 运行时字段:文本、tenant、可信 groups、authorization_revision、as_of、route 与稳定 topic;
  • 离线 oracle:slice、critical、expected status/facts、forbidden document IDs 与私有正文 canary。

代码用 _runtime_query() 白名单投影运行时字段,期望字段不传给回答器。effective_from 含起点、effective_to 不含终点,即 [from, to);若业务字段是“结束日期当天有效”,应在摄取时转换为下一日 exclusive date。

两个字段的真实边界

本项目把 subject_groups 视为 host 已可信解析的执行上下文;authorization_revision 只是随 trace 绑定的 opaque 教学标签,代码没有调用 authorization resolver,也不验证该 revision 对 groups、tenant 或资源策略的真实含义。任意非空标签仍可能通过 fixture 校验,因此本项目不能证明身份/策略快照兼容性。

Citation 也只绑定 fixture 内手写的 document_id + fact_id + source_revision,并验证 fact statement 位于当前 document.text;它没有 raw/canonical hash、精确 source span、parse/chunk/index generation 或删除墓碑链。要验证这些上游合同,继续完成项目:从来源到引用的离线证据链。

实践任务

任务 A:增加“归档/非发布文档”

新增一个 status=archived 的高相似文档和 query。要求:

  • Fixture schema 仍通过;
  • 文档不进入 retrieved;
  • 公共响应及引用不出现其 document ID;
  • 新增测试覆盖正常与 -O。

这只证明 lifecycle hard filter,不证明删除。真正的删除要在 版本、删除与权限 中验证 delete event、source sequence、tombstone、投影/缓存传播、防旧快照复活和保留/物理清除证据。

任务 B:预算压缩

把 max_context_chars 调小,观察 dropped.reason。回答被拒绝时,判断是 retrieval miss 还是 context budget 导致。

任务 C:从规则组件替换为真实组件

按顺序替换:

  1. 字符召回 → BM25,并保留旧版对照;
  2. 再增加 Embedding/dense 通道与融合;
  3. topic rule → reranker;
  4. 抽取生成 → LLM 结构化输出;
  5. 逐步增加模型 judge,但保留确定性权限和 citation ID 检查。
  6. 接入第 9 课的 source-span/generation manifest;不要把手写 source_revision 标签当作内容绑定证明。

每次只替换一层,锁定 revision 并运行同一测试集。

任务 D:输入与公共输出边界红队

复制 fixture 后分别做两项最小篡改:在任意字符串中写入 JSON 转义的孤立 surrogate,以及将一个 min_* 阈值改为无法转换为有限浮点数的超大整数。两次 CLI 都应以退出码 2 返回受控契约错误,而不是 traceback。随后用绝对 --fixture 路径运行 ask,确认 public stdout 不回显该路径、候选或审计字段。这个练习只验证本地输入/投影边界,不替代网关限流、日志脱敏或真实身份认证。

生产化迁移清单

  • 身份、tenant 与 group 来自可信认证层;
  • Parser/chunk/embedding/index 都有 revision;
  • 删除、过期和权限变更传播到缓存与副本;
  • Sparse/dense 分开观测,融合有 qrels 实验;
  • Reranker 输出验证未知、重复、缺失与非有限值;
  • Token 预算使用真实 tokenizer;
  • Evidence 使用 source/span/revision;
  • LLM 输出有 schema、超时、限流和失败状态;
  • Claim support 有人工校准,关键字段有确定性检查;
  • 外部文档视为不可信数据,工具最小权限;
  • 日志脱敏、限权、有保留期;
  • 离线、影子、小流量与回滚门禁齐全。

项目验收

  • 八个 query 的正常状态符合 fixture;
  • 旧版、跨 tenant 与 ACL 文档先过滤后评分;
  • Canonical 镜像不重复占用上下文;
  • 冲突证据不被合成为第三个答案;
  • 实时问题不走知识检索;
  • Answer 只能由已验证 claims 渲染,claim/citation 能回溯到 selected context;
  • 公共响应不含 filter/candidate trace,未授权语料变化不改变公共输出;
  • 三类依赖故障有确定、安全、可观测行为;
  • 分层 eval 正常 PASS、故障注入 BLOCK,完整 SHA-256 可复算;
  • 73 项测试在正常、-O、warnings-as-errors 与组合模式通过;
  • 没有网络、密钥、缓存、模型或大型数据依赖。

自测

  1. 为什么 fixture 中的 expected 字段不能作为线上回答输入?
  2. 为什么本项目能验证 citation provenance,却不能证明真实 LLM 的所有语义忠实性?
  3. 为什么公共响应连聚合过滤计数也不应显示?
  4. Reranker fallback 为什么不能重新扩大候选权限范围?
  5. 把字符检索换成向量检索后,哪些契约不应改变?

返回 RAG 目录。

参考资料

来源获取日期:2026-07-22。