docs(harness): annotate RAG backend core classes and add retrieval learning note
- Annotate LookupKnowledgeTool, KnowledgeEvidencePostProcessor, RrfFusion, KnowledgeDocumentRetriever - Add RAG retrieval learning note: L0 navigation, multi-recall + RRF, qualityScore, degradation, contract semantics, validation (audit + offline eval), discussion insights
This commit is contained in:
@@ -65,6 +65,7 @@ public class KnowledgeDocumentRetriever {
|
||||
}
|
||||
}
|
||||
|
||||
/** 检索命中 → 统一的候选模型:映射字段 + 补 hitReasons(semantic_rank + attempt)。 */
|
||||
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName, List<KnowledgeSearchHit> hits) {
|
||||
if (hits == null || hits.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -94,6 +95,7 @@ public class KnowledgeDocumentRetriever {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** 组装单次 attempt 的 trace 元信息(候选数/耗时/顶分/错误/usable)。 */
|
||||
private RetrievalTrace.Attempt attempt(String name,
|
||||
String query,
|
||||
String categoryFilter,
|
||||
@@ -113,6 +115,7 @@ public class KnowledgeDocumentRetriever {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 取首条命中分数(用作该 attempt 的顶分)。 */
|
||||
private Double topScore(List<KnowledgeSearchHit> hits) {
|
||||
if (hits == null || hits.isEmpty() || hits.get(0).score() == null) {
|
||||
return null;
|
||||
@@ -120,6 +123,7 @@ public class KnowledgeDocumentRetriever {
|
||||
return hits.get(0).score();
|
||||
}
|
||||
|
||||
/** 小写去空格(searchMode 配置比对用)。 */
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -57,10 +57,17 @@ public class KnowledgeEvidencePostProcessor {
|
||||
@Value("${rag.return-n:5}")
|
||||
private int returnN = 5;
|
||||
|
||||
/**
|
||||
* 检索后处理主入口:打分 → 排序 → 去重/截断 → 判级 → 装配。
|
||||
*
|
||||
* <p>关键决策:qualityScore 不参与排序(排序按检索权威序 originalRank),
|
||||
* 只用于判级(relevance)和闸门(isLowQuality)——防关键词 boost 操纵。
|
||||
*/
|
||||
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
|
||||
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
|
||||
int batchSize = safeCandidates.size();
|
||||
|
||||
// ① 打分 + 排序:qualityScore 归一化,但排序仍按 originalRank(检索权威序)
|
||||
List<ScoredCandidate> ranked = safeCandidates.stream()
|
||||
.map(candidate -> score(query, candidate, batchSize))
|
||||
.sorted(Comparator
|
||||
@@ -68,6 +75,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.thenComparing(s -> resolveEvidenceKey(s.candidate()), Comparator.nullsLast(String::compareTo)))
|
||||
.toList();
|
||||
|
||||
// ② 去重/截断:evidenceKey 去重 + 每文档 chunk 上限 + returnN
|
||||
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
||||
List<RerankTrace.Item> traceItems = new ArrayList<>();
|
||||
Map<String, Integer> chunksPerDocument = new HashMap<>();
|
||||
@@ -85,10 +93,12 @@ public class KnowledgeEvidencePostProcessor {
|
||||
String docBucket = resolveDocBucket(candidate, evidenceKey);
|
||||
|
||||
if (deduped.containsKey(evidenceKey)) {
|
||||
// 同 evidenceKey:合并 hitReasons/补 breadcrumb,不新增
|
||||
mergeEvidence(deduped.get(evidenceKey), toBlock(candidate, evidenceKey, scored));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 每文档 chunk 上限:超出则跳过该候选
|
||||
int used = chunksPerDocument.getOrDefault(docBucket, 0);
|
||||
if (used >= effectiveMaxChunks) {
|
||||
continue;
|
||||
@@ -121,6 +131,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 质量闸门:无可用证据、或顶分低于参考阈值 → 低质量(触发 LookupKnowledgeTool 降级重试)。
|
||||
*/
|
||||
public boolean isLowQuality(EvidencePostprocessResult result) {
|
||||
if (result == null || !result.hasUsableEvidence()) {
|
||||
return true;
|
||||
@@ -140,6 +153,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return referenceThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个候选装配成 EvidenceBlock:content 截断 800 字 + 合并 hitReasons。
|
||||
*/
|
||||
private EvidenceBlock toBlock(RetrievedEvidenceCandidate candidate,
|
||||
String evidenceKey,
|
||||
ScoredCandidate scored) {
|
||||
@@ -157,6 +173,10 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据身份:优先检索给的 evidenceKey;否则组合 docId + chunkIndex + id + rank
|
||||
* (chunk 级去重身份,同文档多 chunk 可并存)。
|
||||
*/
|
||||
private String resolveEvidenceKey(RetrievedEvidenceCandidate candidate) {
|
||||
if (candidate.getEvidenceKey() != null && !candidate.getEvidenceKey().isBlank()) {
|
||||
return candidate.getEvidenceKey();
|
||||
@@ -168,6 +188,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
candidate.getOriginalRank());
|
||||
}
|
||||
|
||||
/** 文档分桶键:有 docId 用 docId,否则退回 evidenceKey(用于每文档 chunk 上限)。 */
|
||||
private String resolveDocBucket(RetrievedEvidenceCandidate candidate, String evidenceKey) {
|
||||
String docId = EvidenceIdentity.trimToNull(candidate.getDocId());
|
||||
if (docId != null) {
|
||||
@@ -176,6 +197,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return evidenceKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打分:qualityScore 归一化(按 scoreLabel 分支);L0 重叠只写解释,不改分数。
|
||||
*/
|
||||
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate, int batchSize) {
|
||||
double quality = RetrievalScoreNormalizer.toQualityScore(
|
||||
candidate.getScoreLabel(),
|
||||
@@ -185,7 +209,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
maxL2Distance,
|
||||
candidate.getDenseDistance());
|
||||
List<String> explain = new ArrayList<>();
|
||||
// L0 重叠仅解释,不改变 quality / 排序
|
||||
// L0 重叠仅解释,不改变 quality / 排序(防关键词碰瓷)
|
||||
if (matchesAny(candidate, query.getDomainHints())) {
|
||||
explain.add("l0_domain_overlap");
|
||||
}
|
||||
@@ -198,6 +222,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return new ScoredCandidate(candidate, quality, explain);
|
||||
}
|
||||
|
||||
/** 候选字段与 L0 提示是否重叠(仅解释用)。 */
|
||||
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
|
||||
if (hints == null || hints.isEmpty()) {
|
||||
return false;
|
||||
@@ -217,6 +242,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相关等级判定:顶分 >= 0.75 → PRECISE(+提示);>= 0.5 → REFERENCE(+提示);否则无。
|
||||
*/
|
||||
private RelevanceAssessment computeRelevance(List<ScoredCandidate> ranked) {
|
||||
if (ranked.isEmpty()) {
|
||||
return new RelevanceAssessment(null, null);
|
||||
@@ -232,6 +260,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return new RelevanceAssessment(null, null);
|
||||
}
|
||||
|
||||
/** 同 evidenceKey 命中:合并 hitReasons + 补 breadcrumb(去重不丢信息)。 */
|
||||
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
|
||||
Set<String> reasons = new LinkedHashSet<>();
|
||||
if (existing.getHitReasons() != null) {
|
||||
|
||||
@@ -10,17 +10,28 @@ import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Reciprocal Rank Fusion helpers.
|
||||
* Reciprocal Rank Fusion 工具:把多路检索的排名列表融合成一个分数排序。
|
||||
*
|
||||
* <pre>
|
||||
* RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||
* </pre>
|
||||
*
|
||||
* <p>只依赖排名不依赖原始分数——屏蔽跨路分数尺度不可比的问题;
|
||||
* 每路可加权(w <= 0 时按 1.0 等权),k 是平滑参数(默认 60,可配)。
|
||||
*/
|
||||
public final class RrfFusion {
|
||||
|
||||
private RrfFusion() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 融合多路排名:对每路的每个 item 累加 w/(k+rank),按总分降序输出。
|
||||
*
|
||||
* @param paths 多路排名(每路带 name / items / weight)
|
||||
* @param rrfK 平滑参数 k(至少 1)
|
||||
* @param identityFn 跨路识别同一 item 的身份函数(如 evidenceKey)
|
||||
* @return 融合后排序(含每路排名明细)
|
||||
*/
|
||||
public static <T> List<Scored<T>> fuse(List<RankedPath<T>> paths,
|
||||
int rrfK,
|
||||
Function<T, String> identityFn) {
|
||||
@@ -45,7 +56,7 @@ public final class RrfFusion {
|
||||
continue;
|
||||
}
|
||||
int rank = i + 1;
|
||||
double contrib = weight / (k + rank);
|
||||
double contrib = weight / (k + rank); // 排名越前贡献越大
|
||||
Acc<T> bucket = acc.computeIfAbsent(id, ignored -> new Acc<>(item));
|
||||
bucket.score += contrib;
|
||||
bucket.ranks.put(path.name(), rank);
|
||||
@@ -57,12 +68,14 @@ public final class RrfFusion {
|
||||
Acc<T> value = entry.getValue();
|
||||
scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks)));
|
||||
}
|
||||
// 总分降序(两路共识的靠前),同分按身份稳定排序
|
||||
scored.sort(Comparator
|
||||
.comparingDouble((Scored<T> s) -> s.rrfScore()).reversed()
|
||||
.thenComparing(Scored::identity));
|
||||
return scored;
|
||||
}
|
||||
|
||||
/** 一路检索结果:name(路名)+ items(按排名顺序)+ weight(可选加权,≤0 视为等权)。 */
|
||||
public record RankedPath<T>(String name, List<T> items, double weight) {
|
||||
public RankedPath {
|
||||
Objects.requireNonNull(name, "name");
|
||||
@@ -70,9 +83,11 @@ public final class RrfFusion {
|
||||
}
|
||||
}
|
||||
|
||||
/** 融合后的单个 item:identity + 原始 item + rrfScore + 每路排名明细。 */
|
||||
public record Scored<T>(String identity, T item, double rrfScore, Map<String, Integer> ranks) {
|
||||
}
|
||||
|
||||
/** 跨路累加器:同一 identity 的 item 累加 RRF 分并记录各路排名。 */
|
||||
private static final class Acc<T> {
|
||||
private final T item;
|
||||
private double score;
|
||||
|
||||
@@ -77,6 +77,10 @@ public class LookupKnowledgeTool {
|
||||
@Autowired
|
||||
private LookupResultAssembler resultAssembler;
|
||||
|
||||
/**
|
||||
* 解析检索宽度配置:retrieveK 优先级 rag.retrieve-k > rag.top-k > 默认 3。
|
||||
* return-n 由后处理器持有,这里只做可观测性记录。
|
||||
*/
|
||||
@PostConstruct
|
||||
void resolveRetrievalWidths() {
|
||||
int fallback = legacyTopK > 0 ? legacyTopK : 3;
|
||||
@@ -89,12 +93,27 @@ public class LookupKnowledgeTool {
|
||||
retrieveK, legacyTopK, returnNConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 检索主入口(legacy 后端,不感知 Harness)。
|
||||
*
|
||||
* <p>流程(模块化三段):
|
||||
* <ol>
|
||||
* <li>检索前:QueryTransformer.transform → KnowledgeQuery(分类过滤/域/关键词);</li>
|
||||
* <li>检索:DocumentRetriever.retrieve(FILTERED 或 UNFILTERED,retrieveK 候选);</li>
|
||||
* <li>检索后:PostProcessor.process(qualityScore/去重/判级);</li>
|
||||
* <li>低质量降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查;</li>
|
||||
* <li>打包 + 组装:ContextPacker.pack → LookupResultAssembler.assemble → LookupResult。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>返回的 LookupResult 是内部契约,Agent 可见字段由 RagResultProjector 再裁剪。
|
||||
*/
|
||||
public LookupResult lookupKnowledge(String query) {
|
||||
log.info("========================================");
|
||||
log.info(">>> [工具调用] lookup_knowledge");
|
||||
log.info(">>> metadata: query_chars={}, retrieveK={}", query == null ? 0 : query.length(), retrieveK);
|
||||
log.info("----------------------------------------");
|
||||
|
||||
// ── 检索前:查询理解(L0)──
|
||||
KnowledgeQuery knowledgeQuery = queryTransformer.transform(query);
|
||||
log.info("[QueryTransformer] categoryFilter={}, domainHintCount={}, keywordCount={}",
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
@@ -104,6 +123,7 @@ public class LookupKnowledgeTool {
|
||||
List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
|
||||
String fallbackReason = null;
|
||||
|
||||
// ── 检索:首轮(有分类过滤则 FILTERED,否则 UNFILTERED)──
|
||||
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
|
||||
? ATTEMPT_UNFILTERED_VECTOR
|
||||
: ATTEMPT_FILTERED_VECTOR;
|
||||
@@ -112,6 +132,7 @@ public class LookupKnowledgeTool {
|
||||
knowledgeQuery.getRewrittenQuery(),
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
retrieveK);
|
||||
// ── 检索后:质量统一 + 去重 + 判级 ──
|
||||
EvidencePostprocessResult selectedEvidence = evidencePostProcessor.process(
|
||||
knowledgeQuery,
|
||||
firstAttempt.candidates());
|
||||
@@ -119,6 +140,7 @@ public class LookupKnowledgeTool {
|
||||
attempts.add(firstAttempt.attempt());
|
||||
String selectedAttemptName = firstAttemptName;
|
||||
|
||||
// ── 降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查(L0 边界可被推翻)──
|
||||
if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) {
|
||||
fallbackReason = selectedEvidence.hasUsableEvidence()
|
||||
? FALLBACK_LOW_QUALITY
|
||||
@@ -139,6 +161,7 @@ public class LookupKnowledgeTool {
|
||||
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
|
||||
}
|
||||
|
||||
// ── 打包 + 组装(内部契约出口)──
|
||||
ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks());
|
||||
RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName,
|
||||
fallbackReason, selectedEvidence);
|
||||
@@ -148,6 +171,7 @@ public class LookupKnowledgeTool {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 用后处理结果补充 attempt 的观测字段:topSimilarity + usable(是否达参考阈值)。 */
|
||||
private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) {
|
||||
attempt.setTopSimilarity(evidence.getTopSimilarity());
|
||||
attempt.setUsable(evidence.hasUsableEvidence()
|
||||
@@ -155,6 +179,7 @@ public class LookupKnowledgeTool {
|
||||
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
|
||||
}
|
||||
|
||||
/** 组装完整检索路径 Trace(原始/改写 query、分类过滤、选中 attempt、降级原因、query 提示)。 */
|
||||
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
|
||||
List<RetrievalTrace.Attempt> attempts,
|
||||
String selectedAttempt,
|
||||
@@ -181,6 +206,7 @@ public class LookupKnowledgeTool {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 返回日志:found / relevanceLevel / 证据数 / 检索路径(供排查)。 */
|
||||
private void logReturn(LookupResult result) {
|
||||
log.info("----------------------------------------");
|
||||
log.info("<<< [工具返回] lookup_knowledge");
|
||||
|
||||
Reference in New Issue
Block a user