feat(rag): close eval pipeline with live snapshots
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user