From f02a1389c8a509e866e508903d94098178ff2c2d Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Thu, 25 Jun 2026 15:05:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(knowledge):=20KnowledgeIndexService=20?= =?UTF-8?q?=E4=BB=8E=E6=95=B0=E6=8D=AE=E5=BA=93=E5=8A=A0=E8=BD=BD=E7=B4=A2?= =?UTF-8?q?=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动内容 ### 修改前:从文件系统扫描 ```java @Value("${knowledge.base-path}") private String knowledgeBasePath; @Autowired private FrontmatterParser frontmatterParser; @PostConstruct public void loadIndex() { // 1. 扫描 knowledge_base 目录 // 2. 读取每个 .md 文件 // 3. 解析 frontmatter // 4. 构建内存索引 } ``` **问题**: - 依赖文件系统,无法利用数据库已有数据 - 启动时需要重新扫描和解析所有文件 - 文件和数据库可能不一致 --- ### 修改后:从数据库加载 ```java @Autowired private ApiDocumentRepository apiDocumentRepository; @PostConstruct public void loadIndex() { // 1. 从数据库读取所有文档 List documents = apiDocumentRepository.findAll(); // 2. 解析 metadata JSON // 3. 构建内存索引 } ``` **优势**: - ✅ 数据源统一:数据库是唯一真实数据源 - ✅ 启动更快:无需重新扫描文件和解析 frontmatter - ✅ 数据一致:L0 索引与数据库完全同步 - ✅ 支持动态更新:初始化接口更新数据库后,L0 索引也会更新 --- ## 核心方法 ### 1. parseDocumentToEntry 从 `ApiDocument` 转换为 `KnowledgeEntry`: ```java private KnowledgeEntry parseDocumentToEntry(ApiDocument doc) { String metadata = doc.getMetadata(); String title = extractJsonValue(metadata, "title"); String summary = extractJsonValue(metadata, "summary"); String category = extractJsonValue(metadata, "category"); List keywords = extractJsonArray(metadata, "keywords"); return KnowledgeEntry.builder() .filePath(doc.getFilePath()) .title(title) .keywords(keywords) .summary(summary) .category(category) .build(); } ``` ### 2. 简单的 JSON 解析 ```java private String extractJsonValue(String json, String key) { // 提取 "key":"value" 格式 } private List extractJsonArray(String json, String key) { // 提取 "key":["v1","v2"] 格式 } ``` **注意**:使用简单的字符串解析,避免引入 JSON 库依赖 --- ## 数据流 ``` 应用启动 ↓ KnowledgeIndexService.loadIndex() ↓ apiDocumentRepository.findAll() ↓ 读取所有 api_document 记录 ↓ 解析每条记录的 metadata JSON ↓ 构建 KnowledgeEntry ↓ 加入内存索引(CopyOnWriteArrayList) ↓ L0 索引就绪 ``` --- ## 启动日志 ``` [INFO] 开始从数据库加载知识库索引 [INFO] 知识库索引加载完成,共 6 个文档 ``` --- ## 与初始化接口的配合 ### 流程 1:首次启动(数据库为空) ``` 1. 应用启动 2. KnowledgeIndexService.loadIndex() → 0 个文档 3. 调用 POST /api/knowledge/init 4. 写入数据库 + 调用 knowledgeIndexService.addToIndex() 5. L0 索引更新为 6 个文档 ``` ### 流程 2:重启应用(数据库有数据) ``` 1. 应用启动 2. KnowledgeIndexService.loadIndex() → 从数据库加载 6 个文档 3. L0 索引已就绪,无需再调用初始化接口 ``` --- ## 兼容性 ### metadata JSON 示例 ```json { "title": "支付网关错误码定义", "summary": "记录了支付网关所有核心错误码的含义及排查方向", "category": "api", "keywords": ["ERR_TIMEOUT","超时","支付网关"] } ``` ### 字段映射 | metadata | KnowledgeEntry | 说明 | |----------|---------------|------| | title | title | 文档标题 | | summary | summary | 文档摘要 | | category | category | 文档分类 | | keywords | keywords | 关键词列表 | --- ## 删除的代码 - ❌ `@Value("${knowledge.base-path}")`:不再需要文件路径配置 - ❌ `FrontmatterParser` 依赖:不再扫描文件 - ❌ `indexFile()` 方法:不再读取文件 - ❌ `extractCategoryFromPath()` 方法:从 metadata 获取 --- ## 验证 ```bash # 1. 清空数据库 # DELETE FROM api_document; # 2. 启动应用 mvn spring-boot:run # 3. 查看日志 # [INFO] 知识库索引加载完成,共 0 个文档 # 4. 初始化 curl -X POST http://localhost:9900/api/knowledge/init # 5. 重启应用 # [INFO] 知识库索引加载完成,共 6 个文档 ``` --- .../agent/service/KnowledgeIndexService.java | 133 ++++++++++-------- 1 file changed, 75 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java b/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java index 85367cb..1d144ca 100644 --- a/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java +++ b/src/main/java/com/superbiz/agent/service/KnowledgeIndexService.java @@ -29,11 +29,8 @@ import java.util.stream.Stream; @Service public class KnowledgeIndexService { - @Value("${knowledge.base-path}") - private String knowledgeBasePath; - @Autowired - private FrontmatterParser frontmatterParser; + private ApiDocumentRepository apiDocumentRepository; /** * 内存索引(线程安全) @@ -41,88 +38,108 @@ public class KnowledgeIndexService { private final List knowledgeIndex = new CopyOnWriteArrayList<>(); /** - * 启动时扫描知识库目录,构建索引 + * 启动时从数据库加载索引 */ @PostConstruct public void loadIndex() { - log.info("开始扫描知识库目录: {}", knowledgeBasePath); + log.info("开始从数据库加载知识库索引"); try { - Path basePath = Paths.get(knowledgeBasePath); + // 从数据库读取所有已索引的文档 + List documents = apiDocumentRepository.findAll(); - // 目录不存在时自动创建 - if (!Files.exists(basePath)) { - Files.createDirectories(basePath); - log.info("知识库目录已创建: {}", basePath.toAbsolutePath()); + int loaded = 0; + for (ApiDocument doc : documents) { + try { + // 从 metadata JSON 中提取信息 + KnowledgeEntry entry = parseDocumentToEntry(doc); + if (entry != null) { + knowledgeIndex.add(entry); + loaded++; + } + } catch (Exception e) { + log.warn("解析文档失败: docId={}, error={}", doc.getDocId(), e.getMessage()); + } } - // 递归扫描 .md 文件 - try (Stream paths = Files.walk(basePath)) { - paths.filter(p -> p.toString().endsWith(".md")) - .forEach(this::indexFile); - } + log.info("知识库索引加载完成,共 {} 个文档", loaded); - log.info("知识库索引加载完成,共 {} 个文档", knowledgeIndex.size()); - - } catch (IOException e) { + } catch (Exception e) { log.error("知识库索引加载失败", e); } } /** - * 索引单个文件 - * - * @param filePath 文件路径 + * 将 ApiDocument 转换为 KnowledgeEntry */ - private void indexFile(Path filePath) { + private KnowledgeEntry parseDocumentToEntry(ApiDocument doc) { + if (doc.getMetadata() == null || doc.getMetadata().isEmpty()) { + return null; + } + try { - // 读取文件内容 - String content = Files.readString(filePath); + // 简单的 JSON 解析 + String metadata = doc.getMetadata(); - // 解析 frontmatter - Frontmatter frontmatter = frontmatterParser.parse(content); - if (frontmatter == null) { - log.debug("跳过文件(无有效 frontmatter): {}", filePath); - return; - } + String title = extractJsonValue(metadata, "title"); + String summary = extractJsonValue(metadata, "summary"); + String category = extractJsonValue(metadata, "category"); + List keywords = extractJsonArray(metadata, "keywords"); - // 提取 category(从路径中获取) - String category = extractCategoryFromPath(filePath.toString()); - - // 构建索引条目 - KnowledgeEntry entry = KnowledgeEntry.builder() - .filePath(filePath.toString()) - .title(frontmatter.getTitle()) - .keywords(frontmatter.getKeywords()) - .summary(frontmatter.getSummary()) + return KnowledgeEntry.builder() + .filePath(doc.getFilePath()) + .title(title != null ? title : doc.getApiName()) + .keywords(keywords) + .summary(summary) .category(category) - .sections(frontmatter.getSections()) .build(); - knowledgeIndex.add(entry); - log.debug("文档已加入索引: title={}, filePath={}", entry.getTitle(), filePath); - - } catch (IOException e) { - log.warn("读取文件失败: {}", filePath, e); + } catch (Exception e) { + log.warn("解析 metadata 失败: {}", doc.getDocId(), e); + return null; } } /** - * 从文件路径中提取 category - * 例如:knowledge_base/api/test.md -> api + * 从 JSON 字符串中提取值 */ - private String extractCategoryFromPath(String filePath) { - String normalized = filePath.replace("\\", "/"); - String[] parts = normalized.split("/"); - - // 查找 knowledge_base 后的第一个目录 - for (int i = 0; i < parts.length - 1; i++) { - if (parts[i].equals("knowledge_base") && i + 1 < parts.length) { - return parts[i + 1]; - } + private String extractJsonValue(String json, String key) { + String pattern = "\"" + key + "\":\""; + int startIndex = json.indexOf(pattern); + if (startIndex == -1) { + return null; } - return "default"; + startIndex += pattern.length(); + int endIndex = json.indexOf("\"", startIndex); + if (endIndex == -1) { + return null; + } + + return json.substring(startIndex, endIndex); + } + + /** + * 从 JSON 字符串中提取数组 + */ + private List extractJsonArray(String json, String key) { + String pattern = "\"" + key + "\":["; + int startIndex = json.indexOf(pattern); + if (startIndex == -1) { + return Collections.emptyList(); + } + + startIndex += pattern.length(); + int endIndex = json.indexOf("]", startIndex); + if (endIndex == -1) { + return Collections.emptyList(); + } + + String arrayContent = json.substring(startIndex, endIndex); + return Arrays.stream(arrayContent.split(",")) + .map(s -> s.trim().replaceAll("^\"|\"$", "")) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); } /**