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;
/**
* 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
@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<String> hitReasons;
}
@@ -6,26 +6,37 @@ import lombok.Data;
import java.util.List;
/**
* Output of post-retrieval processing before context packing.
* 检索后处理输出(打包 / 组装前的中间结果)。
*
* <p>由 {@code KnowledgeEvidencePostProcessor} 生成,再交给 context packer 与 result assembler。</p>
*/
@Data
@Builder
public class EvidencePostprocessResult {
/** 后处理前候选数。 */
private Integer candidateCount;
/** 去重后 evidence 条数。 */
private Integer evidenceBlockCount;
private List<EvidenceBlock> 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();
}
@@ -6,25 +6,45 @@ import lombok.Data;
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
@Builder
public class KnowledgeQuery {
/** Agent 传入的原始检索句(trim 后)。 */
private String originalQuery;
/**
* 改写后的检索句。
* 当前实现未做真正 rewrite,通常等于 originalQuery。
*/
private String rewrittenQuery;
/** L0 命中的 domain/category 列表,用于 boost / trace。 */
private List<String> domainHints;
/** L0 命中的关键词。 */
private List<String> matchedKeywords;
/**
* 实体提示。
* 当前实现基本等同 matchedKeywords,预留更细实体抽取。
*/
private List<String> entities;
/**
* 向量检索 category 过滤条件。
* 仅当 L0 恰好命中一个 domain 时非空;否则 null(不过滤)。
*/
private String categoryFilter;
/** L0 命中文档标题,主要用于 trace 解释。 */
private List<String> l0Titles;
/** L0 命中文档条数。 */
private Integer l0MatchCount;
}
@@ -6,64 +6,67 @@ import lombok.Data;
import java.util.List;
/**
* 知识库查询结果
* lookup_knowledge 内部完整结果(legacy executor 出口)。
*
* <p>字段比 Agent 可见契约更丰富,便于审计与调试。
* 进入 Agent 前会经 {@code RagResultProjector} 裁剪为 {@code RagToolResult}。</p>
*/
@Data
@Builder
public class LookupResult {
/**
* 是否找到结果
* 是否找到可用证据(evidenceBlocks 非空且通过后处理可用性判断)。
*/
private boolean found;
/**
* Structured evidence blocks after retrieval post-processing.
* 后处理后的结构化证据列表(文档级去重后)。
*/
private List<EvidenceBlock> 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<String> retrievedDomainsThisSession;
/**
* 系统消息(如去重提示)
* 系统消息,如无证据提示或历史 dedup 提示。
*/
private String message;
}
@@ -7,7 +7,9 @@ import java.util.List;
import java.util.Map;
/**
* Trace of retrieval attempts used by lookup_knowledge.
* 一次 lookup_knowledge 调用的检索轨迹(内部可观测,默认不投影给 Agent)。
*
* <p>记录 query 理解结果、每次 attempt、最终选用路径与 fallback 原因,便于 trace 回放。</p>
*/
@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<String, Object> queryHints;
private List<Attempt> 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;
}
}
@@ -7,35 +7,57 @@ import java.util.List;
import java.util.Map;
/**
* Normalized vector retrieval candidate before evidence post-processing.
* L1 向量命中后、后处理前的统一候选结构。
*
* <p>由 {@code KnowledgeDocumentRetriever} 从 {@code VectorSearchService.SearchResult} 映射而来。
* 后处理会基于它做归一化、规则 boost、去重并生成 {@link EvidenceBlock}。</p>
*/
@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<String, String> metadata;
/** 初步命中原因,后处理会追加 boost reasons。 */
private List<String> hitReasons;
}
@@ -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 工具适配器。
*
* <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 {
/** 兼容旧检索后端:只接收 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 工具调用。
*
* <p>流程:校验 request.query -&gt; boundary.execute(legacy) -&gt; projector.project。</p>
*/
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(),
@@ -2,10 +2,21 @@ package com.superbiz.agent.harness.tool.contract;
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(
/** 证据引用 id;当前实现常等于 source(文档级)。 */
@JsonProperty("document_id") String documentId,
@JsonProperty("source") String source,
@JsonProperty("title") String title,
@JsonProperty("breadcrumb") String breadcrumb,
/** 截断后的正文摘录(对应内部 content)。 */
@JsonProperty("excerpt") String excerpt) {
}
@@ -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 契约。
*
* <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 {
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<RagEvidence> evidence = new ArrayList<>();
// 文档级唯一集合:相同 documentId 只保留首次出现
Set<String> 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()
@@ -13,8 +13,21 @@ import java.util.regex.Matcher;
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
public class DocumentChunkService {
@@ -9,14 +9,24 @@ import java.util.ArrayList;
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
public class KnowledgeContextPacker {
/** 打包总字符预算(含 header)。 */
@Value("${rag.context-pack.char-budget:4000}")
private int charBudget = 4000;
/**
* 按排名顺序打包证据。
* 单条证据若剩余预算不足,会截断 body;若连 header 都放不下,则整条省略。
*/
public ContextPack pack(List<EvidenceBlock> blocks) {
List<EvidenceBlock> 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");
@@ -11,7 +11,20 @@ import java.util.List;
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
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<RetrievedEvidenceCandidate> toCandidates(String attemptName,
List<VectorSearchService.SearchResult> 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<String, String> 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<String, String> 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<RetrievedEvidenceCandidate> candidates) {
}
@@ -18,7 +18,24 @@ import java.util.Map;
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
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 &gt;= 该阈值,才可能判 HIGHLY_RELEVANT / PRECISE。 */
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
private double highlyRelevantThreshold = 0.75;
/** baseScore &gt;= 该阈值视为可用参考;低于则 isLowQuality=true,可能触发 unfiltered retry。 */
@Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold = 0.5;
/**
* 对一次 attempt 的候选做后处理,产出可交给打包/组装的证据结果。
*/
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
// 先打分排序:baseScore 来自向量距离,finalScore = base + 规则 boost
List<ScoredCandidate> 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<EvidenceBlock> 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<String> 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<String, String> 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<ScoredCandidate> 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<String> 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,
@@ -26,8 +26,21 @@ import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 知识库索引服务
* 负责 L0 精确匹配索引的管理
* L0 知识索引服务(关键词 / domain hint,不是向量库)。
*
* <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
@Service
@@ -128,10 +141,15 @@ public class KnowledgeIndexService {
}
}
/** 兼容旧调用:只返回命中的文档条目。 */
public List<KnowledgeEntry> 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<String> 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<KnowledgeEntry> matches,
List<String> 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;
}
@@ -6,7 +6,17 @@ import org.springframework.stereotype.Service;
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
public class KnowledgeQueryTransformer {
@@ -17,15 +27,23 @@ public class KnowledgeQueryTransformer {
this.knowledgeIndexService = knowledgeIndexService;
}
/**
* 将原始 query 转为检索控制结构。
*
* <p>{@code categoryFilter} 仅在 L0 恰好命中一个 domain 时非空;
* 多 domain 或零 domain 时为 null,避免错误收窄召回。</p>
*/
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())
@@ -9,11 +9,18 @@ import org.springframework.stereotype.Service;
import java.util.List;
/**
* Assembles evidence-first LookupResult instances.
* 将后处理结果组装为统一的 {@link LookupResult}。
*
* <p>这是 lookup_knowledge 内部契约出口:found / evidenceBlocks / traces / relevance。
* Agent 最终看到的字段集合由 {@code RagResultProjector} 再裁剪一层。</p>
*/
@Service
public class LookupResultAssembler {
/**
* 正常检索路径组装。
* found=true 当且仅当后处理认为存在可用 evidenceBlocks。
*/
public LookupResult assemble(EvidencePostprocessResult evidence,
ContextPack contextPack,
RetrievalTrace retrievalTrace) {
@@ -32,6 +39,12 @@ public class LookupResultAssembler {
.build();
}
/**
* 会话级“文档已检索过”的占位结果构造器。
*
* <p>历史设计用于 RetrievedDocTracker 去重回包;当前主链路默认不再调用。
* 保留方法是为了兼容旧调用点/测试,不代表 session dedup 仍在生效。</p>
*/
public LookupResult deduped(LookupResult original, List<String> retrievedDomains, String docKey) {
return LookupResult.builder()
.found(false)
@@ -26,8 +26,17 @@ import java.time.LocalDateTime;
import java.util.*;
/**
* 向量索引服务
* 负责读取文件、生成向量、存储到 Milvus
* 向量索引写入服务(RAG 入库侧)。
*
* <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
public class VectorIndexService {
@@ -302,6 +311,12 @@ public class VectorIndexService {
return metadata;
}
/**
* 构造送入 embedding 模型的文本。
*
* <p>把标题链路注入向量语义,缓解“正文片段缺上下文”导致的召回漂移。
* 注意:这里只影响向量,不影响 Milvus content 字段存储的原文。</p>
*/
static String buildEmbeddingText(DocumentChunk chunk) {
String content = trimToEmpty(chunk.getContent());
String title = trimToEmpty(chunk.getTitle());
@@ -26,10 +26,28 @@ import java.util.List;
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
* VectorStore, the original Milvus SDK path, or automatic fallback.</p>
* <p>对上层({@link KnowledgeDocumentRetriever})只暴露稳定 API:
* {@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
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<SearchResult> 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<SearchResult> 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<SearchResult> 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<SearchResult> 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<String> 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<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
@@ -262,6 +306,16 @@ public class VectorSearchService {
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
@Getter
public static class SearchResult {
@@ -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)。
*
* <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
@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<RetrievalTrace.Attempt> 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<RetrievalTrace.Attempt> attempts,
String selectedAttempt,