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