feat: add spring ai retrieval sidecar

This commit is contained in:
aruo
2026-07-05 03:22:03 +08:00
parent 5197712719
commit b9ec07de57
21 changed files with 944 additions and 0 deletions
@@ -0,0 +1,23 @@
package com.superbiz.agent.config;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Configuration
@ConfigurationProperties(prefix = "rag.sidecar.spring-ai")
public class RagSidecarProperties {
private boolean enabled = false;
private int contentPreviewLimit = 300;
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setContentPreviewLimit(int contentPreviewLimit) {
this.contentPreviewLimit = contentPreviewLimit;
}
}
@@ -0,0 +1,31 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ComparableRetrievalResult {
private String path;
private Integer rank;
private String id;
private String source;
private String docId;
private String title;
private String breadcrumb;
private String category;
private String contentPreview;
private String scoreLabel;
private Double scoreValue;
}
@@ -0,0 +1,17 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class RetrievalComparisonCase {
private String caseId;
private String scenario;
private String query;
private String category;
}
@@ -0,0 +1,21 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class RetrievalComparisonReport {
private String generatedAt;
private int caseCount;
private int topK;
private String sidecarStatus;
private List<RetrievalComparisonResult> results;
}
@@ -0,0 +1,25 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class RetrievalComparisonResult {
private String caseId;
private String scenario;
private String query;
private String category;
private List<ComparableRetrievalResult> currentResults;
private SidecarRetrievalResponse sidecar;
private List<String> differences;
}
@@ -0,0 +1,21 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class SidecarRetrievalResponse {
private boolean enabled;
private boolean available;
private String status;
private String errorMessage;
private List<ComparableRetrievalResult> results;
}
@@ -0,0 +1,179 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.config.RagSidecarProperties;
import com.superbiz.agent.dto.ComparableRetrievalResult;
import com.superbiz.agent.dto.RetrievalComparisonCase;
import com.superbiz.agent.dto.RetrievalComparisonReport;
import com.superbiz.agent.dto.RetrievalComparisonResult;
import com.superbiz.agent.dto.SidecarRetrievalResponse;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
public class RagRetrievalSidecarComparisonService {
private final VectorSearchService vectorSearchService;
private final SpringAiVectorStoreSidecarService sidecarService;
private final RetrievalResultNormalizer normalizer;
private final RagSidecarProperties properties;
private final ObjectMapper objectMapper;
public RagRetrievalSidecarComparisonService(VectorSearchService vectorSearchService,
SpringAiVectorStoreSidecarService sidecarService,
RetrievalResultNormalizer normalizer,
RagSidecarProperties properties,
ObjectMapper objectMapper) {
this.vectorSearchService = vectorSearchService;
this.sidecarService = sidecarService;
this.normalizer = normalizer;
this.properties = properties;
this.objectMapper = objectMapper;
}
public RetrievalComparisonReport compare(List<RetrievalComparisonCase> cases, int topK) {
List<RetrievalComparisonResult> results = new ArrayList<>();
String sidecarStatus = "not_run";
for (RetrievalComparisonCase comparisonCase : cases) {
List<ComparableRetrievalResult> currentResults = normalizeCurrentResults(
vectorSearchService.searchSimilarDocuments(
comparisonCase.getQuery(),
topK,
comparisonCase.getCategory()
)
);
SidecarRetrievalResponse sidecar = sidecarService.search(
comparisonCase.getQuery(),
topK,
comparisonCase.getCategory()
);
sidecarStatus = sidecar.getStatus();
results.add(RetrievalComparisonResult.builder()
.caseId(comparisonCase.getCaseId())
.scenario(comparisonCase.getScenario())
.query(comparisonCase.getQuery())
.category(comparisonCase.getCategory())
.currentResults(currentResults)
.sidecar(sidecar)
.differences(compareDifferences(currentResults, sidecar.getResults()))
.build());
}
return RetrievalComparisonReport.builder()
.generatedAt(OffsetDateTime.now(ZoneOffset.UTC).toString())
.caseCount(cases.size())
.topK(topK)
.sidecarStatus(sidecarStatus)
.results(results)
.build();
}
public RetrievalComparisonReport compareGoldenCases(Path caseFile) throws IOException {
var root = objectMapper.readTree(caseFile.toFile());
int topK = root.path("topK").asInt(5);
List<RetrievalComparisonCase> cases = new ArrayList<>();
for (var node : root.path("cases")) {
cases.add(RetrievalComparisonCase.builder()
.caseId(node.path("caseId").asText())
.scenario(node.path("scenario").asText())
.query(node.path("query").asText())
.build());
}
return compare(cases, topK);
}
public void writeReports(RetrievalComparisonReport report, Path jsonPath, Path markdownPath) throws IOException {
createParentDirectories(jsonPath);
createParentDirectories(markdownPath);
objectMapper.writerWithDefaultPrettyPrinter().writeValue(jsonPath.toFile(), report);
Files.writeString(markdownPath, renderMarkdown(report));
}
private void createParentDirectories(Path path) throws IOException {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
}
private List<ComparableRetrievalResult> normalizeCurrentResults(List<VectorSearchService.SearchResult> rawResults) {
List<ComparableRetrievalResult> results = new ArrayList<>();
for (int i = 0; i < rawResults.size(); i++) {
results.add(normalizer.fromCurrent(rawResults.get(i), i + 1, properties.getContentPreviewLimit()));
}
return results;
}
private List<String> compareDifferences(List<ComparableRetrievalResult> currentResults,
List<ComparableRetrievalResult> sidecarResults) {
if (sidecarResults == null || sidecarResults.isEmpty()) {
return List.of("sidecar_unavailable_or_empty");
}
List<String> differences = new ArrayList<>();
String currentTopSource = currentResults.isEmpty() ? null : currentResults.get(0).getSource();
String sidecarTopSource = sidecarResults.get(0).getSource();
if (!Objects.equals(currentTopSource, sidecarTopSource)) {
differences.add("top_source_differs");
}
String currentTopBreadcrumb = currentResults.isEmpty() ? null : currentResults.get(0).getBreadcrumb();
String sidecarTopBreadcrumb = sidecarResults.get(0).getBreadcrumb();
if (!Objects.equals(currentTopBreadcrumb, sidecarTopBreadcrumb)) {
differences.add("top_breadcrumb_differs");
}
String currentScoreLabel = currentResults.isEmpty() ? null : currentResults.get(0).getScoreLabel();
String sidecarScoreLabel = sidecarResults.get(0).getScoreLabel();
if (!Objects.equals(currentScoreLabel, sidecarScoreLabel)) {
differences.add("score_label_differs");
}
return differences;
}
private String renderMarkdown(RetrievalComparisonReport report) {
StringBuilder builder = new StringBuilder();
builder.append("# RAG Sidecar Retrieval Comparison\n\n");
builder.append("Generated at: `").append(report.getGeneratedAt()).append("`\n\n");
builder.append("- Cases: ").append(report.getCaseCount()).append("\n");
builder.append("- Top K: ").append(report.getTopK()).append("\n");
builder.append("- Sidecar status: `").append(report.getSidecarStatus()).append("`\n\n");
builder.append("| Case | Query | Current Top | Sidecar Top | Differences |\n");
builder.append("|---|---|---|---|---|\n");
for (RetrievalComparisonResult result : report.getResults()) {
builder.append("| ")
.append(nullToBlank(result.getCaseId()))
.append(" | ")
.append(escapePipe(result.getQuery()))
.append(" | ")
.append(formatTop(result.getCurrentResults()))
.append(" | ")
.append(formatTop(result.getSidecar() != null ? result.getSidecar().getResults() : List.of()))
.append(" | ")
.append(String.join("<br>", result.getDifferences()))
.append(" |\n");
}
return builder.toString();
}
private String formatTop(List<ComparableRetrievalResult> results) {
if (results == null || results.isEmpty()) {
return "";
}
ComparableRetrievalResult top = results.get(0);
return escapePipe(nullToBlank(top.getSource())) + " (" + nullToBlank(top.getScoreLabel()) + ")";
}
private String escapePipe(String value) {
return nullToBlank(value).replace("|", "\\|");
}
private String nullToBlank(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,96 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.dto.ComparableRetrievalResult;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class RetrievalResultNormalizer {
private final ObjectMapper objectMapper;
public RetrievalResultNormalizer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public ComparableRetrievalResult fromCurrent(VectorSearchService.SearchResult result, int rank, int previewLimit) {
Map<String, String> metadata = parseMetadata(result.getMetadata());
String source = firstNonBlank(metadata.get("_source"), metadata.get("source"), result.getMetadata(), result.getId());
return ComparableRetrievalResult.builder()
.path("current")
.rank(rank)
.id(result.getId())
.source(source)
.docId(metadata.get("docId"))
.title(metadata.get("title"))
.breadcrumb(metadata.get("breadcrumb"))
.category(metadata.get("category"))
.contentPreview(truncate(result.getContent(), previewLimit))
.scoreLabel("l2_distance")
.scoreValue((double) result.getScore())
.build();
}
public ComparableRetrievalResult fromSidecar(Document document, int rank, int previewLimit) {
Map<String, String> metadata = stringifyMetadata(document.getMetadata());
String source = firstNonBlank(metadata.get("_source"), metadata.get("source"), metadata.get("docId"), document.getId());
return ComparableRetrievalResult.builder()
.path("sidecar")
.rank(rank)
.id(document.getId())
.source(source)
.docId(metadata.get("docId"))
.title(metadata.get("title"))
.breadcrumb(metadata.get("breadcrumb"))
.category(metadata.get("category"))
.contentPreview(truncate(document.getText(), previewLimit))
.scoreLabel("similarity")
.scoreValue(document.getScore())
.build();
}
private Map<String, String> parseMetadata(String metadata) {
if (metadata == null || metadata.isBlank()) {
return Map.of();
}
try {
Map<?, ?> raw = objectMapper.readValue(metadata, Map.class);
return stringifyMetadata(raw);
} catch (Exception e) {
return Map.of();
}
}
private Map<String, String> stringifyMetadata(Map<?, ?> raw) {
if (raw == null || raw.isEmpty()) {
return Map.of();
}
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : raw.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
result.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
}
}
return result;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private String truncate(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
}
}
@@ -0,0 +1,81 @@
package com.superbiz.agent.service;
import com.superbiz.agent.config.RagSidecarProperties;
import com.superbiz.agent.dto.ComparableRetrievalResult;
import com.superbiz.agent.dto.SidecarRetrievalResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
public class SpringAiVectorStoreSidecarService {
private final RagSidecarProperties properties;
private final ObjectProvider<VectorStore> vectorStoreProvider;
private final RetrievalResultNormalizer normalizer;
public SpringAiVectorStoreSidecarService(RagSidecarProperties properties,
ObjectProvider<VectorStore> vectorStoreProvider,
RetrievalResultNormalizer normalizer) {
this.properties = properties;
this.vectorStoreProvider = vectorStoreProvider;
this.normalizer = normalizer;
}
public SidecarRetrievalResponse search(String query, int topK, String category) {
if (!properties.isEnabled()) {
return unavailable("disabled", null);
}
VectorStore vectorStore = vectorStoreProvider.getIfAvailable();
if (vectorStore == null) {
return unavailable("missing_vector_store", "No Spring AI VectorStore bean is available");
}
try {
SearchRequest.Builder builder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThresholdAll();
if (category != null && !category.isBlank()) {
builder.filterExpression("category == '" + escapeFilterValue(category) + "'");
}
List<Document> documents = vectorStore.similaritySearch(builder.build());
List<ComparableRetrievalResult> results = new ArrayList<>();
for (int i = 0; i < documents.size(); i++) {
results.add(normalizer.fromSidecar(documents.get(i), i + 1, properties.getContentPreviewLimit()));
}
return SidecarRetrievalResponse.builder()
.enabled(true)
.available(true)
.status("available")
.results(results)
.build();
} catch (Exception e) {
log.warn("Spring AI sidecar retrieval failed: {}", e.getMessage());
return unavailable("query_failed", e.getMessage());
}
}
private SidecarRetrievalResponse unavailable(String status, String errorMessage) {
return SidecarRetrievalResponse.builder()
.enabled(properties.isEnabled())
.available(false)
.status(status)
.errorMessage(errorMessage)
.results(List.of())
.build();
}
private String escapeFilterValue(String value) {
return value.replace("'", "\\'");
}
}