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();
+ }
}