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:
@@ -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 -> 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 -> boundary.execute(legacy) -> 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 >= 该阈值,才可能判 HIGHLY_RELEVANT / PRECISE。 */
|
||||
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
|
||||
private double highlyRelevantThreshold = 0.75;
|
||||
|
||||
/** baseScore >= 该阈值视为可用参考;低于则 isLowQuality=true,可能触发 unfiltered retry。 */
|
||||
@Value("${retrieval.normalization.reference-threshold:0.5}")
|
||||
private double referenceThreshold = 0.5;
|
||||
|
||||
/**
|
||||
* 对一次 attempt 的候选做后处理,产出可交给打包/组装的证据结果。
|
||||
*/
|
||||
public EvidencePostprocessResult process(KnowledgeQuery query, List<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>负责:读文件/文档块 -> 切片 -> embedding -> 写入 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 < 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,
|
||||
|
||||
Reference in New Issue
Block a user