Files
SuperBizAgent-java/mvp/engineering/rag/RAG证据链探索笔记-从py-rag响应到引用验真.md
T
zhuyongxin 473bb5d004 docs(mvp): update RAG docs for py-rag extraction
- rewrite RAG architecture doc for py-rag service contract mapping, ingest and rebuild ops
- refresh observability/trace doc for post-L0 single-attempt semantics
- add py-rag chain exploration note; mark superseded Milvus/L0 notes with status banners
- close ISS-017 (superseded by L0 sinking); mark knowledge_domain table orphaned
- refresh architecture/mvp/engineering indexes
2026-09-30 17:03:50 +08:00

17 KiB
Raw Blame History

RAG 证据链探索笔记:从 py-rag 响应到引用验真

日期:2026-09-29
范围:RAG 模块抽离后的完整数据链路——每个站点"数据长什么样、字段怎么来、为什么这样设计"
读者:要理解或维护 lookup_knowledge 证据链的工程同学
关联文档:

本文按一次真实代码探索的顺序组织:从 py-rag 返回的 JSON 出发,沿着数据走过的每一站, 讲清关键字段、加工规则与设计取舍,最后到 Agent 引用验真收口。


0. 全景路线图

py-rag JSON ──► KnowledgeSearchHit        站点1-2:数据形态与防腐层映射
                    │
             ① 后处理 ──► EvidencePostprocessResult   站点3:去重/限流/判级
                    │
             ② 打包   ──► ContextPack                 站点4:文本储备(非 Agent 口粮)
                    │
             ③ 组装   ──► LookupResult                站点5:内部真相全集
                    │
             ④ 投影   ──► RagToolResult ──► Agent     站点6-7:冻结契约与 Agent 视图
                    │
             ⑤ Agent 写报告引用 toolCallId
                    │
             ⑥ EvidenceGuard 对账验真 ──► 语义审查 ──► 发布    站点8:证据安全链
   (旁路:每站关键字段 ──► tool_invocation 审计表,供人排障)

一句话定位:py-rag 负责"把对的片段找出来",Java 侧负责"把找到的证据管起来"; 检索质量可以整体外换,证据治理一寸不动——这次抽离(71 文件 / 约 -8000 行)本身就是证明。


1. 站点一:py-rag 返回的数据结构

响应 JSON 按职责分四块,四块数据两条去路(①②③喂后处理,④喂审计):

{
  "query": "网关超时怎么排查",            // ① 入参回显
  "mode": "hybrid",                      // ① 查询模式(hybrid | semantic)
  "hits": [                              // ② 命中数组(检索的基本单位是 chunk)
    {
      "evidence_key":  "e2e-gateway-b9c1fa12-md-34223174#chunk-1",   // chunk 级身份
      "document_id":   "e2e-gateway-b9c1fa12-md-34223174",           // 所属文档
      "source":        "e2e-gateway-b9c1fa12-md",                    // 来源路径
      "title":         "网关超时排查",
      "breadcrumb":    "网关超时排查 > 处理步骤",
      "excerpt":       "网关超时先检查 upstream 配置…",               // 正文
      "quality_score": 0.9147,                                       // rerank 绝对分
      "relevance_level": "PRECISE"                                   // py-rag 自己的判级(Java 不用)
    }
  ],
  "relevance_level": "PRECISE",          // ③ 顶层判级(top1)
  "evidence_status": "supported",        // ③ 业务状态
  "retrieval_trace": {                   // ④ 过程记录(不参与后处理,进审计)
    "mode": "hybrid", "filters": {"category": "gateway"},
    "recall_count": 20, "rerank_model": "BAAI/bge-reranker-v2-m3",
    "no_evidence_basis": null
  }
}

关键语义:

  • evidence_status 只有两类:supported(正常)/ no_evidence(查到了但都是垃圾或无候选, 此时 hits 恒为空)。它是 200 正常业务响应,Java 侧直接走"无知识可用"分支,不重试不报错。
  • relevance_level 是分数的档位化:quality_score ≥0.75 → PRECISE,≥0.5 → REFERENCE, <0.5 不输出。阈值当前未校准(契约已知边界)。它是给模型的,分数是给系统的—— 同一信息两种表达,服务两种消费者。
  • 命中没有独立 metadata map(旧 Milvus 方案遗留概念),文档归属信息就是那几个平铺字段。

