refactor(rag): extract retrieval and ingest to py-rag service
- replace in-process Milvus stack with PyRagClient + PyRagKnowledgeSearchAdapter behind KnowledgeSearchPort (RERANK score passthrough) - move document ingest to py-rag /documents:ingest; DocumentManagementService keeps MySQL ledger + local files - sink L0 query understanding to py-rag; drop KnowledgeQueryTransformer, single UNFILTERED_VECTOR attempt - remove Milvus deps, config classes, dead demo services and obsolete rebuild scripts - compose/Makefile reduced to MySQL/Redis; add pyrag.* config
This commit is contained in:
@@ -11,7 +11,6 @@ import com.superbiz.agent.tool.LookupKnowledgeTool;
|
||||
import com.superbiz.agent.service.KnowledgeContextPacker;
|
||||
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
|
||||
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
|
||||
import com.superbiz.agent.service.KnowledgeQueryTransformer;
|
||||
import com.superbiz.agent.service.LookupResultAssembler;
|
||||
import com.superbiz.agent.repository.AgentStepRepository;
|
||||
import com.superbiz.agent.repository.AgentReasoningAuditRepository;
|
||||
@@ -84,7 +83,6 @@ class HarnessChatConfigurationTest {
|
||||
.withBean(ChatModel.class, () -> mock(ChatModel.class))
|
||||
.withBean(RedisTemplate.class, () -> mock(RedisTemplate.class))
|
||||
.withBean(LookupKnowledgeTool.class, () -> mock(LookupKnowledgeTool.class))
|
||||
.withBean(KnowledgeQueryTransformer.class, () -> mock(KnowledgeQueryTransformer.class))
|
||||
.withBean(KnowledgeDocumentRetriever.class, () -> mock(KnowledgeDocumentRetriever.class))
|
||||
.withBean(KnowledgeEvidencePostProcessor.class, () -> mock(KnowledgeEvidencePostProcessor.class))
|
||||
.withBean(KnowledgeContextPacker.class, () -> mock(KnowledgeContextPacker.class))
|
||||
|
||||
@@ -4,18 +4,13 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 单独测试 Redis 连接
|
||||
* 禁用 Milvus 以避免启动失败
|
||||
* 单独测试 Redis 连接(需要可达的 Redis 实例)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@TestPropertySource(properties = {
|
||||
"spring.autoconfigure.exclude=org.example.config.MilvusConfig"
|
||||
})
|
||||
class RedisConnectionTest {
|
||||
|
||||
@Autowired(required = false)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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 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.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Generates RAG retrieval fixtures from the real {@link LookupKnowledgeTool} bean.
|
||||
*
|
||||
* <p>Disabled by default: writes repository files and needs the live retrieval stack
|
||||
* (embedding + Milvus hybrid collection + optional MySQL/L0).</p>
|
||||
*
|
||||
* <p>System properties (via Maven {@code -D}):</p>
|
||||
* <ul>
|
||||
* <li>{@code rag.snapshot.enabled=true} — required to run</li>
|
||||
* <li>{@code retrieval.search.mode=hybrid|dense} — default hybrid</li>
|
||||
* <li>{@code retrieval.kb-scope} — default empty unless set (scripts use {@code rag-eval})</li>
|
||||
* <li>{@code rag.snapshot.cases} / {@code rag.snapshot.fixtures} / {@code rag.snapshot.retrievedAt}</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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;
|
||||
|
||||
/**
|
||||
* Bind retrieval mode/scope early so {@code VectorSearchService} / store filters see them.
|
||||
*/
|
||||
@DynamicPropertySource
|
||||
static void retrievalProperties(DynamicPropertyRegistry registry) {
|
||||
String mode = System.getProperty("retrieval.search.mode", "hybrid");
|
||||
if (mode == null || mode.isBlank()) {
|
||||
mode = "hybrid";
|
||||
}
|
||||
String normalized = mode.trim().toLowerCase(Locale.ROOT);
|
||||
registry.add("retrieval.search.mode", () -> normalized);
|
||||
|
||||
String kbScope = System.getProperty("retrieval.kb-scope", "");
|
||||
if (kbScope != null && !kbScope.isBlank()) {
|
||||
registry.add("retrieval.kb-scope", kbScope::trim);
|
||||
}
|
||||
}
|
||||
|
||||
@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());
|
||||
String searchMode = normalizeMode(System.getProperty("retrieval.search.mode", "hybrid"));
|
||||
String kbScope = blankToNull(System.getProperty("retrieval.kb-scope", ""));
|
||||
|
||||
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");
|
||||
|
||||
LookupResult lookupResult = lookupKnowledgeTool.lookupKnowledge(query);
|
||||
|
||||
ObjectNode fixture = objectMapper.createObjectNode();
|
||||
fixture.put("caseId", caseId);
|
||||
fixture.put("query", query);
|
||||
fixture.put("retrievedAt", retrievedAt);
|
||||
fixture.put("searchMode", searchMode);
|
||||
if (kbScope != null) {
|
||||
fixture.put("kbScope", kbScope);
|
||||
}
|
||||
fixture.set("lookupResult", objectMapper.valueToTree(lookupResult));
|
||||
|
||||
Path output = fixturesDir.resolve(caseId + ".json");
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(output.toFile(), fixture);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeMode(String mode) {
|
||||
if (mode == null || mode.isBlank()) {
|
||||
return "hybrid";
|
||||
}
|
||||
return mode.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
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,539 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.config.DocumentChunkConfig;
|
||||
import com.superbiz.agent.dto.DocumentChunk;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 当前分片策略的单元测试 — 覆盖旧能力回归 + Phase 1 新增能力
|
||||
*/
|
||||
@DisplayName("DocumentChunkService 分片策略")
|
||||
class DocumentChunkServiceTest {
|
||||
|
||||
private DocumentChunkService service;
|
||||
private DocumentChunkConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
config = new DocumentChunkConfig();
|
||||
config.setMaxSize(800);
|
||||
config.setMaxTokens(500);
|
||||
config.setMaxTokensHard(600);
|
||||
config.setOverlap(100);
|
||||
service = new DocumentChunkService();
|
||||
try {
|
||||
var field = DocumentChunkService.class.getDeclaredField("chunkConfig");
|
||||
field.setAccessible(true);
|
||||
field.set(service, config);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 回归:边界条件 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("边界条件")
|
||||
class BoundaryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null 内容 → 空列表")
|
||||
void nullContent_returnsEmpty() {
|
||||
List<DocumentChunk> chunks = service.chunkDocument(null, "/test/null.md");
|
||||
assertTrue(chunks.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空字符串 → 空列表")
|
||||
void emptyContent_returnsEmpty() {
|
||||
List<DocumentChunk> chunks = service.chunkDocument(" \n ", "/test/empty.md");
|
||||
assertTrue(chunks.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("短文档(≤maxSize)→ 1个分块")
|
||||
void shortDocument_singleChunk() {
|
||||
String content = "这是一篇短文档,内容不超过800个字符。";
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/short.md");
|
||||
|
||||
assertEquals(1, chunks.size());
|
||||
assertEquals(content, chunks.get(0).getContent());
|
||||
assertEquals(0, chunks.get(0).getChunkIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("恰好 maxSize 边界 → 1个分块")
|
||||
void exactlyMaxSize_singleChunk() {
|
||||
String content = "A".repeat(800);
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/boundary.md");
|
||||
assertEquals(1, chunks.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 回归:标题分割 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Markdown 标题分割")
|
||||
class HeadingSplitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("单个 H1 标题 → section 继承标题")
|
||||
void singleHeading_titlePropagates() {
|
||||
String content = "# CPU高负载问题\n\n这是CPU高负载的描述内容。";
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/cpu.md");
|
||||
|
||||
assertEquals(1, chunks.size());
|
||||
assertEquals("CPU高负载问题", chunks.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多个标题 → 按标题边界分割")
|
||||
void multipleHeadings_splitAtHeadings() {
|
||||
String content =
|
||||
"# CPU高负载\n\nCPU问题的详细描述。\n\n" +
|
||||
"# 内存高负载\n\n内存问题的详细描述。";
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/multi.md");
|
||||
|
||||
assertEquals(2, chunks.size());
|
||||
assertEquals("CPU高负载", chunks.get(0).getTitle());
|
||||
assertEquals("内存高负载", chunks.get(1).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多级标题(H1/H2/H3)→ 标题独立不冲突")
|
||||
void multiLevelHeadings() {
|
||||
String content =
|
||||
"# 一级标题\n\n一级内容。\n\n" +
|
||||
"## 二级标题\n\n二级内容。\n\n" +
|
||||
"### 三级标题\n\n三级内容。";
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/levels.md");
|
||||
assertEquals(3, chunks.size());
|
||||
assertEquals("一级标题", chunks.get(0).getTitle());
|
||||
assertEquals("二级标题", chunks.get(1).getTitle());
|
||||
assertEquals("三级标题", chunks.get(2).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("H1-H6 全部支持")
|
||||
void allHeadingLevels() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 1; i <= 6; i++) {
|
||||
sb.append("#".repeat(i)).append(" 标题").append(i).append("\n\n内容").append(i).append("。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/h1h6.md");
|
||||
assertEquals(6, chunks.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无标题文档 → 整个文档作为1个 section")
|
||||
void noHeadings_entireAsOneSection() {
|
||||
String content = "纯文本没有标题。\n\n第二段内容。\n\n第三段内容。";
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/nohead.md");
|
||||
assertFalse(chunks.isEmpty());
|
||||
assertNull(chunks.get(0).getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 回归:段落边界切分 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("超长章节 — 段落边界切分")
|
||||
class ParagraphSplitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("短章节(≤maxSize)→ 不进入段落切割")
|
||||
void shortSection_noParagraphSplit() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 测试\n\n");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
sb.append("段落").append(i).append(":这是一段短内容。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/short_sec.md");
|
||||
assertEquals(1, chunks.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("超长章节 → 在段落边界切分")
|
||||
void longSection_splitsAtParagraphBoundaries() {
|
||||
config.setMaxSize(50);
|
||||
config.setMaxTokens(30);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 长章节\n\n");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
sb.append("段落").append(i).append(":ABCDEFGHIJKLMNOPQRSTUVWXYZ。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/long_sec.md");
|
||||
assertTrue(chunks.size() >= 2, "超长章节应切分为多个分块,实际: " + chunks.size());
|
||||
|
||||
// 所有分块携带相同的 title
|
||||
for (DocumentChunk c : chunks) {
|
||||
assertEquals("长章节", c.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 回归:chunkIndex 元数据 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("分块元数据")
|
||||
class ChunkMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("chunkIndex 自增且唯一")
|
||||
void chunkIndexSequential() {
|
||||
config.setMaxSize(50);
|
||||
config.setMaxTokens(30);
|
||||
|
||||
StringBuilder sb = new StringBuilder("# Meta\n\n");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
sb.append("段落").append(i).append(":填充内容以触发切分机制。ABCDE。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/meta.md");
|
||||
assertTrue(chunks.size() >= 2);
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
assertEquals(i, chunks.get(i).getChunkIndex(),
|
||||
"chunkIndex 应从0开始连续递增");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("startIndex/endIndex 范围合法 — 无漂移")
|
||||
void indexRangeValid_noDrift() {
|
||||
String content = "# 标题\n\n测试内容。";
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/index.md");
|
||||
|
||||
for (DocumentChunk c : chunks) {
|
||||
assertTrue(c.getStartOffset() >= 0);
|
||||
assertTrue(c.getEndOffset() > c.getStartOffset(),
|
||||
"endIndex(" + c.getEndOffset() + ") 应 > startIndex(" + c.getStartOffset() + ")");
|
||||
assertTrue(c.getEndOffset() <= content.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增:Token 估算 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Token 估算")
|
||||
class TokenEstimationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("纯中文 800 字符 ≈ 800 tokens → 短章节不切")
|
||||
void pureChinese_fewerTokensThanMax() {
|
||||
config.setMaxTokens(400);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 中文测试\n\n");
|
||||
// 纯中文 ~300 字符 ≈ 300 tokens
|
||||
for (int i = 0; i < 3; i++) {
|
||||
sb.append("这是纯中文测试内容的第十").append(i).append("段落。");
|
||||
sb.append("每个中文字符大约占用一个令牌的位置。");
|
||||
sb.append("因此这段文本的令牌数大致等于字符数。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/cn_tokens.md");
|
||||
// 300 字符 ≈ 300 tokens < 400 maxTokens → 1 个分块
|
||||
assertEquals(1, chunks.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("纯英文 2000 字符 ≈ 500 tokens → 刚好不超过上限")
|
||||
void pureEnglish_moreCharactersSameTokens() {
|
||||
config.setMaxTokens(200);
|
||||
config.setMaxTokensHard(250);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# English Test\n\n");
|
||||
for (int i = 0; i < 8; i++) {
|
||||
sb.append("This is paragraph number ").append(i)
|
||||
.append(" containing English text. ")
|
||||
.append("English characters are much cheaper in tokens. ")
|
||||
.append("More filler text here to reach the limit properly. ")
|
||||
.append("Yet another sentence for good measure. ")
|
||||
.append("Still more words needed to reach token limit here.\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/en_tokens.md");
|
||||
// 大量英文才占少量 token → 分块数应少于用字符计数的版本
|
||||
assertTrue(chunks.size() >= 2, "1200+ 字符英文应切分");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增:列表结构感知 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("列表结构感知")
|
||||
class ListStructureTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("有序列表项之间不切分 — 即使超过 maxTokens")
|
||||
void orderedList_notSplitBetweenItems() {
|
||||
config.setMaxTokens(80);
|
||||
config.setMaxTokensHard(200);
|
||||
config.setOverlap(30);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 排查步骤\n\n");
|
||||
// 5个有序列表项,每项 ~40 字符 ≈ 40 tokens,总共 ~200 tokens
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
sb.append(i).append(". 这是排查步骤第").append(i)
|
||||
.append("项,包含具体的操作指引和注意事项说明。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/ordered_list.md");
|
||||
|
||||
// 5项应保持在一起(未触及 hard 上限)
|
||||
assertEquals(1, chunks.size(),
|
||||
"有序列表项不应被拆散,实际分块数: " + chunks.size());
|
||||
|
||||
String content = chunks.get(0).getContent();
|
||||
assertTrue(content.contains("1. "), "应包含第1项");
|
||||
assertTrue(content.contains("5. "), "应包含第5项");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有序列表触及硬上限 → 在列表项边界强制切分")
|
||||
void orderedList_hardLimitSplits() {
|
||||
config.setMaxTokens(50);
|
||||
config.setMaxTokensHard(100);
|
||||
config.setOverlap(20);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 长列表\n\n");
|
||||
// 每项 ~60 tokens,硬上限 100 → 最多装 1 项多
|
||||
for (int i = 1; i <= 6; i++) {
|
||||
sb.append(i).append(". 这是很长的排查步骤内容,包含详细的说明信息。")
|
||||
.append("每个步骤都要执行多个检查操作。继续填充文本以增加令牌计数。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/long_list.md");
|
||||
|
||||
System.out.println(" 长列表硬上限测试 — 实际分块数: " + chunks.size());
|
||||
for (DocumentChunk c : chunks) {
|
||||
System.out.println(" Chunk #" + c.getChunkIndex() + ": " + c.getContent().length() + "字符 "
|
||||
+ "| start=" + c.getStartOffset() + " end=" + c.getEndOffset()
|
||||
+ " | preview=" + c.getContent().substring(0, Math.min(60, c.getContent().length())).replace("\n", "\\n"));
|
||||
}
|
||||
|
||||
// 硬上限会强制切分,但每个分块内的列表项应保持连续
|
||||
assertTrue(chunks.size() >= 2, "长列表应至少触发1次切分,实际: " + chunks.size());
|
||||
|
||||
// 验证:除了第一个分块(可能是标题),其余应包含列表项
|
||||
for (int i = 1; i < chunks.size(); i++) {
|
||||
DocumentChunk c = chunks.get(i);
|
||||
assertFalse(c.getContent().isEmpty());
|
||||
assertTrue(c.getContent().matches("(?s).*\\d+\\.\\s.*"),
|
||||
"非标题分块应包含列表项,Chunk #" + c.getChunkIndex()
|
||||
+ " preview: " + c.getContent().substring(0, Math.min(60, c.getContent().length())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无序列表项之间不切分")
|
||||
void unorderedList_notSplitBetweenItems() {
|
||||
config.setMaxTokens(80);
|
||||
config.setMaxTokensHard(200);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 检查清单\n\n");
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
sb.append("- 检查项").append(i).append(":确认服务运行状态正常并记录相关指标。\n\n");
|
||||
}
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/unordered_list.md");
|
||||
assertEquals(1, chunks.size(), "无序列表项不应被拆散");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("列表结束后普通段落应从下一段落开始新分块")
|
||||
void listEnds_normalParagraphStartsNewChunk() {
|
||||
config.setMaxTokens(150);
|
||||
config.setMaxTokensHard(250);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 文档\n\n");
|
||||
// 先一个普通段落
|
||||
sb.append("这是介绍段落,描述系统的整体架构和设计思路。\n\n");
|
||||
// 有序列表
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
sb.append(i).append(". 列表项第").append(i).append("条,包含操作说明。\n\n");
|
||||
}
|
||||
// 普通段落
|
||||
sb.append("这是总结段落,包含上述操作完成后需要关注的监控指标。\n\n");
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/list_mixed.md");
|
||||
assertTrue(chunks.size() >= 1);
|
||||
// 列表项应保持在一起
|
||||
for (DocumentChunk c : chunks) {
|
||||
String content = c.getContent();
|
||||
// 分块中不应有孤立的单个列表项(除非只有一个)
|
||||
if (content.contains("1. ") && content.contains("3. ")) {
|
||||
// 这个分块包含了全部3个列表项 → 正确
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增:代码块结构感知 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("代码块结构感知")
|
||||
class CodeBlockTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("代码块内部不切分")
|
||||
void codeBlock_notSplitInside() {
|
||||
config.setMaxTokens(60);
|
||||
config.setMaxTokensHard(200);
|
||||
config.setOverlap(20);
|
||||
|
||||
String content =
|
||||
"# 代码示例\n\n" +
|
||||
"以下是配置代码:\n\n" +
|
||||
"```yaml\n" +
|
||||
"server:\n" +
|
||||
" port: 8080\n" +
|
||||
" host: localhost\n" +
|
||||
" timeout: 30s\n" +
|
||||
"```\n\n" +
|
||||
"配置说明结束。";
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/code.md");
|
||||
|
||||
// 代码块应保持完整(未触及硬上限)
|
||||
// 验证:至少有一个分块包含完整的 ```...```
|
||||
boolean foundCompleteBlock = false;
|
||||
for (DocumentChunk c : chunks) {
|
||||
String text = c.getContent();
|
||||
if (text.contains("```yaml") && text.contains("```") &&
|
||||
text.indexOf("```yaml") < text.lastIndexOf("```")) {
|
||||
foundCompleteBlock = true;
|
||||
}
|
||||
}
|
||||
// 可能整体在一个分块中
|
||||
assertTrue(chunks.size() >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 可视化 ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("可视化 — 打印切分结果")
|
||||
class VisualInspectionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("模拟运维文档 — 展示新策略效果")
|
||||
void realWorldAIOpsDoc() {
|
||||
config.setMaxTokens(150);
|
||||
config.setMaxTokensHard(200);
|
||||
config.setOverlap(40);
|
||||
|
||||
String doc = """
|
||||
# CPU高负载问题排查指南
|
||||
|
||||
## 问题现象
|
||||
|
||||
服务器CPU使用率持续超过90%,系统响应变慢,用户反馈页面加载超时。
|
||||
监控告警系统连续发出多条CPU使用率告警。
|
||||
|
||||
## 排查步骤
|
||||
|
||||
1. 登录服务器,执行 top 命令查看当前CPU使用率最高的进程。记录进程ID和CPU占用百分比。
|
||||
|
||||
2. 使用 ps aux | grep {进程名} 确认相关服务的运行状态。检查是否有异常进程占用资源。
|
||||
|
||||
3. 查看应用日志,重点关注最近15分钟的ERROR级别日志。使用 tail -n 500 命令。
|
||||
|
||||
4. 检查数据库连接池状态,确认是否有慢查询或连接泄漏。查看慢查询日志。
|
||||
|
||||
5. 检查JVM内存使用情况和GC日志。使用 jstat -gcutil {pid} 1000 命令观察GC频率。
|
||||
|
||||
## 常见原因
|
||||
|
||||
1. 死循环或递归调用导致CPU满载。检查是否有未设置退出条件的循环逻辑。
|
||||
2. 大量正则表达式匹配操作。检查是否有未编译的正则在循环中使用。
|
||||
|
||||
## 解决方案
|
||||
|
||||
根据排查结果采取对应措施:代码问题则回滚或热修复;资源不足则扩容。
|
||||
处理完成后持续观察监控指标30分钟,确认CPU使用率恢复正常。
|
||||
""";
|
||||
|
||||
List<DocumentChunk> chunks = service.chunkDocument(doc, "/kb/cpu_high_usage.md");
|
||||
|
||||
System.out.println("========================================");
|
||||
System.out.println(" Phase 1 新策略效果 — 模拟运维文档");
|
||||
System.out.println(" 配置: maxTokens=150, hard=200, overlap=40");
|
||||
System.out.println(" 总字符数: " + doc.length());
|
||||
System.out.println(" 总分块数: " + chunks.size());
|
||||
System.out.println("========================================\n");
|
||||
|
||||
for (DocumentChunk c : chunks) {
|
||||
System.out.println("┌─ Chunk #" + c.getChunkIndex());
|
||||
System.out.println("│ Title: " + (c.getTitle() != null ? c.getTitle() : "(无)"));
|
||||
System.out.println("│ Range: [" + c.getStartOffset() + "→" + c.getEndOffset() + "] (" + c.getContent().length() + "字符)");
|
||||
// 显示前150字符
|
||||
String preview = c.getContent().length() > 120
|
||||
? c.getContent().substring(0, 120).replace("\n", "\\n") + "..."
|
||||
: c.getContent().replace("\n", "\\n");
|
||||
System.out.println("│ Preview: " + preview);
|
||||
System.out.println("└──────────────────────\n");
|
||||
}
|
||||
|
||||
assertTrue(chunks.size() >= 3, "应产生多个分块");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("中英混排对比 — token vs 字符计数差异")
|
||||
void mixedContentComparison() {
|
||||
config.setMaxTokens(100);
|
||||
config.setMaxTokensHard(150);
|
||||
config.setOverlap(30);
|
||||
|
||||
String chinese = "这是中文内容示范。中文每个字符在LLM中约占用1个token。" +
|
||||
"因此这段文本在上下文窗口中占用的token数较多。" +
|
||||
"继续填充文字以触发切分逻辑,验证中文token估算是否合理。" +
|
||||
"更多中文文本来增加令牌计数。";
|
||||
|
||||
String english = "This is English content. Each word may take one or two tokens. " +
|
||||
"A sentence like this one actually consumes relatively few tokens compared to " +
|
||||
"Chinese characters. More English text to reach the same token count as above. " +
|
||||
"Still need more words because English is very efficient in tokenization. " +
|
||||
"Adding even more content to make this paragraph long enough to test properly.";
|
||||
|
||||
List<DocumentChunk> cnChunks = service.chunkDocument("# CN\n\n" + chinese + "\n\n" + chinese, "/test/cn.md");
|
||||
List<DocumentChunk> enChunks = service.chunkDocument("# EN\n\n" + english + "\n\n" + english, "/test/en.md");
|
||||
|
||||
System.out.println("========================================");
|
||||
System.out.println(" Token 计数对比");
|
||||
System.out.println(" 配置: maxTokens=100, overlap=30");
|
||||
System.out.println("========================================");
|
||||
System.out.println(" 中文文档: " + (chinese.length() * 2) + "字符 → " + cnChunks.size() + "个分块");
|
||||
System.out.println(" 英文文档: " + (english.length() * 2) + "字符 → " + enChunks.size() + "个分块");
|
||||
|
||||
for (DocumentChunk c : cnChunks) {
|
||||
System.out.println(" 中文Chunk#" + c.getChunkIndex() + ": " + c.getContent().length() + "字符");
|
||||
}
|
||||
for (DocumentChunk c : enChunks) {
|
||||
System.out.println(" 英文Chunk#" + c.getChunkIndex() + ": " + c.getContent().length() + "字符");
|
||||
}
|
||||
System.out.println(" ★ 现在中文和英文的分块数更接近(基于 token 而非字符)");
|
||||
System.out.println("========================================");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
@@ -39,16 +38,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.Frontmatter;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* FrontmatterParser 单元测试
|
||||
*/
|
||||
class FrontmatterParserTest {
|
||||
|
||||
private FrontmatterParser parser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
parser = new FrontmatterParser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_withValidFrontmatter() {
|
||||
String content = "---\ntitle: Test\n---\nContent";
|
||||
assertTrue(parser.hasFrontmatter(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_withoutFrontmatter() {
|
||||
String content = "# Just a title\nContent";
|
||||
assertFalse(parser.hasFrontmatter(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_nullContent() {
|
||||
assertFalse(parser.hasFrontmatter(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_emptyContent() {
|
||||
assertFalse(parser.hasFrontmatter(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_validFrontmatter() {
|
||||
String content = """
|
||||
---
|
||||
title: 支付网关错误码
|
||||
keywords: [ERR_TIMEOUT, 超时, 支付网关]
|
||||
summary: 记录了支付网关所有核心错误码
|
||||
category: api
|
||||
---
|
||||
|
||||
# 正文内容
|
||||
""";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("支付网关错误码", result.getTitle());
|
||||
assertEquals(3, result.getKeywords().size());
|
||||
assertTrue(result.getKeywords().contains("ERR_TIMEOUT"));
|
||||
assertEquals("记录了支付网关所有核心错误码", result.getSummary());
|
||||
assertEquals("api", result.getCategory());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_withoutFrontmatter() {
|
||||
String content = "# Just content\nNo frontmatter here";
|
||||
assertNull(parser.parse(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_missingRequiredFields() {
|
||||
String content = """
|
||||
---
|
||||
title: Only Title
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
// 缺少 keywords 和 summary,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_malformedYaml() {
|
||||
String content = """
|
||||
---
|
||||
title: Test
|
||||
keywords: [unclosed array
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
// YAML 格式错误,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_noClosingDelimiter() {
|
||||
String content = """
|
||||
---
|
||||
title: Test
|
||||
keywords: [test]
|
||||
summary: Test summary
|
||||
|
||||
Content without closing ---
|
||||
""";
|
||||
|
||||
// 缺少结束标记,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_windowsLineEndings() {
|
||||
String content = "---\r\ntitle: Test\r\nkeywords: [test]\r\nsummary: Summary\r\n---\r\nContent";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Test", result.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_withOptionalFields() {
|
||||
String content = """
|
||||
---
|
||||
title: Test Document
|
||||
keywords: [test, doc]
|
||||
summary: A test document
|
||||
version: 1.0.0
|
||||
author: Test Author
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Test Document", result.getTitle());
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 全链路验证:DeepSeek → BGE-M3 → Milvus
|
||||
*/
|
||||
@SpringBootTest
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
@DisplayName("DeepSeek → BGE-M3 → Milvus 全链路")
|
||||
class FullPipelineSmokeTest {
|
||||
|
||||
@Autowired
|
||||
private ChatModel chatModel;
|
||||
|
||||
@Autowired
|
||||
private EmbeddingModel embeddingModel;
|
||||
|
||||
@Autowired
|
||||
private VectorEmbeddingService vectorEmbeddingService;
|
||||
|
||||
@Autowired
|
||||
private VectorSearchService vectorSearchService;
|
||||
|
||||
@TestConfiguration
|
||||
static class MockToolConfig {
|
||||
@Bean
|
||||
public ToolCallbackProvider toolCallbackProvider() {
|
||||
return () -> new ToolCallback[0];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ① Chat: DeepSeek =====
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("Chat: DeepSeek 聊天验证")
|
||||
void chatDeepSeekWorks() {
|
||||
System.out.println("\n===== ① Chat: DeepSeek =====");
|
||||
System.out.println("ChatModel: " + chatModel.getClass().getSimpleName());
|
||||
System.out.println("ChatOptions: " + chatModel.toString());
|
||||
|
||||
// 直接调用 chat
|
||||
var response = chatModel.call(new Prompt("请用一句话介绍你自己"));
|
||||
String text = response.getResult().getOutput().getText();
|
||||
assertNotNull(text);
|
||||
assertFalse(text.isEmpty());
|
||||
System.out.println("Response: " + text.substring(0, Math.min(200, text.length())) + "...");
|
||||
System.out.println("Chat ✓");
|
||||
}
|
||||
|
||||
// ===== ② Embedding: BGE-M3 via SiliconFlow =====
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("Embedding: BGE-M3 向量生成验证")
|
||||
void embeddingBgeM3Works() {
|
||||
System.out.println("\n===== ② Embedding: BGE-M3 (SiliconFlow) =====");
|
||||
System.out.println("EmbeddingModel: " + embeddingModel.getClass().getSimpleName());
|
||||
|
||||
String text = "你好,这是一条测试文本";
|
||||
List<Float> vector = vectorEmbeddingService.generateEmbedding(text);
|
||||
|
||||
assertNotNull(vector);
|
||||
assertFalse(vector.isEmpty());
|
||||
assertEquals(1024, vector.size(), "BGE-M3 应返回 1024 维向量");
|
||||
|
||||
// 非零校验
|
||||
boolean hasNonZero = vector.stream().anyMatch(v -> Math.abs(v) > 1e-6);
|
||||
assertTrue(hasNonZero, "向量不能全为零");
|
||||
|
||||
// L2 范数校验:BGE-M3 输出应为 L2 归一化的单位向量
|
||||
double norm = Math.sqrt(vector.stream().mapToDouble(v -> (double) v * v).sum());
|
||||
|
||||
System.out.println("维度: " + vector.size());
|
||||
System.out.println("前5维: " + vector.subList(0, Math.min(5, vector.size())));
|
||||
System.out.println("L2 范数: " + String.format("%.10f", norm));
|
||||
System.out.println("是否归一化 (|norm - 1.0| < 0.01): " + (Math.abs(norm - 1.0) < 0.01));
|
||||
|
||||
assertEquals(1.0, norm, 0.01, "BGE-M3 向量应为 L2 归一化单位向量,实际范数=" + norm);
|
||||
System.out.println("Embedding ✓");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("Embedding: BGE-M3 批量向量生成验证")
|
||||
void embeddingBatchWorks() {
|
||||
System.out.println("\n===== ③ Embedding 批量 =====");
|
||||
List<String> texts = List.of("文本一", "文本二", "文本三");
|
||||
List<List<Float>> results = vectorEmbeddingService.generateEmbeddings(texts);
|
||||
|
||||
assertEquals(3, results.size());
|
||||
for (List<Float> r : results) {
|
||||
assertEquals(1024, r.size());
|
||||
}
|
||||
System.out.println("批量生成: " + results.size() + " 个 向量,各 " + results.get(0).size() + " 维 ✓");
|
||||
}
|
||||
|
||||
// ===== ③ Milvus: 向量搜索 =====
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
@DisplayName("Milvus: 连接 + 搜索验证")
|
||||
void milvusSearchWorks() {
|
||||
System.out.println("\n===== ④ Milvus: 向量搜索 =====");
|
||||
|
||||
// 用 BGE-M3 生成查询向量
|
||||
String query = "内部文档";
|
||||
List<Float> queryVector = vectorEmbeddingService.generateQueryVector(query);
|
||||
assertNotNull(queryVector);
|
||||
assertEquals(1024, queryVector.size());
|
||||
|
||||
// 搜索
|
||||
List<VectorSearchService.SearchResult> results =
|
||||
vectorSearchService.searchSimilarDocuments(query, 3);
|
||||
|
||||
assertNotNull(results);
|
||||
System.out.println("查询: " + query);
|
||||
System.out.println("返回: " + results.size() + " 条");
|
||||
|
||||
if (!results.isEmpty()) {
|
||||
// 至少有结果,验证结构
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
var r = results.get(i);
|
||||
assertNotNull(r.getId());
|
||||
assertNotNull(r.getContent());
|
||||
System.out.println(" [" + (i + 1) + "] id=" + r.getId()
|
||||
+ ", score=" + String.format("%.4f", r.getScore())
|
||||
+ ", content=" + r.getContent().substring(0, Math.min(50, r.getContent().length())) + "...");
|
||||
}
|
||||
} else {
|
||||
System.out.println("(Milvus 中暂无数据,但连接正常)");
|
||||
}
|
||||
|
||||
System.out.println("Milvus ✓");
|
||||
}
|
||||
|
||||
// ===== 汇总 =====
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
@DisplayName("总结")
|
||||
void summary() {
|
||||
System.out.println("\n==========================================");
|
||||
System.out.println("全链路验证完成:");
|
||||
System.out.println(" ① Chat → DeepSeek ✓");
|
||||
System.out.println(" ② Embedding → BGE-M3 ✓ (SiliconFlow, 1024维)");
|
||||
System.out.println(" ③ 向量存储 → Milvus ✓ (Zilliz Cloud)");
|
||||
System.out.println("==========================================");
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* KnowledgeIndexService 单元测试
|
||||
*/
|
||||
class KnowledgeIndexServiceTest {
|
||||
|
||||
private KnowledgeIndexService service;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new KnowledgeIndexService();
|
||||
ReflectionTestUtils.setField(service, "knowledgeBasePath", tempDir.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_singleMatch() {
|
||||
// 准备测试数据
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.title("Test")
|
||||
.keywords(List.of("ERR_TIMEOUT", "超时"))
|
||||
.summary("Test summary")
|
||||
.category("api")
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 测试匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("ERR_TIMEOUT");
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("Test", results.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_caseInsensitive() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("ERR_TIMEOUT"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 小写查询应该匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("err_timeout");
|
||||
assertEquals(1, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_partialMatch() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("支付网关"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 包含关键词的查询应该匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("支付网关超时问题");
|
||||
assertEquals(1, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_multipleMatches() {
|
||||
KnowledgeEntry entry1 = KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.title("Doc 1")
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
|
||||
KnowledgeEntry entry2 = KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.title("Doc 2")
|
||||
.keywords(List.of("超时", "错误"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry1);
|
||||
service.addToIndex(entry2);
|
||||
|
||||
// 应该匹配两个文档
|
||||
List<KnowledgeEntry> results = service.exactMatch("超时");
|
||||
assertEquals(2, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAnalyzeQuery_returnsStructuredHint() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("mysql.md")
|
||||
.title("MySQL Doc")
|
||||
.keywords(List.of("mysql", "connection pool"))
|
||||
.category("database")
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("mysql connection pool timeout");
|
||||
|
||||
assertEquals(1, hint.matches().size());
|
||||
assertEquals(List.of("mysql", "connection pool"), hint.matchedKeywords());
|
||||
assertEquals(List.of("database"), hint.domains());
|
||||
assertEquals(List.of("mysql", "connection pool"), hint.entities());
|
||||
assertEquals(List.of("MySQL Doc"), hint.titles());
|
||||
assertEquals("database", hint.singleDomainOrNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAnalyzeQuery_multipleDomainsHasNoSingleDomain() {
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("mysql.md")
|
||||
.keywords(List.of("timeout"))
|
||||
.category("database")
|
||||
.build());
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("api.md")
|
||||
.keywords(List.of("timeout"))
|
||||
.category("api")
|
||||
.build());
|
||||
|
||||
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("timeout");
|
||||
|
||||
assertEquals(2, hint.matches().size());
|
||||
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()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("错误码"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 不匹配的查询
|
||||
List<KnowledgeEntry> results = service.exactMatch("限流");
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_emptyQuery() {
|
||||
List<KnowledgeEntry> results = service.exactMatch("");
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_nullQuery() {
|
||||
List<KnowledgeEntry> results = service.exactMatch(null);
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_success() throws Exception {
|
||||
// 创建测试文件
|
||||
Path testFile = tempDir.resolve("test.md");
|
||||
String content = "Test content line 1\nTest content line 2\n";
|
||||
Files.writeString(testFile, content);
|
||||
|
||||
// 读取文件
|
||||
String result = service.readDocument(testFile.toString(), 100);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("Test content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_relativePathUnderBasePath() throws Exception {
|
||||
Path categoryDir = tempDir.resolve("payment");
|
||||
Files.createDirectories(categoryDir);
|
||||
Path testFile = categoryDir.resolve("relative.md");
|
||||
Files.writeString(testFile, "Relative content");
|
||||
|
||||
String result = service.readDocument("payment/relative.md", 100);
|
||||
|
||||
assertEquals("Relative content", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_legacyPathAlreadyContainsBasePath() throws Exception {
|
||||
Path categoryDir = tempDir.resolve("payment");
|
||||
Files.createDirectories(categoryDir);
|
||||
Path testFile = categoryDir.resolve("legacy.md");
|
||||
Files.writeString(testFile, "Legacy content");
|
||||
|
||||
String result = service.readDocument(tempDir.getFileName() + "/payment/legacy.md", 100);
|
||||
|
||||
assertEquals("Legacy content", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_exceedsMaxChars() throws Exception {
|
||||
// 创建超长内容
|
||||
String longContent = "x".repeat(3000);
|
||||
Path testFile = tempDir.resolve("long.md");
|
||||
Files.writeString(testFile, longContent);
|
||||
|
||||
// 读取限制字符数
|
||||
String result = service.readDocument(testFile.toString(), 2000);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2003, result.length()); // 2000 + "..."
|
||||
assertTrue(result.endsWith("..."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_fileNotFound() {
|
||||
String result = service.readDocument("nonexistent.md", 100);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddToIndex() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("new.md")
|
||||
.title("New Document")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
List<KnowledgeEntry> results = service.exactMatch("test");
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("New Document", results.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveFromIndex() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("remove.md")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
assertEquals(1, service.exactMatch("test").size());
|
||||
|
||||
service.removeFromIndex("remove.md");
|
||||
assertEquals(0, service.exactMatch("test").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetIndexSize() {
|
||||
assertEquals(0, service.getIndexSize());
|
||||
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.keywords(List.of("test"))
|
||||
.build());
|
||||
|
||||
assertEquals(1, service.getIndexSize());
|
||||
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.keywords(List.of("test"))
|
||||
.build());
|
||||
|
||||
assertEquals(2, service.getIndexSize());
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import io.milvus.client.MilvusServiceClient;
|
||||
import io.milvus.grpc.DataType;
|
||||
import io.milvus.grpc.FlushResponse;
|
||||
import io.milvus.grpc.MutationResult;
|
||||
import io.milvus.grpc.SearchResults;
|
||||
import io.milvus.grpc.ShowCollectionsResponse;
|
||||
import io.milvus.common.clientenum.ConsistencyLevelEnum;
|
||||
import io.milvus.param.ConnectParam;
|
||||
import io.milvus.param.IndexType;
|
||||
import io.milvus.param.MetricType;
|
||||
import io.milvus.param.R;
|
||||
import io.milvus.param.RpcStatus;
|
||||
import io.milvus.param.collection.*;
|
||||
import io.milvus.param.dml.InsertParam;
|
||||
import io.milvus.param.dml.SearchParam;
|
||||
import io.milvus.param.index.CreateIndexParam;
|
||||
import io.milvus.response.SearchResultsWrapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("Milvus 连接验证")
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class MilvusConnectionTest {
|
||||
|
||||
private static final String COLLECTION = "conn_test";
|
||||
private static final int DIM = 128;
|
||||
|
||||
private static MilvusServiceClient client;
|
||||
|
||||
@BeforeAll
|
||||
static void connect() {
|
||||
String host = envOrDefault("MILVUS_HOST",
|
||||
"in03-4a578da0f27ce9d.serverless.aws-eu-central-1.cloud.zilliz.com");
|
||||
int port = Integer.parseInt(envOrDefault("MILVUS_PORT", "443"));
|
||||
String token = System.getenv("MILVUS_TOKEN");
|
||||
|
||||
assertNotNull(token, "环境变量 MILVUS_TOKEN 未设置");
|
||||
|
||||
ConnectParam connectParam = ConnectParam.newBuilder()
|
||||
.withHost(host)
|
||||
.withPort(port)
|
||||
.withToken(token)
|
||||
.withSecure(true)
|
||||
.withDatabaseName("db_4a578da0f27ce9d")
|
||||
.withConnectTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
client = new MilvusServiceClient(connectParam);
|
||||
System.out.println("连接目标: " + host + ":" + port);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void disconnect() {
|
||||
if (client != null) {
|
||||
try {
|
||||
client.dropCollection(DropCollectionParam.newBuilder()
|
||||
.withCollectionName(COLLECTION).build());
|
||||
} catch (Exception ignored) {}
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeMsg(R<?> resp) {
|
||||
try {
|
||||
return resp.getMessage();
|
||||
} catch (Exception e) {
|
||||
return "(no message)";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("1. 连接成功 - 能列出 collection")
|
||||
void listCollections() {
|
||||
R<ShowCollectionsResponse> resp = client.showCollections(
|
||||
ShowCollectionsParam.newBuilder().build());
|
||||
|
||||
System.out.println("listCollections status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
|
||||
assertEquals(0, resp.getStatus(), "连接失败,status=" + resp.getStatus());
|
||||
|
||||
List<String> names = resp.getData().getCollectionNamesList();
|
||||
System.out.println("现有 collections: " + names);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("2. 创建测试 collection")
|
||||
void createCollection() {
|
||||
client.dropCollection(DropCollectionParam.newBuilder()
|
||||
.withCollectionName(COLLECTION).build());
|
||||
|
||||
FieldType idField = FieldType.newBuilder()
|
||||
.withName("id")
|
||||
.withDataType(DataType.Int64)
|
||||
.withPrimaryKey(true)
|
||||
.withAutoID(true)
|
||||
.build();
|
||||
|
||||
FieldType vectorField = FieldType.newBuilder()
|
||||
.withName("vector")
|
||||
.withDataType(DataType.FloatVector)
|
||||
.withDimension(DIM)
|
||||
.build();
|
||||
|
||||
CollectionSchemaParam schema = CollectionSchemaParam.newBuilder()
|
||||
.addFieldType(idField)
|
||||
.addFieldType(vectorField)
|
||||
.build();
|
||||
|
||||
R<RpcStatus> resp = client.createCollection(
|
||||
CreateCollectionParam.newBuilder()
|
||||
.withCollectionName(COLLECTION)
|
||||
.withSchema(schema)
|
||||
.build());
|
||||
|
||||
System.out.println("createCollection status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
|
||||
assertEquals(0, resp.getStatus(), "创建 collection 失败");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("3. 插入数据 + flush")
|
||||
void insertAndFlush() {
|
||||
List<Float> vec1 = makeVector(1.0f);
|
||||
List<Float> vec2 = makeVector(2.0f);
|
||||
List<Float> vec3 = makeVector(3.0f);
|
||||
|
||||
List<InsertParam.Field> fields = Collections.singletonList(
|
||||
new InsertParam.Field("vector", Arrays.asList(vec1, vec2, vec3))
|
||||
);
|
||||
|
||||
R<MutationResult> insertResp = client.insert(
|
||||
InsertParam.newBuilder()
|
||||
.withCollectionName(COLLECTION)
|
||||
.withFields(fields)
|
||||
.build());
|
||||
|
||||
System.out.println("insert status: " + insertResp.getStatus() + ", msg: " + safeMsg(insertResp));
|
||||
assertEquals(0, insertResp.getStatus(), "插入失败");
|
||||
|
||||
// 官方示例要求:insert 后必须 flush,数据才对搜索可见
|
||||
R<FlushResponse> flushResp = client.flush(FlushParam.newBuilder()
|
||||
.withCollectionNames(Collections.singletonList(COLLECTION))
|
||||
.withSyncFlush(true)
|
||||
.withSyncFlushWaitingTimeout(30L)
|
||||
.build());
|
||||
|
||||
System.out.println("flush status: " + flushResp.getStatus() + ", msg: " + safeMsg(flushResp));
|
||||
assertEquals(0, flushResp.getStatus(), "flush 失败");
|
||||
System.out.println("插入 3 条数据并 flush 完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
@DisplayName("4. 创建索引 + 加载")
|
||||
void createIndexAndLoad() {
|
||||
R<RpcStatus> indexResp = client.createIndex(
|
||||
CreateIndexParam.newBuilder()
|
||||
.withCollectionName(COLLECTION)
|
||||
.withFieldName("vector")
|
||||
.withIndexType(IndexType.AUTOINDEX)
|
||||
.withMetricType(MetricType.L2)
|
||||
.build());
|
||||
|
||||
System.out.println("createIndex status: " + indexResp.getStatus() + ", msg: " + safeMsg(indexResp));
|
||||
assertEquals(0, indexResp.getStatus(), "创建索引失败");
|
||||
|
||||
R<RpcStatus> loadResp = client.loadCollection(
|
||||
LoadCollectionParam.newBuilder()
|
||||
.withCollectionName(COLLECTION)
|
||||
.withSyncLoad(true)
|
||||
.withSyncLoadWaitingTimeout(30L)
|
||||
.build());
|
||||
|
||||
System.out.println("load status: " + loadResp.getStatus() + ", msg: " + safeMsg(loadResp));
|
||||
assertEquals(0, loadResp.getStatus(), "加载失败");
|
||||
System.out.println("索引创建 + 加载完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
@DisplayName("5. 向量搜索")
|
||||
void search() throws InterruptedException {
|
||||
Thread.sleep(3000);
|
||||
|
||||
List<Float> queryVec = makeVector(1.1f);
|
||||
|
||||
R<SearchResults> resp = null;
|
||||
for (int retry = 0; retry < 10; retry++) {
|
||||
resp = client.search(
|
||||
SearchParam.newBuilder()
|
||||
.withCollectionName(COLLECTION)
|
||||
.withMetricType(MetricType.L2)
|
||||
.withTopK(2)
|
||||
.withVectors(Collections.singletonList(queryVec))
|
||||
.withVectorFieldName("vector")
|
||||
.withParams("{}")
|
||||
.withConsistencyLevel(ConsistencyLevelEnum.STRONG)
|
||||
.build());
|
||||
|
||||
if (resp.getStatus() == 0) break;
|
||||
System.out.println("search retry " + (retry + 1) + ": status=" + resp.getStatus() + ", msg=" + safeMsg(resp));
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
|
||||
System.out.println("search status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
|
||||
assertEquals(0, resp.getStatus(), "搜索失败");
|
||||
|
||||
SearchResultsWrapper wrapper = new SearchResultsWrapper(resp.getData().getResults());
|
||||
List<SearchResultsWrapper.IDScore> scores = wrapper.getIDScore(0);
|
||||
|
||||
assertFalse(scores.isEmpty(), "搜索结果不应为空");
|
||||
System.out.println("搜索结果 (top " + scores.size() + "):");
|
||||
for (SearchResultsWrapper.IDScore idScore : scores) {
|
||||
System.out.println(" score=" + idScore.getScore() + ", id=" + idScore.getLongID());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Float> makeVector(float val) {
|
||||
Float[] arr = new Float[DIM];
|
||||
Arrays.fill(arr, val);
|
||||
return Arrays.asList(arr);
|
||||
}
|
||||
|
||||
private static String envOrDefault(String key, String defaultVal) {
|
||||
String val = System.getenv(key);
|
||||
return (val != null && !val.isEmpty()) ? val : defaultVal;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
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.SidecarRetrievalResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RagRetrievalSidecarComparisonServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void compareWritesSeparateSidecarReports() throws Exception {
|
||||
VectorSearchService vectorSearchService = mock(VectorSearchService.class);
|
||||
SpringAiVectorStoreSidecarService sidecarService = mock(SpringAiVectorStoreSidecarService.class);
|
||||
RagSidecarProperties properties = new RagSidecarProperties();
|
||||
RetrievalResultNormalizer normalizer = new RetrievalResultNormalizer(new ObjectMapper());
|
||||
RagRetrievalSidecarComparisonService comparisonService = new RagRetrievalSidecarComparisonService(
|
||||
vectorSearchService,
|
||||
sidecarService,
|
||||
normalizer,
|
||||
properties,
|
||||
new ObjectMapper()
|
||||
);
|
||||
|
||||
VectorSearchService.SearchResult current = new VectorSearchService.SearchResult();
|
||||
current.setId("current-1");
|
||||
current.setMetadata("{\"_source\":\"current.md\",\"breadcrumb\":\"A\",\"category\":\"api\"}");
|
||||
current.setContent("current content");
|
||||
current.setScore(0.1f);
|
||||
when(vectorSearchService.searchSimilarDocuments("timeout", 3, "api"))
|
||||
.thenReturn(List.of(current));
|
||||
when(sidecarService.search("timeout", 3, "api"))
|
||||
.thenReturn(SidecarRetrievalResponse.builder()
|
||||
.enabled(true)
|
||||
.available(true)
|
||||
.status("available")
|
||||
.results(List.of(ComparableRetrievalResult.builder()
|
||||
.path("sidecar")
|
||||
.rank(1)
|
||||
.source("sidecar.md")
|
||||
.breadcrumb("B")
|
||||
.scoreLabel("similarity")
|
||||
.scoreValue(0.9)
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
RetrievalComparisonReport report = comparisonService.compare(List.of(
|
||||
RetrievalComparisonCase.builder()
|
||||
.caseId("case-1")
|
||||
.scenario("aiops")
|
||||
.query("timeout")
|
||||
.category("api")
|
||||
.build()
|
||||
), 3);
|
||||
|
||||
assertEquals(1, report.getCaseCount());
|
||||
assertEquals("available", report.getSidecarStatus());
|
||||
assertTrue(report.getResults().get(0).getDifferences().contains("top_source_differs"));
|
||||
Path json = tempDir.resolve("sidecar.json");
|
||||
Path markdown = tempDir.resolve("sidecar.md");
|
||||
comparisonService.writeReports(report, json, markdown);
|
||||
|
||||
assertTrue(Files.readString(json).contains("\"sidecarStatus\""));
|
||||
assertTrue(Files.readString(markdown).contains("RAG Sidecar Retrieval Comparison"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compareGoldenCasesLoadsExistingCaseShape() throws Exception {
|
||||
VectorSearchService vectorSearchService = mock(VectorSearchService.class);
|
||||
SpringAiVectorStoreSidecarService sidecarService = mock(SpringAiVectorStoreSidecarService.class);
|
||||
RagRetrievalSidecarComparisonService comparisonService = new RagRetrievalSidecarComparisonService(
|
||||
vectorSearchService,
|
||||
sidecarService,
|
||||
new RetrievalResultNormalizer(new ObjectMapper()),
|
||||
new RagSidecarProperties(),
|
||||
new ObjectMapper()
|
||||
);
|
||||
when(vectorSearchService.searchSimilarDocuments("query", 2, null)).thenReturn(List.of());
|
||||
when(sidecarService.search("query", 2, null))
|
||||
.thenReturn(SidecarRetrievalResponse.builder()
|
||||
.enabled(false)
|
||||
.available(false)
|
||||
.status("disabled")
|
||||
.results(List.of())
|
||||
.build());
|
||||
Path cases = tempDir.resolve("cases.json");
|
||||
Files.writeString(cases, """
|
||||
{
|
||||
"topK": 2,
|
||||
"cases": [
|
||||
{"caseId": "case-1", "scenario": "chat", "query": "query"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
RetrievalComparisonReport report = comparisonService.compareGoldenCases(cases);
|
||||
|
||||
assertEquals(1, report.getCaseCount());
|
||||
assertEquals(2, report.getTopK());
|
||||
assertEquals("disabled", report.getSidecarStatus());
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.dto.ComparableRetrievalResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.document.Document;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class RetrievalResultNormalizerTest {
|
||||
|
||||
private final RetrievalResultNormalizer normalizer = new RetrievalResultNormalizer(new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void fromCurrentParsesMetadataAndLabelsDistanceScore() {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId("vec-1");
|
||||
result.setMetadata("{\"docId\":\"doc-1\",\"_source\":\"docs/api.md\",\"title\":\"API\",\"breadcrumb\":\"A > B\",\"category\":\"api\"}");
|
||||
result.setContent("abcdef");
|
||||
result.setScore(0.25f);
|
||||
|
||||
ComparableRetrievalResult comparable = normalizer.fromCurrent(result, 1, 3);
|
||||
|
||||
assertEquals("current", comparable.getPath());
|
||||
assertEquals("docs/api.md", comparable.getSource());
|
||||
assertEquals("doc-1", comparable.getDocId());
|
||||
assertEquals("API", comparable.getTitle());
|
||||
assertEquals("A > B", comparable.getBreadcrumb());
|
||||
assertEquals("api", comparable.getCategory());
|
||||
assertEquals("abc...", comparable.getContentPreview());
|
||||
assertEquals("l2_distance", comparable.getScoreLabel());
|
||||
assertEquals(0.25, comparable.getScoreValue(), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromSidecarNormalizesDocumentMetadataAndLabelsSimilarityScore() {
|
||||
Document document = Document.builder()
|
||||
.id("doc-vector")
|
||||
.text("sidecar content")
|
||||
.metadata(Map.of(
|
||||
"docId", "doc-2",
|
||||
"_source", "docs/sidecar.md",
|
||||
"title", "Sidecar",
|
||||
"breadcrumb", "Root > Sidecar",
|
||||
"category", "rag"
|
||||
))
|
||||
.score(0.91)
|
||||
.build();
|
||||
|
||||
ComparableRetrievalResult comparable = normalizer.fromSidecar(document, 2, 100);
|
||||
|
||||
assertEquals("sidecar", comparable.getPath());
|
||||
assertEquals(2, comparable.getRank());
|
||||
assertEquals("docs/sidecar.md", comparable.getSource());
|
||||
assertEquals("similarity", comparable.getScoreLabel());
|
||||
assertEquals(0.91, comparable.getScoreValue(), 0.0001);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import io.milvus.client.MilvusServiceClient;
|
||||
import io.milvus.param.ConnectParam;
|
||||
import io.milvus.param.R;
|
||||
import io.milvus.param.collection.HasCollectionParam;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 简单的 Milvus 连接测试
|
||||
*/
|
||||
public class SimpleMilvusTest {
|
||||
|
||||
@Test
|
||||
public void testConnection() {
|
||||
String host = System.getenv().getOrDefault(
|
||||
"MILVUS_HOST",
|
||||
"in03-4a578da0f27ce9d.serverless.aws-eu-central-1.cloud.zilliz.com");
|
||||
int port = Integer.parseInt(System.getenv().getOrDefault("MILVUS_PORT", "443"));
|
||||
String token = System.getenv("MILVUS_TOKEN");
|
||||
Assumptions.assumeTrue(token != null && !token.isBlank(), "MILVUS_TOKEN is required");
|
||||
|
||||
System.out.println("尝试连接 Milvus...");
|
||||
System.out.println("Host: " + host);
|
||||
System.out.println("Port: " + port);
|
||||
|
||||
try {
|
||||
MilvusServiceClient client = new MilvusServiceClient(
|
||||
ConnectParam.newBuilder()
|
||||
.withHost(host)
|
||||
.withPort(port)
|
||||
.withToken(token)
|
||||
.withSecure(true)
|
||||
.withConnectTimeout(10L, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
);
|
||||
|
||||
System.out.println("✓ 客户端创建成功");
|
||||
|
||||
// 测试连接:查询是否存在某个 collection
|
||||
R<Boolean> response = client.hasCollection(
|
||||
HasCollectionParam.newBuilder()
|
||||
.withCollectionName("test_collection")
|
||||
.build()
|
||||
);
|
||||
|
||||
System.out.println("✓ 连接成功!");
|
||||
System.out.println("Status Code: " + response.getStatus());
|
||||
|
||||
if (response.getStatus() == 0 || response.getStatus() == io.milvus.param.R.Status.Success.getCode()) {
|
||||
System.out.println("✓ Milvus 集群状态:正常运行");
|
||||
} else {
|
||||
System.out.println("✗ 响应状态异常: " + response.getStatus());
|
||||
}
|
||||
|
||||
client.close();
|
||||
System.out.println("✓ 连接已关闭");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("✗ 连接失败:" + e.getMessage());
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException("Milvus 连接失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.config.RagSidecarProperties;
|
||||
import com.superbiz.agent.dto.SidecarRetrievalResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SpringAiVectorStoreSidecarServiceTest {
|
||||
|
||||
@Test
|
||||
void disabledSidecarDoesNotRequestVectorStore() {
|
||||
RagSidecarProperties properties = new RagSidecarProperties();
|
||||
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
|
||||
SpringAiVectorStoreSidecarService service = new SpringAiVectorStoreSidecarService(
|
||||
properties,
|
||||
provider,
|
||||
new RetrievalResultNormalizer(new ObjectMapper())
|
||||
);
|
||||
|
||||
SidecarRetrievalResponse response = service.search("query", 3, null);
|
||||
|
||||
assertFalse(response.isEnabled());
|
||||
assertFalse(response.isAvailable());
|
||||
assertEquals("disabled", response.getStatus());
|
||||
verify(provider, never()).getIfAvailable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledSidecarReportsMissingVectorStore() {
|
||||
RagSidecarProperties properties = new RagSidecarProperties();
|
||||
properties.setEnabled(true);
|
||||
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
|
||||
when(provider.getIfAvailable()).thenReturn(null);
|
||||
SpringAiVectorStoreSidecarService service = new SpringAiVectorStoreSidecarService(
|
||||
properties,
|
||||
provider,
|
||||
new RetrievalResultNormalizer(new ObjectMapper())
|
||||
);
|
||||
|
||||
SidecarRetrievalResponse response = service.search("query", 3, "api");
|
||||
|
||||
assertEquals("missing_vector_store", response.getStatus());
|
||||
assertFalse(response.isAvailable());
|
||||
assertEquals(0, response.getResults().size());
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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 {
|
||||
|
||||
@Test
|
||||
void buildEmbeddingTextIncludesTitleAndBreadcrumb() {
|
||||
DocumentChunk chunk = DocumentChunk.builder()
|
||||
.title("Connection Pool")
|
||||
.breadcrumb("Database > MySQL > Connection Pool")
|
||||
.content("Check active connections and leak detection.")
|
||||
.build();
|
||||
|
||||
String embeddingText = VectorIndexService.buildEmbeddingText(chunk);
|
||||
|
||||
assertEquals("""
|
||||
Title: Connection Pool
|
||||
Path: Database > MySQL > Connection Pool
|
||||
Content:
|
||||
Check active connections and leak detection.""", embeddingText);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildEmbeddingTextKeepsPlainContentWhenNoStructureExists() {
|
||||
DocumentChunk chunk = DocumentChunk.builder()
|
||||
.title(" ")
|
||||
.breadcrumb(null)
|
||||
.content("Plain chunk content.")
|
||||
.build();
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class VectorSearchServiceTest {
|
||||
|
||||
@Test
|
||||
void denseModeCallsHybridStoreDenseSearch() {
|
||||
MilvusHybridKnowledgeStore store = mock(MilvusHybridKnowledgeStore.class);
|
||||
VectorEmbeddingService embeddingService = mock(VectorEmbeddingService.class);
|
||||
when(embeddingService.generateQueryVector("query")).thenReturn(List.of(0.1f, 0.2f));
|
||||
VectorSearchService.SearchResult expected = result("doc-1", 0.2f);
|
||||
when(store.searchDense(eq("query"), eq(List.of(0.1f, 0.2f)), eq(3), isNull()))
|
||||
.thenReturn(List.of(expected));
|
||||
when(store.collectionName()).thenReturn("biz");
|
||||
|
||||
VectorSearchService service = new VectorSearchService();
|
||||
ReflectionTestUtils.setField(service, "knowledgeStore", store);
|
||||
ReflectionTestUtils.setField(service, "embeddingService", embeddingService);
|
||||
ReflectionTestUtils.setField(service, "searchMode", "dense");
|
||||
|
||||
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
|
||||
|
||||
assertEquals(List.of(expected), results);
|
||||
verify(store).searchDense(eq("query"), eq(List.of(0.1f, 0.2f)), eq(3), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hybridModeCallsHybridStoreHybridSearch() {
|
||||
MilvusHybridKnowledgeStore store = mock(MilvusHybridKnowledgeStore.class);
|
||||
VectorEmbeddingService embeddingService = mock(VectorEmbeddingService.class);
|
||||
when(embeddingService.generateQueryVector("pool")).thenReturn(List.of(0.3f));
|
||||
VectorSearchService.SearchResult expected = result("doc-h", 0.4f);
|
||||
when(store.searchHybrid(eq("pool"), eq(List.of(0.3f)), eq(5), eq("mysql")))
|
||||
.thenReturn(List.of(expected));
|
||||
when(store.collectionName()).thenReturn("biz");
|
||||
|
||||
VectorSearchService service = new VectorSearchService();
|
||||
ReflectionTestUtils.setField(service, "knowledgeStore", store);
|
||||
ReflectionTestUtils.setField(service, "embeddingService", embeddingService);
|
||||
ReflectionTestUtils.setField(service, "searchMode", "hybrid");
|
||||
|
||||
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("pool", 5, "mysql");
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("doc-h", results.get(0).getId());
|
||||
verify(store).searchHybrid(eq("pool"), eq(List.of(0.3f)), eq(5), eq("mysql"));
|
||||
}
|
||||
|
||||
private static VectorSearchService.SearchResult result(String id, float score) {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId(id);
|
||||
result.setScore(score);
|
||||
result.setRawScore((double) score);
|
||||
result.setScoreLabel("dense");
|
||||
result.setContent("content");
|
||||
result.setMetadata("{}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import com.superbiz.agent.client.PyRagClient;
|
||||
import com.superbiz.agent.client.PyRagClient.PyRagSearchHit;
|
||||
import com.superbiz.agent.client.PyRagClient.PyRagSearchRequest;
|
||||
import com.superbiz.agent.client.PyRagClient.PyRagSearchResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link PyRagKnowledgeSearchAdapter} 请求/响应映射契约测试。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PyRagKnowledgeSearchAdapterTest {
|
||||
|
||||
@Mock
|
||||
private PyRagClient pyRagClient;
|
||||
|
||||
@Test
|
||||
void mapsRequestModeTopKAndCategory() {
|
||||
PyRagKnowledgeSearchAdapter adapter = new PyRagKnowledgeSearchAdapter(pyRagClient);
|
||||
when(pyRagClient.search(any())).thenReturn(new PyRagSearchResponse(
|
||||
"q", "hybrid", List.of(), "PRECISE", "supported", null));
|
||||
|
||||
adapter.search(new KnowledgeSearchRequest("网关超时怎么排查", 20, "gateway", KnowledgeSearchMode.HYBRID));
|
||||
|
||||
ArgumentCaptor<PyRagSearchRequest> captor = ArgumentCaptor.forClass(PyRagSearchRequest.class);
|
||||
verify(pyRagClient).search(captor.capture());
|
||||
PyRagSearchRequest request = captor.getValue();
|
||||
assertEquals("网关超时怎么排查", request.query());
|
||||
assertEquals("hybrid", request.mode());
|
||||
assertEquals(20, request.retrieveK());
|
||||
assertEquals(20, request.returnN());
|
||||
assertEquals(20, request.maxChunksPerDocument());
|
||||
assertEquals("gateway", request.category());
|
||||
assertNull(request.kbScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
void denseModeMapsToSemanticWithoutCategory() {
|
||||
PyRagKnowledgeSearchAdapter adapter = new PyRagKnowledgeSearchAdapter(pyRagClient);
|
||||
when(pyRagClient.search(any())).thenReturn(new PyRagSearchResponse(
|
||||
"q", "semantic", List.of(), null, "supported", null));
|
||||
|
||||
adapter.search(KnowledgeSearchRequest.dense("性能优化", 5, null));
|
||||
|
||||
ArgumentCaptor<PyRagSearchRequest> captor = ArgumentCaptor.forClass(PyRagSearchRequest.class);
|
||||
verify(pyRagClient).search(captor.capture());
|
||||
assertEquals("semantic", captor.getValue().mode());
|
||||
assertNull(captor.getValue().category());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsHitsWithRerankScoreAndEvidenceIdentity() {
|
||||
PyRagKnowledgeSearchAdapter adapter = new PyRagKnowledgeSearchAdapter(pyRagClient);
|
||||
when(pyRagClient.search(any())).thenReturn(new PyRagSearchResponse(
|
||||
"网关超时怎么排查",
|
||||
"hybrid",
|
||||
List.of(new PyRagSearchHit(
|
||||
"e2e-gateway-b9c1fa12-md-34223174#chunk-1",
|
||||
"e2e-gateway-b9c1fa12-md-34223174",
|
||||
"e2e-gateway-b9c1fa12-md",
|
||||
"网关超时排查",
|
||||
"网关超时排查 > 处理步骤",
|
||||
"网关超时先检查 upstream 配置…",
|
||||
0.9147,
|
||||
"PRECISE")),
|
||||
"PRECISE",
|
||||
"supported",
|
||||
null));
|
||||
|
||||
List<KnowledgeSearchHit> hits = adapter.search(
|
||||
new KnowledgeSearchRequest("网关超时怎么排查", 5, "gateway", KnowledgeSearchMode.HYBRID));
|
||||
|
||||
assertEquals(1, hits.size());
|
||||
KnowledgeSearchHit hit = hits.get(0);
|
||||
assertEquals("e2e-gateway-b9c1fa12-md-34223174#chunk-1", hit.evidenceKey());
|
||||
assertEquals("e2e-gateway-b9c1fa12-md-34223174", hit.docId());
|
||||
assertEquals(1, hit.chunkIndex());
|
||||
assertEquals("网关超时先检查 upstream 配置…", hit.content());
|
||||
assertEquals(0.9147, hit.score(), 1e-9);
|
||||
assertEquals(0.9147, hit.rawScore(), 1e-9);
|
||||
assertEquals(RetrievalScoreLabels.RERANK, hit.scoreLabel());
|
||||
assertEquals("e2e-gateway-b9c1fa12-md", hit.source());
|
||||
assertEquals("网关超时排查", hit.title());
|
||||
assertEquals("网关超时排查 > 处理步骤", hit.breadcrumb());
|
||||
assertEquals(1, hit.originalRank());
|
||||
assertNull(hit.denseDistance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noEvidenceReturnsEmptyListAsNormalBusinessResult() {
|
||||
PyRagKnowledgeSearchAdapter adapter = new PyRagKnowledgeSearchAdapter(pyRagClient);
|
||||
when(pyRagClient.search(any())).thenReturn(new PyRagSearchResponse(
|
||||
"乱码查询", "hybrid", List.of(), null, "no_evidence", null));
|
||||
|
||||
List<KnowledgeSearchHit> hits = adapter.search(
|
||||
KnowledgeSearchRequest.dense("乱码查询", 5, null));
|
||||
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hitWithoutChunkMarkerStillGetsStableIdentity() {
|
||||
PyRagKnowledgeSearchAdapter adapter = new PyRagKnowledgeSearchAdapter(pyRagClient);
|
||||
when(pyRagClient.search(any())).thenReturn(new PyRagSearchResponse(
|
||||
"q",
|
||||
"hybrid",
|
||||
List.of(new PyRagSearchHit(
|
||||
"legacy-doc", "legacy-doc", "legacy-doc", "Legacy",
|
||||
null, "content", 0.42, "REFERENCE")),
|
||||
"REFERENCE",
|
||||
"supported",
|
||||
null));
|
||||
|
||||
List<KnowledgeSearchHit> hits = adapter.search(
|
||||
KnowledgeSearchRequest.dense("q", 3, null));
|
||||
|
||||
assertEquals(1, hits.size());
|
||||
// 无 #chunk-N 标记:chunkIndex 为 null,evidenceKey 保留服务端原值
|
||||
assertNull(hits.get(0).chunkIndex());
|
||||
assertEquals("legacy-doc", hits.get(0).evidenceKey());
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,25 @@ class RetrievalScoreNormalizerTest {
|
||||
assertTrue(hybridRank2 < hybridRank);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rerankScorePassesThroughClamped() {
|
||||
// py-rag rerank 绝对分:quality = score 原样(越大越好),不受 L2/rank 分支影响
|
||||
assertEquals(0.9147, RetrievalScoreNormalizer.toQualityScore(
|
||||
RetrievalScoreLabels.RERANK, 0.9147, 1, 20, 2.0, null), 1e-9);
|
||||
assertEquals(0.0, RetrievalScoreNormalizer.toQualityScore(
|
||||
RetrievalScoreLabels.RERANK, null, 1, 20, 2.0, null), 1e-9);
|
||||
assertEquals(1.0, RetrievalScoreNormalizer.toQualityScore(
|
||||
RetrievalScoreLabels.RERANK, 1.7, 1, 20, 2.0, null), 1e-9);
|
||||
assertEquals(0.0, RetrievalScoreNormalizer.toQualityScore(
|
||||
RetrievalScoreLabels.RERANK, -0.3, 1, 20, 2.0, null), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalizeAliases() {
|
||||
assertEquals(RetrievalScoreLabels.DENSE, RetrievalScoreLabels.canonicalize("l2_distance"));
|
||||
assertEquals(RetrievalScoreLabels.HYBRID, RetrievalScoreLabels.canonicalize("rrf_fused"));
|
||||
assertEquals(RetrievalScoreLabels.HYBRID, RetrievalScoreLabels.canonicalize("bm25_only_no_dense"));
|
||||
assertEquals(RetrievalScoreLabels.RERANK, RetrievalScoreLabels.canonicalize("rerank"));
|
||||
assertEquals(RetrievalScoreLabels.RERANK, RetrievalScoreLabels.canonicalize("quality_score"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RrfFusionTest {
|
||||
|
||||
@Test
|
||||
void multiPathAgreementOutranksSinglePathHead() {
|
||||
List<String> dense = List.of("a", "b", "c");
|
||||
List<String> lexical = List.of("c", "b", "d");
|
||||
|
||||
List<RrfFusion.Scored<String>> fused = RrfFusion.fuse(
|
||||
List.of(
|
||||
new RrfFusion.RankedPath<>("dense", dense, 1.0),
|
||||
new RrfFusion.RankedPath<>("lexical", lexical, 1.0)
|
||||
),
|
||||
60,
|
||||
s -> s
|
||||
);
|
||||
|
||||
// c: dense#3 + lexical#1 ; b: dense#2 + lexical#2 ; a: dense#1 only
|
||||
// With k=60, c edges b slightly, and both beat single-path a.
|
||||
assertEquals("c", fused.get(0).identity());
|
||||
assertEquals("b", fused.get(1).identity());
|
||||
assertEquals("a", fused.get(2).identity());
|
||||
assertTrue(fused.get(0).rrfScore() > fused.get(2).rrfScore());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathWeightCanElevateSecondaryPath() {
|
||||
List<String> dense = List.of("a", "b");
|
||||
List<String> lexical = List.of("b", "a");
|
||||
|
||||
List<RrfFusion.Scored<String>> fused = RrfFusion.fuse(
|
||||
List.of(
|
||||
new RrfFusion.RankedPath<>("dense", dense, 1.0),
|
||||
new RrfFusion.RankedPath<>("lexical", lexical, 2.0)
|
||||
),
|
||||
60,
|
||||
s -> s
|
||||
);
|
||||
|
||||
assertEquals("b", fused.get(0).identity());
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
package com.superbiz.agent.service.retrieval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class VectorKnowledgeSearchAdapterHybridTest {
|
||||
|
||||
@Test
|
||||
void adapterMapsStoreHitsWithChunkIdentity() {
|
||||
VectorSearchService vectorSearchService = mock(VectorSearchService.class);
|
||||
when(vectorSearchService.searchSimilarDocuments("pool timeout", 3, "mysql")).thenReturn(List.of(
|
||||
result("id-1",
|
||||
"{\"_source\":\"c.md\",\"docId\":\"c\",\"chunkIndex\":0,\"title\":\"mysql pool timeout\"}",
|
||||
"mysql pool timeout runbook",
|
||||
0.35f)
|
||||
));
|
||||
|
||||
VectorKnowledgeSearchAdapter adapter =
|
||||
new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper());
|
||||
|
||||
List<KnowledgeSearchHit> hits = adapter.search(
|
||||
new KnowledgeSearchRequest("pool timeout", 3, "mysql", KnowledgeSearchMode.HYBRID));
|
||||
|
||||
assertEquals(1, hits.size());
|
||||
assertEquals("c#chunk-0", hits.get(0).evidenceKey());
|
||||
assertEquals("c.md", hits.get(0).source());
|
||||
verify(vectorSearchService).searchSimilarDocuments("pool timeout", 3, "mysql");
|
||||
}
|
||||
|
||||
private static VectorSearchService.SearchResult result(String id, String metadata, String content, float score) {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId(id);
|
||||
result.setMetadata(metadata);
|
||||
result.setContent(content);
|
||||
result.setScore(score);
|
||||
result.setRawScore((double) score);
|
||||
result.setScoreLabel("dense");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package com.superbiz.agent.tool;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.service.KnowledgeContextPacker;
|
||||
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
|
||||
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
|
||||
import com.superbiz.agent.service.KnowledgeIndexService;
|
||||
import com.superbiz.agent.service.KnowledgeQueryTransformer;
|
||||
import com.superbiz.agent.service.LookupResultAssembler;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.superbiz.agent.service.retrieval.VectorKnowledgeSearchAdapter;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchHit;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchPort;
|
||||
import com.superbiz.agent.service.retrieval.KnowledgeSearchRequest;
|
||||
import com.superbiz.agent.service.retrieval.RetrievalScoreLabels;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
@@ -19,25 +17,26 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* LookupKnowledgeTool evidence-first contract tests.
|
||||
*
|
||||
* <p>检索后端为 py-rag(经 {@link KnowledgeSearchPort} mock);L0 query 理解已下沉服务端,
|
||||
* categoryFilter 恒为 null,走 UNFILTERED_VECTOR 单 attempt 主路径。</p>
|
||||
*/
|
||||
class LookupKnowledgeToolTest {
|
||||
|
||||
@Mock
|
||||
private KnowledgeIndexService knowledgeIndexService;
|
||||
|
||||
@Mock
|
||||
private VectorSearchService vectorSearchService;
|
||||
private KnowledgeSearchPort knowledgeSearchPort;
|
||||
|
||||
private LookupKnowledgeTool tool;
|
||||
|
||||
@@ -50,9 +49,8 @@ class LookupKnowledgeToolTest {
|
||||
ReflectionTestUtils.setField(postProcessor, "maxChunksPerDocument", 2);
|
||||
KnowledgeContextPacker contextPacker = new KnowledgeContextPacker();
|
||||
tool = new LookupKnowledgeTool();
|
||||
ReflectionTestUtils.setField(tool, "queryTransformer", new KnowledgeQueryTransformer(knowledgeIndexService));
|
||||
ReflectionTestUtils.setField(tool, "documentRetriever",
|
||||
new KnowledgeDocumentRetriever(new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper())));
|
||||
new KnowledgeDocumentRetriever(knowledgeSearchPort));
|
||||
ReflectionTestUtils.setField(tool, "evidencePostProcessor", postProcessor);
|
||||
ReflectionTestUtils.setField(tool, "contextPacker", contextPacker);
|
||||
ReflectionTestUtils.setField(tool, "resultAssembler", new LookupResultAssembler());
|
||||
@@ -60,17 +58,11 @@ class LookupKnowledgeToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void filteredL1SuccessDoesNotRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult result = searchResult(
|
||||
"vec-1",
|
||||
"{\"_source\":\"db.md\",\"docId\":\"db\",\"chunkIndex\":0,\"title\":\"Database Doc\",\"category\":\"database\"}",
|
||||
"mysql timeout runbook",
|
||||
0.2f);
|
||||
void lookupSuccessSingleAttemptUnfiltered() {
|
||||
KnowledgeSearchHit hit = hit("db#chunk-0", "db", 0, "db.md", "Database Doc",
|
||||
"mysql timeout runbook", 0.9);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(List.of(result));
|
||||
when(knowledgeSearchPort.search(any())).thenReturn(List.of(hit));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
@@ -80,76 +72,30 @@ class LookupKnowledgeToolTest {
|
||||
assertEquals("db#chunk-0", lookup.getEvidenceBlocks().get(0).getEvidenceKey());
|
||||
assertNotNull(lookup.getContextPack());
|
||||
assertTrue(lookup.getContextPack().getPackedText().contains("mysql timeout runbook"));
|
||||
assertEquals("FILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("UNFILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals(1, lookup.getRetrievalTrace().getAttempts().size());
|
||||
assertEquals("PRECISE", lookup.getRelevanceLevel());
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, "database");
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments("mysql timeout", 3, null);
|
||||
// 原始 query 直传、无 category 收窄(L0 已下沉 py-rag)
|
||||
verify(knowledgeSearchPort).search(new KnowledgeSearchRequest("mysql timeout", 3, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void filteredLowQualityTriggersRawUnfilteredRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult weak = searchResult(
|
||||
"weak",
|
||||
"{\"_source\":\"weak.md\",\"docId\":\"weak\",\"chunkIndex\":0,\"title\":\"Weak\"}",
|
||||
"weak candidate",
|
||||
1.4f);
|
||||
VectorSearchService.SearchResult strong = searchResult(
|
||||
"strong",
|
||||
"{\"_source\":\"strong.md\",\"docId\":\"strong\",\"chunkIndex\":0,\"title\":\"Strong\"}",
|
||||
"mysql timeout strong runbook",
|
||||
0.2f);
|
||||
void rerankScoreDrivesRelevanceLevel() {
|
||||
// rerank 绝对分 0.6:quality 原样采用 → REFERENCE(>=0.5 且 <0.75)
|
||||
KnowledgeSearchHit hit = hit("ref#chunk-0", "ref", 0, "ref.md", "Reference Doc",
|
||||
"reference level content", 0.6);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(List.of(weak));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, null))
|
||||
.thenReturn(List.of(strong));
|
||||
when(knowledgeSearchPort.search(any())).thenReturn(List.of(hit));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
LookupResult lookup = tool.lookupKnowledge("reference query");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR_RETRY", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("filtered_vector_low_quality", lookup.getRetrievalTrace().getFallbackReason());
|
||||
assertEquals(2, lookup.getRetrievalTrace().getAttempts().size());
|
||||
assertEquals("strong.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, "database");
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, null);
|
||||
assertEquals("REFERENCE", lookup.getRelevanceLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filteredNoEvidenceTriggersRawUnfilteredRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult strong = searchResult(
|
||||
"strong",
|
||||
"{\"_source\":\"strong.md\",\"docId\":\"strong\",\"chunkIndex\":0,\"title\":\"Strong\"}",
|
||||
"mysql timeout strong runbook",
|
||||
0.2f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, null))
|
||||
.thenReturn(List.of(strong));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR_RETRY", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("filtered_vector_no_evidence", lookup.getRetrievalTrace().getFallbackReason());
|
||||
assertEquals("strong.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void l0HintsDoNotBecomeStandaloneEvidenceWhenL1Fails() {
|
||||
KnowledgeEntry entry = entry("fallback.md", "Fallback Doc", "fallback", "database");
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("fallback")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("fallback", 3, "database"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("fallback", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
void emptyHitsYieldNoEvidence() {
|
||||
when(knowledgeSearchPort.search(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("fallback");
|
||||
|
||||
@@ -157,52 +103,26 @@ class LookupKnowledgeToolTest {
|
||||
assertEquals(0, lookup.getEvidenceBlockCount());
|
||||
assertTrue(lookup.getEvidenceBlocks().isEmpty());
|
||||
assertEquals("no_evidence", lookup.getRetrievalTrace().getEvidenceStatus());
|
||||
assertTrue(String.valueOf(lookup.getRetrievalTrace().getQueryHints()).contains("Fallback Doc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noL0HintUsesUnfilteredVectorSearch() {
|
||||
VectorSearchService.SearchResult result = searchResult(
|
||||
"vec-1",
|
||||
"{\"_source\":\"perf.md\",\"docId\":\"perf\",\"chunkIndex\":0,\"title\":\"Perf\"}",
|
||||
"performance tuning guide",
|
||||
0.3f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("性能优化"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
when(vectorSearchService.searchSimilarDocuments("性能优化", 3, null))
|
||||
.thenReturn(List.of(result));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("性能优化");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("perf.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
verify(vectorSearchService).searchSimilarDocuments("性能优化", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesRetrievalOrderAndContextPackMetadataWithoutBoostRerank() {
|
||||
KnowledgeEntry entry = entry("payment.md", "Payment", "ERR_TIMEOUT", "payment");
|
||||
VectorSearchService.SearchResult first = searchResult(
|
||||
"a",
|
||||
"{\"_source\":\"a.md\",\"docId\":\"a\",\"chunkIndex\":0,\"title\":\"Generic\",\"category\":\"other\"}",
|
||||
"generic troubleshooting",
|
||||
0.4f);
|
||||
VectorSearchService.SearchResult second = searchResult(
|
||||
"b",
|
||||
"{\"_source\":\"b.md\",\"docId\":\"b\",\"chunkIndex\":0,\"title\":\"Payment ERR_TIMEOUT\",\"breadcrumb\":\"Payment > Timeout\",\"category\":\"payment\"}",
|
||||
"payment ERR_TIMEOUT timeout diagnosis",
|
||||
0.45f);
|
||||
KnowledgeSearchHit first = hit("a#chunk-0", "a", 0, "a.md", "Generic",
|
||||
"generic troubleshooting", 0.95);
|
||||
KnowledgeSearchHit second = hit("b#chunk-0", "b", 0, "b.md", "Payment ERR_TIMEOUT",
|
||||
"payment ERR_TIMEOUT timeout diagnosis", 0.55);
|
||||
second = new KnowledgeSearchHit(
|
||||
second.id(), second.content(), second.score(), second.rawScore(), second.scoreLabel(),
|
||||
second.metadataJson(), second.metadata(), second.docId(), second.chunkIndex(),
|
||||
second.evidenceKey(), second.source(), second.title(), "Payment > Timeout",
|
||||
2, second.denseDistance());
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("ERR_TIMEOUT")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("ERR_TIMEOUT", 3, "payment"))
|
||||
.thenReturn(List.of(first, second));
|
||||
when(knowledgeSearchPort.search(any())).thenReturn(List.of(first, second));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
// originalRank order wins; keyword/domain boost must not promote second over first
|
||||
// originalRank 顺序权威;服务端 rerank 分不得改变主序
|
||||
assertEquals("a.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
assertEquals("b.md", lookup.getEvidenceBlocks().get(1).getSource());
|
||||
assertTrue(lookup.getRerankTrace().getItems().stream()
|
||||
@@ -216,21 +136,12 @@ class LookupKnowledgeToolTest {
|
||||
|
||||
@Test
|
||||
void keepsDistinctChunksFromSameSource() {
|
||||
KnowledgeEntry entry = entry("shared.md", "Shared", "shared", "payment");
|
||||
VectorSearchService.SearchResult first = searchResult(
|
||||
"a",
|
||||
"{\"_source\":\"shared.md\",\"docId\":\"shared\",\"chunkIndex\":0,\"title\":\"Shared\"}",
|
||||
"shared content 1",
|
||||
0.2f);
|
||||
VectorSearchService.SearchResult second = searchResult(
|
||||
"b",
|
||||
"{\"_source\":\"shared.md\",\"docId\":\"shared\",\"chunkIndex\":1,\"title\":\"Shared\"}",
|
||||
"shared content 2",
|
||||
0.25f);
|
||||
KnowledgeSearchHit first = hit("shared#chunk-0", "shared", 0, "shared.md", "Shared",
|
||||
"shared content 1", 0.9);
|
||||
KnowledgeSearchHit second = hit("shared#chunk-1", "shared", 1, "shared.md", "Shared",
|
||||
"shared content 2", 0.85);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("shared")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("shared", 3, "payment"))
|
||||
.thenReturn(List.of(first, second));
|
||||
when(knowledgeSearchPort.search(any())).thenReturn(List.of(first, second));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("shared");
|
||||
|
||||
@@ -243,48 +154,29 @@ class LookupKnowledgeToolTest {
|
||||
assertEquals("shared content 2", lookup.getEvidenceBlocks().get(1).getContent());
|
||||
}
|
||||
|
||||
private KnowledgeEntry entry(String filePath, String title, String keyword, String category) {
|
||||
return KnowledgeEntry.builder()
|
||||
.filePath(filePath)
|
||||
.title(title)
|
||||
.keywords(List.of(keyword))
|
||||
.summary(title + " summary")
|
||||
.category(category)
|
||||
.build();
|
||||
}
|
||||
|
||||
private VectorSearchService.SearchResult searchResult(String id,
|
||||
String metadata,
|
||||
String content,
|
||||
float score) {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId(id);
|
||||
result.setMetadata(metadata);
|
||||
result.setContent(content);
|
||||
result.setScore(score);
|
||||
result.setRawScore((double) score);
|
||||
result.setScoreLabel("dense");
|
||||
return result;
|
||||
}
|
||||
|
||||
private KnowledgeIndexService.L0Hint hint(KnowledgeEntry... entries) {
|
||||
List<KnowledgeEntry> matches = List.of(entries);
|
||||
List<String> keywords = matches.stream()
|
||||
.flatMap(entry -> entry.getKeywords() == null
|
||||
? java.util.stream.Stream.empty()
|
||||
: entry.getKeywords().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> domains = matches.stream()
|
||||
.map(KnowledgeEntry::getCategory)
|
||||
.filter(category -> category != null && !category.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> titles = matches.stream()
|
||||
.map(KnowledgeEntry::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
return new KnowledgeIndexService.L0Hint(matches, keywords, domains, keywords, titles);
|
||||
/** 构造 py-rag 形态的命中:rerank 绝对分 + evidenceKey(docId#chunk-N)。 */
|
||||
private KnowledgeSearchHit hit(String evidenceKey,
|
||||
String docId,
|
||||
int chunkIndex,
|
||||
String source,
|
||||
String title,
|
||||
String content,
|
||||
double score) {
|
||||
return new KnowledgeSearchHit(
|
||||
evidenceKey,
|
||||
content,
|
||||
score,
|
||||
score,
|
||||
RetrievalScoreLabels.RERANK,
|
||||
null,
|
||||
Map.of(),
|
||||
docId,
|
||||
chunkIndex,
|
||||
evidenceKey,
|
||||
source,
|
||||
title,
|
||||
null,
|
||||
1,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user