package vip.mate.wiki.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.dto.PageSearchResult; import vip.mate.wiki.dto.RelatedPageResult; import vip.mate.wiki.dto.WikiPageLite; import vip.mate.wiki.metrics.WikiMetrics; import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.repository.WikiPageMapper; import vip.mate.wiki.retrieval.SnippetExtractor; import java.time.Duration; import java.util.*; import java.util.stream.Collectors; /** * RFC-011 + RFC-032: Hybrid retrieval service. *

* Three search modes: keyword (DB LIKE), semantic (chunk vectors), * hybrid (RRF fusion). RFC-032 adds: N+1 fix, two-phase keyword search, * relation boost, snippet extraction, and PageSearchResult DTO. */ @Slf4j @Service public class HybridRetriever { private final WikiPageService pageService; private final WikiChunkService chunkService; private final WikiEmbeddingService embeddingService; private final WikiProperties properties; private final WikiPageMapper pageMapper; private final WikiMetrics metrics; @Autowired(required = false) private WikiRelationService relationService; private static final double RELATION_BOOST = 0.15; public HybridRetriever(WikiPageService pageService, WikiChunkService chunkService, WikiEmbeddingService embeddingService, WikiProperties properties, WikiPageMapper pageMapper, WikiMetrics metrics) { this.pageService = pageService; this.chunkService = chunkService; this.embeddingService = embeddingService; this.properties = properties; this.pageMapper = pageMapper; this.metrics = metrics; } public enum Mode { KEYWORD, SEMANTIC, HYBRID } /** * Legacy page hit record (kept for backward compatibility). */ public record PageHit(Long pageId, String slug, String title, String summary, double score) {} /** * Chunk-level search result (semantic search). *

* RFC-051 PR-1c: {@code pageNumber} and {@code headerBreadcrumb} are * populated when the chunk has those columns set (lazy ingest with * preprocessor on, or backfilled chunks). Both are nullable. */ public record ChunkHit(Long chunkId, Long rawId, String snippet, float score, Integer pageNumber, String headerBreadcrumb) { /** Backwards-compatible factory for callers that don't yet pass metadata. */ public ChunkHit(Long chunkId, Long rawId, String snippet, float score) { this(chunkId, rawId, snippet, score, null, null); } } /** * RFC-032: Enhanced search returning PageSearchResult with snippet and matchedBy metadata. */ public List search(Long kbId, String query, String modeStr, int topK) { Mode mode = parseMode(modeStr); long startNanos = System.nanoTime(); List semantic = List.of(); List keyword = List.of(); if (mode != Mode.KEYWORD && embeddingService.isAvailable()) { semantic = semanticSearch(kbId, query, topK * 3); } if (mode != Mode.SEMANTIC) { keyword = keywordSearch(kbId, query, topK * 3); } if (mode == Mode.SEMANTIC && semantic.isEmpty()) { keyword = keywordSearch(kbId, query, topK * 3); } List fused; if (mode == Mode.KEYWORD || semantic.isEmpty()) { fused = keyword; } else if (mode == Mode.SEMANTIC) { fused = semantic; } else { fused = rrfFuse(semantic, keyword, 60); } // RFC-032: Relation boost (1-hop expansion on top-3 seeds) fused = applyRelationBoost(fused, kbId, topK); // Batch-fetch page info (N+1 fix) List topIds = fused.stream().limit(topK).map(ri -> ri.pageId).toList(); if (topIds.isEmpty()) { metrics.recordRetrieval(mode.name().toLowerCase(), Duration.ofNanos(System.nanoTime() - startNanos), 0); return List.of(); } Map liteMap = pageMapper.selectBatchLite(topIds) .stream().collect(Collectors.toMap(WikiPageLite::id, p -> p)); // Build result with snippets List results = new ArrayList<>(); for (RankedItem ri : fused.stream().limit(topK).toList()) { WikiPageLite lite = liteMap.get(ri.pageId); if (lite == null) continue; // RFC-051 PR-2: hide system pages (overview / log) from search results. if (lite.isSystem()) continue; String snippet = null; if (!ri.matchedBy.contains("relation_boost")) { String content = pageMapper.selectContentById(ri.pageId); if (content != null) { snippet = SnippetExtractor.extract(content, query); } } 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(), ri.matchedBy, reason, ri.score)); } metrics.recordRetrieval(mode.name().toLowerCase(), Duration.ofNanos(System.nanoTime() - startNanos), results.size()); return results; } /** * Legacy searchPages — returns PageHit for backward compatibility. */ public List searchPages(Long kbId, String query, String modeStr, int topK) { return search(kbId, query, modeStr, topK).stream() .map(r -> new PageHit(null, r.slug(), r.title(), r.summary(), r.score())) .toList(); } /** * Chunk-level semantic search. */ public List searchChunks(Long kbId, String query, int topK) { if (!embeddingService.isAvailable()) return List.of(); float[] queryVec = embeddingService.embedQuery(kbId, query); if (queryVec == null) return List.of(); List allChunks = chunkService.listByKbId(kbId); return allChunks.stream() .filter(c -> c.getEmbedding() != null) .map(c -> { float[] chunkVec = WikiEmbeddingService.bytesToFloats(c.getEmbedding()); float score = WikiEmbeddingService.cosine(queryVec, chunkVec); String snippet = c.getContent().length() > 300 ? c.getContent().substring(0, 300) + "..." : c.getContent(); return new ChunkHit(c.getId(), c.getRawId(), snippet, score, c.getPageNumber(), c.getHeaderBreadcrumb()); }) .sorted(Comparator.comparingDouble(ChunkHit::score).reversed()) .limit(topK) .toList(); } // ==================== Internal methods ==================== /** Semantic search: chunk cosine → aggregate to page level */ private List semanticSearch(Long kbId, String query, int limit) { float[] queryVec = embeddingService.embedQuery(kbId, query); if (queryVec == null) return List.of(); List allChunks = chunkService.listByKbId(kbId); if (allChunks.isEmpty()) return List.of(); Map chunkScores = new HashMap<>(); for (WikiChunkEntity chunk : allChunks) { if (chunk.getEmbedding() == null || chunk.getRawId() == null) continue; float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding()); float score = WikiEmbeddingService.cosine(queryVec, vec); chunkScores.merge(chunk.getRawId(), score, Math::max); } List allPages = pageService.listByKbId(kbId); Map pageScores = new HashMap<>(); for (WikiPageEntity page : allPages) { String rawIds = page.getSourceRawIds(); if (rawIds == null) continue; for (String rawIdStr : rawIds.replaceAll("[\\[\\]\\s]", "").split(",")) { try { long rawId = Long.parseLong(rawIdStr.trim()); Float score = chunkScores.get(rawId); if (score != null) { pageScores.merge(page.getId(), (double) score, Math::max); } } catch (NumberFormatException ignored) {} } } return pageScores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(limit) .map(e -> new RankedItem(e.getKey(), e.getValue(), List.of("semantic"))) .toList(); } /** * RFC-032: Two-phase keyword search — fast path (title+summary) first, * full content search only if needed to fill topK. */ private List keywordSearch(Long kbId, String query, int limit) { String kw = "%" + query.toLowerCase() .replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_") + "%"; // Phase 1: fast path (title + summary only) List fastIds = pageMapper.searchFastIds(kbId, kw, limit); List ranked = new ArrayList<>(); for (int i = 0; i < fastIds.size(); i++) { ranked.add(new RankedItem(fastIds.get(i), 1.0 / (i + 1), List.of("title"))); } if (fastIds.size() >= limit) return ranked; // Phase 2: full content search (supplement) List contentIds = pageMapper.searchContentIds(kbId, kw, fastIds, limit - fastIds.size()); for (int i = 0; i < contentIds.size(); i++) { ranked.add(new RankedItem(contentIds.get(i), 1.0 / (fastIds.size() + i + 1), List.of("content"))); } return ranked; } /** RRF fusion: score = Σ 1/(k + rank_i) */ private List rrfFuse(List a, List b, int k) { Map fused = new HashMap<>(); Map> matchedByMap = new HashMap<>(); for (int i = 0; i < a.size(); i++) { fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum); matchedByMap.computeIfAbsent(a.get(i).pageId, x -> new ArrayList<>()).addAll(a.get(i).matchedBy); } for (int i = 0; i < b.size(); i++) { fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum); matchedByMap.computeIfAbsent(b.get(i).pageId, x -> new ArrayList<>()).addAll(b.get(i).matchedBy); } return fused.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .map(e -> new RankedItem(e.getKey(), e.getValue(), matchedByMap.getOrDefault(e.getKey(), List.of()).stream().distinct().toList())) .toList(); } /** * 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(); // 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)); if (seedLites.isEmpty()) continue; WikiPageLite seed = seedLites.get(0); // RFC-051 PR-5: don't expand 1-hop neighborhood from a system page seed. // Otherwise the overview / log neighborhood — typically every page that // shares a raw with them — leaks into search results via boost. PR-2's // result-emit filter drops the system pages themselves; this guard ensures // we don't even use them as expansion roots. if (seed.isSystem()) continue; try { relationService.relatedPages(kbId, seed.slug(), 3) .forEach(r -> { WikiPageEntity relPage = pageService.getBySlug(kbId, r.slug()); 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) { log.debug("[HybridRetriever] Relation boost failed for seed {}: {}", seed.slug(), e.getMessage()); } } Set existingIds = hits.stream().map(h -> h.pageId).collect(Collectors.toSet()); rawScoreMap.keySet().removeAll(existingIds); 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) -> { 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; } private String buildReason(WikiPageLite lite, List matchedBy, String query) { if (matchedBy.contains("relation_boost")) return "Structurally related to top search results"; if (matchedBy.contains("title") && matchedBy.contains("semantic")) return "Title and semantic match"; if (matchedBy.contains("title")) return "Title match"; if (matchedBy.contains("semantic")) return "Semantic similarity"; if (matchedBy.contains("content")) return "Content match"; return "Keyword match"; } private Mode parseMode(String mode) { if (mode == null || mode.isBlank()) { String defaultMode = properties.getSearchDefaultMode(); return switch (defaultMode) { case "keyword" -> Mode.KEYWORD; case "semantic" -> Mode.SEMANTIC; default -> Mode.HYBRID; }; } return switch (mode.toLowerCase()) { case "keyword" -> Mode.KEYWORD; case "semantic" -> Mode.SEMANTIC; default -> Mode.HYBRID; }; } /** * 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(); } }