feat(wiki): normalized relation boost + reason in search results

This commit is contained in:
matevip 2026-04-25 19:02:34 +08:00
parent a1e40d6eae
commit 6474d0e6be
2 changed files with 91 additions and 9 deletions

View File

@ -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.
* <p>
* Why it matters: the flat 0.15 was bigger than typical RRF scores
* (~0.020.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;
}

View File

@ -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.
* <p>
* 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<RankedItem> applyRelationBoost(List<RankedItem> hits, Long kbId, int topK) {
if (relationService == null || hits.isEmpty()) return hits;
List<Long> seedIds = hits.stream().limit(3).map(h -> h.pageId).toList();
Map<Long, Double> 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<Long, Double> rawScoreMap = new HashMap<>();
Map<Long, RelationReasonRecord> reasonMap = new HashMap<>();
for (Long seedId : seedIds) {
List<WikiPageLite> 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 seedneighbor 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<Long> 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<Long, Double> 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<RankedItem> 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<String> 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<String> matchedBy, String relationReason) {
/** Back-compat ctor — keyword/semantic items don't carry a relation reason. */
RankedItem(Long pageId, double score, List<String> matchedBy) {
this(pageId, score, matchedBy, null);
}
}
/** Internal: which seed/signals contributed the strongest relation pull to a candidate. */
private record RelationReasonRecord(String seedSlug, List<String> 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();
}
}