diff --git a/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java b/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java index 3d5c0fa..a3357fa 100644 --- a/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java +++ b/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java @@ -6,23 +6,38 @@ import lombok.Data; import java.util.List; /** - * Structured evidence returned by knowledge retrieval. + * 内部证据块(后处理输出,尚未投影到 Agent 契约)。 + * + *

一条 block 通常对应一次向量命中的一个 chunk 内容(截断后)。 + * 注意:当前后处理按 source 去重,同文档多 chunk 可能被合并掉,只保留最高分 content。

+ * + *

投影到 Agent 时由 {@code RagResultProjector} 转为 {@code RagEvidence} + * (document_id / source / title / breadcrumb / excerpt)。

*/ @Data @Builder public class EvidenceBlock { + /** 来源标识,常见为文件路径、upload:docId 或 docId。 */ private String source; private String title; + /** Markdown 标题链路,例如 "故障排查 > 连接池耗尽"。 */ private String breadcrumb; + /** 检索层标记,当前 L1 向量召回为 "L1"。 */ private String retrievalLayer; + /** 证据正文(后处理阶段可能已截断)。 */ private String content; + /** + * 原始兼容分(多为 L2 距离,越小越相似)。 + * Agent 投影层不会暴露该字段。 + */ private Double score; + /** 命中原因,如 semantic_rank、domain_match 等,供内部 trace。 */ private List hitReasons; } diff --git a/src/main/java/com/superbiz/agent/dto/EvidencePostprocessResult.java b/src/main/java/com/superbiz/agent/dto/EvidencePostprocessResult.java index 4ad7b80..3064eeb 100644 --- a/src/main/java/com/superbiz/agent/dto/EvidencePostprocessResult.java +++ b/src/main/java/com/superbiz/agent/dto/EvidencePostprocessResult.java @@ -6,26 +6,37 @@ import lombok.Data; import java.util.List; /** - * Output of post-retrieval processing before context packing. + * 检索后处理输出(打包 / 组装前的中间结果)。 + * + *

由 {@code KnowledgeEvidencePostProcessor} 生成,再交给 context packer 与 result assembler。

*/ @Data @Builder public class EvidencePostprocessResult { + /** 后处理前候选数。 */ private Integer candidateCount; + /** 去重后 evidence 条数。 */ private Integer evidenceBlockCount; private List evidenceBlocks; + /** PRECISE / HIGHLY_RELEVANT / REFERENCE;不可用时 null。 */ private String relevanceLevel; + /** 给模型的完整性/天花板提示。 */ private String completenessHint; private RerankTrace rerankTrace; + /** + * 排序第一名的 baseScore(0~1 相似度,不含规则 boost)。 + * 用于 isLowQuality 与 attempt.topSimilarity。 + */ private Double topSimilarity; + /** 当前定义:evidenceBlocks 非空即视为有可用证据。 */ public boolean hasUsableEvidence() { return evidenceBlocks != null && !evidenceBlocks.isEmpty(); } diff --git a/src/main/java/com/superbiz/agent/dto/KnowledgeQuery.java b/src/main/java/com/superbiz/agent/dto/KnowledgeQuery.java index 8e81ccc..86a2123 100644 --- a/src/main/java/com/superbiz/agent/dto/KnowledgeQuery.java +++ b/src/main/java/com/superbiz/agent/dto/KnowledgeQuery.java @@ -6,25 +6,45 @@ import lombok.Data; import java.util.List; /** - * Query understanding output used by the knowledge retrieval pipeline. + * 检索前 query understanding 的输出(L0 -> pipeline 控制面)。 + * + *

由 {@code KnowledgeQueryTransformer} 生成,供 L1 过滤、规则 rerank 与 trace 使用。 + * 不是 Agent 可见契约。

*/ @Data @Builder public class KnowledgeQuery { + /** Agent 传入的原始检索句(trim 后)。 */ private String originalQuery; + /** + * 改写后的检索句。 + * 当前实现未做真正 rewrite,通常等于 originalQuery。 + */ private String rewrittenQuery; + /** L0 命中的 domain/category 列表,用于 boost / trace。 */ private List domainHints; + /** L0 命中的关键词。 */ private List matchedKeywords; + /** + * 实体提示。 + * 当前实现基本等同 matchedKeywords,预留更细实体抽取。 + */ private List entities; + /** + * 向量检索 category 过滤条件。 + * 仅当 L0 恰好命中一个 domain 时非空;否则 null(不过滤)。 + */ private String categoryFilter; + /** L0 命中文档标题,主要用于 trace 解释。 */ private List l0Titles; + /** L0 命中文档条数。 */ private Integer l0MatchCount; } diff --git a/src/main/java/com/superbiz/agent/dto/LookupResult.java b/src/main/java/com/superbiz/agent/dto/LookupResult.java index e7b2722..c43a07c 100644 --- a/src/main/java/com/superbiz/agent/dto/LookupResult.java +++ b/src/main/java/com/superbiz/agent/dto/LookupResult.java @@ -6,64 +6,67 @@ import lombok.Data; import java.util.List; /** - * 知识库查询结果 + * lookup_knowledge 内部完整结果(legacy executor 出口)。 + * + *

字段比 Agent 可见契约更丰富,便于审计与调试。 + * 进入 Agent 前会经 {@code RagResultProjector} 裁剪为 {@code RagToolResult}。

*/ @Data @Builder public class LookupResult { /** - * 是否找到结果 + * 是否找到可用证据(evidenceBlocks 非空且通过后处理可用性判断)。 */ private boolean found; /** - * Structured evidence blocks after retrieval post-processing. + * 后处理后的结构化证据列表(文档级去重后)。 */ private List evidenceBlocks; /** - * Packed Agent-facing context assembled from evidence blocks. + * 按字符预算打包后的文本上下文(内部使用;Agent 主路径不一定消费)。 */ private ContextPack contextPack; /** - * Retrieval attempts and fallback trace. + * 检索 attempt / fallback 轨迹。 */ private RetrievalTrace retrievalTrace; /** - * Rule-based rerank explanation. + * 规则 rerank 解释(base/final score 与 boost 原因)。 */ private RerankTrace rerankTrace; /** - * Candidate count before evidence deduplication. + * 去重前的候选条数。 */ private Integer evidenceCandidateCount; /** - * Evidence block count after post-processing. + * 去重后的 evidence block 条数。 */ private Integer evidenceBlockCount; /** - * 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE + * 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE;不可用时为 null。 */ private String relevanceLevel; /** - * 兜底信号:告诉 LLM 知识库的"天花板" + * 给模型的“知识库天花板”提示,例如已高度相关/仅供参考。 */ private String completenessHint; /** - * 本次会话已检索过的域列表(行动记忆) + * 会话已检索域列表(历史行动记忆字段;当前主链路未必填充)。 */ private List retrievedDomainsThisSession; /** - * 系统消息(如去重提示) + * 系统消息,如无证据提示或历史 dedup 提示。 */ private String message; } diff --git a/src/main/java/com/superbiz/agent/dto/RetrievalTrace.java b/src/main/java/com/superbiz/agent/dto/RetrievalTrace.java index 08b4834..3cf0f81 100644 --- a/src/main/java/com/superbiz/agent/dto/RetrievalTrace.java +++ b/src/main/java/com/superbiz/agent/dto/RetrievalTrace.java @@ -7,7 +7,9 @@ import java.util.List; import java.util.Map; /** - * Trace of retrieval attempts used by lookup_knowledge. + * 一次 lookup_knowledge 调用的检索轨迹(内部可观测,默认不投影给 Agent)。 + * + *

记录 query 理解结果、每次 attempt、最终选用路径与 fallback 原因,便于 trace 回放。

*/ @Data @Builder @@ -17,29 +19,50 @@ public class RetrievalTrace { private String rewrittenQuery; + /** 首次检索使用的 category 过滤;无过滤时为 null。 */ private String categoryFilter; + /** + * 最终采用的 attempt 名。 + * 例如 FILTERED_VECTOR / UNFILTERED_VECTOR / UNFILTERED_VECTOR_RETRY。 + */ private String selectedAttempt; + /** + * 触发降级的原因;未降级时为 null。 + * 例如 filtered_vector_no_evidence / filtered_vector_low_quality。 + */ private String fallbackReason; + /** supported / no_evidence。 */ private String evidenceStatus; + /** L0 hint 快照:domains、keywords、entities、titles 等。 */ private Map queryHints; private List attempts; + /** + * 单次检索 attempt 的可观测快照。 + */ @Data @Builder public static class Attempt { + /** FILTERED_VECTOR / UNFILTERED_VECTOR / UNFILTERED_VECTOR_RETRY 等。 */ private String name; private String query; private String categoryFilter; private Integer candidateCount; + /** + * 是否可用。 + * retriever 初值:有候选且无错误;后处理会按相似度阈值再收紧。 + */ private Boolean usable; private String errorMessage; private Integer durationMs; + /** 向量层 top score(兼容 L2 距离,越小越相似)。 */ private Double topScore; + /** 后处理归一化后的 top 相似度(0~1,越大越相似)。 */ private Double topSimilarity; } } diff --git a/src/main/java/com/superbiz/agent/dto/RetrievedEvidenceCandidate.java b/src/main/java/com/superbiz/agent/dto/RetrievedEvidenceCandidate.java index 49fab0c..4ae80da 100644 --- a/src/main/java/com/superbiz/agent/dto/RetrievedEvidenceCandidate.java +++ b/src/main/java/com/superbiz/agent/dto/RetrievedEvidenceCandidate.java @@ -7,35 +7,57 @@ import java.util.List; import java.util.Map; /** - * Normalized vector retrieval candidate before evidence post-processing. + * L1 向量命中后、后处理前的统一候选结构。 + * + *

由 {@code KnowledgeDocumentRetriever} 从 {@code VectorSearchService.SearchResult} 映射而来。 + * 后处理会基于它做归一化、规则 boost、去重并生成 {@link EvidenceBlock}。

*/ @Data @Builder public class RetrievedEvidenceCandidate { + /** 向量库记录 id。 */ private String id; + /** + * 来源标识(_source / source / filePath / docId 等)。 + * 当前后处理去重主要依赖该字段,粒度偏文档级。 + */ private String source; private String title; private String breadcrumb; + /** chunk 正文原文(后处理前未截断或仅底层原样)。 */ private String content; + /** 固定为 L1(向量层);预留多路召回标记。 */ private String retrievalLayer; + /** 所属 attempt 名,如 FILTERED_VECTOR。 */ private String retrievalAttempt; + /** + * 兼容 L2 距离分(越小越相似),后处理会 normalize 成 baseScore。 + */ private Double score; + /** 底层原始分。 */ private Double rawScore; + /** rawScore 语义标签:l2_distance / similarity。 */ private String scoreLabel; + /** 向量召回顺序(从 1 起),规则 rerank 前的名次。 */ private Integer originalRank; + /** + * 扁平化 metadata(string map)。 + * 可能含 docId、chunkIndex、category、kb_scope 等;chunkIndex 尚未提升为一等字段。 + */ private Map metadata; + /** 初步命中原因,后处理会追加 boost reasons。 */ private List hitReasons; } diff --git a/src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java b/src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java index 8186c3c..4c552b8 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java +++ b/src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java @@ -11,9 +11,22 @@ import com.superbiz.agent.harness.tool.projection.RagResultProjector; import java.util.Objects; -/** Bridges a typed RAG request and a legacy knowledge executor through ToolBoundary. */ +/** + * Harness 侧 RAG 工具适配器。 + * + *

连接三层:

+ *
    + *
  1. 解析 Agent 的 typed request({@link RagToolRequest})
  2. + *
  3. 经 {@link ToolBoundary} 执行预算/审计等边界控制
  4. + *
  5. 调用 legacy {@code LookupKnowledgeTool},再用 {@link RagResultProjector} + * 投影成冻结的 Agent 可见契约
  6. + *
+ * + *

这样检索实现可演进,而 Agent tool schema 与 EvidenceGuard 契约保持稳定。

+ */ public final class RagToolAdapter { + /** 兼容旧检索后端:只接收 query,返回可序列化的 LookupResult(或等价 Map)。 */ @FunctionalInterface public interface LegacyExecutor { Object execute(String query) throws Exception; @@ -32,6 +45,11 @@ public final class RagToolAdapter { this.legacyExecutor = Objects.requireNonNull(legacyExecutor, "legacyExecutor must not be null"); } + /** + * 执行一次 lookup_knowledge 工具调用。 + * + *

流程:校验 request.query -> boundary.execute(legacy) -> projector.project。

+ */ public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) { try { RagToolRequest request = objectMapper.readValue(envelope.requestJson(), RagToolRequest.class); @@ -39,7 +57,9 @@ public final class RagToolAdapter { return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST); } return boundary.execute(context, envelope, + // legacy 原始 JSON(内部 LookupResult) ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())), + // 投影为 Agent 契约(RagToolResult) raw -> projector.project(request, envelope.toolCallId(), raw)); } catch (Exception e) { return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(), diff --git a/src/main/java/com/superbiz/agent/harness/tool/contract/RagEvidence.java b/src/main/java/com/superbiz/agent/harness/tool/contract/RagEvidence.java index 972e195..317d9a5 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/contract/RagEvidence.java +++ b/src/main/java/com/superbiz/agent/harness/tool/contract/RagEvidence.java @@ -2,10 +2,21 @@ package com.superbiz.agent.harness.tool.contract; import com.fasterxml.jackson.annotation.JsonProperty; +/** + * Agent 可见的单条 RAG 证据(冻结契约)。 + * + *

由 {@code RagResultProjector} 从内部 {@code EvidenceBlock} 投影而来。 + * EvidenceGuard / 诊断报告引用时使用 {@code document_id}。

+ * + *

注意:当 legacy block 未提供 document_id 时,投影层常把 source 当作 document_id, + * 因此同 source 的多个 chunk 在 Agent 侧也会去重成一条。

+ */ public record RagEvidence( + /** 证据引用 id;当前实现常等于 source(文档级)。 */ @JsonProperty("document_id") String documentId, @JsonProperty("source") String source, @JsonProperty("title") String title, @JsonProperty("breadcrumb") String breadcrumb, + /** 截断后的正文摘录(对应内部 content)。 */ @JsonProperty("excerpt") String excerpt) { } diff --git a/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java b/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java index 263b49f..c3af9b2 100644 --- a/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java +++ b/src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java @@ -15,7 +15,31 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -/** Projects legacy knowledge retrieval JSON into the frozen RAG contract. */ +/** + * 把 legacy {@code LookupResult} JSON 投影成冻结的 Agent 可见 RAG 契约。 + * + *

为什么需要投影

+ * 内部检索结果字段较多(retrievalTrace、rerankTrace、score、hitReasons、contextPack…), + * Agent / EvidenceGuard 只应看到受控、有界、可引用的子集。 + * + *

保留给 Agent 的字段

+ *
    + *
  • evidenceStatus / toolCallId / query
  • + *
  • evidence[]:document_id, source, title, breadcrumb, excerpt
  • + *
  • relevanceLevel、truncated、returned_count
  • + *
+ * + *

刻意丢弃

+ * score、hitReasons、retrievalTrace、rerankTrace、contextPack 等内部可观测细节。 + * + *

去重与预算(读代码关键)

+ *
    + *
  • 按 {@code document_id} 去重;若 block 无 document_id,则回退 source/title
  • + *
  • 因此同 source 的多个 chunk 在此也会被压成 1 条(与后处理文档级去重叠加)
  • + *
  • 条数上限 {@link ToolProjectionLimits#maxEvidence()},excerpt 字符上限, + * 以及总 UTF-8 字节预算(超限从后往前删 evidence)
  • + *
+ */ public final class RagResultProjector { private final ObjectMapper objectMapper; @@ -26,6 +50,11 @@ public final class RagResultProjector { this.limits = limits; } + /** + * @param request Agent 原始请求(用于回填/截断 query) + * @param toolCallId 框架分配的本次工具调用 id,进入结果供证据引用 + * @param rawResponse legacy LookupResult 的 JSON 字符串 + */ public ProjectedToolResult project(RagToolRequest request, String toolCallId, String rawResponse) throws Exception { if (request == null || toolCallId == null || toolCallId.isBlank()) { @@ -40,6 +69,7 @@ public final class RagResultProjector { String query = bounded(request.query(), limits.maxQueryChars()); truncated = !query.equals(request.query()); List evidence = new ArrayList<>(); + // 文档级唯一集合:相同 documentId 只保留首次出现 Set documentIds = new HashSet<>(); JsonNode blocks = root.has("evidenceBlocks") ? root.get("evidenceBlocks") : root.get("evidence_blocks"); if (blocks != null && blocks.isArray()) { @@ -59,9 +89,11 @@ public final class RagResultProjector { } String source = text(block, "source"); String title = text(block, "title"); + // legacy EvidenceBlock 通常没有 document_id,实际常退化为 source String documentId = firstNonBlank(text(block, "document_id"), source, title, "legacy-document-" + ordinal); if (!documentIds.add(documentId)) { + // 同 documentId 重复:丢弃后续条,并标记 truncated truncated = true; continue; } @@ -86,10 +118,12 @@ public final class RagResultProjector { ? relevanceLevel(root) : null; RagToolResult result = new RagToolResult( status, toolCallId, query, evidence, evidence.size(), relevanceLevel, truncated); + // 总字节预算:仍超限则从尾部删 evidence,直到放得下或变 no_evidence result = fitBudget(result, truncated); return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus()); } + /** 按 maxAgentUtf8Bytes 从后往前删 evidence,保证 Agent 侧 payload 有界。 */ private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception { RagToolResult current = result; while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes() diff --git a/src/main/java/com/superbiz/agent/service/DocumentChunkService.java b/src/main/java/com/superbiz/agent/service/DocumentChunkService.java index ded84a7..fe98b64 100644 --- a/src/main/java/com/superbiz/agent/service/DocumentChunkService.java +++ b/src/main/java/com/superbiz/agent/service/DocumentChunkService.java @@ -13,8 +13,21 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * 文档分片服务 - * 负责将长文档切分为多个有语义完整性的小片段 + * 文档切片服务(RAG 入库前处理)。 + * + *

把长 Markdown/文本切成带 title/breadcrumb 的 {@link com.superbiz.agent.dto.DocumentChunk}, + * 供 {@link VectorIndexService} 向量化。

+ * + *

策略摘要

+ *
    + *
  1. 先按 Markdown 标题分 section,并维护 breadcrumb 层级
  2. + *
  3. section 过长再按段落累积;用 token 估算做软边界 / 硬上限
  4. + *
  5. 尽量不在有序/无序列表或未闭合代码块中间切断
  6. + *
  7. 相邻 chunk 保留 overlap,减轻边界语义断裂
  8. + *
+ * + *

检索命中单个 chunk 后,当前主链路不会自动回补同章节相邻 chunk + * (上下文重建仍是后续增强点)。

*/ @Service public class DocumentChunkService { diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java b/src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java index a1bafb2..31aec11 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java @@ -9,14 +9,24 @@ import java.util.ArrayList; import java.util.List; /** - * Packs final evidence blocks into compact Agent-facing context. + * 把已排序的 evidenceBlocks 压成一段有预算上限的文本。 + * + *

输入顺序即优先级:排在前面的证据先占预算,超预算的 source 记入 omittedSources。

+ * + *

注意:当前 Harness 投影给 Agent 的主要是结构化 evidence 列表, + * {@link ContextPack#getPackedText()} 更多用于内部/调试/审计,不保证出现在 Agent 最终 tool view。

*/ @Service public class KnowledgeContextPacker { + /** 打包总字符预算(含 header)。 */ @Value("${rag.context-pack.char-budget:4000}") private int charBudget = 4000; + /** + * 按排名顺序打包证据。 + * 单条证据若剩余预算不足,会截断 body;若连 header 都放不下,则整条省略。 + */ public ContextPack pack(List blocks) { List safeBlocks = blocks == null ? List.of() : blocks; StringBuilder packed = new StringBuilder(); @@ -48,6 +58,7 @@ public class KnowledgeContextPacker { .build(); } + /** 每条证据的可读 header,便于人工阅读 packedText。 */ private String buildHeader(int rank, EvidenceBlock block) { StringBuilder header = new StringBuilder(); header.append("[Evidence ").append(rank).append("]\n"); diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java b/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java index b487424..1bf7530 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java @@ -11,7 +11,20 @@ import java.util.List; import java.util.Map; /** - * Vector retrieval adapter for the modular knowledge pipeline. + * L1 向量检索适配器。 + * + *

职责是把 {@link VectorSearchService} 的原始命中,转成 pipeline 统一使用的 + * {@link RetrievedEvidenceCandidate},并记录单次 attempt 的 trace。

+ * + *

本类不做质量判断、不做 rerank、不做上下文打包;那些属于后处理阶段。

+ * + *

字段映射要点

+ *
    + *
  • {@code source}:优先 metadata._source / source / filePath / docId
  • + *
  • {@code title/breadcrumb}:来自 chunk metadata,用于展示与规则 boost
  • + *
  • {@code score}:兼容后的距离分(SDK 为 L2;Spring AI 路径会映射成兼容 L2)
  • + *
  • metadata 中的 chunkIndex 目前只留在 map 里,未提升为一等字段
  • + *
*/ @Service public class KnowledgeDocumentRetriever { @@ -24,6 +37,15 @@ public class KnowledgeDocumentRetriever { this.objectMapper = objectMapper; } + /** + * 执行一次向量检索 attempt。 + * + * @param attemptName 写入 trace 的 attempt 名(FILTERED_VECTOR / UNFILTERED_VECTOR 等) + * @param query 检索文本 + * @param categoryFilter 可选 category 元数据过滤;null 表示不过滤 + * @param topK 召回条数 + * @return attempt 元信息 + 候选列表;异常时 candidates 为空,error 记在 attempt 上 + */ public RetrievalAttemptResult retrieve(String attemptName, String query, String categoryFilter, int topK) { long start = System.currentTimeMillis(); try { @@ -36,6 +58,7 @@ public class KnowledgeDocumentRetriever { candidates ); } catch (Exception e) { + // 检索失败不向上抛:由上层按“无候选 / 低质量”路径继续(例如 fallback retry) return new RetrievalAttemptResult( attempt(attemptName, query, categoryFilter, 0, e.getMessage(), (int) (System.currentTimeMillis() - start), null), @@ -44,6 +67,10 @@ public class KnowledgeDocumentRetriever { } } + /** + * 将向量库原始结果规范化为候选证据。 + * originalRank 从 1 开始,对应向量召回顺序(尚未规则 rerank)。 + */ private List toCandidates(String attemptName, List results) { if (results == null || results.isEmpty()) { @@ -53,6 +80,7 @@ public class KnowledgeDocumentRetriever { for (int i = 0; i < results.size(); i++) { VectorSearchService.SearchResult result = results.get(i); Map metadata = parseMetadata(result.getMetadata()); + // source 是后续去重/展示的主标识;当前实现偏“文档级”,同文档多 chunk 可能共享 source String source = firstNonBlank( metadata.get("_source"), metadata.get("source"), @@ -92,6 +120,7 @@ public class KnowledgeDocumentRetriever { .query(query) .categoryFilter(categoryFilter) .candidateCount(candidateCount) + // 此处 usable 只表示“有候选且无错误”;后处理还会用相似度阈值再收紧 .usable(errorMessage == null && candidateCount > 0) .errorMessage(errorMessage) .durationMs(durationMs) @@ -106,6 +135,7 @@ public class KnowledgeDocumentRetriever { return (double) results.get(0).getScore(); } + /** metadata 在向量库中多为 JSON 字符串,这里压成 string map 方便后处理读取。 */ private Map parseMetadata(String metadata) { if (metadata == null || metadata.isBlank()) { return Map.of(); @@ -133,6 +163,12 @@ public class KnowledgeDocumentRetriever { return null; } + /** + * 单次检索 attempt 的结果包。 + * + * @param attempt 可观测元数据(耗时、过滤条件、错误等) + * @param candidates 规范化后的证据候选 + */ public record RetrievalAttemptResult(RetrievalTrace.Attempt attempt, List candidates) { } diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java b/src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java index 038ebe5..d1baa45 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java @@ -18,7 +18,24 @@ import java.util.Map; import java.util.Set; /** - * Post-retrieval evidence normalization, rerank, and evidence block assembly. + * 检索后处理:分数归一化、规则 rerank、证据块组装、相关等级判定。 + * + *

处理步骤

+ *
    + *
  1. 把候选 L2 距离归一成 0~1 的 baseScore
  2. + *
  3. 用 L0 hint(domain/entity/keyword)做规则加分,得到 finalScore
  4. + *
  5. 按 finalScore 降序排序
  6. + *
  7. 按 sourceKey 去重后生成 {@link EvidenceBlock}
  8. + *
  9. 根据 top baseScore + hint 支撑计算 relevanceLevel
  10. + *
+ * + *

重要行为(读代码时容易误解)

+ *
    + *
  • “rerank” 是规则加权,不是 cross-encoder / LLM rerank
  • + *
  • 去重 key 优先 source/title,粒度偏文档级:同文档多个 chunk 可能被压成一条, + * 且 merge 时只合并 hitReasons,不拼接 content
  • + *
  • content 在此截断到 800 字符;Agent 侧 projector 还可能再截断
  • + *
*/ @Service public class KnowledgeEvidencePostProcessor { @@ -31,17 +48,24 @@ public class KnowledgeEvidencePostProcessor { private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档"; private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度"; + /** L2 距离上限,用于把距离映射到 [0,1] 相似度。 */ @Value("${retrieval.normalization.max-l2-distance:2.0}") private double maxL2Distance = 2.0; + /** baseScore >= 该阈值,才可能判 HIGHLY_RELEVANT / PRECISE。 */ @Value("${retrieval.normalization.highly-relevant-threshold:0.75}") private double highlyRelevantThreshold = 0.75; + /** baseScore >= 该阈值视为可用参考;低于则 isLowQuality=true,可能触发 unfiltered retry。 */ @Value("${retrieval.normalization.reference-threshold:0.5}") private double referenceThreshold = 0.5; + /** + * 对一次 attempt 的候选做后处理,产出可交给打包/组装的证据结果。 + */ public EvidencePostprocessResult process(KnowledgeQuery query, List candidates) { List safeCandidates = candidates == null ? List.of() : candidates; + // 先打分排序:baseScore 来自向量距离,finalScore = base + 规则 boost List ranked = safeCandidates.stream() .map(candidate -> score(query, candidate)) .sorted(Comparator.comparingDouble(ScoredCandidate::finalScore).reversed()) @@ -61,6 +85,7 @@ public class KnowledgeEvidencePostProcessor { .score(candidate.getScore()) .hitReasons(mergeReasons(candidate.getHitReasons(), scored.boostReasons())) .build(); + // 注意:key 未使用 chunkIndex,同 source 的多个 chunk 会走 merge 分支 String key = sourceKey(block, "candidate-" + candidate.getOriginalRank()); if (!deduped.containsKey(key)) { deduped.put(key, block); @@ -72,11 +97,13 @@ public class KnowledgeEvidencePostProcessor { .boostReasons(scored.boostReasons()) .build()); } else { + // 重复 key:保留先放入的(更高分)content,只补充 reasons/breadcrumb mergeEvidence(deduped.get(key), block); } } List blocks = new ArrayList<>(deduped.values()); + // topSimilarity 用排序后第一名的 baseScore(未含 boost),供质量阈值判断 Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).baseScore(); RelevanceAssessment assessment = computeRelevance(query, ranked); return EvidencePostprocessResult.builder() @@ -90,6 +117,10 @@ public class KnowledgeEvidencePostProcessor { .build(); } + /** + * 是否低质量,用于触发 filtered -> unfiltered 降级。 + * 无可用证据,或 topSimilarity 低于 referenceThreshold,都视为低质量。 + */ public boolean isLowQuality(EvidencePostprocessResult result) { if (result == null || !result.hasUsableEvidence()) { return true; @@ -98,6 +129,10 @@ public class KnowledgeEvidencePostProcessor { return topSimilarity == null || topSimilarity < referenceThreshold; } + /** + * L2 距离 -> 相似度。 + * 距离越小越相似:similarity = 1 - min(l2, max) / max。 + */ public double normalizeL2(Double l2Score) { if (l2Score == null) { return 0.0; @@ -110,6 +145,10 @@ public class KnowledgeEvidencePostProcessor { return referenceThreshold; } + /** + * 规则打分:baseScore + domain/entity/keyword/source_type boost。 + * boost 只影响排序,不改变用于阈值判断的 baseScore。 + */ private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate) { double baseScore = normalizeL2(candidate.getScore()); double finalScore = baseScore; @@ -135,6 +174,7 @@ public class KnowledgeEvidencePostProcessor { return new ScoredCandidate(candidate, baseScore, finalScore, boosts); } + /** 在 source/title/breadcrumb/content/metadata 拼接串上做子串匹配(大小写不敏感)。 */ private boolean matchesAny(RetrievedEvidenceCandidate candidate, List hints) { if (hints == null || hints.isEmpty()) { return false; @@ -154,6 +194,7 @@ public class KnowledgeEvidencePostProcessor { return false; } + /** runbook / guide / case 类来源轻微加分。 */ private boolean isPreferredSourceType(RetrievedEvidenceCandidate candidate) { Map metadata = candidate.getMetadata(); if (metadata == null || metadata.isEmpty()) { @@ -167,6 +208,10 @@ public class KnowledgeEvidencePostProcessor { return normalized.contains("runbook") || normalized.contains("guide") || normalized.contains("case"); } + /** + * 相关等级只看 top1 的 baseScore(向量相似度),PRECISE 额外要求 L0 hint 有支撑。 + * 低于 referenceThreshold 时 level/hint 都为 null,表示不可用参考。 + */ private RelevanceAssessment computeRelevance(KnowledgeQuery query, List ranked) { if (ranked.isEmpty()) { return new RelevanceAssessment(null, null); @@ -190,6 +235,9 @@ public class KnowledgeEvidencePostProcessor { || matchesAny(top.candidate(), query.getMatchedKeywords()); } + /** + * 同 key 合并策略:不覆盖已有 content(保留更高分的那条),只补 reasons 和空 breadcrumb。 + */ private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) { Set reasons = new LinkedHashSet<>(); if (existing.getHitReasons() != null) { @@ -216,6 +264,10 @@ public class KnowledgeEvidencePostProcessor { return new ArrayList<>(merged); } + /** + * 当前去重 key:source -> title -> breadcrumb -> fallback。 + * 因此“同文档不同 chunk”若 source 相同,会被视为重复。 + */ private String sourceKey(EvidenceBlock block, String fallback) { return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback); } @@ -240,6 +292,7 @@ public class KnowledgeEvidencePostProcessor { return value == null ? "" : value; } + /** 内部打分结果:baseScore 用于阈值,finalScore 用于排序。 */ private record ScoredCandidate(RetrievedEvidenceCandidate candidate, double baseScore, double finalScore, diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java b/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java index d52066c..fdb47dc 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java @@ -26,8 +26,21 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; /** - * 知识库索引服务 - * 负责 L0 精确匹配索引的管理 + * L0 知识索引服务(关键词 / domain hint,不是向量库)。 + * + *

定位

+ * 从 MySQL {@code api_document.metadata}(frontmatter)加载文档级关键词与 category, + * 供检索前 query understanding 使用。L0 输出只作为: + *
    + *
  • 可选 category filter(唯一 domain 时)
  • + *
  • rerank 的 domain/keyword/entity boost 信号
  • + *
  • trace 可解释信息
  • + *
+ * L0 命中文档不会直接当作事实 evidence;证据正文只来自 L1 向量召回。 + * + *

匹配方式(当前较粗)

+ * {@code query.contains(keyword) || keyword.contains(query)},大小写不敏感。 + * 没有分词、别名归一或停用词;短词/泛词可能误命中。 */ @Slf4j @Service @@ -128,10 +141,15 @@ public class KnowledgeIndexService { } } + /** 兼容旧调用:只返回命中的文档条目。 */ public List exactMatch(String query) { return analyzeQuery(query).matches(); } + /** + * 分析 query,产出 L0 hint。 + * 遍历内存索引,收集匹配 keyword、domain、title;不做向量检索。 + */ public L0Hint analyzeQuery(String query) { long startTime = System.currentTimeMillis(); @@ -200,6 +218,12 @@ public class KnowledgeIndexService { return value.trim(); } + /** + * 关键词双向包含匹配。 + * query 已在调用方 lower-case;keyword 在此 lower-case。 + * 例:query="mysql timeout" 可命中 keyword="mysql"; + * 反过来 keyword="mysql connection pool timeout" 也可能被短 query 命中。 + */ private List matchedKeywords(KnowledgeEntry entry, String query) { if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) { return List.of(); @@ -283,6 +307,15 @@ public class KnowledgeIndexService { return List.copyOf(knowledgeIndex); } + /** + * L0 分析结果。 + * + * @param matches 命中的文档条目(仅 hint,不是 evidence) + * @param matchedKeywords 命中的关键词 + * @param domains 命中文档的 category 集合 + * @param entities 当前实现等同 matchedKeywords,预留实体字段 + * @param titles 命中文档标题 + */ public record L0Hint( List matches, List matchedKeywords, @@ -294,6 +327,7 @@ public class KnowledgeIndexService { return new L0Hint(List.of(), List.of(), List.of(), List.of(), List.of()); } + /** 仅当恰好一个 domain 时返回,用于安全地加 category filter。 */ public String singleDomainOrNull() { return domains.size() == 1 ? domains.get(0) : null; } diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java b/src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java index 95ef6d1..dcc40d2 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java @@ -6,7 +6,17 @@ import org.springframework.stereotype.Service; import java.util.List; /** - * Converts a raw Agent query into retrieval-control hints. + * 检索前的 query 理解层(L0 出口)。 + * + *

输入是 Agent 的原始检索句,输出 {@link KnowledgeQuery},供后续 L1 过滤与 rerank 使用。

+ * + *

当前能力边界

+ *
    + *
  • 会做:关键词匹配、domain/entity/title hint、唯一 domain 时生成 categoryFilter
  • + *
  • 不会做:真正的 query rewrite / 同义词扩展 / 多 query 改写 + * ({@code rewrittenQuery} 目前等于 {@code originalQuery})
  • + *
  • L0 命中文档正文不会直接当作 evidence;证据只来自 L1 向量召回
  • + *
*/ @Service public class KnowledgeQueryTransformer { @@ -17,15 +27,23 @@ public class KnowledgeQueryTransformer { this.knowledgeIndexService = knowledgeIndexService; } + /** + * 将原始 query 转为检索控制结构。 + * + *

{@code categoryFilter} 仅在 L0 恰好命中一个 domain 时非空; + * 多 domain 或零 domain 时为 null,避免错误收窄召回。

+ */ public KnowledgeQuery transform(String rawQuery) { String normalized = rawQuery == null ? "" : rawQuery.trim(); KnowledgeIndexService.L0Hint hint = knowledgeIndexService.analyzeQuery(normalized); return KnowledgeQuery.builder() .originalQuery(normalized) + // 预留改写字段;当前未实现 rewrite,保持与 original 一致 .rewrittenQuery(normalized) .domainHints(safeList(hint.domains())) .matchedKeywords(safeList(hint.matchedKeywords())) .entities(safeList(hint.entities())) + // 只有唯一 domain 才作为向量 metadata 的 category 过滤条件 .categoryFilter(hint.singleDomainOrNull()) .l0Titles(safeList(hint.titles())) .l0MatchCount(hint.matches() == null ? 0 : hint.matches().size()) diff --git a/src/main/java/com/superbiz/agent/service/LookupResultAssembler.java b/src/main/java/com/superbiz/agent/service/LookupResultAssembler.java index 550d5e0..55f75b6 100644 --- a/src/main/java/com/superbiz/agent/service/LookupResultAssembler.java +++ b/src/main/java/com/superbiz/agent/service/LookupResultAssembler.java @@ -9,11 +9,18 @@ import org.springframework.stereotype.Service; import java.util.List; /** - * Assembles evidence-first LookupResult instances. + * 将后处理结果组装为统一的 {@link LookupResult}。 + * + *

这是 lookup_knowledge 内部契约出口:found / evidenceBlocks / traces / relevance。 + * Agent 最终看到的字段集合由 {@code RagResultProjector} 再裁剪一层。

*/ @Service public class LookupResultAssembler { + /** + * 正常检索路径组装。 + * found=true 当且仅当后处理认为存在可用 evidenceBlocks。 + */ public LookupResult assemble(EvidencePostprocessResult evidence, ContextPack contextPack, RetrievalTrace retrievalTrace) { @@ -32,6 +39,12 @@ public class LookupResultAssembler { .build(); } + /** + * 会话级“文档已检索过”的占位结果构造器。 + * + *

历史设计用于 RetrievedDocTracker 去重回包;当前主链路默认不再调用。 + * 保留方法是为了兼容旧调用点/测试,不代表 session dedup 仍在生效。

+ */ public LookupResult deduped(LookupResult original, List retrievedDomains, String docKey) { return LookupResult.builder() .found(false) diff --git a/src/main/java/com/superbiz/agent/service/VectorIndexService.java b/src/main/java/com/superbiz/agent/service/VectorIndexService.java index 6ab8355..11cc1e7 100644 --- a/src/main/java/com/superbiz/agent/service/VectorIndexService.java +++ b/src/main/java/com/superbiz/agent/service/VectorIndexService.java @@ -26,8 +26,17 @@ import java.time.LocalDateTime; import java.util.*; /** - * 向量索引服务 - * 负责读取文件、生成向量、存储到 Milvus + * 向量索引写入服务(RAG 入库侧)。 + * + *

负责:读文件/文档块 -> 切片 -> embedding -> 写入 Milvus。 + * 检索读取走 {@link VectorSearchService},双方通过 collection + metadata 约定衔接。

+ * + *

metadata 关键字段

+ * docId / _source / chunkIndex / totalChunks / title / breadcrumb / category / kb_scope + * + *

embedding 文本

+ * 见 {@link #buildEmbeddingText(DocumentChunk)}:会把 title、breadcrumb 拼进向量文本, + * 而入库 content 字段仍保存原始 chunk 正文(检索返回的是 content,不是 embedding 拼接串)。 */ @Service public class VectorIndexService { @@ -302,6 +311,12 @@ public class VectorIndexService { return metadata; } + /** + * 构造送入 embedding 模型的文本。 + * + *

把标题链路注入向量语义,缓解“正文片段缺上下文”导致的召回漂移。 + * 注意:这里只影响向量,不影响 Milvus content 字段存储的原文。

+ */ static String buildEmbeddingText(DocumentChunk chunk) { String content = trimToEmpty(chunk.getContent()); String title = trimToEmpty(chunk.getTitle()); diff --git a/src/main/java/com/superbiz/agent/service/VectorSearchService.java b/src/main/java/com/superbiz/agent/service/VectorSearchService.java index e436abd..c717993 100644 --- a/src/main/java/com/superbiz/agent/service/VectorSearchService.java +++ b/src/main/java/com/superbiz/agent/service/VectorSearchService.java @@ -26,10 +26,28 @@ import java.util.List; import java.util.Map; /** - * Vector retrieval facade used by lookup_knowledge. + * L1 向量检索门面。 * - *

The public API stays stable while the implementation can route to Spring AI - * VectorStore, the original Milvus SDK path, or automatic fallback.

+ *

对上层({@link KnowledgeDocumentRetriever})只暴露稳定 API: + * {@link #searchSimilarDocuments(String, int, String)}。底层实现可切换:

+ *
    + *
  • {@code sdk}:Milvus SDK 直连
  • + *
  • {@code spring} / {@code spring-ai}:Spring AI VectorStore
  • + *
  • {@code auto}(默认):先 Spring AI,失败再 fallback 到 SDK
  • + *
+ * + *

分数兼容约定

+ * 后处理 {@code KnowledgeEvidencePostProcessor} 按“L2 距离越小越相似”归一化。 + * 因此本类统一把 {@link SearchResult#score} 填成兼容 L2 距离: + *
    + *
  • SDK 路径:直接用 Milvus L2 score
  • + *
  • Spring AI 路径:优先 metadata.distance;否则把 similarity 映射为 + * {@code (1 - similarity) * maxL2Distance}
  • + *
+ * + *

过滤

+ * 可选 category + 全局 {@code retrieval.kb-scope}。两条实现路径的 filter 语法不同, + * 但语义一致:只在对应 metadata 字段上收窄。 */ @Service public class VectorSearchService { @@ -48,12 +66,15 @@ public class VectorSearchService { @Autowired private ObjectMapper objectMapper; + /** 检索实现路由:auto / spring / spring-ai / sdk。 */ @Value("${retrieval.vector-store.mode:auto}") private String vectorStoreMode = "auto"; + /** similarity -> 兼容 L2 时使用的距离上限,需与后处理归一化配置一致。 */ @Value("${retrieval.normalization.max-l2-distance:2.0}") private double maxL2Distance = 2.0; + /** 非空时只检索该 kb_scope 下的 chunk(多租户/多知识库隔离)。 */ @Value("${retrieval.kb-scope:}") private String kbScope = ""; @@ -61,6 +82,13 @@ public class VectorSearchService { return searchSimilarDocuments(query, topK, null); } + /** + * 按 query 召回 topK 相似文档片段。 + * + * @param query 检索文本(会再 embedding) + * @param topK 返回条数 + * @param category 可选 category 过滤;null/blank 表示不过滤 + */ public List searchSimilarDocuments(String query, int topK, String category) { String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase(); return switch (mode) { @@ -74,6 +102,7 @@ public class VectorSearchService { }; } + /** auto:Spring AI 优先,任意异常则降级 SDK(保证检索可用性)。 */ private List searchWithAutoFallback(String query, int topK, String category) { try { return searchSimilarDocumentsWithVectorStore(query, topK, category); @@ -84,6 +113,10 @@ public class VectorSearchService { } } + /** + * Spring AI VectorStore 路径。 + * 注意:写入索引仍主要由 Milvus SDK 完成;这里是读适配,依赖双方 metadata schema 一致。 + */ List searchSimilarDocumentsWithVectorStore(String query, int topK, String category) { VectorStore vectorStore = vectorStoreProvider != null ? vectorStoreProvider.getIfAvailable() : null; if (vectorStore == null) { @@ -95,6 +128,7 @@ public class VectorSearchService { SearchRequest.Builder builder = SearchRequest.builder() .query(query) .topK(topK) + // 不做框架层阈值截断,相关性判断交给后处理 .similarityThresholdAll(); String filterExpression = buildSpringAiFilterExpression(category); if (filterExpression != null) { @@ -111,6 +145,7 @@ public class VectorSearchService { result.setMetadata(toJson(document.getMetadata())); result.setRawScore(document.getScore()); result.setScoreLabel("similarity"); + // 下游统一按 L2 距离消费,这里做兼容映射 result.setScore(toCompatibleL2Distance(document)); results.add(result); } @@ -118,6 +153,7 @@ public class VectorSearchService { return results; } + /** Milvus SDK 原生 L2 检索路径。 */ List searchSimilarDocumentsWithSdk(String query, int topK, String category) { try { logger.info("Starting Milvus SDK search: topK={}, category={}, kbScope={}", @@ -152,6 +188,7 @@ public class VectorSearchService { SearchResult result = new SearchResult(); result.setId((String) wrapper.getIDScore(0).get(i).get("id")); result.setContent((String) wrapper.getFieldData("content", 0).get(i)); + // Milvus L2:数值越小越相似 result.setScore(wrapper.getIDScore(0).get(i).getScore()); result.setRawScore((double) result.getScore()); result.setScoreLabel("l2_distance"); @@ -172,6 +209,7 @@ public class VectorSearchService { } } + /** 优先使用 metadata.distance;否则把 similarity 映射为兼容 L2。 */ private float toCompatibleL2Distance(Document document) { Double distance = extractDistance(document.getMetadata()); if (distance != null) { @@ -180,6 +218,10 @@ public class VectorSearchService { return toCompatibleL2Distance(document.getScore()); } + /** + * similarity ∈ [0,1] 越大越相似 -> 兼容 L2 距离。 + * 映射:distance = (1 - similarity) * maxL2Distance + */ private float toCompatibleL2Distance(Double similarity) { if (similarity == null) { return (float) maxL2Distance; @@ -221,6 +263,7 @@ public class VectorSearchService { return value.replace("'", "\\'"); } + /** Spring AI filter DSL,例如:category == 'mysql' && kb_scope == 'prod' */ String buildSpringAiFilterExpression(String category) { List parts = new ArrayList<>(); String categoryFilter = trimToNull(category); @@ -234,6 +277,7 @@ public class VectorSearchService { return parts.isEmpty() ? null : String.join(" && ", parts); } + /** Milvus boolean expr,字段在 JSON metadata 内。 */ String buildSdkFilterExpression(String category) { List parts = new ArrayList<>(); String categoryFilter = trimToNull(category); @@ -262,6 +306,16 @@ public class VectorSearchService { return value.replace("\\", "\\\\").replace("\"", "\\\""); } + /** + * 统一检索命中结构。 + * + *
    + *
  • {@code score}:兼容 L2 距离,供后处理 normalizeL2
  • + *
  • {@code rawScore}:底层原始分(similarity 或 l2)
  • + *
  • {@code scoreLabel}:解释 rawScore 语义
  • + *
  • {@code metadata}:JSON 字符串,含 docId/chunkIndex/title 等
  • + *
+ */ @Setter @Getter public static class SearchResult { diff --git a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java index a4f9163..1070ad8 100644 --- a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java +++ b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java @@ -21,14 +21,41 @@ import java.util.List; import java.util.Map; /** - * Harness backend for knowledge retrieval. Agent-facing schema is owned by HarnessEvidenceTools. + * 知识库检索后端(legacy executor)。 + * + *

Agent 侧工具 schema 由 {@code HarnessEvidenceTools} 声明;本类只负责真正执行检索, + * 再经 {@code RagToolAdapter} + {@code RagResultProjector} 投影成 Agent 可见契约。

+ * + *

主链路

+ *
+ * query
+ *   -> KnowledgeQueryTransformer      // L0:domain/keyword hint,可选 category filter
+ *   -> KnowledgeDocumentRetriever     // L1:向量召回 topK
+ *   -> KnowledgeEvidencePostProcessor // 归一化、规则 rerank、组装 evidenceBlocks
+ *   -> [可选] 去掉 category 后重试      // filtered 结果质量不足时
+ *   -> KnowledgeContextPacker         // 按字符预算打包文本
+ *   -> LookupResultAssembler          // 统一 LookupResult
+ * 
+ * + *

降级策略

+ *
    + *
  • 有 categoryFilter:先 FILTERED_VECTOR
  • + *
  • 若结果无证据或 topSimilarity < referenceThreshold:再 UNFILTERED_VECTOR_RETRY
  • + *
  • 无 categoryFilter:直接 UNFILTERED_VECTOR
  • + *
+ * + *

注意:本类返回的是内部 {@link LookupResult},不是 Agent 最终看到的 JSON。 + * 会话级文档去重目前不在这里做。

*/ @Slf4j @Component public class LookupKnowledgeTool { + /** 首次检索:带 L0 推导出的 category 过滤。 */ private static final String ATTEMPT_FILTERED_VECTOR = "FILTERED_VECTOR"; + /** 首次检索:L0 未给出唯一 domain,不做 category 过滤。 */ private static final String ATTEMPT_UNFILTERED_VECTOR = "UNFILTERED_VECTOR"; + /** 降级重试:去掉 category 过滤,用原始 query 再搜一次。 */ private static final String ATTEMPT_UNFILTERED_VECTOR_RETRY = "UNFILTERED_VECTOR_RETRY"; private static final String FALLBACK_NO_EVIDENCE = "filtered_vector_no_evidence"; private static final String FALLBACK_LOW_QUALITY = "filtered_vector_low_quality"; @@ -52,10 +79,10 @@ public class LookupKnowledgeTool { private LookupResultAssembler resultAssembler; /** - * 查询知识库文档。 + * 执行一次知识库检索,返回 evidence-first 的内部结果。 * - * @param query 查询关键词 - * @return 查询结果 + * @param query Agent / Harness 传入的检索语句(不是最终用户原话的完整上下文) + * @return 含 evidenceBlocks、contextPack、retrievalTrace 的 LookupResult */ public LookupResult lookupKnowledge(String query) { log.info("========================================"); @@ -63,6 +90,7 @@ public class LookupKnowledgeTool { log.info(">>> metadata: query_chars={}", query == null ? 0 : query.length()); log.info("----------------------------------------"); + // 1) Query understanding:L0 只产 hint/filter,不直接当事实证据 KnowledgeQuery knowledgeQuery = queryTransformer.transform(query); log.info("[QueryTransformer] categoryFilter={}, domainHintCount={}, keywordCount={}", knowledgeQuery.getCategoryFilter(), @@ -72,6 +100,7 @@ public class LookupKnowledgeTool { List attempts = new ArrayList<>(); String fallbackReason = null; + // 2) 首次 L1 向量检索(有唯一 domain 则带 category filter) String firstAttemptName = knowledgeQuery.getCategoryFilter() == null ? ATTEMPT_UNFILTERED_VECTOR : ATTEMPT_FILTERED_VECTOR; @@ -87,6 +116,8 @@ public class LookupKnowledgeTool { attempts.add(firstAttempt.attempt()); String selectedAttemptName = firstAttemptName; + // 3) filtered 路径质量不足时,去掉 category 用原始 query 重试一次 + // 重试结果会整体替换首次结果(不是与首次融合) if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) { fallbackReason = selectedEvidence.hasUsableEvidence() ? FALLBACK_LOW_QUALITY @@ -107,6 +138,7 @@ public class LookupKnowledgeTool { selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY; } + // 4) 打包 + 组装最终内部结果(供 projector / 审计消费) ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks()); RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName, fallbackReason, selectedEvidence); @@ -116,6 +148,10 @@ public class LookupKnowledgeTool { return result; } + /** + * 用后处理后的相似度回填 attempt 可观测字段。 + * usable 要求:有可用证据,且 topSimilarity 达到 reference 阈值。 + */ private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) { attempt.setTopSimilarity(evidence.getTopSimilarity()); attempt.setUsable(evidence.hasUsableEvidence() @@ -123,6 +159,7 @@ public class LookupKnowledgeTool { && evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold()); } + /** 汇总本次检索的 query hint、attempt 列表与最终选用路径,便于 trace 回放。 */ private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query, List attempts, String selectedAttempt,