docs(harness): annotate RAG backend core classes and add retrieval learning note
- Annotate LookupKnowledgeTool, KnowledgeEvidencePostProcessor, RrfFusion, KnowledgeDocumentRetriever - Add RAG retrieval learning note: L0 navigation, multi-recall + RRF, qualityScore, degradation, contract semantics, validation (audit + offline eval), discussion insights
This commit is contained in:
@@ -0,0 +1,347 @@
|
|||||||
|
# Harness RAG 检索体系学习笔记:从 query 到可验证证据
|
||||||
|
|
||||||
|
**更新日期**:2026-08-03
|
||||||
|
**主题**:lookup_knowledge 完整后端链路——检索前/检索/检索后/打包/组装/降级/契约/验证
|
||||||
|
**设计文档**:`mvp/engineering/rag/`(RAG 排序、Hybrid 质量分、relevance_level 等)
|
||||||
|
**代码视角**:[Harness tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链衔接)
|
||||||
|
|
||||||
|
## 1. 定位与骨架
|
||||||
|
|
||||||
|
一次 `lookup_knowledge` 从 query 到证据的旅程(模块化三段 + 收尾):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q["query"] --> A["检索前<br/>KnowledgeQueryTransformer<br/>L0 导航(分类/域/关键词)"]
|
||||||
|
A --> B["检索<br/>KnowledgeDocumentRetriever<br/>dense + BM25 → RRF 融合"]
|
||||||
|
B --> C["检索后<br/>KnowledgeEvidencePostProcessor<br/>qualityScore/去重/判级/闸门"]
|
||||||
|
C -->|"低质"| B2["降级重试<br/>UNFILTERED_VECTOR_RETRY<br/>(去过滤 + 原始 query)"]
|
||||||
|
C --> D["打包<br/>KnowledgeContextPacker"]
|
||||||
|
D --> E["组装<br/>LookupResultAssembler<br/>→ LookupResult"]
|
||||||
|
E --> F["投影<br/>RagResultProjector<br/>→ RagToolResult(Agent 契约)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键特征**:模块化三段各自独立 Service、降级是阶段间控制流、双轨可观测(RetrievalTrace + RerankTrace)、LookupResult 是内部契约(Agent 看到的是投影后的 RagToolResult)。
|
||||||
|
|
||||||
|
## 2. 检索前:L0 导航(缩短边界,不决定边界)
|
||||||
|
|
||||||
|
- **产出**:categoryFilter / domainHints / matchedKeywords / entities / l0Titles(从 query 语义推导)
|
||||||
|
- **只缩短边界**:categoryFilter 限定「搜哪些分类」(FILTERED_VECTOR)
|
||||||
|
- **不决定边界**:低质 → 降级去掉过滤重查(L0 边界可被推翻)
|
||||||
|
- **只解释不打分**:L0 命中只写 hitReasons(l0_domain_overlap),不改 qualityScore 和排序——防关键词碰瓷
|
||||||
|
- **语义差异应对**:降级用原始 query(非 rewritten)——抹掉 L0 推导误差
|
||||||
|
|
||||||
|
## 3. 检索:多路召回 + RRF
|
||||||
|
|
||||||
|
**为什么混合检索**:旧方案(dense 语义 + L0 关键词加权重排)有词频碰瓷误差——词频高但相关性不高的排前面。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 召回
|
||||||
|
D["dense ANN(L2)<br/>抓语义相似"]
|
||||||
|
S["sparse BM25<br/>抓精确匹配"]
|
||||||
|
end
|
||||||
|
D --> R["RRF 融合<br/>score = Σ w/(k + rank)"]
|
||||||
|
S --> R
|
||||||
|
R --> F["融合排序(originalRank)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键决策**:
|
||||||
|
- **RRF 用排名不用分数**——屏蔽跨路分数尺度不可比(dense 的 L2 vs BM25 的稀疏分)
|
||||||
|
- **k=60**(`retrieval.hybrid.rrf-k`)——平滑参数,排名差异对分数的影响平缓
|
||||||
|
- **加权是预留能力**:RrfFusion 支持 `w/(k+rank)`,但当前 Milvus 服务端走等权 RRFRanker(只传 k)——想让某路更可信时再调旋钮
|
||||||
|
- **召回优先**(RAG 排序文档观点):候选池只有 3 条时,精排只能换座位,召不回的内容永远排不上来
|
||||||
|
|
||||||
|
## 4. 检索后:qualityScore 统一 + 质量闸门
|
||||||
|
|
||||||
|
### 4.1 为什么需要统一分数
|
||||||
|
|
||||||
|
三路返回三种分数(L2 距离 / BM25 稀疏分 / RRF 融合分)——不可比,必须统一成 qualityScore ∈ [0,1]。
|
||||||
|
|
||||||
|
### 4.2 打分(RetrievalScoreNormalizer)
|
||||||
|
|
||||||
|
```text
|
||||||
|
DENSE: l2ToQuality(score) = 1 - clamp(L2)/maxL2 (maxL2 默认 2.0)
|
||||||
|
HYBRID: denseDistance != null ? l2ToQuality(denseDistance) ← 恢复绝对质量
|
||||||
|
: rankToQuality(rank, batchSize) ← BM25-only 保守回退
|
||||||
|
```
|
||||||
|
|
||||||
|
**denseDistance 的来源**(隐藏机制):hybrid 融合后**再单独跑一次 searchDense**,按 id 把 L2 补到融合结果上——因为服务端 RRF 只输出融合分,原始 L2 信息丢了。`attachDenseDistances` 只填充不改 score/label/order(排序评估分离的又一体现)。
|
||||||
|
|
||||||
|
### 4.3 排序与评估分离(核心设计)
|
||||||
|
|
||||||
|
```text
|
||||||
|
originalRank(RRF 融合序)→ 排序:谁在前面(相对序)
|
||||||
|
qualityScore(L2/rank) → 评估:够不够格、要不要降级(绝对度)
|
||||||
|
→ 排序不用 quality 重排(防 boost 操纵)
|
||||||
|
→ quality 只被判级和闸门消费
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么排名不能证明质量**:排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)——排第 1 只代表「这批里最好」,不代表「够好」(候选池全是低质时排第 1 的也低质);RRF 分本身不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别。
|
||||||
|
|
||||||
|
### 4.4 五步流程(process)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 打分(toQualityScore)→ ② 排序(originalRank,不用 quality 重排)
|
||||||
|
③ 去重/截断:evidenceKey 合并 + maxChunksPerDocument=2 + returnN=5
|
||||||
|
④ 判级:top qualityScore ≥0.75→PRECISE / ≥0.5→REFERENCE
|
||||||
|
⑤ 闸门:topSimilarity <0.5 → 低质 → 降级重查
|
||||||
|
```
|
||||||
|
|
||||||
|
**两级去重**(粒度不同):
|
||||||
|
```
|
||||||
|
evidenceKey 去重(chunk 级):同 chunk(docId#chunkIndex)被两路召回 → mergeEvidence 合并
|
||||||
|
—— mergeEvidence 只合并 hitReasons + 补 breadcrumb,不处理 content(同 chunk 内容相同)
|
||||||
|
maxChunksPerDocument(文档级):同文档不同 chunk 最多 2 个 → 防单文档垄断证据槽位
|
||||||
|
```
|
||||||
|
|
||||||
|
**判级用 ranked(全量)不是 deduped**——判级评估「整体质量」(全量 top),去重决定「输出内容」(合并片段),两件事平行。
|
||||||
|
|
||||||
|
**BM25-only 的弱点**:无 dense 邻居 → rankToQuality 回退(排第 1 恒为 1.0)——整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)。改善方向:文本相似度兜底(绝对信号)+ 批次一致性检查(整体水平),而非返回 BM25 分(统计度量无绝对语义)。
|
||||||
|
|
||||||
|
## 5. 契约语义:relevance_level 是什么、不是什么
|
||||||
|
|
||||||
|
- **是什么**:一次 lookup_knowledge 调用整体有多相关的**粗档标签**(判级产出:PRECISE/REFERENCE/null)
|
||||||
|
- **不是什么**:不是单条 evidence 的分数、不是相似度数值、不是「结论可发布」判据
|
||||||
|
- **Agent 正确用法**:evidence_status 管有没有证据,relevance_level 管这批评据多硬/要不要再查,实际写诊断引用的是 evidence[].excerpt
|
||||||
|
- **REFERENCE ≠ NO_GAIN**:一般相关可能仍排除一个假设——语义价值由模型判断(progress 原则)
|
||||||
|
|
||||||
|
## 6. 打包与组装
|
||||||
|
|
||||||
|
### ContextPacker(打包)
|
||||||
|
|
||||||
|
```
|
||||||
|
输入顺序即优先级 → 逐条塞进 4000 字符预算
|
||||||
|
单条放不下 → content 截断(+"...")
|
||||||
|
连 header 都放不下 → 整条省略(记 omittedSources)
|
||||||
|
输出:packedText + strategy + charBudget/usedChars + included/omittedSources
|
||||||
|
```
|
||||||
|
|
||||||
|
**当前定位**:Agent 主要看结构化 evidence 列表,packedText 更多用于内部/调试/审计(Harness 用结构化列表因为可验真——evidence_ref 引用 document_id)。
|
||||||
|
|
||||||
|
### LookupResultAssembler(组装)
|
||||||
|
|
||||||
|
- **内部契约出口**:found / evidenceBlocks / 双数量 / 双 trace / relevanceLevel / completenessHint / message
|
||||||
|
- **found 在这里综合判定**:evidence.hasUsableEvidence()
|
||||||
|
- **message 语义**:found=false 时「知识库未检索到可用证据,请结合日志、指标、告警继续排查」——证据不足不是失败,是换方向引导
|
||||||
|
- **与投影的关系**:LookupResult 是后端完整出口,RagResultProjector 再裁剪成 Agent 契约(两层契约)
|
||||||
|
|
||||||
|
## 7. 降级:突破 L0 边界的兜底
|
||||||
|
|
||||||
|
```
|
||||||
|
触发:categoryFilter != null && isLowQuality(无证据 或 topSimilarity < 0.5)
|
||||||
|
动作:原始 query(非 rewritten)+ 去掉分类过滤 + 覆盖选择(不合并两轮)
|
||||||
|
原因分类:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(完全没查到)
|
||||||
|
有限降级:只一次(防重查风暴);retry 低质也接受(降级失败直接返回)
|
||||||
|
trace:attempts 记录全部尝试(FILTERED/UNFILTERED/UNFILTERED_RETRY)
|
||||||
|
+ selectedAttempt + fallbackReason → 可对比「过滤 vs 全域」判断 L0 过滤是否过度
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 投影衔接(RagResultProjector)
|
||||||
|
|
||||||
|
- **契约转换**:LookupResult JSON → RagToolResult(evidence[] + relevance_level + evidence_status + truncated)
|
||||||
|
- **四重有界**:query 500 字 / excerpt 1200 字 / 条数 8(maxEvidence)/ 总字节 16KB(fitBudget)
|
||||||
|
- **优先级链去重**:evidenceKey → document_id → docId#chunk-idx → legacy 序号(chunk 级身份)
|
||||||
|
- **诚实标记**:一切有损(截断/去重丢弃/query 截断)都置 truncated;fitBudget 裁空诚实降级 NO_EVIDENCE + relevance 置 null(没有证据就没有相关度,自洽)
|
||||||
|
- **两级数量限制**:后端 returnN=5(业务目标值)vs maxEvidence=8(Harness 护栏)——5<8 时护栏休眠,后端配置失控时兜底
|
||||||
|
|
||||||
|
## 9. 验证:审计 + 离线评测
|
||||||
|
|
||||||
|
### 9.1 审计 vs Trace(两套记录系统)
|
||||||
|
|
||||||
|
| | DiagnosisTrace(事件流) | ToolInvocationAudit(调用档案) |
|
||||||
|
|---|---|---|
|
||||||
|
| 粒度 | 事件(一次调用多个事件) | 记录(一次调用一行) |
|
||||||
|
| 覆盖 | Run 全生命周期 | 仅工具调用 |
|
||||||
|
| 内容 | metadata-only(不存 raw) | 完整(raw + agentResult + enrichments) |
|
||||||
|
| 用途 | 时序回放 / Token 对账 | 单次调用深查 |
|
||||||
|
|
||||||
|
```
|
||||||
|
RAG 后端双 trace(Retrieval/Rerank)→ 进 LookupResult → Harness
|
||||||
|
→ 安全字段提取 → tool_invocation 审计(enrichments)
|
||||||
|
→ 轻量事件 → DiagnosisTrace
|
||||||
|
审计在投影之后、给模型之前(ToolBoundary.execute 内先落库)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 离线评测设计(eval/rag-retrieval)
|
||||||
|
|
||||||
|
```
|
||||||
|
三层:offline(fixtures × golden-cases,无真实栈)/ snapshot 生成(真实跑一次冻结)/ live smoke
|
||||||
|
断言:行为契约(expectedDocIds/Breadcrumbs/Keywords/SelectedAttempt/FallbackReason/EvidenceStatus)
|
||||||
|
+ 不断言 raw scores / chunkId / 全序(脆或内部实现)
|
||||||
|
基线:baseline.json + diff(区分有意改进 vs 无意回归)
|
||||||
|
隔离:seed-docs + kb_scope=rag-eval
|
||||||
|
```
|
||||||
|
|
||||||
|
**断言设计原则**:
|
||||||
|
- 断言「用户可感知的结果 + 管道行为」,不断言「实现细节」(chunk/分数/全序)
|
||||||
|
- 双信号:fail 抓行为回归 + diff 抓「绿了但漂了」(通过但退化可见)
|
||||||
|
- **期望来自设计意图,不从当前输出反推**(否则固化 bug)
|
||||||
|
- golden set 是演进的:初期种子(设计意图)→ 中期真实数据(**必须人工验证**——成功案例只是观测,不是契约)→ 持续事故固化
|
||||||
|
|
||||||
|
### 9.3 指标(缺的下一步)
|
||||||
|
|
||||||
|
```
|
||||||
|
检索质量:recall@k / Hit@k / MRR(复用 expectedDocIds + rank,低成本)
|
||||||
|
管道行为:降级触发正确率 / 降级有效率 / 过滤误伤率(复用 trace 字段)
|
||||||
|
质量闸门:低质识别准确率 / 降级误杀率(BM25-only 弱信号代价可测)
|
||||||
|
需要新标注:precision@k(负例)/ nDCG(相关度分级)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 易错点
|
||||||
|
|
||||||
|
| 易错 | 正确 |
|
||||||
|
|---|---|
|
||||||
|
| RRF 排序了就不用打分 | 排序(RRF)与评估(qualityScore)分离——RRF 管谁在前,L2 管够不够格 |
|
||||||
|
| 排第 1 = 质量好 | 排第 1 只代表「这批里最好」——候选池全低质时排第 1 也低质 |
|
||||||
|
| 返回 BM25 分能解决 BM25-only | BM25 是统计度量(无界/依赖集合),无绝对语义——用文本相似度/一致性检查 |
|
||||||
|
| denseDistance 是融合分 | 是融合后再跑一次 dense 探测的 L2(RRF 丢了原始 L2) |
|
||||||
|
| 去重和判级有先后 | 平行:去重管输出(deduped),判级管评估(ranked 全量 top) |
|
||||||
|
| 后端 returnN=5,maxEvidence=8 多余 | returnN 是业务目标值,maxEvidence 是 Harness 护栏(防配置失控) |
|
||||||
|
| 线上成功案例可直接当 golden | 成功只是「当前实现没出错」的观测——必须人工确认设计意图 |
|
||||||
|
| 审计在给模型之后 | 审计在投影后、给模型前(ToolBoundary 内先落库) |
|
||||||
|
|
||||||
|
## 11. 讨论沉淀:值得记住的问题与洞见
|
||||||
|
|
||||||
|
本节收录学习过程中的关键问答——按价值分层,面试准备直接翻这里。
|
||||||
|
|
||||||
|
### 11.1 触及设计本质(第一梯队)
|
||||||
|
|
||||||
|
**① 「RRF 已经排序了,为什么还要打分」——排序与评估分离**
|
||||||
|
|
||||||
|
```text
|
||||||
|
RRF 管「谁在前面」(融合排序,相对序)
|
||||||
|
qualityScore 管「够不够格」(质量评估,绝对度)
|
||||||
|
|
||||||
|
为什么排名不能证明质量:
|
||||||
|
排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)
|
||||||
|
排第 1 只代表「这批里最好」,不代表「够好」——候选池全是低质时排第 1 也低质
|
||||||
|
RRF 分不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别
|
||||||
|
```
|
||||||
|
|
||||||
|
**② 「最终落地到 dense 决定,RRF 白用了吗」——谁被评估 vs 评估够不够**
|
||||||
|
|
||||||
|
```text
|
||||||
|
RRF/BM25 管:召回 + 排序(BM25 路召回 dense 召不回的候选,RRF 让两路共识靠前)
|
||||||
|
dense L2 管:质量评估的绝对标尺(唯一有绝对语义的)
|
||||||
|
→ 分工:RRF 决定「谁能被评估」,dense 决定「评估结果够不够」
|
||||||
|
→ 「排第一但 dense 低质 → 降级」不是矛盾,是排序与评估分离的价值(发现域选错)
|
||||||
|
```
|
||||||
|
|
||||||
|
**③ 「能不能返回 BM25 分当质量」——度量类型决定能否设阈值**
|
||||||
|
|
||||||
|
```text
|
||||||
|
几何度量(L2):embedding 空间稳定 → 能设 0.75/0.5 绝对阈值
|
||||||
|
统计度量(BM25):无界、依赖集合 IDF、随集合演进漂移 → 设不了稳定阈值
|
||||||
|
→ 质量评估需要绝对标尺,只能来自几何度量或可解释相似度(字符重叠)
|
||||||
|
→ 改善 BM25-only:文本相似度兜底 / 批次一致性检查 / fail-closed,而非返回 BM25 分
|
||||||
|
```
|
||||||
|
|
||||||
|
**④ 「BM25-only 质量有问题」(自己发现的设计弱项)**
|
||||||
|
|
||||||
|
```text
|
||||||
|
rank 回退:排第 1 恒为 1.0 → 整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)
|
||||||
|
根因:rank 是相对序(第 1 名不代表够 0.5),顶位给满分是「排序最好 = 质量满分」的错误等价
|
||||||
|
改进:rank 顶位保守化 / 文本相似度兜底 / 批次一致性检查
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 隐藏机制(第二梯队)
|
||||||
|
|
||||||
|
**⑤ 「denseDistance 是融合分还是 dense 分」**
|
||||||
|
|
||||||
|
```text
|
||||||
|
是 dense 那一路的 L2——RRF 服务端融合只输出融合分,原始 L2 信息丢了
|
||||||
|
→ attachDenseDistances 融合后再单独跑一次 searchDense,按 id 把 L2 补到融合结果
|
||||||
|
→ 只填充不改 score/label/order(排序评估分离的又一体现)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⑥ 「为什么降级只一次」——有限降级**
|
||||||
|
|
||||||
|
```text
|
||||||
|
降级 = 突破 L0 边界重查(原始 query + 去过滤 + 覆盖选择)
|
||||||
|
只降级一次:预算约束(retrieveK × 2 检索成本)防重查风暴
|
||||||
|
fallbackReason 区分:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(没查到)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⑦ 「returnN=5 为什么 maxEvidence=8」——目标值 vs 护栏**
|
||||||
|
|
||||||
|
```text
|
||||||
|
returnN=5:RAG 业务目标值(rag.return-n)——打算给 5 条
|
||||||
|
maxEvidence=8:Harness 安全上限(ToolProjectionLimits)——最多允许多少
|
||||||
|
两级解耦:业务层和安全层各自配置;5<8 时护栏休眠,后端配置失控时兜底
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 方法论沉淀(第三梯队,可迁移)
|
||||||
|
|
||||||
|
**⑧ 从 0 设计离线评测的八步**
|
||||||
|
|
||||||
|
```text
|
||||||
|
目标(回归保护)→ 粒度(工具级)→ 输入(冻结快照)→ 断言(行为契约)
|
||||||
|
→ 用例(行为维度覆盖)→ 隔离(种子数据)→ 基线(区分有意/无意变化)→ 成本(分层运行)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⑨ golden set 怎么设计**
|
||||||
|
|
||||||
|
```text
|
||||||
|
行为清单 → 每个行为一个 case → query 拟真(能触发目标行为)
|
||||||
|
→ 期望来自设计意图(不从当前输出反推——否则固化 bug)→ 补负例/边界
|
||||||
|
→ 演进:初期种子打底 → 中期真实数据(人工验证后转契约)→ 持续事故固化
|
||||||
|
```
|
||||||
|
|
||||||
|
**⑩ 「线上成功案例能不能直接用」——观测 ≠ 契约**
|
||||||
|
|
||||||
|
```text
|
||||||
|
线上成功只是「当前实现没出错」的观测:可能恰好没触发 bug 路径、结果碰巧对
|
||||||
|
→ 必须人工确认「结果确实符合设计意图」后才从观测升级为契约
|
||||||
|
→ 失败案例则明确「期望应该怎样」作回归保护
|
||||||
|
```
|
||||||
|
|
||||||
|
**⑪ 「不用 chunk 断言也是数据原因吗」——不是**
|
||||||
|
|
||||||
|
```text
|
||||||
|
chunk 边界是切分实现细节:算法优化/文档微调都让 chunk 偏移 → 合法重构被误判回归
|
||||||
|
契约语义的证据单位是文档级(document_id 常等于 source)——chunk 模型都看不到
|
||||||
|
→ 即使数据充足也不该断言 chunk(和数据量无关)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 三个核心洞见(最值得记住)
|
||||||
|
|
||||||
|
```text
|
||||||
|
① 排序与评估分离:RRF 管「序」(相对),L2 管「度」(绝对)——排名不能证明质量
|
||||||
|
② 度量类型决定能不能设阈值:几何(L2)可以,统计(BM25)不行
|
||||||
|
③ 期望来自设计意图,不从实现反推——这是评测和 golden set 的分水岭
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 面试话术(30 秒)
|
||||||
|
|
||||||
|
### 11.1 排序与评估为什么分离
|
||||||
|
|
||||||
|
> "RRF 管『谁在前面』(融合排序),qualityScore 管『这批结果够不够格』(质量评估)——打分不是重排,是排序后的质量校验。RRF 分是排名派生的相对值,没法设绝对阈值(排第 1 不代表够 0.5,候选池全是低质时排第 1 的也低质);qualityScore 把 dense L2 归一化成 [0,1] 的绝对质量,用于判级(0.75/0.5 阈值)和闸门(<0.5 触发降级)。排序决定看哪些,评估决定够不够好。"
|
||||||
|
|
||||||
|
### 11.2 为什么 BM25 分不能当质量
|
||||||
|
|
||||||
|
> "质量评估需要绝对标尺,绝对标尺只能来自几何度量(L2 距离——embedding 空间稳定)或可解释的相似度(字符重叠),不能来自统计度量(BM25——无界、依赖文档集合的 IDF、随集合演进漂移)。BM25-only 命中用排名回退估质量(保守),但顶位给满分是设计弱项——改进方向是文本相似度兜底或批次一致性检查,而不是返回 BM25 分。"
|
||||||
|
|
||||||
|
### 11.3 降级设计
|
||||||
|
|
||||||
|
> "降级是突破 L0 过滤边界的兜底:带分类过滤检索结果低质(无证据或 topSimilarity<0.5)时,用原始 query + 去掉分类过滤重查一次(UNFILTERED_VECTOR_RETRY),结果覆盖选择、记录进 trace。fallbackReason 区分『查到了但低质』vs『完全没查到』;只降级一次(预算约束防重查风暴),降级失败也直接以低质结果返回。attempts 列表让审计能对比过滤 vs 全域检索差异,判断 L0 过滤是否过度。"
|
||||||
|
|
||||||
|
### 11.4 golden set 怎么设计
|
||||||
|
|
||||||
|
> "golden set 是行为契约的清单:先列要保护的行为,每个行为一个 case(不耦合可定位);query 用能触发目标行为的真实形态;期望来自设计意图(我知道这个文档属于这个场景),绝不从当前输出反推(否则固化 bug);补负例与边界;golden set 是演进的——初期人为种子打底,中期真实数据必须人工验证后才能转契约,持续事故修复固化。核心:断言用户可感知的结果 + 管道行为,不断言实现细节。"
|
||||||
|
|
||||||
|
## 13. 代码位置索引
|
||||||
|
|
||||||
|
| 类 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| `LookupKnowledgeTool` | `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java` |
|
||||||
|
| `KnowledgeQueryTransformer` | `src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java` |
|
||||||
|
| `KnowledgeDocumentRetriever` | `src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java` |
|
||||||
|
| `KnowledgeEvidencePostProcessor` | `src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java` |
|
||||||
|
| `KnowledgeContextPacker` | `src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java` |
|
||||||
|
| `LookupResultAssembler` | `src/main/java/com/superbiz/agent/service/LookupResultAssembler.java` |
|
||||||
|
| `RetrievalScoreNormalizer` | `src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java` |
|
||||||
|
| `RrfFusion` | `src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java` |
|
||||||
|
| `MilvusHybridKnowledgeStore` | `src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java` |
|
||||||
|
| `RagResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java` |
|
||||||
|
| 审计链路 | `src/main/java/com/superbiz/agent/harness/audit/`(RagLookupAuditEnricher / JpaToolInvocationAuditSink) |
|
||||||
|
| 离线评测 | `eval/rag-retrieval/` + `scripts/eval_rag_retrieval.py` |
|
||||||
@@ -65,6 +65,7 @@ public class KnowledgeDocumentRetriever {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 检索命中 → 统一的候选模型:映射字段 + 补 hitReasons(semantic_rank + attempt)。 */
|
||||||
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName, List<KnowledgeSearchHit> hits) {
|
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName, List<KnowledgeSearchHit> hits) {
|
||||||
if (hits == null || hits.isEmpty()) {
|
if (hits == null || hits.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -94,6 +95,7 @@ public class KnowledgeDocumentRetriever {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组装单次 attempt 的 trace 元信息(候选数/耗时/顶分/错误/usable)。 */
|
||||||
private RetrievalTrace.Attempt attempt(String name,
|
private RetrievalTrace.Attempt attempt(String name,
|
||||||
String query,
|
String query,
|
||||||
String categoryFilter,
|
String categoryFilter,
|
||||||
@@ -113,6 +115,7 @@ public class KnowledgeDocumentRetriever {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取首条命中分数(用作该 attempt 的顶分)。 */
|
||||||
private Double topScore(List<KnowledgeSearchHit> hits) {
|
private Double topScore(List<KnowledgeSearchHit> hits) {
|
||||||
if (hits == null || hits.isEmpty() || hits.get(0).score() == null) {
|
if (hits == null || hits.isEmpty() || hits.get(0).score() == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -120,6 +123,7 @@ public class KnowledgeDocumentRetriever {
|
|||||||
return hits.get(0).score();
|
return hits.get(0).score();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小写去空格(searchMode 配置比对用)。 */
|
||||||
private static String trim(String value) {
|
private static String trim(String value) {
|
||||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,17 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
@Value("${rag.return-n:5}")
|
@Value("${rag.return-n:5}")
|
||||||
private int returnN = 5;
|
private int returnN = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索后处理主入口:打分 → 排序 → 去重/截断 → 判级 → 装配。
|
||||||
|
*
|
||||||
|
* <p>关键决策:qualityScore 不参与排序(排序按检索权威序 originalRank),
|
||||||
|
* 只用于判级(relevance)和闸门(isLowQuality)——防关键词 boost 操纵。
|
||||||
|
*/
|
||||||
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
|
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
|
||||||
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
|
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
|
||||||
int batchSize = safeCandidates.size();
|
int batchSize = safeCandidates.size();
|
||||||
|
|
||||||
|
// ① 打分 + 排序:qualityScore 归一化,但排序仍按 originalRank(检索权威序)
|
||||||
List<ScoredCandidate> ranked = safeCandidates.stream()
|
List<ScoredCandidate> ranked = safeCandidates.stream()
|
||||||
.map(candidate -> score(query, candidate, batchSize))
|
.map(candidate -> score(query, candidate, batchSize))
|
||||||
.sorted(Comparator
|
.sorted(Comparator
|
||||||
@@ -68,6 +75,7 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
.thenComparing(s -> resolveEvidenceKey(s.candidate()), Comparator.nullsLast(String::compareTo)))
|
.thenComparing(s -> resolveEvidenceKey(s.candidate()), Comparator.nullsLast(String::compareTo)))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
// ② 去重/截断:evidenceKey 去重 + 每文档 chunk 上限 + returnN
|
||||||
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
||||||
List<RerankTrace.Item> traceItems = new ArrayList<>();
|
List<RerankTrace.Item> traceItems = new ArrayList<>();
|
||||||
Map<String, Integer> chunksPerDocument = new HashMap<>();
|
Map<String, Integer> chunksPerDocument = new HashMap<>();
|
||||||
@@ -85,10 +93,12 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
String docBucket = resolveDocBucket(candidate, evidenceKey);
|
String docBucket = resolveDocBucket(candidate, evidenceKey);
|
||||||
|
|
||||||
if (deduped.containsKey(evidenceKey)) {
|
if (deduped.containsKey(evidenceKey)) {
|
||||||
|
// 同 evidenceKey:合并 hitReasons/补 breadcrumb,不新增
|
||||||
mergeEvidence(deduped.get(evidenceKey), toBlock(candidate, evidenceKey, scored));
|
mergeEvidence(deduped.get(evidenceKey), toBlock(candidate, evidenceKey, scored));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每文档 chunk 上限:超出则跳过该候选
|
||||||
int used = chunksPerDocument.getOrDefault(docBucket, 0);
|
int used = chunksPerDocument.getOrDefault(docBucket, 0);
|
||||||
if (used >= effectiveMaxChunks) {
|
if (used >= effectiveMaxChunks) {
|
||||||
continue;
|
continue;
|
||||||
@@ -121,6 +131,9 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 质量闸门:无可用证据、或顶分低于参考阈值 → 低质量(触发 LookupKnowledgeTool 降级重试)。
|
||||||
|
*/
|
||||||
public boolean isLowQuality(EvidencePostprocessResult result) {
|
public boolean isLowQuality(EvidencePostprocessResult result) {
|
||||||
if (result == null || !result.hasUsableEvidence()) {
|
if (result == null || !result.hasUsableEvidence()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -140,6 +153,9 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
return referenceThreshold;
|
return referenceThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个候选装配成 EvidenceBlock:content 截断 800 字 + 合并 hitReasons。
|
||||||
|
*/
|
||||||
private EvidenceBlock toBlock(RetrievedEvidenceCandidate candidate,
|
private EvidenceBlock toBlock(RetrievedEvidenceCandidate candidate,
|
||||||
String evidenceKey,
|
String evidenceKey,
|
||||||
ScoredCandidate scored) {
|
ScoredCandidate scored) {
|
||||||
@@ -157,6 +173,10 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证据身份:优先检索给的 evidenceKey;否则组合 docId + chunkIndex + id + rank
|
||||||
|
* (chunk 级去重身份,同文档多 chunk 可并存)。
|
||||||
|
*/
|
||||||
private String resolveEvidenceKey(RetrievedEvidenceCandidate candidate) {
|
private String resolveEvidenceKey(RetrievedEvidenceCandidate candidate) {
|
||||||
if (candidate.getEvidenceKey() != null && !candidate.getEvidenceKey().isBlank()) {
|
if (candidate.getEvidenceKey() != null && !candidate.getEvidenceKey().isBlank()) {
|
||||||
return candidate.getEvidenceKey();
|
return candidate.getEvidenceKey();
|
||||||
@@ -168,6 +188,7 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
candidate.getOriginalRank());
|
candidate.getOriginalRank());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文档分桶键:有 docId 用 docId,否则退回 evidenceKey(用于每文档 chunk 上限)。 */
|
||||||
private String resolveDocBucket(RetrievedEvidenceCandidate candidate, String evidenceKey) {
|
private String resolveDocBucket(RetrievedEvidenceCandidate candidate, String evidenceKey) {
|
||||||
String docId = EvidenceIdentity.trimToNull(candidate.getDocId());
|
String docId = EvidenceIdentity.trimToNull(candidate.getDocId());
|
||||||
if (docId != null) {
|
if (docId != null) {
|
||||||
@@ -176,6 +197,9 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
return evidenceKey;
|
return evidenceKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打分:qualityScore 归一化(按 scoreLabel 分支);L0 重叠只写解释,不改分数。
|
||||||
|
*/
|
||||||
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate, int batchSize) {
|
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate, int batchSize) {
|
||||||
double quality = RetrievalScoreNormalizer.toQualityScore(
|
double quality = RetrievalScoreNormalizer.toQualityScore(
|
||||||
candidate.getScoreLabel(),
|
candidate.getScoreLabel(),
|
||||||
@@ -185,7 +209,7 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
maxL2Distance,
|
maxL2Distance,
|
||||||
candidate.getDenseDistance());
|
candidate.getDenseDistance());
|
||||||
List<String> explain = new ArrayList<>();
|
List<String> explain = new ArrayList<>();
|
||||||
// L0 重叠仅解释,不改变 quality / 排序
|
// L0 重叠仅解释,不改变 quality / 排序(防关键词碰瓷)
|
||||||
if (matchesAny(candidate, query.getDomainHints())) {
|
if (matchesAny(candidate, query.getDomainHints())) {
|
||||||
explain.add("l0_domain_overlap");
|
explain.add("l0_domain_overlap");
|
||||||
}
|
}
|
||||||
@@ -198,6 +222,7 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
return new ScoredCandidate(candidate, quality, explain);
|
return new ScoredCandidate(candidate, quality, explain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 候选字段与 L0 提示是否重叠(仅解释用)。 */
|
||||||
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
|
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
|
||||||
if (hints == null || hints.isEmpty()) {
|
if (hints == null || hints.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -217,6 +242,9 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相关等级判定:顶分 >= 0.75 → PRECISE(+提示);>= 0.5 → REFERENCE(+提示);否则无。
|
||||||
|
*/
|
||||||
private RelevanceAssessment computeRelevance(List<ScoredCandidate> ranked) {
|
private RelevanceAssessment computeRelevance(List<ScoredCandidate> ranked) {
|
||||||
if (ranked.isEmpty()) {
|
if (ranked.isEmpty()) {
|
||||||
return new RelevanceAssessment(null, null);
|
return new RelevanceAssessment(null, null);
|
||||||
@@ -232,6 +260,7 @@ public class KnowledgeEvidencePostProcessor {
|
|||||||
return new RelevanceAssessment(null, null);
|
return new RelevanceAssessment(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同 evidenceKey 命中:合并 hitReasons + 补 breadcrumb(去重不丢信息)。 */
|
||||||
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
|
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
|
||||||
Set<String> reasons = new LinkedHashSet<>();
|
Set<String> reasons = new LinkedHashSet<>();
|
||||||
if (existing.getHitReasons() != null) {
|
if (existing.getHitReasons() != null) {
|
||||||
|
|||||||
@@ -10,17 +10,28 @@ import java.util.Objects;
|
|||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reciprocal Rank Fusion helpers.
|
* Reciprocal Rank Fusion 工具:把多路检索的排名列表融合成一个分数排序。
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* RRF_w(d) = Σ w_i / (k + rank_i(d))
|
* RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||||
* </pre>
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>只依赖排名不依赖原始分数——屏蔽跨路分数尺度不可比的问题;
|
||||||
|
* 每路可加权(w <= 0 时按 1.0 等权),k 是平滑参数(默认 60,可配)。
|
||||||
*/
|
*/
|
||||||
public final class RrfFusion {
|
public final class RrfFusion {
|
||||||
|
|
||||||
private RrfFusion() {
|
private RrfFusion() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 融合多路排名:对每路的每个 item 累加 w/(k+rank),按总分降序输出。
|
||||||
|
*
|
||||||
|
* @param paths 多路排名(每路带 name / items / weight)
|
||||||
|
* @param rrfK 平滑参数 k(至少 1)
|
||||||
|
* @param identityFn 跨路识别同一 item 的身份函数(如 evidenceKey)
|
||||||
|
* @return 融合后排序(含每路排名明细)
|
||||||
|
*/
|
||||||
public static <T> List<Scored<T>> fuse(List<RankedPath<T>> paths,
|
public static <T> List<Scored<T>> fuse(List<RankedPath<T>> paths,
|
||||||
int rrfK,
|
int rrfK,
|
||||||
Function<T, String> identityFn) {
|
Function<T, String> identityFn) {
|
||||||
@@ -45,7 +56,7 @@ public final class RrfFusion {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int rank = i + 1;
|
int rank = i + 1;
|
||||||
double contrib = weight / (k + rank);
|
double contrib = weight / (k + rank); // 排名越前贡献越大
|
||||||
Acc<T> bucket = acc.computeIfAbsent(id, ignored -> new Acc<>(item));
|
Acc<T> bucket = acc.computeIfAbsent(id, ignored -> new Acc<>(item));
|
||||||
bucket.score += contrib;
|
bucket.score += contrib;
|
||||||
bucket.ranks.put(path.name(), rank);
|
bucket.ranks.put(path.name(), rank);
|
||||||
@@ -57,12 +68,14 @@ public final class RrfFusion {
|
|||||||
Acc<T> value = entry.getValue();
|
Acc<T> value = entry.getValue();
|
||||||
scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks)));
|
scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks)));
|
||||||
}
|
}
|
||||||
|
// 总分降序(两路共识的靠前),同分按身份稳定排序
|
||||||
scored.sort(Comparator
|
scored.sort(Comparator
|
||||||
.comparingDouble((Scored<T> s) -> s.rrfScore()).reversed()
|
.comparingDouble((Scored<T> s) -> s.rrfScore()).reversed()
|
||||||
.thenComparing(Scored::identity));
|
.thenComparing(Scored::identity));
|
||||||
return scored;
|
return scored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一路检索结果:name(路名)+ items(按排名顺序)+ weight(可选加权,≤0 视为等权)。 */
|
||||||
public record RankedPath<T>(String name, List<T> items, double weight) {
|
public record RankedPath<T>(String name, List<T> items, double weight) {
|
||||||
public RankedPath {
|
public RankedPath {
|
||||||
Objects.requireNonNull(name, "name");
|
Objects.requireNonNull(name, "name");
|
||||||
@@ -70,9 +83,11 @@ public final class RrfFusion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 融合后的单个 item:identity + 原始 item + rrfScore + 每路排名明细。 */
|
||||||
public record Scored<T>(String identity, T item, double rrfScore, Map<String, Integer> ranks) {
|
public record Scored<T>(String identity, T item, double rrfScore, Map<String, Integer> ranks) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跨路累加器:同一 identity 的 item 累加 RRF 分并记录各路排名。 */
|
||||||
private static final class Acc<T> {
|
private static final class Acc<T> {
|
||||||
private final T item;
|
private final T item;
|
||||||
private double score;
|
private double score;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ public class LookupKnowledgeTool {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private LookupResultAssembler resultAssembler;
|
private LookupResultAssembler resultAssembler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析检索宽度配置:retrieveK 优先级 rag.retrieve-k > rag.top-k > 默认 3。
|
||||||
|
* return-n 由后处理器持有,这里只做可观测性记录。
|
||||||
|
*/
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
void resolveRetrievalWidths() {
|
void resolveRetrievalWidths() {
|
||||||
int fallback = legacyTopK > 0 ? legacyTopK : 3;
|
int fallback = legacyTopK > 0 ? legacyTopK : 3;
|
||||||
@@ -89,12 +93,27 @@ public class LookupKnowledgeTool {
|
|||||||
retrieveK, legacyTopK, returnNConfig);
|
retrieveK, legacyTopK, returnNConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG 检索主入口(legacy 后端,不感知 Harness)。
|
||||||
|
*
|
||||||
|
* <p>流程(模块化三段):
|
||||||
|
* <ol>
|
||||||
|
* <li>检索前:QueryTransformer.transform → KnowledgeQuery(分类过滤/域/关键词);</li>
|
||||||
|
* <li>检索:DocumentRetriever.retrieve(FILTERED 或 UNFILTERED,retrieveK 候选);</li>
|
||||||
|
* <li>检索后:PostProcessor.process(qualityScore/去重/判级);</li>
|
||||||
|
* <li>低质量降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查;</li>
|
||||||
|
* <li>打包 + 组装:ContextPacker.pack → LookupResultAssembler.assemble → LookupResult。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>返回的 LookupResult 是内部契约,Agent 可见字段由 RagResultProjector 再裁剪。
|
||||||
|
*/
|
||||||
public LookupResult lookupKnowledge(String query) {
|
public LookupResult lookupKnowledge(String query) {
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info(">>> [工具调用] lookup_knowledge");
|
log.info(">>> [工具调用] lookup_knowledge");
|
||||||
log.info(">>> metadata: query_chars={}, retrieveK={}", query == null ? 0 : query.length(), retrieveK);
|
log.info(">>> metadata: query_chars={}, retrieveK={}", query == null ? 0 : query.length(), retrieveK);
|
||||||
log.info("----------------------------------------");
|
log.info("----------------------------------------");
|
||||||
|
|
||||||
|
// ── 检索前:查询理解(L0)──
|
||||||
KnowledgeQuery knowledgeQuery = queryTransformer.transform(query);
|
KnowledgeQuery knowledgeQuery = queryTransformer.transform(query);
|
||||||
log.info("[QueryTransformer] categoryFilter={}, domainHintCount={}, keywordCount={}",
|
log.info("[QueryTransformer] categoryFilter={}, domainHintCount={}, keywordCount={}",
|
||||||
knowledgeQuery.getCategoryFilter(),
|
knowledgeQuery.getCategoryFilter(),
|
||||||
@@ -104,6 +123,7 @@ public class LookupKnowledgeTool {
|
|||||||
List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
|
List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
|
||||||
String fallbackReason = null;
|
String fallbackReason = null;
|
||||||
|
|
||||||
|
// ── 检索:首轮(有分类过滤则 FILTERED,否则 UNFILTERED)──
|
||||||
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
|
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
|
||||||
? ATTEMPT_UNFILTERED_VECTOR
|
? ATTEMPT_UNFILTERED_VECTOR
|
||||||
: ATTEMPT_FILTERED_VECTOR;
|
: ATTEMPT_FILTERED_VECTOR;
|
||||||
@@ -112,6 +132,7 @@ public class LookupKnowledgeTool {
|
|||||||
knowledgeQuery.getRewrittenQuery(),
|
knowledgeQuery.getRewrittenQuery(),
|
||||||
knowledgeQuery.getCategoryFilter(),
|
knowledgeQuery.getCategoryFilter(),
|
||||||
retrieveK);
|
retrieveK);
|
||||||
|
// ── 检索后:质量统一 + 去重 + 判级 ──
|
||||||
EvidencePostprocessResult selectedEvidence = evidencePostProcessor.process(
|
EvidencePostprocessResult selectedEvidence = evidencePostProcessor.process(
|
||||||
knowledgeQuery,
|
knowledgeQuery,
|
||||||
firstAttempt.candidates());
|
firstAttempt.candidates());
|
||||||
@@ -119,6 +140,7 @@ public class LookupKnowledgeTool {
|
|||||||
attempts.add(firstAttempt.attempt());
|
attempts.add(firstAttempt.attempt());
|
||||||
String selectedAttemptName = firstAttemptName;
|
String selectedAttemptName = firstAttemptName;
|
||||||
|
|
||||||
|
// ── 降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查(L0 边界可被推翻)──
|
||||||
if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) {
|
if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) {
|
||||||
fallbackReason = selectedEvidence.hasUsableEvidence()
|
fallbackReason = selectedEvidence.hasUsableEvidence()
|
||||||
? FALLBACK_LOW_QUALITY
|
? FALLBACK_LOW_QUALITY
|
||||||
@@ -139,6 +161,7 @@ public class LookupKnowledgeTool {
|
|||||||
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
|
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 打包 + 组装(内部契约出口)──
|
||||||
ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks());
|
ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks());
|
||||||
RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName,
|
RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName,
|
||||||
fallbackReason, selectedEvidence);
|
fallbackReason, selectedEvidence);
|
||||||
@@ -148,6 +171,7 @@ public class LookupKnowledgeTool {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用后处理结果补充 attempt 的观测字段:topSimilarity + usable(是否达参考阈值)。 */
|
||||||
private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) {
|
private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) {
|
||||||
attempt.setTopSimilarity(evidence.getTopSimilarity());
|
attempt.setTopSimilarity(evidence.getTopSimilarity());
|
||||||
attempt.setUsable(evidence.hasUsableEvidence()
|
attempt.setUsable(evidence.hasUsableEvidence()
|
||||||
@@ -155,6 +179,7 @@ public class LookupKnowledgeTool {
|
|||||||
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
|
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组装完整检索路径 Trace(原始/改写 query、分类过滤、选中 attempt、降级原因、query 提示)。 */
|
||||||
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
|
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
|
||||||
List<RetrievalTrace.Attempt> attempts,
|
List<RetrievalTrace.Attempt> attempts,
|
||||||
String selectedAttempt,
|
String selectedAttempt,
|
||||||
@@ -181,6 +206,7 @@ public class LookupKnowledgeTool {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回日志:found / relevanceLevel / 证据数 / 检索路径(供排查)。 */
|
||||||
private void logReturn(LookupResult result) {
|
private void logReturn(LookupResult result) {
|
||||||
log.info("----------------------------------------");
|
log.info("----------------------------------------");
|
||||||
log.info("<<< [工具返回] lookup_knowledge");
|
log.info("<<< [工具返回] lookup_knowledge");
|
||||||
|
|||||||
Reference in New Issue
Block a user