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 {