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

337 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RAG 证据链探索笔记:从 py-rag 响应到引用验真
**日期**:2026-09-29
**范围**:RAG 模块抽离后的完整数据链路——每个站点"数据长什么样、字段怎么来、为什么这样设计"
**读者**:要理解或维护 `lookup_knowledge` 证据链的工程同学
**关联文档**:
- 架构规范:[../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)(本次抽离后的权威口径)
- Trace / 审计:[../../architecture/RAG检索可观测性与审计.md](../../architecture/RAG检索可观测性与审计.md)
- 契约原文:py-rag 仓库 `docs/Java接入文档.md`(API v1 冻结面)
- 前置知识:`RAG-Hybrid质量分与后处理.md`(分数语义演进史)
> 本文按一次真实代码探索的顺序组织:从 py-rag 返回的 JSON 出发,沿着数据走过的每一站,
> 讲清关键字段、加工规则与设计取舍,最后到 Agent 引用验真收口。
---
## 0. 全景路线图
```text
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 按职责分四块,四块数据两条去路(①②③喂后处理,④喂审计):
```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 个字段**:
```text
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`
```text
① 打分 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`:最终序账本
```java
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 字符):
```text
策略 ranked_evidence_char_budget:名次即优先级,先到先得
[Evidence 1]
source: gateway-timeout.md
breadcrumb: 网关超时排查 > 处理步骤
reasons: semantic_rank:1, attempt:UNFILTERED_VECTOR ← 召回溯源标签
content:
网关超时先检查 upstream 配置…
预算见底:装得下 header → 正文截断加 "...";连 header 都放不下 → 整条进 omittedSources
```
```java
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` 把三样东西合体成内部契约出口:
```text
EvidencePostprocessResult(证据集+档位+trace)┐
ContextPack(打包文本) ├─► LookupResult
RetrievalTrace(检索路径) ┘
```
只有两个字段是"算"出来的:`found = hasUsableEvidence()`(false 时附固定兜底文案
"知识库未检索到可用证据,请结合日志、指标、告警继续排查");两个 count 把"进多少/出多少"
带给审计。其余字段一一搬运。
---
## 6. 站点六:投影——`RagResultProjector`(加工链最后一站)
**输入是 LookupResult 的 JSON 字符串而非对象**——内部结构随便演化,冻结契约纹丝不动,
解耦的关键就是这层"字符串边界"(字段名还带防御性别名对:`evidenceBlocks`/`evidence_blocks`)。
```text
① 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
```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 纯规则验真:
```text
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 校验 |