feat(rag): close eval pipeline with live snapshots

This commit is contained in:
zhuyongxin
2026-07-06 21:39:27 +08:00
parent cf3333d607
commit ed7efc58b7
47 changed files with 2613 additions and 177 deletions
@@ -1,5 +1,6 @@
package com.superbiz.agent.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -39,6 +40,13 @@ public class Frontmatter {
*/
private String category;
private String source;
private String breadcrumb;
@JsonProperty("kb_scope")
private String kbScope;
/**
* 章节锚点(预留字段,MVP 不使用)
* Key: 章节标题,Value: 章节 Markdown 标题
@@ -39,6 +39,8 @@ public class KnowledgeEntry {
*/
private String category;
private String kbScope;
/**
* 章节锚点(预留字段,MVP 不使用)
*/
@@ -126,11 +126,13 @@ public class DocumentManagementService {
// 5. 解析 frontmatter
long frontmatterStart = System.currentTimeMillis();
Frontmatter frontmatter = null;
String bodyText = text;
if (frontmatterParser.hasFrontmatter(text)) {
frontmatter = frontmatterParser.parse(text);
if (frontmatter != null) {
// LLM 补全 covers / whenToRetrieve(已有值则跳过)
documentFieldEnricher.enrich(frontmatter, text, category);
bodyText = frontmatterParser.stripFrontmatter(text);
documentFieldEnricher.enrich(frontmatter, bodyText, category);
log.info("解析到frontmatter: title={}, keywords={}, time={}ms",
frontmatter.getTitle(), frontmatter.getKeywords(), System.currentTimeMillis() - frontmatterStart);
} else {
@@ -142,7 +144,7 @@ public class DocumentManagementService {
// 6. 分块
long chunkStart = System.currentTimeMillis();
List<DocumentChunk> chunks = documentChunkService.chunkDocument(text, fileName);
List<DocumentChunk> chunks = documentChunkService.chunkDocument(bodyText, fileName);
if (chunks.isEmpty()) {
throw new DocumentProcessException(fileName, "upload", "文档分块失败");
}
@@ -150,7 +152,7 @@ public class DocumentManagementService {
fileName, chunks.size(), System.currentTimeMillis() - chunkStart);
// 7. 创建文档元数据
String docId = UUID.randomUUID().toString();
String docId = resolveDocumentId(frontmatter);
String metadataJson = null;
if (frontmatter != null) {
try {
@@ -181,7 +183,7 @@ public class DocumentManagementService {
// 8. 向量化并索引
try {
long vectorStart = System.currentTimeMillis();
vectorIndexService.indexDocumentChunks(docId, chunks, category);
vectorIndexService.indexDocumentChunks(docId, chunks, category, frontmatter);
document.setStatus("INDEXED");
document.setIndexedAt(LocalDateTime.now());
apiDocumentRepository.save(document);
@@ -203,6 +205,7 @@ public class DocumentManagementService {
.keywords(frontmatter.getKeywords())
.summary(frontmatter.getSummary())
.category(category)
.kbScope(frontmatter.getKbScope())
.sections(frontmatter.getSections())
.covers(frontmatter.getCovers())
.whenToRetrieve(frontmatter.getWhenToRetrieve())
@@ -311,6 +314,16 @@ public class DocumentManagementService {
}
}
private String resolveDocumentId(Frontmatter frontmatter) {
if (frontmatter != null && frontmatter.getSource() != null) {
String source = frontmatter.getSource().trim();
if (!source.isEmpty() && source.length() <= 64) {
return source;
}
}
return UUID.randomUUID().toString();
}
/**
* 根据 docId 查询文档
*/
@@ -62,6 +62,9 @@ public class FrontmatterParser {
.keywords((java.util.List<String>) map.get("keywords"))
.summary((String) map.get("summary"))
.category((String) map.get("category"))
.source((String) map.get("source"))
.breadcrumb((String) map.get("breadcrumb"))
.kbScope(firstString(map, "kb_scope", "kbScope"))
.sections((Map<String, String>) map.get("sections"))
.version((String) map.get("version"))
.author((String) map.get("author"))
@@ -87,6 +90,35 @@ public class FrontmatterParser {
}
}
public String stripFrontmatter(String content) {
if (!hasFrontmatter(content)) {
return content;
}
String trimmed = content.trim();
int secondDelimiter = trimmed.indexOf("\n---", 3);
int delimiterLength = 4;
if (secondDelimiter == -1) {
secondDelimiter = trimmed.indexOf("\r\n---", 3);
delimiterLength = 5;
}
if (secondDelimiter == -1) {
return content;
}
int bodyStart = secondDelimiter + delimiterLength;
if (bodyStart < trimmed.length()) {
char next = trimmed.charAt(bodyStart);
if (next == '\r') {
bodyStart++;
}
if (bodyStart < trimmed.length() && trimmed.charAt(bodyStart) == '\n') {
bodyStart++;
}
}
return trimmed.substring(Math.min(bodyStart, trimmed.length())).stripLeading();
}
/**
* 提取 frontmatter 文本(两个 --- 之间的内容)
*
@@ -115,4 +147,14 @@ public class FrontmatterParser {
// 提取 frontmatter(不包含 --- 标记)
return content.substring(3, secondDelimiter).trim();
}
private String firstString(Map<String, Object> map, String... keys) {
for (String key : keys) {
Object value = map.get(key);
if (value instanceof String text && !text.isBlank()) {
return text;
}
}
return null;
}
}
@@ -36,6 +36,9 @@ public class KnowledgeIndexService {
@Value("${knowledge.base-path:knowledge_base}")
private String knowledgeBasePath;
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
@Autowired
private ApiDocumentRepository apiDocumentRepository;
@@ -114,6 +117,7 @@ public class KnowledgeIndexService {
.keywords(frontmatter.getKeywords())
.summary(frontmatter.getSummary())
.category(frontmatter.getCategory())
.kbScope(frontmatter.getKbScope())
.covers(frontmatter.getCovers())
.whenToRetrieve(frontmatter.getWhenToRetrieve())
.build();
@@ -144,6 +148,9 @@ public class KnowledgeIndexService {
Set<String> titles = new LinkedHashSet<>();
for (KnowledgeEntry entry : knowledgeIndex) {
if (!matchesConfiguredScope(entry)) {
continue;
}
List<String> entryMatchedKeywords = matchedKeywords(entry, queryLower);
if (entryMatchedKeywords.isEmpty()) {
continue;
@@ -178,6 +185,21 @@ public class KnowledgeIndexService {
return !matchedKeywords(entry, query).isEmpty();
}
private boolean matchesConfiguredScope(KnowledgeEntry entry) {
String scope = trimToNull(kbScope);
if (scope == null) {
return true;
}
return scope.equals(trimToNull(entry.getKbScope()));
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private List<String> matchedKeywords(KnowledgeEntry entry, String query) {
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
return List.of();
@@ -8,6 +8,7 @@ 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -21,6 +22,9 @@ public class SpringAiVectorStoreSidecarService {
private final ObjectProvider<VectorStore> vectorStoreProvider;
private final RetrievalResultNormalizer normalizer;
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
public SpringAiVectorStoreSidecarService(RagSidecarProperties properties,
ObjectProvider<VectorStore> vectorStoreProvider,
RetrievalResultNormalizer normalizer) {
@@ -44,8 +48,9 @@ public class SpringAiVectorStoreSidecarService {
.query(query)
.topK(topK)
.similarityThresholdAll();
if (category != null && !category.isBlank()) {
builder.filterExpression("category == '" + escapeFilterValue(category) + "'");
String filterExpression = buildFilterExpression(category);
if (filterExpression != null) {
builder.filterExpression(filterExpression);
}
List<Document> documents = vectorStore.similaritySearch(builder.build());
@@ -78,4 +83,24 @@ public class SpringAiVectorStoreSidecarService {
private String escapeFilterValue(String value) {
return value.replace("'", "\\'");
}
String buildFilterExpression(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("category == '" + escapeFilterValue(categoryFilter) + "'");
}
String scopeFilter = trimToNull(kbScope);
if (scopeFilter != null) {
parts.add("kb_scope == '" + escapeFilterValue(scopeFilter) + "'");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}
@@ -11,6 +11,7 @@ import lombok.Getter;
import lombok.Setter;
import com.superbiz.agent.constant.MilvusConstants;
import com.superbiz.agent.dto.DocumentChunk;
import com.superbiz.agent.dto.Frontmatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -176,6 +177,10 @@ public class VectorIndexService {
* @throws Exception 索引失败时抛出异常
*/
public void indexDocumentChunks(String docId, List<DocumentChunk> chunks, String category) throws Exception {
indexDocumentChunks(docId, chunks, category, null);
}
public void indexDocumentChunks(String docId, List<DocumentChunk> chunks, String category, Frontmatter frontmatter) throws Exception {
if (chunks == null || chunks.isEmpty()) {
throw new IllegalArgumentException("文档分块列表为空");
}
@@ -194,7 +199,7 @@ public class VectorIndexService {
List<Float> vector = embeddingService.generateEmbedding(buildEmbeddingText(chunk));
// 构建元数据(使用 docId 和 category)
Map<String, Object> metadata = buildDocumentMetadata(docId, chunk, chunks.size(), category);
Map<String, Object> metadata = buildDocumentMetadata(docId, chunk, chunks.size(), category, frontmatter);
// 插入到 Milvus
insertToMilvus(chunk.getContent(), vector, metadata, chunk.getChunkIndex());
@@ -253,30 +258,47 @@ public class VectorIndexService {
/**
* 构建文档元数据(用于上传文档)
*/
private Map<String, Object> buildDocumentMetadata(String docId, DocumentChunk chunk, int totalChunks, String category) {
static Map<String, Object> buildDocumentMetadata(String docId, DocumentChunk chunk, int totalChunks, String category) {
return buildDocumentMetadata(docId, chunk, totalChunks, category, null);
}
static Map<String, Object> buildDocumentMetadata(String docId,
DocumentChunk chunk,
int totalChunks,
String category,
Frontmatter frontmatter) {
Map<String, Object> metadata = new HashMap<>();
// 文档标识
String source = firstNonBlank(frontmatter != null ? frontmatter.getSource() : null, "upload:" + docId);
metadata.put("docId", docId);
metadata.put("_source", "upload:" + docId); // 区分文件索引和上传文档
metadata.put("_source", source); // 区分文件索引和上传文档
metadata.put("source", source);
// 分片信息
metadata.put("chunkIndex", chunk.getChunkIndex());
metadata.put("totalChunks", totalChunks);
// 标题信息
if (chunk.getTitle() != null && !chunk.getTitle().isEmpty()) {
metadata.put("title", chunk.getTitle());
String title = firstNonBlank(chunk.getTitle(), frontmatter != null ? frontmatter.getTitle() : null);
if (title != null) {
metadata.put("title", title);
}
// 面包屑导航(完整标题层级路径)
if (chunk.getBreadcrumb() != null && !chunk.getBreadcrumb().isEmpty()) {
metadata.put("breadcrumb", chunk.getBreadcrumb());
String breadcrumb = firstNonBlank(frontmatter != null ? frontmatter.getBreadcrumb() : null, chunk.getBreadcrumb());
if (breadcrumb != null) {
metadata.put("breadcrumb", breadcrumb);
}
// 文档类别
metadata.put("category", category != null && !category.isBlank() ? category : "upload");
String kbScope = trimToNull(frontmatter != null ? frontmatter.getKbScope() : null);
if (kbScope != null) {
metadata.put("kb_scope", kbScope);
}
return metadata;
}
@@ -304,6 +326,23 @@ public class VectorIndexService {
return value == null ? "" : value.trim();
}
private static String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String firstNonBlank(String... values) {
for (String value : values) {
String trimmed = trimToNull(value);
if (trimmed != null) {
return trimmed;
}
}
return null;
}
/**
* 删除文件的旧数据(根据 metadata._source)
*/
@@ -54,6 +54,9 @@ public class VectorSearchService {
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0;
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
public List<SearchResult> searchSimilarDocuments(String query, int topK) {
return searchSimilarDocuments(query, topK, null);
}
@@ -62,7 +65,7 @@ public class VectorSearchService {
String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase();
return switch (mode) {
case "sdk" -> searchSimilarDocumentsWithSdk(query, topK, category);
case "spring-ai" -> searchSimilarDocumentsWithVectorStore(query, topK, category);
case "spring", "spring-ai" -> searchSimilarDocumentsWithVectorStore(query, topK, category);
case "auto" -> searchWithAutoFallback(query, topK, category);
default -> {
logger.warn("Unknown retrieval.vector-store.mode={}, using auto mode", vectorStoreMode);
@@ -86,15 +89,16 @@ public class VectorSearchService {
throw new IllegalStateException("Spring AI VectorStore bean is unavailable");
}
logger.info("Starting Spring AI VectorStore search: query={}, topK={}, category={}", query, topK, category);
logger.info("Starting Spring AI VectorStore search: query={}, topK={}, category={}, kbScope={}",
query, topK, category, effectiveKbScope());
SearchRequest.Builder builder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThresholdAll();
if (category != null && !category.trim().isEmpty()) {
String filterExpression = "category == '" + escapeFilterValue(category.trim()) + "'";
String filterExpression = buildSpringAiFilterExpression(category);
if (filterExpression != null) {
builder.filterExpression(filterExpression);
logger.info("Spring AI VectorStore category filter: {}", filterExpression);
logger.info("Spring AI VectorStore metadata filter: {}", filterExpression);
}
List<Document> documents = vectorStore.similaritySearch(builder.build());
@@ -115,7 +119,8 @@ public class VectorSearchService {
List<SearchResult> searchSimilarDocumentsWithSdk(String query, int topK, String category) {
try {
logger.info("Starting Milvus SDK search: query={}, topK={}, category={}", query, topK, category);
logger.info("Starting Milvus SDK search: query={}, topK={}, category={}, kbScope={}",
query, topK, category, effectiveKbScope());
List<Float> queryVector = embeddingService.generateQueryVector(query);
logger.debug("Query vector generated, dimension={}", queryVector.size());
@@ -129,10 +134,10 @@ public class VectorSearchService {
.withOutFields(List.of("id", "content", "metadata"))
.withParams("{\"nprobe\":10}");
if (category != null && !category.trim().isEmpty()) {
String expr = String.format("metadata[\"category\"] == \"%s\"", category);
String expr = buildSdkFilterExpression(category);
if (expr != null) {
searchParamBuilder.withExpr(expr);
logger.info("Milvus SDK category filter: {}", expr);
logger.info("Milvus SDK metadata filter: {}", expr);
}
R<SearchResults> searchResponse = milvusClient.search(searchParamBuilder.build());
@@ -215,6 +220,47 @@ public class VectorSearchService {
return value.replace("'", "\\'");
}
String buildSpringAiFilterExpression(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("category == '" + escapeFilterValue(categoryFilter) + "'");
}
String scopeFilter = effectiveKbScope();
if (scopeFilter != null) {
parts.add("kb_scope == '" + escapeFilterValue(scopeFilter) + "'");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
String buildSdkFilterExpression(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("metadata[\"category\"] == \"" + escapeMilvusString(categoryFilter) + "\"");
}
String scopeFilter = effectiveKbScope();
if (scopeFilter != null) {
parts.add("metadata[\"kb_scope\"] == \"" + escapeMilvusString(scopeFilter) + "\"");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private String effectiveKbScope() {
return trimToNull(kbScope);
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private String escapeMilvusString(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
@Setter
@Getter
public static class SearchResult {
+1
View File
@@ -157,6 +157,7 @@ rag:
# 检索归一化配置
retrieval:
kb-scope: "" # empty means search all legacy documents; use rag-eval for eval seed docs
vector-store:
mode: auto # auto | spring-ai | sdk
normalization:
@@ -0,0 +1,93 @@
package com.superbiz.agent.eval;
import com.superbiz.agent.Main;
import com.superbiz.agent.domain.entity.ApiDocument;
import com.superbiz.agent.dto.DocumentUploadRequest;
import com.superbiz.agent.dto.Frontmatter;
import com.superbiz.agent.repository.ApiDocumentRepository;
import com.superbiz.agent.service.DocumentManagementService;
import com.superbiz.agent.service.FrontmatterParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Imports canonical RAG eval documents through the real document pipeline.
*
* <p>Disabled by default because it writes DB rows, local knowledge files, and
* vector index records in the configured runtime environment.</p>
*/
@SpringBootTest(
classes = Main.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = "spring.main.web-application-type=none"
)
@EnabledIfSystemProperty(named = "rag.seed.enabled", matches = "true")
class RagEvalSeedImporterTest {
private static final Path DEFAULT_SEED_DOCS = Path.of("eval/rag-retrieval/seed-docs");
@Autowired
private DocumentManagementService documentManagementService;
@Autowired
private FrontmatterParser frontmatterParser;
@Autowired
private ApiDocumentRepository apiDocumentRepository;
@Test
void importSeedDocuments() throws Exception {
Path seedDir = Path.of(System.getProperty("rag.seed.docs", DEFAULT_SEED_DOCS.toString()));
List<Path> docs;
try (var stream = Files.list(seedDir)) {
docs = stream
.filter(path -> path.getFileName().toString().endsWith(".md"))
.sorted()
.toList();
}
assertFalse(docs.isEmpty(), "seed docs directory must contain markdown files");
for (Path docPath : docs) {
String content = Files.readString(docPath, StandardCharsets.UTF_8);
Frontmatter frontmatter = frontmatterParser.parse(content);
if (frontmatter == null || frontmatter.getSource() == null || frontmatter.getSource().isBlank()) {
throw new IllegalArgumentException("seed doc must include frontmatter source: " + docPath);
}
apiDocumentRepository.findByDocId(frontmatter.getSource().trim())
.map(ApiDocument::getDocId)
.ifPresent(documentManagementService::deleteDocument);
String fileName = docPath.getFileName().toString();
MockMultipartFile file = new MockMultipartFile(
"file",
fileName,
"text/markdown",
content.getBytes(StandardCharsets.UTF_8)
);
DocumentUploadRequest request = DocumentUploadRequest.builder()
.file(file)
.category(resolveCategory(frontmatter))
.build();
documentManagementService.uploadDocument(request);
}
}
private String resolveCategory(Frontmatter frontmatter) {
if (frontmatter.getCategory() != null && !frontmatter.getCategory().isBlank()) {
return frontmatter.getCategory().trim();
}
return "rag-eval";
}
}
@@ -0,0 +1,85 @@
package com.superbiz.agent.eval;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.superbiz.agent.Main;
import com.superbiz.agent.dto.LookupResult;
import com.superbiz.agent.tool.LookupKnowledgeTool;
import com.superbiz.agent.util.SessionContextHolder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Generates RAG retrieval fixtures from the real LookupKnowledgeTool bean.
*
* <p>This class is disabled by default because it writes repository files and
* depends on the configured runtime retrieval stack.</p>
*/
@SpringBootTest(
classes = Main.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = "spring.main.web-application-type=none"
)
@EnabledIfSystemProperty(named = "rag.snapshot.enabled", matches = "true")
class RagLookupSnapshotGeneratorTest {
private static final Path DEFAULT_CASES = Path.of("eval/rag-retrieval/cases/golden-cases.json");
private static final Path DEFAULT_FIXTURES = Path.of("eval/rag-retrieval/fixtures");
@Autowired
private LookupKnowledgeTool lookupKnowledgeTool;
@Autowired
private ObjectMapper objectMapper;
@Test
void generateLookupResultFixtures() throws Exception {
Path casesPath = Path.of(System.getProperty("rag.snapshot.cases", DEFAULT_CASES.toString()));
Path fixturesDir = Path.of(System.getProperty("rag.snapshot.fixtures", DEFAULT_FIXTURES.toString()));
String retrievedAt = System.getProperty("rag.snapshot.retrievedAt", Instant.now().toString());
JsonNode root = objectMapper.readTree(casesPath.toFile());
JsonNode cases = root.path("cases");
assertTrue(cases.isArray(), "golden cases file must contain a cases array");
Files.createDirectories(fixturesDir);
for (JsonNode testCase : cases) {
String caseId = requiredText(testCase, "caseId");
String query = requiredText(testCase, "query");
SessionContextHolder.clear();
LookupResult lookupResult;
try {
lookupResult = lookupKnowledgeTool.lookupKnowledge(query);
} finally {
SessionContextHolder.clear();
}
ObjectNode fixture = objectMapper.createObjectNode();
fixture.put("caseId", caseId);
fixture.put("query", query);
fixture.put("retrievedAt", retrievedAt);
fixture.set("lookupResult", objectMapper.valueToTree(lookupResult));
Path output = fixturesDir.resolve(caseId + ".json");
objectMapper.writerWithDefaultPrettyPrinter().writeValue(output.toFile(), fixture);
}
}
private String requiredText(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
if (value == null || value.asText().isBlank()) {
throw new IllegalArgumentException("golden case is missing required field: " + fieldName);
}
return value.asText();
}
}
@@ -1,5 +1,6 @@
package com.superbiz.agent.service;
import com.superbiz.agent.dto.Frontmatter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
@@ -38,4 +39,16 @@ class DocumentManagementServiceTest {
assertEquals("payment/runbook.md", storedPath);
assertTrue(Files.exists(tempDir.resolve("payment").resolve("runbook.md")));
}
@Test
void resolveDocumentIdUsesFrontmatterSourceWhenItFitsDatabaseColumn() {
DocumentManagementService service = new DocumentManagementService();
Frontmatter frontmatter = Frontmatter.builder()
.source("mysql-connection-pool")
.build();
String docId = ReflectionTestUtils.invokeMethod(service, "resolveDocumentId", frontmatter);
assertEquals("mysql-connection-pool", docId);
}
}
@@ -146,4 +146,48 @@ class FrontmatterParserTest {
assertEquals("1.0.0", result.getVersion());
assertEquals("Test Author", result.getAuthor());
}
@Test
void testParse_withRetrievalMetadata() {
String content = """
---
title: MySQL Connection Pool
keywords: [connection pool, HikariCP]
summary: Diagnose exhausted MySQL connection pools
category: database
source: mysql-connection-pool
breadcrumb: Database > MySQL > Connection Pool
kb_scope: rag-eval
---
Content
""";
Frontmatter result = parser.parse(content);
assertNotNull(result);
assertEquals("mysql-connection-pool", result.getSource());
assertEquals("Database > MySQL > Connection Pool", result.getBreadcrumb());
assertEquals("rag-eval", result.getKbScope());
}
@Test
void testStripFrontmatter_returnsMarkdownBodyOnly() {
String content = """
---
title: Test
keywords: [frontmatter-only]
summary: Summary
---
# Body
Body content
""";
String body = parser.stripFrontmatter(content);
assertFalse(body.contains("frontmatter-only"));
assertTrue(body.startsWith("# Body"));
assertTrue(body.contains("Body content"));
}
}
@@ -138,6 +138,49 @@ class KnowledgeIndexServiceTest {
assertNull(hint.singleDomainOrNull());
}
@Test
void testAnalyzeQuery_filtersByConfiguredKbScope() {
ReflectionTestUtils.setField(service, "kbScope", "rag-eval");
service.addToIndex(KnowledgeEntry.builder()
.filePath("legacy.md")
.keywords(List.of("timeout"))
.category("legacy")
.build());
service.addToIndex(KnowledgeEntry.builder()
.filePath("eval.md")
.keywords(List.of("timeout"))
.category("eval")
.kbScope("rag-eval")
.build());
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("timeout");
assertEquals(1, hint.matches().size());
assertEquals(List.of("eval"), hint.domains());
assertEquals("eval", hint.singleDomainOrNull());
}
@Test
void testAnalyzeQuery_keepsLegacyEntriesWhenNoScopeConfigured() {
ReflectionTestUtils.setField(service, "kbScope", "");
service.addToIndex(KnowledgeEntry.builder()
.filePath("legacy.md")
.keywords(List.of("timeout"))
.category("legacy")
.build());
service.addToIndex(KnowledgeEntry.builder()
.filePath("eval.md")
.keywords(List.of("timeout"))
.category("eval")
.kbScope("rag-eval")
.build());
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("timeout");
assertEquals(2, hint.matches().size());
assertNull(hint.singleDomainOrNull());
}
@Test
void testExactMatch_noMatch() {
KnowledgeEntry entry = KnowledgeEntry.builder()
@@ -1,8 +1,11 @@
package com.superbiz.agent.service;
import com.superbiz.agent.dto.DocumentChunk;
import com.superbiz.agent.dto.Frontmatter;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class VectorIndexServiceTest {
@@ -34,4 +37,35 @@ class VectorIndexServiceTest {
assertEquals("Plain chunk content.", VectorIndexService.buildEmbeddingText(chunk));
}
@Test
void buildDocumentMetadataUsesFrontmatterRetrievalFields() {
DocumentChunk chunk = DocumentChunk.builder()
.chunkIndex(0)
.title("Chunk Title")
.breadcrumb("Chunk > Path")
.content("content")
.build();
Frontmatter frontmatter = Frontmatter.builder()
.title("Document Title")
.source("mysql-connection-pool")
.breadcrumb("Database > MySQL > Connection Pool")
.kbScope("rag-eval")
.build();
Map<String, Object> metadata = VectorIndexService.buildDocumentMetadata(
"mysql-connection-pool",
chunk,
2,
"database",
frontmatter
);
assertEquals("mysql-connection-pool", metadata.get("docId"));
assertEquals("mysql-connection-pool", metadata.get("_source"));
assertEquals("mysql-connection-pool", metadata.get("source"));
assertEquals("database", metadata.get("category"));
assertEquals("rag-eval", metadata.get("kb_scope"));
assertEquals("Database > MySQL > Connection Pool", metadata.get("breadcrumb"));
}
}
@@ -13,6 +13,7 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -151,6 +152,45 @@ class VectorSearchServiceTest {
assertTrue(request.toString().contains("api"));
}
@Test
void springModeUsesVectorStoreAlias() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
VectorSearchService service = new VectorSearchService();
setMode(service, "spring");
setVectorStore(service, provider);
service.searchSimilarDocuments("query", 5, "api");
verify(vectorStore).similaritySearch(any(SearchRequest.class));
}
@Test
void defaultScopeDoesNotAddMetadataFilter() {
VectorSearchService service = new VectorSearchService();
setKbScope(service, "");
assertNull(service.buildSpringAiFilterExpression(null));
assertNull(service.buildSdkFilterExpression(null));
assertEquals("category == 'api'", service.buildSpringAiFilterExpression("api"));
assertEquals("metadata[\"category\"] == \"api\"", service.buildSdkFilterExpression("api"));
}
@Test
void configuredScopeCombinesWithCategoryFilter() {
VectorSearchService service = new VectorSearchService();
setKbScope(service, "rag-eval");
assertEquals("kb_scope == 'rag-eval'", service.buildSpringAiFilterExpression(null));
assertEquals("category == 'api' && kb_scope == 'rag-eval'",
service.buildSpringAiFilterExpression("api"));
assertEquals("metadata[\"category\"] == \"api\" && metadata[\"kb_scope\"] == \"rag-eval\"",
service.buildSdkFilterExpression("api"));
}
private static void setMode(VectorSearchService service, String mode) {
ReflectionTestUtils.setField(service, "vectorStoreMode", mode);
}
@@ -161,6 +201,10 @@ class VectorSearchServiceTest {
ReflectionTestUtils.setField(service, "maxL2Distance", 2.0);
}
private static void setKbScope(VectorSearchService service, String kbScope) {
ReflectionTestUtils.setField(service, "kbScope", kbScope);
}
private static VectorSearchService.SearchResult result(String id, float score) {
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
result.setId(id);