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由 {@code KnowledgeEvidencePostProcessor} 生成,再交给 context packer 与 result assembler。
*/ @Data @Builder public class EvidencePostprocessResult { + /** 后处理前候选数。 */ private Integer candidateCount; + /** 去重后 evidence 条数。 */ private Integer evidenceBlockCount; private List由 {@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字段比 Agent 可见契约更丰富,便于审计与调试。 + * 进入 Agent 前会经 {@code RagResultProjector} 裁剪为 {@code RagToolResult}。
*/ @Data @Builder public class LookupResult { /** - * 是否找到结果 + * 是否找到可用证据(evidenceBlocks 非空且通过后处理可用性判断)。 */ private boolean found; /** - * Structured evidence blocks after retrieval post-processing. + * 后处理后的结构化证据列表(文档级去重后)。 */ private List记录 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由 {@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连接三层:
+ *这样检索实现可演进,而 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 契约。 + * + *把长 Markdown/文本切成带 title/breadcrumb 的 {@link com.superbiz.agent.dto.DocumentChunk}, + * 供 {@link VectorIndexService} 向量化。
+ * + *检索命中单个 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职责是把 {@link VectorSearchService} 的原始命中,转成 pipeline 统一使用的 + * {@link RetrievedEvidenceCandidate},并记录单次 attempt 的 trace。
+ * + *本类不做质量判断、不做 rerank、不做上下文打包;那些属于后处理阶段。
+ * + *输入是 Agent 的原始检索句,输出 {@link KnowledgeQuery},供后续 L1 过滤与 rerank 使用。
+ * + *{@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负责:读文件/文档块 -> 切片 -> embedding -> 写入 Milvus。 + * 检索读取走 {@link VectorSearchService},双方通过 collection + metadata 约定衔接。
+ * + *把标题链路注入向量语义,缓解“正文片段缺上下文”导致的召回漂移。 + * 注意:这里只影响向量,不影响 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)}。底层实现可切换:
+ *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 + *+ * + *
注意:本类返回的是内部 {@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