2. 站点二:防腐层映射——KnowledgeSearchHit

PyRagKnowledgeSearchAdapter 把每个 hit 映射成可移植结构。从此全 Java 侧只认这个类型, py-rag 字段再怎么变只改 adapter 一处。后处理实际只消费其中 7 个字段:

evidenceKey             → 去重键(docId#chunk-N,原样采纳)
docId / chunkIndex      → 文档分桶(单文档上限);chunkIndex 从 "#chunk-N" 解析
content                 ← excerpt,最终证据正文
score + scoreLabel      ← quality_score + 常量 "rerank"(quality 直传)
originalRank            ← 数组下标 +1,排序的权威
source / title / breadcrumb → 透传展示

retrieval_trace 不进这条链——它走审计旁路。进入后处理时,每个 hit 被浓缩成 "身份 + 分数 + 名次 + 正文"四组信息,四步加工全部围绕它们转。


3. 站点三:后处理四步——EvidencePostprocessResult

① 打分    score + scoreLabel → qualityScore(RERANK 分支直传,clamp [0,1])
② 排序    只按 originalRank —— ★ 分数不参与排序,只用于判级
③ 去重截断  evidenceKey 去重 → 单文档 chunk ≤2 → 总数 ≤5(return-n)
④ 判级    顶分 ≥0.75 PRECISE / ≥0.5 REFERENCE / 否则无档

输出结构逐字段(7 条命中进、5 块存出的例子):

字段 规则 例值
candidateCount 输入候选数 7
evidenceBlockCount 存活块数(差值 = 被治理掉的) 5
topSimilarity 排序后第一名的 qualityScore 0.91
relevanceLevel Java 按本地阈值独立判定(不用 py-rag 返回的档位,那个字段映射时已丢弃) PRECISE
completenessHint 档位绑定的天花板提示文案 "知识库中不存在比上述结果更精准的文档"
rerankTrace[] 每个存活块的最终序账本 finalRank 1~5

3.1 去重辨析:三条规则别混淆

层 键 规则 目的
去重 evidenceKey = docId#chunk-N 同一块再现 → 合并(hitReasons 取并集) 同一片段只出现一次
单文档上限 docId(分桶) 同文档不同 chunk 可共存,最多 2 防单文档刷屏
总条数 — 最多 5 上下文预算

合并键是 evidenceKey 而不是 docId——同文档的第 0 段和第 1 段是两份不同证据,按 docId 合并会把多片段证据文档级折叠掉。单次检索正常不会返回同一个 chunk 两次(每个 chunk 是唯一 索引条目),合并分支是给"索引脏数据 / 未来多路归并"准备的防御性兜底,成本一次 map 查询。

3.2 为什么会同文档多块命中——根源在分块

入库时文档切成多个 chunk,每块独立 embedding、独立索引;检索按 chunk 算相似度。 这是刻意的颗粒度选择:整篇文档一个向量会稀释语义,且上下文也塞不下全文。 切块是为了检索得准、取得少;同文档多块命中是切块的自然结果; "chunk 级身份 + 单文档上限"就是为管理这个现象而生的。

3.3 rerankTrace:最终序账本

Item { finalRank; source; baseScore; finalScore; boostReasons }
  • 条数 = 存活块数(finalRank 连续编号);被合并/截断的块不产生条目;
  • baseScore == finalScore 恒相等——双字段是规则加分时代的化石(当年关键词加分, 现已废除防操纵);boostReasons 同理,装的已是纯解释标签;
  • 只记到文档级(无 evidenceKey),chunk 身份要看 evidenceBlocks[];
  • Agent 看不到它,审计默认不落库——活在内存 LookupResult 与评测快照里。

3.4 附带闸门

isLowQuality():无可用块或顶分 <0.5 即低质。原触发 filtered→unfiltered 重试, L0 下沉后分支休眠、闸门保留——将来 Java 侧重引过滤策略可直接接上。


4. 站点四:打包——ContextPack(不是 Agent 口粮)

KnowledgeContextPacker 把证据块压成一段有字符预算的文本(默认 4000 字符):

策略 ranked_evidence_char_budget:名次即优先级,先到先得
  [Evidence 1]
  source: gateway-timeout.md
  breadcrumb: 网关超时排查 > 处理步骤
  reasons: semantic_rank:1, attempt:UNFILTERED_VECTOR   ← 召回溯源标签
  content:
  网关超时先检查 upstream 配置…
  预算见底:装得下 header → 正文截断加 "...";连 header 都放不下 → 整条进 omittedSources
ContextPack { packedText; strategy; charBudget; usedChars; includedSources; omittedSources }

必须澄清的定位:主诊断链路里 Agent 不消费 packedText(主代码零调用)——Agent 拿的是 站点六投影后的结构化列表。它的价值:人工回放可读、评测快照存证、以及将来任何 "证据进 prompt"路径的现成格式化出口(字符预算是与后处理"块数预算"互补的物理闸门)。

reasons: 行是证据的"简历":semantic_rank:N(本次检索第几名)+ attempt:X(哪次尝试产出, 当前只剩 UNFILTERED_VECTOR;历史值 FILTERED_VECTOR / UNFILTERED_VECTOR_RETRY 已随 L0 下沉绝迹)。

小化石:ContextPack 的 javadoc 仍写 "Agent-facing",与 packer 侧注释矛盾—— 它诞生时确实面向 Agent,投影路线成为主路径后退居内部,注释没跟上身份变化。


5. 站点五:组装——LookupResult 真相全集

LookupResultAssembler 把三样东西合体成内部契约出口:

EvidencePostprocessResult(证据集+档位+trace)┐
ContextPack(打包文本)                      ├─► LookupResult
RetrievalTrace(检索路径)                   ┘

只有两个字段是"算"出来的:found = hasUsableEvidence()(false 时附固定兜底文案 "知识库未检索到可用证据,请结合日志、指标、告警继续排查");两个 count 把"进多少/出多少" 带给审计。其余字段一一搬运。


6. 站点六:投影——RagResultProjector(加工链最后一站)

输入是 LookupResult 的 JSON 字符串而非对象——内部结构随便演化,冻结契约纹丝不动, 解耦的关键就是这层"字符串边界"(字段名还带防御性别名对:evidenceBlocks/evidence_blocks)。

① query 截断(≤500 字)                          动了 → truncated
② 逐块过三道闸:
   条数 ≤8(超出 truncated+停止)
   身份去重(evidenceKey → document_id → docId#chunk-N → 序号兜底 的回退链)
   摘录 ≤1200 字(截断 → truncated)
③ evidence_status 客观判定:数组空不空(EVIDENCE_FOUND / NO_EVIDENCE)
④ relevance_level:有证据才读,解析不出 → null
⑤ fitBudget 总字节兜底:整个 JSON 超 16KB → 从尾部逐条裁
   裁到空 → 诚实降级 NO_EVIDENCE;还超 → 抛异常(fail closed)

三道递进预算闸(条数管语义 / 片段管局部 / 字节管整体)集中在 ToolProjectionLimits 一个 record(8 条 / 1200 字 / 16KB,与日志、MySQL 工具共用)。truncated 只要任何一处 动过就置位——Agent 永远知道"看到的可能不全",不会把截断结果当全量。


7. 站点七:Agent 视图——模型实际看到的 JSON

{
  "evidence_status": "EVIDENCE_FOUND",
  "tool_call_id": "call_x1",
  "query": "网关超时怎么排查",
  "evidence": [
    { "document_id": "e2e-gateway-…-34223174#chunk-1",
      "source": "e2e-gateway-b9c1fa12-md",
      "title": "网关超时排查",
      "breadcrumb": "网关超时排查 > 处理步骤",
      "excerpt": "网关超时先检查 upstream 配置…" }
  ],
  "returned_count": 5,
  "relevance_level": "PRECISE",
  "truncated": false
}
字段 模型的正确用法
evidence_status NO_EVIDENCE → 老实换工具(日志/指标/MySQL),不许编
evidence[].excerpt 结论唯一的内容依据
evidence[].document_id 引用坐标——报告里逐字引用它,EvidenceGuard 只认这个
relevance_level PRECISE 可放心下结论;REFERENCE 结合上下文判断,必要时说清还缺什么维度
truncated true → 看到的可能不全,可收窄 query 重搜

三个"看不到":分数(防未校准数字诱导过度自信)、过程(attempt/trace 留给人)、 其他站的内部字段。一句话:留坐标、留内容、留诚实,剥掉一切会误导或撑爆上下文的东西。


8. 站点八:验真——EvidenceGuard(证据安全链第一道闸)

Agent 写完诊断产出结构化草稿 DiagnosisDraft(analysis 带引用的 toolCallIds, conclusion/action_plan 带 basedOnAnalysisIds,limitations 必填)。EvidenceGuard 纯规则验真:

A 结构校验    id 唯一、正文非空、报告引用必须指向已登记分析、limitations 必填
B 引用验真    runId+toolCallId → Redis 账本可查 → READY 状态 → 同 Run(防跨 Run 挪用)
              → kind 语义匹配:NORMAL↔EVIDENCE_FOUND / NEGATIVE_OBSERVATION↔NO_EVIDENCE
C 重读重建    从账本严格反序列化投影(多一个字段都违规)→ 用账本内容重建证据快照

四个设计点:

  1. 证据内容从账本重读,不信模型复述——模型转述的"检索结果"进不了快照;
  2. kind 匹配堵两头撒谎——"查到了装没查到"与"没查到装查到了"都过不去;
  3. runId 绑定 + READY——别的 Run 的证据、失败/过期的调用不可引用;
  4. 纯规则、20 个违规码全枚举——便宜、确定、可审计;"结论是否夸大"留给下一道 SemanticGuard,这里只回答"引用是否真实、结构是否合法"。

9. 附:模型怎么知道要输出那份 JSON

四层合力,没有一刻依赖模型"自觉":

层 机制
格式 DiagnosisDraftOutputSchema(BeanOutputConverter)从 Java 类自动生成 JSON Schema 注入 prompt;解析失败抛异常
语义 diagnosis-agent-prompt.md:证据充分 → 全填并建立完整引用链;证据不足 → conclusion=null 是合法成功(schema 层把 conclusion 类型改成 object|null);limitations 无条件必填
时机 模型自主停止(无有效查询范围即停)+ Harness 强制(连续 NO_GAIN / 预算耗尽 → STOP_REQUIRED 后必须直接交卷)
兜底 格式错/引用违规 → evidence-repair-prompt 修复重试 → 仍败 → 固定降级 FALLBACK

10. 设计思路总结(五条哲学)

  1. 防腐层:换引擎不换证据链——KnowledgeSearchPort 是接缝,抽离时消费面零改动、 250 个测试原样通过,这是接口设计价值的最硬证明。
  2. 分数给系统,档位给模型——未校准的连续分数会诱导过度自信;quality_score 在 Java 侧做闸门和审计,Agent 只见 PRECISE/REFERENCE。
  3. chunk 级身份贯穿始终——入库分块、检索按块、去重按 docId#chunk-N、引用坐标到块、 验真对块。颗粒度统一,才有多片段证据共存与伪引用无处遁形。
  4. 诚实标记——truncated、no_evidence、unchanged、no_evidence_basis: 系统从不假装"看到的即全部",每个不完整/为空都有显式信号与原因。
  5. 所见即所证——投影结果是"模型看到的"与"Redis 存证的"同一份;EvidenceGuard 对账 没有翻译损耗,伪引用无处遁形。

化石清单(读代码时的辨认指南)

化石 现状
RerankTrace.baseScore/finalScore 双字段 恒相等(规则加分已废),保留兼容旧审计格式
boostReasons 字段名 装的是纯解释标签,不再加分
ContextPack javadoc "Agent-facing" 身份已变(内部/储备),注释未跟上
LookupResultAssembler.deduped() 会话级去重回包的历史占位,主链路不再调用
FILTERED_VECTOR / UNFILTERED_VECTOR_RETRY attempt L0 下沉后不可达,仅存于旧 Run 回放
KnowledgeQuery 的 L0 hint 字段 恒空结构,供后处理与 trace 兼容保留

11. 关键字段速查

字段 哪一站 一句话
evidence_key py-rag → 全程 chunk 级身份 docId#chunk-N,去重与验真的锚
quality_score py-rag → 后处理 rerank 绝对分 [0,1],quality 直传
evidence_status py-rag / 投影 两类:supported / no_evidence(正常业务响应)
relevance_level 后处理 → Agent PRECISE/REFERENCE 档位,Java 按本地阈值独立判定
originalRank adapter → 后处理 排序唯一权威,分数不动序
candidateCount / evidenceBlockCount 后处理 进多少 / 出多少,差值即治理幅度
truncated 投影 任何截断都置位,诚实标记
tool_call_ids Draft → EvidenceGuard 引用验真的入口,runId 绑定 + READY 校验