diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index e6918577..759dcf6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -200,4 +200,26 @@ public class WikiProperties { * applies only to the prompt; the applier always sees full content. */ private int enrichBatchPerPageMaxChars = 3000; + + /** + * RFC-051 §9.4: replace the legacy flat 0.15 relation boost with a + * normalized score per query, scaled by {@link #relationBoostLambda}. + * Default {@code false} keeps the legacy ranking; flip on after + * validating against your retrieval test set. + *

+ * Why it matters: the flat 0.15 was bigger than typical RRF scores + * (~0.02–0.05), so boosted neighbors routinely leapfrogged real RRF + * hits. Normalization keeps boost on the same scale as fused scores. + */ + private boolean useNormalizedRelationBoost = false; + + /** + * RFC-051 §9.4: maximum boost contribution from the relation pass when + * {@link #useNormalizedRelationBoost} is on. Each boosted candidate + * gets {@code (rawRelationScore / maxRawRelationScore) * lambda} added + * to its fused score. Default {@code 0.05} is roughly the size of a + * top-3 RRF score, so a max-relation neighbor competes evenly with a + * top-3 RRF hit but doesn't dominate it. + */ + private double relationBoostLambda = 0.05; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java index 15a46763..ba9779f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java @@ -129,6 +129,14 @@ public class HybridRetriever { } String reason = buildReason(lite, ri.matchedBy, query); + // RFC-051 §9.4: when the entry came from relation boost, override / append + // the reason with the seed slug + dominant signals so callers can explain + // why an out-of-search-corpus page surfaced. + if (ri.relationReason() != null && !ri.relationReason().isBlank()) { + reason = (reason == null || reason.isBlank()) + ? ri.relationReason() + : reason + " · " + ri.relationReason(); + } results.add(new PageSearchResult( lite.slug(), lite.title(), lite.summary(), snippet != null ? snippet : lite.summary(), @@ -267,12 +275,20 @@ public class HybridRetriever { /** * RFC-032: 1-hop relation boost on top-3 seed pages. + *

+ * RFC-051 §9.4 makes the boost magnitude data-driven instead of a flat + * constant when {@code mate.wiki.use-normalized-relation-boost} is on. */ private List applyRelationBoost(List hits, Long kbId, int topK) { if (relationService == null || hits.isEmpty()) return hits; List seedIds = hits.stream().limit(3).map(h -> h.pageId).toList(); - Map boostMap = new HashMap<>(); + // Per-candidate aggregate raw score (sum of contributions from each seed-relation + // pair) plus a remembered "best" reason — the seed that contributed the highest + // relation score and its dominant signals. Used for the human-readable reason + // surfaced via PageSearchResult.reason. + Map rawScoreMap = new HashMap<>(); + Map reasonMap = new HashMap<>(); for (Long seedId : seedIds) { List seedLites = pageMapper.selectBatchLite(List.of(seedId)); @@ -287,10 +303,14 @@ public class HybridRetriever { try { relationService.relatedPages(kbId, seed.slug(), 3) .forEach(r -> { - // Find the page ID from slug WikiPageEntity relPage = pageService.getBySlug(kbId, r.slug()); - if (relPage != null) { - boostMap.merge(relPage.getId(), RELATION_BOOST, Double::sum); + if (relPage == null) return; + rawScoreMap.merge(relPage.getId(), r.score(), Double::sum); + // Keep the strongest single seed→neighbor pair as the reason. + RelationReasonRecord existing = reasonMap.get(relPage.getId()); + if (existing == null || r.score() > existing.contribution) { + reasonMap.put(relPage.getId(), + new RelationReasonRecord(seed.slug(), r.signals(), r.score())); } }); } catch (Exception e) { @@ -299,13 +319,31 @@ public class HybridRetriever { } Set existingIds = hits.stream().map(h -> h.pageId).collect(Collectors.toSet()); - boostMap.keySet().removeAll(existingIds); + rawScoreMap.keySet().removeAll(existingIds); - if (boostMap.isEmpty()) return hits; + if (rawScoreMap.isEmpty()) return hits; + + // Choose boost magnitude per candidate: legacy flat constant or normalized × λ. + Map boostMap = new HashMap<>(); + if (properties != null && properties.isUseNormalizedRelationBoost()) { + double maxRaw = rawScoreMap.values().stream().mapToDouble(Double::doubleValue).max().orElse(0.0); + double lambda = Math.max(0, properties.getRelationBoostLambda()); + if (maxRaw <= 0 || lambda <= 0) { + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, 0.0)); + } else { + final double maxRawF = maxRaw; + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, (raw / maxRawF) * lambda)); + } + } else { + rawScoreMap.forEach((pid, raw) -> boostMap.put(pid, RELATION_BOOST)); + } List expanded = new ArrayList<>(hits); - boostMap.forEach((pid, score) -> expanded.add( - new RankedItem(pid, score, List.of("relation_boost")))); + boostMap.forEach((pid, score) -> { + RelationReasonRecord rr = reasonMap.get(pid); + String reason = rr == null ? null : formatRelationReason(rr); + expanded.add(new RankedItem(pid, score, List.of("relation_boost"), reason)); + }); return expanded; } @@ -334,5 +372,27 @@ public class HybridRetriever { }; } - private record RankedItem(Long pageId, double score, List matchedBy) {} + /** + * RFC-051 §9.4: optional human-readable explanation for relation boost + * entries. {@code null} when this RankedItem wasn't produced by the + * relation pass. + */ + private record RankedItem(Long pageId, double score, List matchedBy, String relationReason) { + /** Back-compat ctor — keyword/semantic items don't carry a relation reason. */ + RankedItem(Long pageId, double score, List matchedBy) { + this(pageId, score, matchedBy, null); + } + } + + /** Internal: which seed/signals contributed the strongest relation pull to a candidate. */ + private record RelationReasonRecord(String seedSlug, List signals, double contribution) {} + + private static String formatRelationReason(RelationReasonRecord r) { + if (r == null) return null; + StringBuilder sb = new StringBuilder("related to '").append(r.seedSlug()).append("'"); + if (r.signals() != null && !r.signals().isEmpty()) { + sb.append(" via ").append(String.join("+", r.signals())); + } + return sb.toString(); + } }