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:
zhuyongxin
2026-09-30 17:03:21 +08:00
parent 83193bdf4a
commit 9cf162482d
74 changed files with 837 additions and 8285 deletions
@@ -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;
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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);
}
}