docs(rag): add comments on knowledge retrieval pipeline

Document the lookup_knowledge flow from L0 hints through L1 retrieval,
post-processing, packing, and Agent projection so the boundaries and
current limitations are easier to follow.
This commit is contained in:
zhuyongxin
2026-07-27 16:26:06 +08:00
parent edb6153fd6
commit 99d4f6f216
19 changed files with 480 additions and 37 deletions
@@ -6,23 +6,38 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* Structured evidence returned by knowledge retrieval. * 内部证据块(后处理输出,尚未投影到 Agent 契约)。
*
* <p>一条 block 通常对应一次向量命中的一个 chunk 内容(截断后)。
* 注意:当前后处理按 source 去重,同文档多 chunk 可能被合并掉,只保留最高分 content。</p>
*
* <p>投影到 Agent 时由 {@code RagResultProjector} 转为 {@code RagEvidence}
* (document_id / source / title / breadcrumb / excerpt)。</p>
*/ */
@Data @Data
@Builder @Builder
public class EvidenceBlock { public class EvidenceBlock {
/** 来源标识,常见为文件路径、upload:docId 或 docId。 */
private String source; private String source;
private String title; private String title;
/** Markdown 标题链路,例如 "故障排查 > 连接池耗尽"。 */
private String breadcrumb; private String breadcrumb;
/** 检索层标记,当前 L1 向量召回为 "L1"。 */
private String retrievalLayer; private String retrievalLayer;
/** 证据正文(后处理阶段可能已截断)。 */
private String content; private String content;
/**
* 原始兼容分(多为 L2 距离,越小越相似)。
* Agent 投影层不会暴露该字段。
*/
private Double score; private Double score;
/** 命中原因,如 semantic_rank、domain_match 等,供内部 trace。 */
private List<String> hitReasons; private List<String> hitReasons;
} }
@@ -6,26 +6,37 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* Output of post-retrieval processing before context packing. * 检索后处理输出(打包 / 组装前的中间结果)。
*
* <p>由 {@code KnowledgeEvidencePostProcessor} 生成,再交给 context packer 与 result assembler。</p>
*/ */
@Data @Data
@Builder @Builder
public class EvidencePostprocessResult { public class EvidencePostprocessResult {
/** 后处理前候选数。 */
private Integer candidateCount; private Integer candidateCount;
/** 去重后 evidence 条数。 */
private Integer evidenceBlockCount; private Integer evidenceBlockCount;
private List<EvidenceBlock> evidenceBlocks; private List<EvidenceBlock> evidenceBlocks;
/** PRECISE / HIGHLY_RELEVANT / REFERENCE;不可用时 null。 */
private String relevanceLevel; private String relevanceLevel;
/** 给模型的完整性/天花板提示。 */
private String completenessHint; private String completenessHint;
private RerankTrace rerankTrace; private RerankTrace rerankTrace;
/**
* 排序第一名的 baseScore(0~1 相似度,不含规则 boost)。
* 用于 isLowQuality 与 attempt.topSimilarity。
*/
private Double topSimilarity; private Double topSimilarity;
/** 当前定义:evidenceBlocks 非空即视为有可用证据。 */
public boolean hasUsableEvidence() { public boolean hasUsableEvidence() {
return evidenceBlocks != null && !evidenceBlocks.isEmpty(); return evidenceBlocks != null && !evidenceBlocks.isEmpty();
} }
@@ -6,25 +6,45 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* Query understanding output used by the knowledge retrieval pipeline. * 检索前 query understanding 的输出(L0 -&gt; pipeline 控制面)。
*
* <p>由 {@code KnowledgeQueryTransformer} 生成,供 L1 过滤、规则 rerank 与 trace 使用。
* 不是 Agent 可见契约。</p>
*/ */
@Data @Data
@Builder @Builder
public class KnowledgeQuery { public class KnowledgeQuery {
/** Agent 传入的原始检索句(trim 后)。 */
private String originalQuery; private String originalQuery;
/**
* 改写后的检索句。
* 当前实现未做真正 rewrite,通常等于 originalQuery。
*/
private String rewrittenQuery; private String rewrittenQuery;
/** L0 命中的 domain/category 列表,用于 boost / trace。 */
private List<String> domainHints; private List<String> domainHints;
/** L0 命中的关键词。 */
private List<String> matchedKeywords; private List<String> matchedKeywords;
/**
* 实体提示。
* 当前实现基本等同 matchedKeywords,预留更细实体抽取。
*/
private List<String> entities; private List<String> entities;
/**
* 向量检索 category 过滤条件。
* 仅当 L0 恰好命中一个 domain 时非空;否则 null(不过滤)。
*/
private String categoryFilter; private String categoryFilter;
/** L0 命中文档标题,主要用于 trace 解释。 */
private List<String> l0Titles; private List<String> l0Titles;
/** L0 命中文档条数。 */
private Integer l0MatchCount; private Integer l0MatchCount;
} }
@@ -6,64 +6,67 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* 知识库查询结果 * lookup_knowledge 内部完整结果(legacy executor 出口)。
*
* <p>字段比 Agent 可见契约更丰富,便于审计与调试。
* 进入 Agent 前会经 {@code RagResultProjector} 裁剪为 {@code RagToolResult}。</p>
*/ */
@Data @Data
@Builder @Builder
public class LookupResult { public class LookupResult {
/** /**
* 是否找到结果 * 是否找到可用证据(evidenceBlocks 非空且通过后处理可用性判断)。
*/ */
private boolean found; private boolean found;
/** /**
* Structured evidence blocks after retrieval post-processing. * 后处理后的结构化证据列表(文档级去重后)。
*/ */
private List<EvidenceBlock> evidenceBlocks; private List<EvidenceBlock> evidenceBlocks;
/** /**
* Packed Agent-facing context assembled from evidence blocks. * 按字符预算打包后的文本上下文(内部使用;Agent 主路径不一定消费)。
*/ */
private ContextPack contextPack; private ContextPack contextPack;
/** /**
* Retrieval attempts and fallback trace. * 检索 attempt / fallback 轨迹。
*/ */
private RetrievalTrace retrievalTrace; private RetrievalTrace retrievalTrace;
/** /**
* Rule-based rerank explanation. * 规则 rerank 解释(base/final score 与 boost 原因)。
*/ */
private RerankTrace rerankTrace; private RerankTrace rerankTrace;
/** /**
* Candidate count before evidence deduplication. * 去重前的候选条数。
*/ */
private Integer evidenceCandidateCount; private Integer evidenceCandidateCount;
/** /**
* Evidence block count after post-processing. * 去重后的 evidence block 条数。
*/ */
private Integer evidenceBlockCount; private Integer evidenceBlockCount;
/** /**
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE * 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE;不可用时为 null。
*/ */
private String relevanceLevel; private String relevanceLevel;
/** /**
* 兜底信号:告诉 LLM 知识库的"天花板" * 给模型的“知识库天花板”提示,例如已高度相关/仅供参考。
*/ */
private String completenessHint; private String completenessHint;
/** /**
* 本次会话已检索过的域列表(行动记忆) * 会话已检索域列表(历史行动记忆字段;当前主链路未必填充)。
*/ */
private List<String> retrievedDomainsThisSession; private List<String> retrievedDomainsThisSession;
/** /**
* 系统消息(如去重提示) * 系统消息,如无证据提示或历史 dedup 提示。
*/ */
private String message; private String message;
} }
@@ -7,7 +7,9 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Trace of retrieval attempts used by lookup_knowledge. * 一次 lookup_knowledge 调用的检索轨迹(内部可观测,默认不投影给 Agent)。
*
* <p>记录 query 理解结果、每次 attempt、最终选用路径与 fallback 原因,便于 trace 回放。</p>
*/ */
@Data @Data
@Builder @Builder
@@ -17,29 +19,50 @@ public class RetrievalTrace {
private String rewrittenQuery; private String rewrittenQuery;
/** 首次检索使用的 category 过滤;无过滤时为 null。 */
private String categoryFilter; private String categoryFilter;
/**
* 最终采用的 attempt 名。
* 例如 FILTERED_VECTOR / UNFILTERED_VECTOR / UNFILTERED_VECTOR_RETRY。
*/
private String selectedAttempt; private String selectedAttempt;
/**
* 触发降级的原因;未降级时为 null。
* 例如 filtered_vector_no_evidence / filtered_vector_low_quality。
*/
private String fallbackReason; private String fallbackReason;
/** supported / no_evidence。 */
private String evidenceStatus; private String evidenceStatus;
/** L0 hint 快照:domains、keywords、entities、titles 等。 */
private Map<String, Object> queryHints; private Map<String, Object> queryHints;
private List<Attempt> attempts; private List<Attempt> attempts;
/**
* 单次检索 attempt 的可观测快照。
*/
@Data @Data
@Builder @Builder
public static class Attempt { public static class Attempt {
/** FILTERED_VECTOR / UNFILTERED_VECTOR / UNFILTERED_VECTOR_RETRY 等。 */
private String name; private String name;
private String query; private String query;
private String categoryFilter; private String categoryFilter;
private Integer candidateCount; private Integer candidateCount;
/**
* 是否可用。
* retriever 初值:有候选且无错误;后处理会按相似度阈值再收紧。
*/
private Boolean usable; private Boolean usable;
private String errorMessage; private String errorMessage;
private Integer durationMs; private Integer durationMs;
/** 向量层 top score(兼容 L2 距离,越小越相似)。 */
private Double topScore; private Double topScore;
/** 后处理归一化后的 top 相似度(0~1,越大越相似)。 */
private Double topSimilarity; private Double topSimilarity;
} }
} }
@@ -7,35 +7,57 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Normalized vector retrieval candidate before evidence post-processing. * L1 向量命中后、后处理前的统一候选结构。
*
* <p>由 {@code KnowledgeDocumentRetriever} 从 {@code VectorSearchService.SearchResult} 映射而来。
* 后处理会基于它做归一化、规则 boost、去重并生成 {@link EvidenceBlock}。</p>
*/ */
@Data @Data
@Builder @Builder
public class RetrievedEvidenceCandidate { public class RetrievedEvidenceCandidate {
/** 向量库记录 id。 */
private String id; private String id;
/**
* 来源标识(_source / source / filePath / docId 等)。
* 当前后处理去重主要依赖该字段,粒度偏文档级。
*/
private String source; private String source;
private String title; private String title;
private String breadcrumb; private String breadcrumb;
/** chunk 正文原文(后处理前未截断或仅底层原样)。 */
private String content; private String content;
/** 固定为 L1(向量层);预留多路召回标记。 */
private String retrievalLayer; private String retrievalLayer;
/** 所属 attempt 名,如 FILTERED_VECTOR。 */
private String retrievalAttempt; private String retrievalAttempt;
/**
* 兼容 L2 距离分(越小越相似),后处理会 normalize 成 baseScore。
*/
private Double score; private Double score;
/** 底层原始分。 */
private Double rawScore; private Double rawScore;
/** rawScore 语义标签:l2_distance / similarity。 */
private String scoreLabel; private String scoreLabel;
/** 向量召回顺序(从 1 起),规则 rerank 前的名次。 */
private Integer originalRank; private Integer originalRank;
/**
* 扁平化 metadata(string map)。
* 可能含 docId、chunkIndex、category、kb_scope 等;chunkIndex 尚未提升为一等字段。
*/
private Map<String, String> metadata; private Map<String, String> metadata;
/** 初步命中原因,后处理会追加 boost reasons。 */
private List<String> hitReasons; private List<String> hitReasons;
} }
@@ -11,9 +11,22 @@ import com.superbiz.agent.harness.tool.projection.RagResultProjector;
import java.util.Objects; import java.util.Objects;
/** Bridges a typed RAG request and a legacy knowledge executor through ToolBoundary. */ /**
* Harness 侧 RAG 工具适配器。
*
* <p>连接三层:</p>
* <ol>
* <li>解析 Agent 的 typed request({@link RagToolRequest})</li>
* <li>经 {@link ToolBoundary} 执行预算/审计等边界控制</li>
* <li>调用 legacy {@code LookupKnowledgeTool},再用 {@link RagResultProjector}
* 投影成冻结的 Agent 可见契约</li>
* </ol>
*
* <p>这样检索实现可演进,而 Agent tool schema 与 EvidenceGuard 契约保持稳定。</p>
*/
public final class RagToolAdapter { public final class RagToolAdapter {
/** 兼容旧检索后端:只接收 query,返回可序列化的 LookupResult(或等价 Map)。 */
@FunctionalInterface @FunctionalInterface
public interface LegacyExecutor { public interface LegacyExecutor {
Object execute(String query) throws Exception; Object execute(String query) throws Exception;
@@ -32,6 +45,11 @@ public final class RagToolAdapter {
this.legacyExecutor = Objects.requireNonNull(legacyExecutor, "legacyExecutor must not be null"); this.legacyExecutor = Objects.requireNonNull(legacyExecutor, "legacyExecutor must not be null");
} }
/**
* 执行一次 lookup_knowledge 工具调用。
*
* <p>流程:校验 request.query -&gt; boundary.execute(legacy) -&gt; projector.project。</p>
*/
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) { public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
try { try {
RagToolRequest request = objectMapper.readValue(envelope.requestJson(), RagToolRequest.class); 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 ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
} }
return boundary.execute(context, envelope, return boundary.execute(context, envelope,
// legacy 原始 JSON(内部 LookupResult)
ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())), ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())),
// 投影为 Agent 契约(RagToolResult)
raw -> projector.project(request, envelope.toolCallId(), raw)); raw -> projector.project(request, envelope.toolCallId(), raw));
} catch (Exception e) { } catch (Exception e) {
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(), return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
@@ -2,10 +2,21 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Agent 可见的单条 RAG 证据(冻结契约)。
*
* <p>由 {@code RagResultProjector} 从内部 {@code EvidenceBlock} 投影而来。
* EvidenceGuard / 诊断报告引用时使用 {@code document_id}。</p>
*
* <p>注意:当 legacy block 未提供 document_id 时,投影层常把 source 当作 document_id,
* 因此同 source 的多个 chunk 在 Agent 侧也会去重成一条。</p>
*/
public record RagEvidence( public record RagEvidence(
/** 证据引用 id;当前实现常等于 source(文档级)。 */
@JsonProperty("document_id") String documentId, @JsonProperty("document_id") String documentId,
@JsonProperty("source") String source, @JsonProperty("source") String source,
@JsonProperty("title") String title, @JsonProperty("title") String title,
@JsonProperty("breadcrumb") String breadcrumb, @JsonProperty("breadcrumb") String breadcrumb,
/** 截断后的正文摘录(对应内部 content)。 */
@JsonProperty("excerpt") String excerpt) { @JsonProperty("excerpt") String excerpt) {
} }
@@ -15,7 +15,31 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
/** Projects legacy knowledge retrieval JSON into the frozen RAG contract. */ /**
* 把 legacy {@code LookupResult} JSON 投影成冻结的 Agent 可见 RAG 契约。
*
* <h3>为什么需要投影</h3>
* 内部检索结果字段较多(retrievalTrace、rerankTrace、score、hitReasons、contextPack…),
* Agent / EvidenceGuard 只应看到受控、有界、可引用的子集。
*
* <h3>保留给 Agent 的字段</h3>
* <ul>
* <li>evidenceStatus / toolCallId / query</li>
* <li>evidence[]:document_id, source, title, breadcrumb, excerpt</li>
* <li>relevanceLevel、truncated、returned_count</li>
* </ul>
*
* <h3>刻意丢弃</h3>
* score、hitReasons、retrievalTrace、rerankTrace、contextPack 等内部可观测细节。
*
* <h3>去重与预算(读代码关键)</h3>
* <ul>
* <li>按 {@code document_id} 去重;若 block 无 document_id,则回退 source/title</li>
* <li>因此同 source 的多个 chunk 在此也会被压成 1 条(与后处理文档级去重叠加)</li>
* <li>条数上限 {@link ToolProjectionLimits#maxEvidence()},excerpt 字符上限,
* 以及总 UTF-8 字节预算(超限从后往前删 evidence)</li>
* </ul>
*/
public final class RagResultProjector { public final class RagResultProjector {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -26,6 +50,11 @@ public final class RagResultProjector {
this.limits = limits; this.limits = limits;
} }
/**
* @param request Agent 原始请求(用于回填/截断 query)
* @param toolCallId 框架分配的本次工具调用 id,进入结果供证据引用
* @param rawResponse legacy LookupResult 的 JSON 字符串
*/
public ProjectedToolResult project(RagToolRequest request, String toolCallId, public ProjectedToolResult project(RagToolRequest request, String toolCallId,
String rawResponse) throws Exception { String rawResponse) throws Exception {
if (request == null || toolCallId == null || toolCallId.isBlank()) { if (request == null || toolCallId == null || toolCallId.isBlank()) {
@@ -40,6 +69,7 @@ public final class RagResultProjector {
String query = bounded(request.query(), limits.maxQueryChars()); String query = bounded(request.query(), limits.maxQueryChars());
truncated = !query.equals(request.query()); truncated = !query.equals(request.query());
List<RagEvidence> evidence = new ArrayList<>(); List<RagEvidence> evidence = new ArrayList<>();
// 文档级唯一集合:相同 documentId 只保留首次出现
Set<String> documentIds = new HashSet<>(); Set<String> documentIds = new HashSet<>();
JsonNode blocks = root.has("evidenceBlocks") ? root.get("evidenceBlocks") : root.get("evidence_blocks"); JsonNode blocks = root.has("evidenceBlocks") ? root.get("evidenceBlocks") : root.get("evidence_blocks");
if (blocks != null && blocks.isArray()) { if (blocks != null && blocks.isArray()) {
@@ -59,9 +89,11 @@ public final class RagResultProjector {
} }
String source = text(block, "source"); String source = text(block, "source");
String title = text(block, "title"); String title = text(block, "title");
// legacy EvidenceBlock 通常没有 document_id,实际常退化为 source
String documentId = firstNonBlank(text(block, "document_id"), source, title, String documentId = firstNonBlank(text(block, "document_id"), source, title,
"legacy-document-" + ordinal); "legacy-document-" + ordinal);
if (!documentIds.add(documentId)) { if (!documentIds.add(documentId)) {
// 同 documentId 重复:丢弃后续条,并标记 truncated
truncated = true; truncated = true;
continue; continue;
} }
@@ -86,10 +118,12 @@ public final class RagResultProjector {
? relevanceLevel(root) : null; ? relevanceLevel(root) : null;
RagToolResult result = new RagToolResult( RagToolResult result = new RagToolResult(
status, toolCallId, query, evidence, evidence.size(), relevanceLevel, truncated); status, toolCallId, query, evidence, evidence.size(), relevanceLevel, truncated);
// 总字节预算:仍超限则从尾部删 evidence,直到放得下或变 no_evidence
result = fitBudget(result, truncated); result = fitBudget(result, truncated);
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus()); return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
} }
/** 按 maxAgentUtf8Bytes 从后往前删 evidence,保证 Agent 侧 payload 有界。 */
private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception { private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception {
RagToolResult current = result; RagToolResult current = result;
while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes() while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()
@@ -13,8 +13,21 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 文档分片服务 * 文档切片服务(RAG 入库前处理)。
* 负责将长文档切分为多个有语义完整性的小片段 *
* <p>把长 Markdown/文本切成带 title/breadcrumb 的 {@link com.superbiz.agent.dto.DocumentChunk},
* 供 {@link VectorIndexService} 向量化。</p>
*
* <h3>策略摘要</h3>
* <ol>
* <li>先按 Markdown 标题分 section,并维护 breadcrumb 层级</li>
* <li>section 过长再按段落累积;用 token 估算做软边界 / 硬上限</li>
* <li>尽量不在有序/无序列表或未闭合代码块中间切断</li>
* <li>相邻 chunk 保留 overlap,减轻边界语义断裂</li>
* </ol>
*
* <p>检索命中单个 chunk 后,当前主链路不会自动回补同章节相邻 chunk
* (上下文重建仍是后续增强点)。</p>
*/ */
@Service @Service
public class DocumentChunkService { public class DocumentChunkService {
@@ -9,14 +9,24 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Packs final evidence blocks into compact Agent-facing context. * 把已排序的 evidenceBlocks 压成一段有预算上限的文本。
*
* <p>输入顺序即优先级:排在前面的证据先占预算,超预算的 source 记入 omittedSources。</p>
*
* <p>注意:当前 Harness 投影给 Agent 的主要是结构化 evidence 列表,
* {@link ContextPack#getPackedText()} 更多用于内部/调试/审计,不保证出现在 Agent 最终 tool view。</p>
*/ */
@Service @Service
public class KnowledgeContextPacker { public class KnowledgeContextPacker {
/** 打包总字符预算(含 header)。 */
@Value("${rag.context-pack.char-budget:4000}") @Value("${rag.context-pack.char-budget:4000}")
private int charBudget = 4000; private int charBudget = 4000;
/**
* 按排名顺序打包证据。
* 单条证据若剩余预算不足,会截断 body;若连 header 都放不下,则整条省略。
*/
public ContextPack pack(List<EvidenceBlock> blocks) { public ContextPack pack(List<EvidenceBlock> blocks) {
List<EvidenceBlock> safeBlocks = blocks == null ? List.of() : blocks; List<EvidenceBlock> safeBlocks = blocks == null ? List.of() : blocks;
StringBuilder packed = new StringBuilder(); StringBuilder packed = new StringBuilder();
@@ -48,6 +58,7 @@ public class KnowledgeContextPacker {
.build(); .build();
} }
/** 每条证据的可读 header,便于人工阅读 packedText。 */
private String buildHeader(int rank, EvidenceBlock block) { private String buildHeader(int rank, EvidenceBlock block) {
StringBuilder header = new StringBuilder(); StringBuilder header = new StringBuilder();
header.append("[Evidence ").append(rank).append("]\n"); header.append("[Evidence ").append(rank).append("]\n");
@@ -11,7 +11,20 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Vector retrieval adapter for the modular knowledge pipeline. * L1 向量检索适配器。
*
* <p>职责是把 {@link VectorSearchService} 的原始命中,转成 pipeline 统一使用的
* {@link RetrievedEvidenceCandidate},并记录单次 attempt 的 trace。</p>
*
* <p>本类不做质量判断、不做 rerank、不做上下文打包;那些属于后处理阶段。</p>
*
* <h3>字段映射要点</h3>
* <ul>
* <li>{@code source}:优先 metadata._source / source / filePath / docId</li>
* <li>{@code title/breadcrumb}:来自 chunk metadata,用于展示与规则 boost</li>
* <li>{@code score}:兼容后的距离分(SDK 为 L2;Spring AI 路径会映射成兼容 L2)</li>
* <li>metadata 中的 chunkIndex 目前只留在 map 里,未提升为一等字段</li>
* </ul>
*/ */
@Service @Service
public class KnowledgeDocumentRetriever { public class KnowledgeDocumentRetriever {
@@ -24,6 +37,15 @@ public class KnowledgeDocumentRetriever {
this.objectMapper = objectMapper; 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) { public RetrievalAttemptResult retrieve(String attemptName, String query, String categoryFilter, int topK) {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
try { try {
@@ -36,6 +58,7 @@ public class KnowledgeDocumentRetriever {
candidates candidates
); );
} catch (Exception e) { } catch (Exception e) {
// 检索失败不向上抛:由上层按“无候选 / 低质量”路径继续(例如 fallback retry)
return new RetrievalAttemptResult( return new RetrievalAttemptResult(
attempt(attemptName, query, categoryFilter, 0, e.getMessage(), attempt(attemptName, query, categoryFilter, 0, e.getMessage(),
(int) (System.currentTimeMillis() - start), null), (int) (System.currentTimeMillis() - start), null),
@@ -44,6 +67,10 @@ public class KnowledgeDocumentRetriever {
} }
} }
/**
* 将向量库原始结果规范化为候选证据。
* originalRank 从 1 开始,对应向量召回顺序(尚未规则 rerank)。
*/
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName, private List<RetrievedEvidenceCandidate> toCandidates(String attemptName,
List<VectorSearchService.SearchResult> results) { List<VectorSearchService.SearchResult> results) {
if (results == null || results.isEmpty()) { if (results == null || results.isEmpty()) {
@@ -53,6 +80,7 @@ public class KnowledgeDocumentRetriever {
for (int i = 0; i < results.size(); i++) { for (int i = 0; i < results.size(); i++) {
VectorSearchService.SearchResult result = results.get(i); VectorSearchService.SearchResult result = results.get(i);
Map<String, String> metadata = parseMetadata(result.getMetadata()); Map<String, String> metadata = parseMetadata(result.getMetadata());
// source 是后续去重/展示的主标识;当前实现偏“文档级”,同文档多 chunk 可能共享 source
String source = firstNonBlank( String source = firstNonBlank(
metadata.get("_source"), metadata.get("_source"),
metadata.get("source"), metadata.get("source"),
@@ -92,6 +120,7 @@ public class KnowledgeDocumentRetriever {
.query(query) .query(query)
.categoryFilter(categoryFilter) .categoryFilter(categoryFilter)
.candidateCount(candidateCount) .candidateCount(candidateCount)
// 此处 usable 只表示“有候选且无错误”;后处理还会用相似度阈值再收紧
.usable(errorMessage == null && candidateCount > 0) .usable(errorMessage == null && candidateCount > 0)
.errorMessage(errorMessage) .errorMessage(errorMessage)
.durationMs(durationMs) .durationMs(durationMs)
@@ -106,6 +135,7 @@ public class KnowledgeDocumentRetriever {
return (double) results.get(0).getScore(); return (double) results.get(0).getScore();
} }
/** metadata 在向量库中多为 JSON 字符串,这里压成 string map 方便后处理读取。 */
private Map<String, String> parseMetadata(String metadata) { private Map<String, String> parseMetadata(String metadata) {
if (metadata == null || metadata.isBlank()) { if (metadata == null || metadata.isBlank()) {
return Map.of(); return Map.of();
@@ -133,6 +163,12 @@ public class KnowledgeDocumentRetriever {
return null; return null;
} }
/**
* 单次检索 attempt 的结果包。
*
* @param attempt 可观测元数据(耗时、过滤条件、错误等)
* @param candidates 规范化后的证据候选
*/
public record RetrievalAttemptResult(RetrievalTrace.Attempt attempt, public record RetrievalAttemptResult(RetrievalTrace.Attempt attempt,
List<RetrievedEvidenceCandidate> candidates) { List<RetrievedEvidenceCandidate> candidates) {
} }
@@ -18,7 +18,24 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Post-retrieval evidence normalization, rerank, and evidence block assembly. * 检索后处理:分数归一化、规则 rerank、证据块组装、相关等级判定。
*
* <h3>处理步骤</h3>
* <ol>
* <li>把候选 L2 距离归一成 0~1 的 baseScore</li>
* <li>用 L0 hint(domain/entity/keyword)做规则加分,得到 finalScore</li>
* <li>按 finalScore 降序排序</li>
* <li>按 sourceKey 去重后生成 {@link EvidenceBlock}</li>
* <li>根据 top baseScore + hint 支撑计算 relevanceLevel</li>
* </ol>
*
* <h3>重要行为(读代码时容易误解)</h3>
* <ul>
* <li>“rerank” 是规则加权,不是 cross-encoder / LLM rerank</li>
* <li>去重 key 优先 source/title,粒度偏文档级:同文档多个 chunk 可能被压成一条,
* 且 merge 时只合并 hitReasons,不拼接 content</li>
* <li>content 在此截断到 800 字符;Agent 侧 projector 还可能再截断</li>
* </ul>
*/ */
@Service @Service
public class KnowledgeEvidencePostProcessor { public class KnowledgeEvidencePostProcessor {
@@ -31,17 +48,24 @@ public class KnowledgeEvidencePostProcessor {
private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档"; private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档";
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度"; private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
/** L2 距离上限,用于把距离映射到 [0,1] 相似度。 */
@Value("${retrieval.normalization.max-l2-distance:2.0}") @Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0; private double maxL2Distance = 2.0;
/** baseScore &gt;= 该阈值,才可能判 HIGHLY_RELEVANT / PRECISE。 */
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}") @Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
private double highlyRelevantThreshold = 0.75; private double highlyRelevantThreshold = 0.75;
/** baseScore &gt;= 该阈值视为可用参考;低于则 isLowQuality=true,可能触发 unfiltered retry。 */
@Value("${retrieval.normalization.reference-threshold:0.5}") @Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold = 0.5; private double referenceThreshold = 0.5;
/**
* 对一次 attempt 的候选做后处理,产出可交给打包/组装的证据结果。
*/
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;
// 先打分排序:baseScore 来自向量距离,finalScore = base + 规则 boost
List<ScoredCandidate> ranked = safeCandidates.stream() List<ScoredCandidate> ranked = safeCandidates.stream()
.map(candidate -> score(query, candidate)) .map(candidate -> score(query, candidate))
.sorted(Comparator.comparingDouble(ScoredCandidate::finalScore).reversed()) .sorted(Comparator.comparingDouble(ScoredCandidate::finalScore).reversed())
@@ -61,6 +85,7 @@ public class KnowledgeEvidencePostProcessor {
.score(candidate.getScore()) .score(candidate.getScore())
.hitReasons(mergeReasons(candidate.getHitReasons(), scored.boostReasons())) .hitReasons(mergeReasons(candidate.getHitReasons(), scored.boostReasons()))
.build(); .build();
// 注意:key 未使用 chunkIndex,同 source 的多个 chunk 会走 merge 分支
String key = sourceKey(block, "candidate-" + candidate.getOriginalRank()); String key = sourceKey(block, "candidate-" + candidate.getOriginalRank());
if (!deduped.containsKey(key)) { if (!deduped.containsKey(key)) {
deduped.put(key, block); deduped.put(key, block);
@@ -72,11 +97,13 @@ public class KnowledgeEvidencePostProcessor {
.boostReasons(scored.boostReasons()) .boostReasons(scored.boostReasons())
.build()); .build());
} else { } else {
// 重复 key:保留先放入的(更高分)content,只补充 reasons/breadcrumb
mergeEvidence(deduped.get(key), block); mergeEvidence(deduped.get(key), block);
} }
} }
List<EvidenceBlock> blocks = new ArrayList<>(deduped.values()); List<EvidenceBlock> blocks = new ArrayList<>(deduped.values());
// topSimilarity 用排序后第一名的 baseScore(未含 boost),供质量阈值判断
Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).baseScore(); Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).baseScore();
RelevanceAssessment assessment = computeRelevance(query, ranked); RelevanceAssessment assessment = computeRelevance(query, ranked);
return EvidencePostprocessResult.builder() return EvidencePostprocessResult.builder()
@@ -90,6 +117,10 @@ public class KnowledgeEvidencePostProcessor {
.build(); .build();
} }
/**
* 是否低质量,用于触发 filtered -> unfiltered 降级。
* 无可用证据,或 topSimilarity 低于 referenceThreshold,都视为低质量。
*/
public boolean isLowQuality(EvidencePostprocessResult result) { public boolean isLowQuality(EvidencePostprocessResult result) {
if (result == null || !result.hasUsableEvidence()) { if (result == null || !result.hasUsableEvidence()) {
return true; return true;
@@ -98,6 +129,10 @@ public class KnowledgeEvidencePostProcessor {
return topSimilarity == null || topSimilarity < referenceThreshold; return topSimilarity == null || topSimilarity < referenceThreshold;
} }
/**
* L2 距离 -> 相似度。
* 距离越小越相似:similarity = 1 - min(l2, max) / max。
*/
public double normalizeL2(Double l2Score) { public double normalizeL2(Double l2Score) {
if (l2Score == null) { if (l2Score == null) {
return 0.0; return 0.0;
@@ -110,6 +145,10 @@ public class KnowledgeEvidencePostProcessor {
return referenceThreshold; return referenceThreshold;
} }
/**
* 规则打分:baseScore + domain/entity/keyword/source_type boost。
* boost 只影响排序,不改变用于阈值判断的 baseScore。
*/
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate) { private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate) {
double baseScore = normalizeL2(candidate.getScore()); double baseScore = normalizeL2(candidate.getScore());
double finalScore = baseScore; double finalScore = baseScore;
@@ -135,6 +174,7 @@ public class KnowledgeEvidencePostProcessor {
return new ScoredCandidate(candidate, baseScore, finalScore, boosts); return new ScoredCandidate(candidate, baseScore, finalScore, boosts);
} }
/** 在 source/title/breadcrumb/content/metadata 拼接串上做子串匹配(大小写不敏感)。 */
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;
@@ -154,6 +194,7 @@ public class KnowledgeEvidencePostProcessor {
return false; return false;
} }
/** runbook / guide / case 类来源轻微加分。 */
private boolean isPreferredSourceType(RetrievedEvidenceCandidate candidate) { private boolean isPreferredSourceType(RetrievedEvidenceCandidate candidate) {
Map<String, String> metadata = candidate.getMetadata(); Map<String, String> metadata = candidate.getMetadata();
if (metadata == null || metadata.isEmpty()) { if (metadata == null || metadata.isEmpty()) {
@@ -167,6 +208,10 @@ public class KnowledgeEvidencePostProcessor {
return normalized.contains("runbook") || normalized.contains("guide") || normalized.contains("case"); 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<ScoredCandidate> ranked) { private RelevanceAssessment computeRelevance(KnowledgeQuery query, List<ScoredCandidate> ranked) {
if (ranked.isEmpty()) { if (ranked.isEmpty()) {
return new RelevanceAssessment(null, null); return new RelevanceAssessment(null, null);
@@ -190,6 +235,9 @@ public class KnowledgeEvidencePostProcessor {
|| matchesAny(top.candidate(), query.getMatchedKeywords()); || matchesAny(top.candidate(), query.getMatchedKeywords());
} }
/**
* 同 key 合并策略:不覆盖已有 content(保留更高分的那条),只补 reasons 和空 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) {
@@ -216,6 +264,10 @@ public class KnowledgeEvidencePostProcessor {
return new ArrayList<>(merged); return new ArrayList<>(merged);
} }
/**
* 当前去重 key:source -> title -> breadcrumb -> fallback。
* 因此“同文档不同 chunk”若 source 相同,会被视为重复。
*/
private String sourceKey(EvidenceBlock block, String fallback) { private String sourceKey(EvidenceBlock block, String fallback) {
return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback); return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback);
} }
@@ -240,6 +292,7 @@ public class KnowledgeEvidencePostProcessor {
return value == null ? "" : value; return value == null ? "" : value;
} }
/** 内部打分结果:baseScore 用于阈值,finalScore 用于排序。 */
private record ScoredCandidate(RetrievedEvidenceCandidate candidate, private record ScoredCandidate(RetrievedEvidenceCandidate candidate,
double baseScore, double baseScore,
double finalScore, double finalScore,
@@ -26,8 +26,21 @@ import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
/** /**
* 知识库索引服务 * L0 知识索引服务(关键词 / domain hint,不是向量库)。
* 负责 L0 精确匹配索引的管理 *
* <h3>定位</h3>
* 从 MySQL {@code api_document.metadata}(frontmatter)加载文档级关键词与 category,
* 供检索前 query understanding 使用。L0 输出只作为:
* <ul>
* <li>可选 category filter(唯一 domain 时)</li>
* <li>rerank 的 domain/keyword/entity boost 信号</li>
* <li>trace 可解释信息</li>
* </ul>
* <b>L0 命中文档不会直接当作事实 evidence</b>;证据正文只来自 L1 向量召回。
*
* <h3>匹配方式(当前较粗)</h3>
* {@code query.contains(keyword) || keyword.contains(query)},大小写不敏感。
* 没有分词、别名归一或停用词;短词/泛词可能误命中。
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -128,10 +141,15 @@ public class KnowledgeIndexService {
} }
} }
/** 兼容旧调用:只返回命中的文档条目。 */
public List<KnowledgeEntry> exactMatch(String query) { public List<KnowledgeEntry> exactMatch(String query) {
return analyzeQuery(query).matches(); return analyzeQuery(query).matches();
} }
/**
* 分析 query,产出 L0 hint。
* 遍历内存索引,收集匹配 keyword、domain、title;不做向量检索。
*/
public L0Hint analyzeQuery(String query) { public L0Hint analyzeQuery(String query) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
@@ -200,6 +218,12 @@ public class KnowledgeIndexService {
return value.trim(); return value.trim();
} }
/**
* 关键词双向包含匹配。
* query 已在调用方 lower-case;keyword 在此 lower-case。
* 例:query="mysql timeout" 可命中 keyword="mysql";
* 反过来 keyword="mysql connection pool timeout" 也可能被短 query 命中。
*/
private List<String> matchedKeywords(KnowledgeEntry entry, String query) { private List<String> matchedKeywords(KnowledgeEntry entry, String query) {
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) { if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
return List.of(); return List.of();
@@ -283,6 +307,15 @@ public class KnowledgeIndexService {
return List.copyOf(knowledgeIndex); return List.copyOf(knowledgeIndex);
} }
/**
* L0 分析结果。
*
* @param matches 命中的文档条目(仅 hint,不是 evidence)
* @param matchedKeywords 命中的关键词
* @param domains 命中文档的 category 集合
* @param entities 当前实现等同 matchedKeywords,预留实体字段
* @param titles 命中文档标题
*/
public record L0Hint( public record L0Hint(
List<KnowledgeEntry> matches, List<KnowledgeEntry> matches,
List<String> matchedKeywords, List<String> matchedKeywords,
@@ -294,6 +327,7 @@ public class KnowledgeIndexService {
return new L0Hint(List.of(), List.of(), List.of(), List.of(), List.of()); return new L0Hint(List.of(), List.of(), List.of(), List.of(), List.of());
} }
/** 仅当恰好一个 domain 时返回,用于安全地加 category filter。 */
public String singleDomainOrNull() { public String singleDomainOrNull() {
return domains.size() == 1 ? domains.get(0) : null; return domains.size() == 1 ? domains.get(0) : null;
} }
@@ -6,7 +6,17 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
/** /**
* Converts a raw Agent query into retrieval-control hints. * 检索前的 query 理解层(L0 出口)。
*
* <p>输入是 Agent 的原始检索句,输出 {@link KnowledgeQuery},供后续 L1 过滤与 rerank 使用。</p>
*
* <h3>当前能力边界</h3>
* <ul>
* <li>会做:关键词匹配、domain/entity/title hint、唯一 domain 时生成 categoryFilter</li>
* <li>不会做:真正的 query rewrite / 同义词扩展 / 多 query 改写
* ({@code rewrittenQuery} 目前等于 {@code originalQuery})</li>
* <li>L0 命中文档正文不会直接当作 evidence;证据只来自 L1 向量召回</li>
* </ul>
*/ */
@Service @Service
public class KnowledgeQueryTransformer { public class KnowledgeQueryTransformer {
@@ -17,15 +27,23 @@ public class KnowledgeQueryTransformer {
this.knowledgeIndexService = knowledgeIndexService; this.knowledgeIndexService = knowledgeIndexService;
} }
/**
* 将原始 query 转为检索控制结构。
*
* <p>{@code categoryFilter} 仅在 L0 恰好命中一个 domain 时非空;
* 多 domain 或零 domain 时为 null,避免错误收窄召回。</p>
*/
public KnowledgeQuery transform(String rawQuery) { public KnowledgeQuery transform(String rawQuery) {
String normalized = rawQuery == null ? "" : rawQuery.trim(); String normalized = rawQuery == null ? "" : rawQuery.trim();
KnowledgeIndexService.L0Hint hint = knowledgeIndexService.analyzeQuery(normalized); KnowledgeIndexService.L0Hint hint = knowledgeIndexService.analyzeQuery(normalized);
return KnowledgeQuery.builder() return KnowledgeQuery.builder()
.originalQuery(normalized) .originalQuery(normalized)
// 预留改写字段;当前未实现 rewrite,保持与 original 一致
.rewrittenQuery(normalized) .rewrittenQuery(normalized)
.domainHints(safeList(hint.domains())) .domainHints(safeList(hint.domains()))
.matchedKeywords(safeList(hint.matchedKeywords())) .matchedKeywords(safeList(hint.matchedKeywords()))
.entities(safeList(hint.entities())) .entities(safeList(hint.entities()))
// 只有唯一 domain 才作为向量 metadata 的 category 过滤条件
.categoryFilter(hint.singleDomainOrNull()) .categoryFilter(hint.singleDomainOrNull())
.l0Titles(safeList(hint.titles())) .l0Titles(safeList(hint.titles()))
.l0MatchCount(hint.matches() == null ? 0 : hint.matches().size()) .l0MatchCount(hint.matches() == null ? 0 : hint.matches().size())
@@ -9,11 +9,18 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
/** /**
* Assembles evidence-first LookupResult instances. * 将后处理结果组装为统一的 {@link LookupResult}。
*
* <p>这是 lookup_knowledge 内部契约出口:found / evidenceBlocks / traces / relevance。
* Agent 最终看到的字段集合由 {@code RagResultProjector} 再裁剪一层。</p>
*/ */
@Service @Service
public class LookupResultAssembler { public class LookupResultAssembler {
/**
* 正常检索路径组装。
* found=true 当且仅当后处理认为存在可用 evidenceBlocks。
*/
public LookupResult assemble(EvidencePostprocessResult evidence, public LookupResult assemble(EvidencePostprocessResult evidence,
ContextPack contextPack, ContextPack contextPack,
RetrievalTrace retrievalTrace) { RetrievalTrace retrievalTrace) {
@@ -32,6 +39,12 @@ public class LookupResultAssembler {
.build(); .build();
} }
/**
* 会话级“文档已检索过”的占位结果构造器。
*
* <p>历史设计用于 RetrievedDocTracker 去重回包;当前主链路默认不再调用。
* 保留方法是为了兼容旧调用点/测试,不代表 session dedup 仍在生效。</p>
*/
public LookupResult deduped(LookupResult original, List<String> retrievedDomains, String docKey) { public LookupResult deduped(LookupResult original, List<String> retrievedDomains, String docKey) {
return LookupResult.builder() return LookupResult.builder()
.found(false) .found(false)
@@ -26,8 +26,17 @@ import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** /**
* 向量索引服务 * 向量索引写入服务(RAG 入库侧)。
* 负责读取文件、生成向量、存储到 Milvus *
* <p>负责:读文件/文档块 -&gt; 切片 -&gt; embedding -&gt; 写入 Milvus。
* 检索读取走 {@link VectorSearchService},双方通过 collection + metadata 约定衔接。</p>
*
* <h3>metadata 关键字段</h3>
* docId / _source / chunkIndex / totalChunks / title / breadcrumb / category / kb_scope
*
* <h3>embedding 文本</h3>
* 见 {@link #buildEmbeddingText(DocumentChunk)}:会把 title、breadcrumb 拼进向量文本,
* 而入库 content 字段仍保存原始 chunk 正文(检索返回的是 content,不是 embedding 拼接串)。
*/ */
@Service @Service
public class VectorIndexService { public class VectorIndexService {
@@ -302,6 +311,12 @@ public class VectorIndexService {
return metadata; return metadata;
} }
/**
* 构造送入 embedding 模型的文本。
*
* <p>把标题链路注入向量语义,缓解“正文片段缺上下文”导致的召回漂移。
* 注意:这里只影响向量,不影响 Milvus content 字段存储的原文。</p>
*/
static String buildEmbeddingText(DocumentChunk chunk) { static String buildEmbeddingText(DocumentChunk chunk) {
String content = trimToEmpty(chunk.getContent()); String content = trimToEmpty(chunk.getContent());
String title = trimToEmpty(chunk.getTitle()); String title = trimToEmpty(chunk.getTitle());
@@ -26,10 +26,28 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Vector retrieval facade used by lookup_knowledge. * L1 向量检索门面。
* *
* <p>The public API stays stable while the implementation can route to Spring AI * <p>对上层({@link KnowledgeDocumentRetriever})只暴露稳定 API:
* VectorStore, the original Milvus SDK path, or automatic fallback.</p> * {@link #searchSimilarDocuments(String, int, String)}。底层实现可切换:</p>
* <ul>
* <li>{@code sdk}:Milvus SDK 直连</li>
* <li>{@code spring} / {@code spring-ai}:Spring AI VectorStore</li>
* <li>{@code auto}(默认):先 Spring AI,失败再 fallback 到 SDK</li>
* </ul>
*
* <h3>分数兼容约定</h3>
* 后处理 {@code KnowledgeEvidencePostProcessor} 按“L2 距离越小越相似”归一化。
* 因此本类统一把 {@link SearchResult#score} 填成兼容 L2 距离:
* <ul>
* <li>SDK 路径:直接用 Milvus L2 score</li>
* <li>Spring AI 路径:优先 metadata.distance;否则把 similarity 映射为
* {@code (1 - similarity) * maxL2Distance}</li>
* </ul>
*
* <h3>过滤</h3>
* 可选 category + 全局 {@code retrieval.kb-scope}。两条实现路径的 filter 语法不同,
* 但语义一致:只在对应 metadata 字段上收窄。
*/ */
@Service @Service
public class VectorSearchService { public class VectorSearchService {
@@ -48,12 +66,15 @@ public class VectorSearchService {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
/** 检索实现路由:auto / spring / spring-ai / sdk。 */
@Value("${retrieval.vector-store.mode:auto}") @Value("${retrieval.vector-store.mode:auto}")
private String vectorStoreMode = "auto"; private String vectorStoreMode = "auto";
/** similarity -> 兼容 L2 时使用的距离上限,需与后处理归一化配置一致。 */
@Value("${retrieval.normalization.max-l2-distance:2.0}") @Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0; private double maxL2Distance = 2.0;
/** 非空时只检索该 kb_scope 下的 chunk(多租户/多知识库隔离)。 */
@Value("${retrieval.kb-scope:}") @Value("${retrieval.kb-scope:}")
private String kbScope = ""; private String kbScope = "";
@@ -61,6 +82,13 @@ public class VectorSearchService {
return searchSimilarDocuments(query, topK, null); return searchSimilarDocuments(query, topK, null);
} }
/**
* 按 query 召回 topK 相似文档片段。
*
* @param query 检索文本(会再 embedding)
* @param topK 返回条数
* @param category 可选 category 过滤;null/blank 表示不过滤
*/
public List<SearchResult> searchSimilarDocuments(String query, int topK, String category) { public List<SearchResult> searchSimilarDocuments(String query, int topK, String category) {
String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase(); String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase();
return switch (mode) { return switch (mode) {
@@ -74,6 +102,7 @@ public class VectorSearchService {
}; };
} }
/** auto:Spring AI 优先,任意异常则降级 SDK(保证检索可用性)。 */
private List<SearchResult> searchWithAutoFallback(String query, int topK, String category) { private List<SearchResult> searchWithAutoFallback(String query, int topK, String category) {
try { try {
return searchSimilarDocumentsWithVectorStore(query, topK, category); return searchSimilarDocumentsWithVectorStore(query, topK, category);
@@ -84,6 +113,10 @@ public class VectorSearchService {
} }
} }
/**
* Spring AI VectorStore 路径。
* 注意:写入索引仍主要由 Milvus SDK 完成;这里是读适配,依赖双方 metadata schema 一致。
*/
List<SearchResult> searchSimilarDocumentsWithVectorStore(String query, int topK, String category) { List<SearchResult> searchSimilarDocumentsWithVectorStore(String query, int topK, String category) {
VectorStore vectorStore = vectorStoreProvider != null ? vectorStoreProvider.getIfAvailable() : null; VectorStore vectorStore = vectorStoreProvider != null ? vectorStoreProvider.getIfAvailable() : null;
if (vectorStore == null) { if (vectorStore == null) {
@@ -95,6 +128,7 @@ public class VectorSearchService {
SearchRequest.Builder builder = SearchRequest.builder() SearchRequest.Builder builder = SearchRequest.builder()
.query(query) .query(query)
.topK(topK) .topK(topK)
// 不做框架层阈值截断,相关性判断交给后处理
.similarityThresholdAll(); .similarityThresholdAll();
String filterExpression = buildSpringAiFilterExpression(category); String filterExpression = buildSpringAiFilterExpression(category);
if (filterExpression != null) { if (filterExpression != null) {
@@ -111,6 +145,7 @@ public class VectorSearchService {
result.setMetadata(toJson(document.getMetadata())); result.setMetadata(toJson(document.getMetadata()));
result.setRawScore(document.getScore()); result.setRawScore(document.getScore());
result.setScoreLabel("similarity"); result.setScoreLabel("similarity");
// 下游统一按 L2 距离消费,这里做兼容映射
result.setScore(toCompatibleL2Distance(document)); result.setScore(toCompatibleL2Distance(document));
results.add(result); results.add(result);
} }
@@ -118,6 +153,7 @@ public class VectorSearchService {
return results; return results;
} }
/** Milvus SDK 原生 L2 检索路径。 */
List<SearchResult> searchSimilarDocumentsWithSdk(String query, int topK, String category) { List<SearchResult> searchSimilarDocumentsWithSdk(String query, int topK, String category) {
try { try {
logger.info("Starting Milvus SDK search: topK={}, category={}, kbScope={}", logger.info("Starting Milvus SDK search: topK={}, category={}, kbScope={}",
@@ -152,6 +188,7 @@ public class VectorSearchService {
SearchResult result = new SearchResult(); SearchResult result = new SearchResult();
result.setId((String) wrapper.getIDScore(0).get(i).get("id")); result.setId((String) wrapper.getIDScore(0).get(i).get("id"));
result.setContent((String) wrapper.getFieldData("content", 0).get(i)); result.setContent((String) wrapper.getFieldData("content", 0).get(i));
// Milvus L2:数值越小越相似
result.setScore(wrapper.getIDScore(0).get(i).getScore()); result.setScore(wrapper.getIDScore(0).get(i).getScore());
result.setRawScore((double) result.getScore()); result.setRawScore((double) result.getScore());
result.setScoreLabel("l2_distance"); result.setScoreLabel("l2_distance");
@@ -172,6 +209,7 @@ public class VectorSearchService {
} }
} }
/** 优先使用 metadata.distance;否则把 similarity 映射为兼容 L2。 */
private float toCompatibleL2Distance(Document document) { private float toCompatibleL2Distance(Document document) {
Double distance = extractDistance(document.getMetadata()); Double distance = extractDistance(document.getMetadata());
if (distance != null) { if (distance != null) {
@@ -180,6 +218,10 @@ public class VectorSearchService {
return toCompatibleL2Distance(document.getScore()); return toCompatibleL2Distance(document.getScore());
} }
/**
* similarity ∈ [0,1] 越大越相似 -> 兼容 L2 距离。
* 映射:distance = (1 - similarity) * maxL2Distance
*/
private float toCompatibleL2Distance(Double similarity) { private float toCompatibleL2Distance(Double similarity) {
if (similarity == null) { if (similarity == null) {
return (float) maxL2Distance; return (float) maxL2Distance;
@@ -221,6 +263,7 @@ public class VectorSearchService {
return value.replace("'", "\\'"); return value.replace("'", "\\'");
} }
/** Spring AI filter DSL,例如:category == 'mysql' && kb_scope == 'prod' */
String buildSpringAiFilterExpression(String category) { String buildSpringAiFilterExpression(String category) {
List<String> parts = new ArrayList<>(); List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category); String categoryFilter = trimToNull(category);
@@ -234,6 +277,7 @@ public class VectorSearchService {
return parts.isEmpty() ? null : String.join(" && ", parts); return parts.isEmpty() ? null : String.join(" && ", parts);
} }
/** Milvus boolean expr,字段在 JSON metadata 内。 */
String buildSdkFilterExpression(String category) { String buildSdkFilterExpression(String category) {
List<String> parts = new ArrayList<>(); List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category); String categoryFilter = trimToNull(category);
@@ -262,6 +306,16 @@ public class VectorSearchService {
return value.replace("\\", "\\\\").replace("\"", "\\\""); return value.replace("\\", "\\\\").replace("\"", "\\\"");
} }
/**
* 统一检索命中结构。
*
* <ul>
* <li>{@code score}:兼容 L2 距离,供后处理 normalizeL2</li>
* <li>{@code rawScore}:底层原始分(similarity 或 l2)</li>
* <li>{@code scoreLabel}:解释 rawScore 语义</li>
* <li>{@code metadata}:JSON 字符串,含 docId/chunkIndex/title 等</li>
* </ul>
*/
@Setter @Setter
@Getter @Getter
public static class SearchResult { public static class SearchResult {
@@ -21,14 +21,41 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Harness backend for knowledge retrieval. Agent-facing schema is owned by HarnessEvidenceTools. * 知识库检索后端(legacy executor)。
*
* <p>Agent 侧工具 schema 由 {@code HarnessEvidenceTools} 声明;本类只负责真正执行检索,
* 再经 {@code RagToolAdapter} + {@code RagResultProjector} 投影成 Agent 可见契约。</p>
*
* <h3>主链路</h3>
* <pre>
* query
* -> KnowledgeQueryTransformer // L0:domain/keyword hint,可选 category filter
* -> KnowledgeDocumentRetriever // L1:向量召回 topK
* -> KnowledgeEvidencePostProcessor // 归一化、规则 rerank、组装 evidenceBlocks
* -> [可选] 去掉 category 后重试 // filtered 结果质量不足时
* -> KnowledgeContextPacker // 按字符预算打包文本
* -> LookupResultAssembler // 统一 LookupResult
* </pre>
*
* <h3>降级策略</h3>
* <ul>
* <li>有 categoryFilter:先 FILTERED_VECTOR</li>
* <li>若结果无证据或 topSimilarity &lt; referenceThreshold:再 UNFILTERED_VECTOR_RETRY</li>
* <li>无 categoryFilter:直接 UNFILTERED_VECTOR</li>
* </ul>
*
* <p>注意:本类返回的是内部 {@link LookupResult},不是 Agent 最终看到的 JSON。
* 会话级文档去重目前不在这里做。</p>
*/ */
@Slf4j @Slf4j
@Component @Component
public class LookupKnowledgeTool { public class LookupKnowledgeTool {
/** 首次检索:带 L0 推导出的 category 过滤。 */
private static final String ATTEMPT_FILTERED_VECTOR = "FILTERED_VECTOR"; private static final String ATTEMPT_FILTERED_VECTOR = "FILTERED_VECTOR";
/** 首次检索:L0 未给出唯一 domain,不做 category 过滤。 */
private static final String ATTEMPT_UNFILTERED_VECTOR = "UNFILTERED_VECTOR"; 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 ATTEMPT_UNFILTERED_VECTOR_RETRY = "UNFILTERED_VECTOR_RETRY";
private static final String FALLBACK_NO_EVIDENCE = "filtered_vector_no_evidence"; private static final String FALLBACK_NO_EVIDENCE = "filtered_vector_no_evidence";
private static final String FALLBACK_LOW_QUALITY = "filtered_vector_low_quality"; private static final String FALLBACK_LOW_QUALITY = "filtered_vector_low_quality";
@@ -52,10 +79,10 @@ public class LookupKnowledgeTool {
private LookupResultAssembler resultAssembler; private LookupResultAssembler resultAssembler;
/** /**
* 查询知识库文档。 * 执行一次知识库检索,返回 evidence-first 的内部结果。
* *
* @param query 查询关键词 * @param query Agent / Harness 传入的检索语句(不是最终用户原话的完整上下文)
* @return 查询结果 * @return 含 evidenceBlocks、contextPack、retrievalTrace 的 LookupResult
*/ */
public LookupResult lookupKnowledge(String query) { public LookupResult lookupKnowledge(String query) {
log.info("========================================"); log.info("========================================");
@@ -63,6 +90,7 @@ public class LookupKnowledgeTool {
log.info(">>> metadata: query_chars={}", query == null ? 0 : query.length()); log.info(">>> metadata: query_chars={}", query == null ? 0 : query.length());
log.info("----------------------------------------"); log.info("----------------------------------------");
// 1) Query understanding:L0 只产 hint/filter,不直接当事实证据
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(),
@@ -72,6 +100,7 @@ public class LookupKnowledgeTool {
List<RetrievalTrace.Attempt> attempts = new ArrayList<>(); List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
String fallbackReason = null; String fallbackReason = null;
// 2) 首次 L1 向量检索(有唯一 domain 则带 category filter)
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
? ATTEMPT_UNFILTERED_VECTOR ? ATTEMPT_UNFILTERED_VECTOR
: ATTEMPT_FILTERED_VECTOR; : ATTEMPT_FILTERED_VECTOR;
@@ -87,6 +116,8 @@ public class LookupKnowledgeTool {
attempts.add(firstAttempt.attempt()); attempts.add(firstAttempt.attempt());
String selectedAttemptName = firstAttemptName; String selectedAttemptName = firstAttemptName;
// 3) filtered 路径质量不足时,去掉 category 用原始 query 重试一次
// 重试结果会整体替换首次结果(不是与首次融合)
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
@@ -107,6 +138,7 @@ public class LookupKnowledgeTool {
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY; selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
} }
// 4) 打包 + 组装最终内部结果(供 projector / 审计消费)
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);
@@ -116,6 +148,10 @@ public class LookupKnowledgeTool {
return result; return result;
} }
/**
* 用后处理后的相似度回填 attempt 可观测字段。
* usable 要求:有可用证据,且 topSimilarity 达到 reference 阈值。
*/
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()
@@ -123,6 +159,7 @@ public class LookupKnowledgeTool {
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold()); && evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
} }
/** 汇总本次检索的 query hint、attempt 列表与最终选用路径,便于 trace 回放。 */
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query, private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
List<RetrievalTrace.Attempt> attempts, List<RetrievalTrace.Attempt> attempts,
String selectedAttempt, String selectedAttempt,