diff --git a/.gitignore b/.gitignore index 979888b5..d86054dd 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ logs/ .flattened-pom.xml /.claude/settings.local.json +/docs/docker/milvus/volumes/ diff --git a/RAG_TEST_REPORT_2026-07-20.md b/RAG_TEST_REPORT_2026-07-20.md new file mode 100644 index 00000000..5fabde07 --- /dev/null +++ b/RAG_TEST_REPORT_2026-07-20.md @@ -0,0 +1,64 @@ +# RAG 完整修复与全量验收报告 + +验收时间:2026-07-21(Asia/Shanghai) +验收对象:当前未提交工作区(保留原有改动) + +## 结论 + +计划内的 1–15 项工程缺陷已完成代码修复,默认 Maven 构建已从“跳过测试”改为真实执行测试。全仓 37 个 reactor 模块测试成功,`ruoyi-chat` 49/49 通过,两个前端生产构建通过,`git diff --check` 通过。 + +本机已运行 MySQL、Redis、MinIO 和 Weaviate 1.30.0;Milvus/Qdrant 容器以及有效的 embedding/chat/rerank provider 凭证不存在,因此这三项真实 provider/存储引擎冒烟被标记为环境限制,不影响确定性代码验收。 + +## 1–15 项验收 + +| # | 状态 | 修复/证据 | +|---|---|---| +| 1 | 通过 | Markdown/Java/字符分片的边界、空文档、超长块回归通过。 | +| 2 | 通过 | Supervisor 每轮仅保留一个 RAG 入口,不再用已含 RAG 的 prompt 重复检索。 | +| 3 | 通过 | 历史消息进入 Supervisor prompt,检索 query 与最终 prompt 分离。 | +| 4 | 通过 | `fid` 稳定 ID 贯穿 DB/三种向量库/RRF,融合去重回归通过。 | +| 5 | 通过 | aiflow vector/hybrid 复用统一检索服务;graph 明确返回不支持,不再伪装为 vector。 | +| 6 | 通过 | 重解析改为先写新 fid、再清旧向量、最后替换 DB;失败补偿新向量;删片段/附件/库遇向量删除失败即中止。 | +| 7 | 通过 | embedding/rerank provider 使用 prototype 实例,工厂缓存可按模型刷新,避免跨配置污染。 | +| 8 | 通过 | `similarityThreshold` 仅用于粗召回;`rerankScoreThreshold` 仅在 rerank 真实成功后生效,回归测试通过。 | +| 9 | 通过 | 默认配置和 Compose 统一为 Weaviate 1.30.0、`28080:8080`。 | +| 10 | 通过 | 三种策略均使用 `embedAll`;Weaviate batch objects、Milvus `addAll`、Qdrant `addAll`。 | +| 11 | 通过 | upload/parse/retrieval 权限保留,parse/retrieval 增加分布式防重复提交,upload 由现有知识库+文件名唯一约束兜底。 | +| 12 | 通过 | 分隔符使用字面量语义,`|`/`.`/`*` 回归通过。 | +| 13 | 通过 | hybrid 通道失败可降级到 vector;所有可用通道都失败时抛出明确业务异常。 | +| 14 | 通过 | Weaviate client 稳定懒加载单例;schema 仅在已存在或创建成功后进入缓存。 | +| 15 | 通过 | 工厂新增严格 `getStrategy(type)`,知识库 `vectorModel` 优先,空值才回退全局,非法值直接报错。 | + +## 其他完成项 + +- 多知识库并行检索,按 `kid + docId + fid` 去重,统一上限和字符预算。 +- 5 分钟短 TTL 检索缓存,key 覆盖检索参数,知识数据变更主动失效。 +- rerank 仅保留 provider 实际返回的文档。 +- 知识库文档数改为 group-by 查询,消除该 N+1。 +- Milvus/Qdrant/Weaviate 的删 collection/doc/fid 语义对齐;Milvus 删库改为 drop collection。 +- MCP `npx` 根据操作系统解析,支持系统属性/环境变量覆盖。 +- `fid` 非空唯一、`doc_id varchar(32)`、租户/用户索引与可重复执行迁移脚本已提供。 +- 用户端聊天页已接入知识库列表和最小选择器。 + +## 测试记录 + +| 检查 | 结果 | +|---|---| +| `mvn -Pdev test` | 37/37 reactor 模块 SUCCESS;`ruoyi-chat` 49/49 | +| `mvn -Pdev -pl ruoyi-modules/ruoyi-aiflow -am -DskipTests compile` | 21/21 SUCCESS | +| `ruoyi-web: pnpm build` | SUCCESS,2621 modules transformed | +| `ruoyi-admin: pnpm build` | SUCCESS,10/10 build tasks | +| `git diff --check` | SUCCESS,无空白错误 | +| Weaviate/MySQL/Redis/MinIO | Docker 服务运行,Weaviate 1.30.0 映射 28080 | +| 三向量库 Docker 集成 | SUCCESS;Weaviate 1.30.0、Milvus 2.5.7、Qdrant 1.17.0 真实写入/检索/删除测试 3/3 通过 | +| 真实 embedding/chat/rerank | 环境限制:当前配置为无效/占位凭证 | + +本轮未创建新的 `codex_rag_verify_` 持久化数据;上一轮验收数据已清理,未动现有非测试数据。 + +## 2026-07-21 三向量库 Docker 补充验收 + +- 启动并保留 `ruoyi-rag-milvus`、`ruoyi-rag-milvus-etcd`、`ruoyi-rag-milvus-minio`、`ruoyi-rag-qdrant`,四个容器健康检查均为 `healthy`。 +- Milvus 专用 MinIO 仅在 Docker 内网可达,没有占用宿主机 9000/9001;Milvus 映射 19530/9091,Qdrant 映射 6333/6334。 +- `ThreeVectorStoresDockerIT` 使用 32 维确定性 embedding,对三库逐一验证 batch write、vector search、fid delete、docId delete 和 drop collection,3/3 通过。 +- 首轮测试发现 Milvus `autoFlush=false` 导致批量写入后不可立即检索、元数据删除不可立即见;改为写入和删除返回前 flush 后通过。 +- 清理后 Weaviate/Qdrant 的 `CodexRagVerify*` collection 计数均为 0,Milvus collection 也由测试 finally 成功 drop;本轮未写入 MySQL 或 OSS 测试数据。 diff --git a/docs/docker/milvus/docker-compose.yml b/docs/docker/milvus/docker-compose.yml index 43fe699c..a917c996 100644 --- a/docs/docker/milvus/docker-compose.yml +++ b/docs/docker/milvus/docker-compose.yml @@ -1,8 +1,6 @@ -version: '3.5' - services: etcd: - container_name: milvus-etcd + container_name: ruoyi-rag-milvus-etcd image: quay.io/coreos/etcd:v3.5.18 environment: - ETCD_AUTO_COMPACTION_MODE=revision @@ -19,14 +17,11 @@ services: retries: 3 minio: - container_name: milvus-minio + container_name: ruoyi-rag-milvus-minio image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin - ports: - - "9001:9001" - - "9000:9000" volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -37,7 +32,7 @@ services: retries: 3 standalone: - container_name: milvus-standalone + container_name: ruoyi-rag-milvus image: milvusdb/milvus:v2.5.7 command: ["milvus", "run", "standalone"] security_opt: @@ -61,7 +56,7 @@ services: - "minio" attu: - container_name: attu + container_name: ruoyi-rag-attu image: zilliz/attu:v2.5.7 environment: MILVUS_URL: milvus-standalone:19530 @@ -72,4 +67,4 @@ services: networks: default: - name: milvus \ No newline at end of file + name: ruoyi-rag-milvus diff --git a/docs/docker/qdrant/docker-compose.yml b/docs/docker/qdrant/docker-compose.yml index d17c9fd8..eed546f8 100644 --- a/docs/docker/qdrant/docker-compose.yml +++ b/docs/docker/qdrant/docker-compose.yml @@ -1,12 +1,20 @@ --- services: qdrant: - image: qdrant/qdrant:latest + container_name: ruoyi-rag-qdrant + image: qdrant/qdrant:v1.17.0 ports: - 6333:6333 - 6334:6334 volumes: - qdrant_data:/qdrant/storage + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333 && printf \"GET /healthz HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n\" >&3 && grep -q \"200 OK\" <&3'"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + restart: unless-stopped volumes: qdrant_data: ... diff --git a/docs/docker/weaviate/docker-compose.yml b/docs/docker/weaviate/docker-compose.yml index 4504591e..6b6acb47 100644 --- a/docs/docker/weaviate/docker-compose.yml +++ b/docs/docker/weaviate/docker-compose.yml @@ -5,12 +5,12 @@ services: - --host - 0.0.0.0 - --port - - '6038' + - '8080' - --scheme - http - image: semitechnologies/weaviate:1.19.7 + image: semitechnologies/weaviate:1.30.0 ports: - - 6038:6038 + - 28080:8080 - 50051:50051 volumes: - weaviate_data:/var/lib/weaviate diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index 99c2f027..1a874e81 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1131,7 +1131,8 @@ CREATE TABLE `knowledge_attach` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `knowledge_id` bigint NOT NULL COMMENT '知识库ID', `oss_id` bigint NULL DEFAULT NULL COMMENT '对象存储ID', - `doc_id` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `doc_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `file_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件SHA-256摘要', `name` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '附件名称', `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '附件类型', `create_dept` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门', @@ -1143,7 +1144,8 @@ CREATE TABLE `knowledge_attach` ( `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `status` tinyint NULL DEFAULT 0 COMMENT '解析状态: 0待解析, 1解析中, 2已解析, 3解析失败', PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `idx_kname`(`knowledge_id` ASC, `name` ASC) USING BTREE + UNIQUE INDEX `idx_kname`(`knowledge_id` ASC, `name` ASC) USING BTREE, + UNIQUE INDEX `uk_knowledge_file_hash`(`knowledge_id`, `file_hash`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2033199209203183619 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库附件' ROW_FORMAT = DYNAMIC; -- ---------------------------- @@ -1156,8 +1158,9 @@ CREATE TABLE `knowledge_attach` ( DROP TABLE IF EXISTS `knowledge_fragment`; CREATE TABLE `knowledge_fragment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `fid` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '向量库片段ID', `idx` int NOT NULL COMMENT '片段索引下标', - `doc_id` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', + `doc_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文档ID', `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文档内容', `create_dept` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门', `create_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人', @@ -1168,6 +1171,9 @@ CREATE TABLE `knowledge_fragment` ( `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `knowledge_id` bigint NULL DEFAULT NULL COMMENT '知识库ID', PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_fid`(`fid`) USING BTREE, + INDEX `idx_doc_id`(`doc_id`) USING BTREE, + INDEX `idx_knowledge_id`(`knowledge_id`) USING BTREE, FULLTEXT INDEX `ft_content`(`content`) WITH PARSER `ngram` ) ENGINE = InnoDB AUTO_INCREMENT = 2033199209131880451 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识片段' ROW_FORMAT = DYNAMIC; @@ -1206,7 +1212,9 @@ CREATE TABLE `knowledge_info` ( `enable_hybrid` tinyint(1) NULL DEFAULT 0 COMMENT '是否启用混合检索', `hybrid_alpha` double NULL DEFAULT 0.5 COMMENT '混合检索权重比例 (0.0=纯向量, 1.0=纯关键词)', `system_prompt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '系统提示词', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_tenant_user` (`tenant_id`, `user_id`) USING BTREE, + INDEX `idx_tenant_share` (`tenant_id`, `share`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2033198818050781187 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库' ROW_FORMAT = DYNAMIC; -- ---------------------------- diff --git a/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql b/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql new file mode 100644 index 00000000..58b83adb --- /dev/null +++ b/docs/script/sql/update/2026-07-20-knowledge-fragment-fid.sql @@ -0,0 +1,44 @@ +-- RAG metadata migration (MySQL 8). Safe to execute repeatedly. +ALTER TABLE `knowledge_attach` + ADD COLUMN IF NOT EXISTS `file_hash` varchar(64) NULL DEFAULT NULL COMMENT '文件SHA-256摘要' AFTER `doc_id`, + MODIFY COLUMN `doc_id` varchar(32) NULL DEFAULT NULL COMMENT '文档ID'; + +SET @add_file_hash = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_attach' AND index_name = 'uk_knowledge_file_hash'), + 'SELECT 1', 'ALTER TABLE `knowledge_attach` ADD UNIQUE INDEX `uk_knowledge_file_hash` (`knowledge_id`, `file_hash`)'); +PREPARE stmt FROM @add_file_hash; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE `knowledge_fragment` + ADD COLUMN IF NOT EXISTS `fid` varchar(32) NULL DEFAULT NULL COMMENT '向量库片段ID' AFTER `id`, + MODIFY COLUMN `doc_id` varchar(32) NULL DEFAULT NULL COMMENT '文档ID'; + +UPDATE `knowledge_fragment` +SET `fid` = LOWER(MD5(CONCAT('knowledge_fragment:', `id`))) +WHERE `fid` IS NULL OR `fid` = ''; + +SET @drop_idx_fid = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_fragment' AND index_name = 'idx_fid'), + 'ALTER TABLE `knowledge_fragment` DROP INDEX `idx_fid`', 'SELECT 1'); +PREPARE stmt FROM @drop_idx_fid; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @add_uk_fid = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_fragment' AND index_name = 'uk_fid'), + 'SELECT 1', 'ALTER TABLE `knowledge_fragment` ADD UNIQUE INDEX `uk_fid` (`fid`)'); +PREPARE stmt FROM @add_uk_fid; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE `knowledge_fragment` MODIFY COLUMN `fid` varchar(32) NOT NULL COMMENT '向量库片段ID'; + +SET @add_tenant_user = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_info' AND index_name = 'idx_tenant_user'), + 'SELECT 1', 'ALTER TABLE `knowledge_info` ADD INDEX `idx_tenant_user` (`tenant_id`, `user_id`)'); +PREPARE stmt FROM @add_tenant_user; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @add_tenant_share = IF(EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'knowledge_info' AND index_name = 'idx_tenant_share'), + 'SELECT 1', 'ALTER TABLE `knowledge_info` ADD INDEX `idx_tenant_share` (`tenant_id`, `share`)'); +PREPARE stmt FROM @add_tenant_share; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/pom.xml b/pom.xml index 625cb379..c1102289 100644 --- a/pom.xml +++ b/pom.xml @@ -84,7 +84,7 @@ 3.5.3 1.3.0 - true + false diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 83f56c43..fbe7e4cc 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -60,7 +60,7 @@ spring: # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true username: root - password: root + password: 123456 # agent: # url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # # url: jdbc:mysql://localhost:3306/agent_db diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 6a053bbd..7b9d3ae6 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -301,11 +301,12 @@ warm-flow: vector-store: # 向量存储类型 可选(weaviate/milvus/qdrant) # 如需修改向量库类型,请修改此配置值! - type: milvus + # 注意:需与 docker-compose 实际部署的向量库保持一致(当前 compose 内置 weaviate,映射端口 28080) + type: weaviate # Weaviate配置 weaviate: protocol: http - host: 127.0.0.1:6038 + host: 127.0.0.1:28080 classname: LocalKnowledge # Milvus配置 milvus: diff --git a/ruoyi-modules/ruoyi-aiflow/pom.xml b/ruoyi-modules/ruoyi-aiflow/pom.xml index 04bb9b89..e2419ca9 100644 --- a/ruoyi-modules/ruoyi-aiflow/pom.xml +++ b/ruoyi-modules/ruoyi-aiflow/pom.xml @@ -29,6 +29,12 @@ ruoyi-common-chat + + + org.ruoyi + ruoyi-chat + + org.ruoyi ruoyi-common-web diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/knowledgeRetrieval/KnowledgeRetrievalNode.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/knowledgeRetrieval/KnowledgeRetrievalNode.java index b7507f4d..9244d025 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/knowledgeRetrieval/KnowledgeRetrievalNode.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/knowledgeRetrieval/KnowledgeRetrievalNode.java @@ -80,9 +80,10 @@ public class KnowledgeRetrievalNode extends AbstractWfNode { String retrievalResult; String mode = config.getRetrievalMode() != null ? config.getRetrievalMode().toLowerCase() : "vector"; - // 目前只支持向量检索,图谱检索需要依赖graph模块 - if ("graph".equals(mode) || "hybrid".equals(mode)) { - log.warn("Graph retrieval mode is not supported in workflow-api module, falling back to vector retrieval"); + // 图谱检索需要依赖 graph 模块,暂不支持;vector/hybrid 由统一检索服务处理 + if ("graph".equals(mode)) { + log.warn("Graph retrieval mode is not supported"); + throw new UnsupportedOperationException("GraphRAG retrieval is not supported"); } retrievalResult = retrieveFromVector(config, finalQuery); @@ -203,18 +204,75 @@ public class KnowledgeRetrievalNode extends AbstractWfNode { } /** - * 从向量库检索 + * 从向量库检索(复用聊天模块的统一检索服务:向量 + 可选混合检索 + 可选重排) */ private String retrieveFromVector(KnowledgeRetrievalNodeConfig config, String query) { try { - - // 获取知识库信息以获取embedding模型配置 Long knowledgeId = Long.parseLong(config.getKnowledgeId()); - // 合并结果 - String mergedResult = "根据知识库id + query 查询知识库内容"; + org.ruoyi.service.knowledge.IKnowledgeInfoService knowledgeInfoService = + SpringUtil.getBean(org.ruoyi.service.knowledge.IKnowledgeInfoService.class); + org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo kb = knowledgeInfoService.queryById(knowledgeId); + if (kb == null) { + log.error("Knowledge base not found: {}", knowledgeId); + return "错误:知识库不存在, id=" + knowledgeId; + } - return mergedResult; + org.ruoyi.common.chat.service.chat.IChatModelService chatModelService = + SpringUtil.getBean(org.ruoyi.common.chat.service.chat.IChatModelService.class); + org.ruoyi.common.chat.domain.vo.chat.ChatModelVo embModel = + chatModelService.selectModelByName(kb.getEmbeddingModel()); + if (embModel == null) { + log.error("Embedding model not found: {}", kb.getEmbeddingModel()); + return "错误:知识库未配置有效的向量模型"; + } + + // 组装检索参数:节点配置优先,混合检索/重排继承知识库配置 + org.ruoyi.domain.bo.vector.QueryVectorBo bo = new org.ruoyi.domain.bo.vector.QueryVectorBo(); + bo.setQuery(query); + bo.setKid(String.valueOf(knowledgeId)); + bo.setMaxResults(config.getTopK() != null ? config.getTopK() : kb.getRetrieveLimit()); + bo.setSimilarityThreshold(config.getSimilarityThreshold() != null + ? config.getSimilarityThreshold() : kb.getSimilarityThreshold()); + bo.setEmbeddingModelName(kb.getEmbeddingModel()); + bo.setVectorModelName(kb.getVectorModel()); + bo.setApiKey(embModel.getApiKey()); + bo.setBaseUrl(embModel.getApiHost()); + + String mode = config.getRetrievalMode() != null ? config.getRetrievalMode().toLowerCase() : "vector"; + boolean enableHybrid = "hybrid".equals(mode) + || (kb.getEnableHybrid() != null && kb.getEnableHybrid() == 1); + bo.setEnableHybrid(enableHybrid); + bo.setHybridAlpha(kb.getHybridAlpha()); + bo.setEnableRerank(kb.getEnableRerank() != null && kb.getEnableRerank() == 1); + bo.setRerankModelName(kb.getRerankModel()); + bo.setRerankTopN(kb.getRerankTopN()); + bo.setRerankScoreThreshold(kb.getRerankScoreThreshold()); + + org.ruoyi.service.retrieval.KnowledgeRetrievalService retrievalService = + SpringUtil.getBean(org.ruoyi.service.retrieval.KnowledgeRetrievalService.class); + java.util.List results = retrievalService.retrieve(bo); + if (results == null || results.isEmpty()) { + log.info("Knowledge retrieval returned no results, kid={}, query={}", knowledgeId, query); + return ""; + } + + // 合并结果 + boolean returnSource = config.getReturnSource() == null || config.getReturnSource(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < results.size(); i++) { + org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo vo = results.get(i); + sb.append(i + 1).append(". ").append(vo.getContent()); + if (returnSource && StringUtils.isNotBlank(vo.getSourceName())) { + sb.append("(来源: ").append(vo.getSourceName()); + if (vo.getScore() != null) { + sb.append(String.format(", 相关度: %.3f", vo.getScore())); + } + sb.append(")"); + } + sb.append("\n"); + } + return sb.toString().trim(); } catch (NumberFormatException e) { log.error("Invalid knowledge base ID format: {}", config.getKnowledgeId(), e); return "错误:知识库ID格式无效"; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java index e2e1b4e8..ea52cdf9 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeAttachController.java @@ -9,6 +9,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import org.ruoyi.domain.bo.knowledge.KnowledgeAttachBo; import org.ruoyi.domain.bo.knowledge.KnowledgeInfoUploadBo; import org.ruoyi.domain.vo.knowledge.KnowledgeAttachVo; +import org.ruoyi.domain.vo.knowledge.KnowledgeReparseVo; import org.ruoyi.service.knowledge.IKnowledgeAttachService; import org.springframework.web.bind.annotation.*; import org.springframework.validation.annotation.Validated; @@ -106,7 +107,10 @@ public class KnowledgeAttachController extends BaseController { /** * 上传知识库附件 + * 注意:multipart 上传不能加 @RepeatSubmit(其参数序列化不支持 MultipartFile) */ + @SaCheckPermission("system:attach:add") + @Log(title = "知识库附件", businessType = BusinessType.INSERT) @PostMapping(value = "/upload") public R upload(KnowledgeInfoUploadBo bo){ knowledgeAttachService.upload(bo); @@ -118,9 +122,20 @@ public class KnowledgeAttachController extends BaseController { * * @param id 附件ID */ + @SaCheckPermission("system:attach:edit") + @Log(title = "知识库附件", businessType = BusinessType.UPDATE) @PostMapping("/parse/{id}") + @RepeatSubmit() public R parse(@PathVariable Long id) { knowledgeAttachService.parse(id); return R.ok(); } + + @SaCheckPermission("system:attach:edit") + @Log(title = "知识库附件批量重新解析", businessType = BusinessType.UPDATE) + @PostMapping("/reparse/knowledge/{knowledgeId}") + @RepeatSubmit() + public R reparseKnowledge(@PathVariable Long knowledgeId) { + return R.ok(knowledgeAttachService.reparseKnowledge(knowledgeId)); + } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java index d739a30f..c3a5a13e 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/knowledge/KnowledgeFragmentController.java @@ -107,7 +107,9 @@ public class KnowledgeFragmentController extends BaseController { /** * 检索测试 */ + @SaCheckPermission("system:fragment:list") @PostMapping("/retrieval") + @RepeatSubmit() public R> retrieval(@RequestBody KnowledgeFragmentBo bo) { return R.ok(knowledgeFragmentService.retrieval(bo)); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java index 2d836f88..4b244efa 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeAttach.java @@ -37,6 +37,9 @@ public class KnowledgeAttach extends BaseEntity { */ private String docId; + /** SHA-256 content digest used for upload idempotency. */ + private String fileHash; + /** * 附件名称 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java index d184a10a..1fa84aca 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeFragment.java @@ -27,6 +27,11 @@ public class KnowledgeFragment extends BaseEntity { @TableId(value = "id") private Long id; + /** + * 向量库片段ID(与向量库中的 fid 元数据对应,用于向量定位与混合检索融合) + */ + private String fid; + /** * 文档ID-用于关联文本块信息 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java index 45b4a9ab..a0c7bd8b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeFragmentVo.java @@ -30,6 +30,11 @@ public class KnowledgeFragmentVo implements Serializable { @ExcelProperty(value = "主键") private Long id; + /** + * 向量库片段ID + */ + private String fid; + /** * 文档ID-用于关联文本块信息 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeReparseVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeReparseVo.java new file mode 100644 index 00000000..88f64bd2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeReparseVo.java @@ -0,0 +1,4 @@ +package org.ruoyi.domain.vo.knowledge; + +public record KnowledgeReparseVo(int submitted, int skipped, int total) { +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java index b123e6bb..19ef4337 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/EmbeddingModelFactory.java @@ -77,13 +77,22 @@ public class EmbeddingModelFactory { /** * 刷新模型缓存 - * 根据给定的嵌入模型ID从缓存中移除对应的模型 + * 根据给定的嵌入模型ID解析模型名称后,从缓存中移除对应的模型 * * @param embeddingModelId 嵌入模型的唯一标识ID */ public void refreshModel(Long embeddingModelId) { - // 从模型缓存中移除指定ID的模型 - modelCache.remove(embeddingModelId); + ChatModelVo modelConfig = chatModelService.queryById(embeddingModelId); + if (modelConfig != null) { + modelCache.remove(modelConfig.getModelName()); + } + } + + /** + * 按模型名称刷新缓存 + */ + public void refreshModelByName(String embeddingModelName) { + modelCache.remove(embeddingModelName); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java index 2b9ff9fe..62ede6ef 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/RerankModelFactory.java @@ -55,12 +55,22 @@ public class RerankModelFactory { /** * 刷新模型缓存 - * 根据给定的模型ID从缓存中移除对应的模型 + * 根据给定的模型ID解析模型名称后,从缓存中移除对应的模型 * * @param modelId 模型的唯一标识ID */ public void refreshModel(Long modelId) { - modelCache.remove(modelId); + ChatModelVo modelConfig = chatModelService.queryById(modelId); + if (modelConfig != null) { + modelCache.remove(modelConfig.getModelName()); + } + } + + /** + * 按模型名称刷新缓存 + */ + public void refreshModelByName(String modelName) { + modelCache.remove(modelName); } /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/ResourceLoaderFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/ResourceLoaderFactory.java index de293426..0e5f601c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/ResourceLoaderFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/ResourceLoaderFactory.java @@ -17,7 +17,7 @@ public class ResourceLoaderFactory { private final ExcelTextSplitter excelTextSplitter; public ResourceLoader getLoaderByFileType(String fileType) { - fileType = StringUtils.removeStart(fileType, "."); + fileType = StringUtils.lowerCase(StringUtils.removeStart(StringUtils.trim(fileType), ".")); if (FileTypeConstants.isTextFile(fileType)) { return new TextFileLoader(characterTextSplitter); } else if (FileTypeConstants.isWord(fileType)) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java index a66ea66c..4c4fe536 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/factory/VectorStoreStrategyFactory.java @@ -10,6 +10,7 @@ import org.ruoyi.service.vector.impl.MilvusVectorStoreStrategy; import org.ruoyi.service.vector.impl.QdrantVectorStoreStrategy; import org.ruoyi.service.vector.impl.WeaviateVectorStoreStrategy; import org.springframework.stereotype.Component; +import org.ruoyi.common.core.exception.ServiceException; import java.util.HashMap; import java.util.Map; @@ -45,14 +46,20 @@ public class VectorStoreStrategyFactory { * 获取当前配置的向量库策略 */ public VectorStoreService getStrategy() { - String vectorStoreType = vectorStoreProperties.getType(); + return getStrategy(null); + } + + public VectorStoreService getStrategy(String requestedType) { + String vectorStoreType = requestedType; + if (vectorStoreType == null || vectorStoreType.trim().isEmpty()) { + vectorStoreType = vectorStoreProperties.getType(); + } if (vectorStoreType == null || vectorStoreType.trim().isEmpty()) { vectorStoreType = "weaviate"; // 默认使用weaviate } VectorStoreService strategy = strategies.get(vectorStoreType.toLowerCase()); if (strategy == null) { - log.warn("未找到向量库策略: {}, 使用默认策略: weaviate", vectorStoreType); - strategy = strategies.get("weaviate"); + throw new ServiceException("不支持的向量库类型: " + vectorStoreType); } log.debug("使用向量库策略: {}", vectorStoreType); return strategy; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java index 05b4d780..f6c7aeff 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeAttachMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Select; import org.ruoyi.domain.entity.knowledge.KnowledgeAttach; import org.ruoyi.domain.vo.knowledge.KnowledgeAttachVo; import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus; +import java.util.List; +import java.util.Map; /** * 知识库附件Mapper接口 @@ -21,4 +23,10 @@ public interface KnowledgeAttachMapper extends BaseMapperPlusSELECT knowledge_id AS knowledgeId, COUNT(*) AS documentCount " + + "FROM knowledge_attach WHERE knowledge_id IN " + + "#{id} " + + "GROUP BY knowledge_id") + List> countByKnowledgeIds(@Param("knowledgeIds") List knowledgeIds); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java index 304bb7d5..f710b9ac 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/knowledge/KnowledgeFragmentMapper.java @@ -34,7 +34,7 @@ public interface KnowledgeFragmentMapper extends BaseMapperPlus") List selectFragmentCountByDocIds(@Param("docIds") List docIds); @Select("