10 天以前 f818fa5eca223270f0656e0455c338ec1f2fac88
```
refactor(knowledge-base): 重构RAG向量检索功能的文件关联和异步处理

- 将文件关联接口从 /storageAttachment/add 更改为 /knowledgeBase/file/save
- 修改请求参数格式,使用 knowledgeBaseId 和 storageBlobIds 数组
- 重命名文件列表查询接口为查询向量化状态接口
- 更新删除接口路径为 /knowledgeBase/file/delete
- 添加文件扩展名获取方法并优化文件信息获取逻辑
- 配置异步任务线程池支持 @Async 注解
- 添加详细的异步处理日志记录
- 更新前端文件关联触发逻辑以匹配新接口
- 优化向量化处理流程和错误处理机制
```
已添加1个文件
已修改4个文件
223 ■■■■ 文件已修改
doc/20260608_知识库RAG向量检索功能前端联调文档.md 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/RuoYiApplication.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/controller/KnowledgeBaseController.java 27 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/framework/config/AsyncConfig.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260608_֪ʶ¿âRAGÏòÁ¿¼ìË÷¹¦ÄÜǰ¶ËÁªµ÷Îĵµ.md
@@ -78,31 +78,24 @@
}
```
### 3.2 ä¿å­˜çŸ¥è¯†åº“文件关联
### 3.2 ä¿å­˜çŸ¥è¯†åº“文件关联(触发向量化)
上传完成后,调用附件保存接口关联文件到知识库。
**重要**:上传完成后,**必须**调用此接口来关联文件并触发向量化处理。
**接口地址**:`POST /storageAttachment/add`
**接口地址**:`POST /knowledgeBase/file/save`
**请求参数**:
```json
{
  "recordType": "knowledge_base",
  "recordId": 10,
  "application": "rag_file",
  "storageBlobDTOs": [
    { "id": 123 },
    { "id": 124 }
  ]
  "knowledgeBaseId": 10,
  "storageBlobIds": [123, 124]
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| recordType | String | æ˜¯ | å›ºå®šå€¼ `knowledge_base` |
| recordId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| application | String | æ˜¯ | å›ºå®šå€¼ `rag_file`,标识RAG文件 |
| storageBlobDTOs | List | æ˜¯ | ä¸Šä¼ è¿”回的文件blob列表 |
| knowledgeBaseId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| storageBlobIds | List<Long> | æ˜¯ | ä¸Šä¼ è¿”回的文件blob ID列表 |
**响应结果**:
```json
@@ -112,55 +105,16 @@
}
```
### 3.3 çŸ¥è¯†åº“文件列表
**调用时机**:在 `/common/upload` ä¸Šä¼ æˆåŠŸåŽï¼Œç«‹å³è°ƒç”¨æ­¤æŽ¥å£ã€‚
**接口地址**:`GET /storageAttachment/list`
**注意**:此接口会:
1. ä¿å­˜é™„件关联到 `storage_attachment` è¡¨
2. åˆ›å»ºå‘量记录到 `knowledge_base_vector` è¡¨
3. **异步触发向量化处理**(文件切片 â†’ å‘量嵌入 â†’ å­˜å…¥Pinecone)
**请求参数**:
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| recordType | String | æ˜¯ | å›ºå®šå€¼ `knowledge_base` |
| recordId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| application | String | å¦ | å›ºå®šå€¼ `rag_file` |
---
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "id": 1,
      "storageBlobId": 123,
      "name": "操作手册.docx",
      "url": "/profile/upload/20260608/xxx.docx",
      "previewURL": "/common/preview/xxx?token=yyy",
      "downloadURL": "/common/download/xxx?token=yyy",
      "createTime": "2026-06-08 10:00:00"
    }
  ]
}
```
### 3.4 çŸ¥è¯†åº“文件删除
**接口地址**:`DELETE /storageAttachment/delete`
**请求参数**:
```json
{
  "ids": [1, 2, 3]
}
```
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
### 3.5 æŸ¥è¯¢å‘量化状态(新增接口)
### 3.3 æŸ¥è¯¢å‘量化状态(推荐使用)
**接口地址**:`GET /knowledgeBase/vector/status/{knowledgeBaseId}`
@@ -176,13 +130,35 @@
      "fileType": "docx",
      "vectorStatus": 2,
      "chunkCount": 15,
      "namespace": "kb-10"
      "namespace": "kb-10",
      "vectorError": null
    }
  ]
}
```
### 3.6 é‡æ–°å‘量化文件
### 3.4 åˆ é™¤çŸ¥è¯†åº“文件
**接口地址**:`DELETE /knowledgeBase/file/delete`
**请求参数**:
```json
{
  "ids": [1, 2, 3]
}
```
**注意**:此接口会同时删除向量库中的相关数据。
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
### 3.5 é‡æ–°å‘量化文件
**接口地址**:`POST /knowledgeBase/vector/reprocess/{vectorId}`
@@ -194,7 +170,7 @@
}
```
### 3.7 çŸ¥è¯†åº“问答接口
### 3.6 çŸ¥è¯†åº“问答接口
**接口地址**:`POST /ai/knowledge/chat`(流式返回)
@@ -219,7 +195,7 @@
1. ç™»å½•系统后进入审批管理模块...
```
### 3.8 çŸ¥è¯†åº“问答会话历史
### 3.7 çŸ¥è¯†åº“问答会话历史
**接口地址**:`GET /ai/knowledge/history/{memoryId}`
@@ -320,12 +296,12 @@
  return true
}
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜é™„ä»¶å…³è”
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜æ–‡ä»¶å…³è”å¹¶è§¦å‘å‘é‡åŒ–
const handleUploadSuccess = async (response, file) => {
  if (response.code === 200) {
    uploadedBlobs.value.push(...response.data)
    // è°ƒç”¨é™„件保存接口,关联到知识库
    await saveAttachment()
    // è°ƒç”¨çŸ¥è¯†åº“文件保存接口,关联文件并触发向量化
    await saveKnowledgeBaseFiles()
    ElMessage.success('文件上传成功,正在处理向量化...')
    refreshVectorStatus()
  } else {
@@ -333,13 +309,11 @@
  }
}
// ä¿å­˜é™„件关联到知识库
const saveAttachment = async () => {
  await request.post('/storageAttachment/add', {
    recordType: 'knowledge_base',
    recordId: selectedKnowledgeBase.value,
    application: 'rag_file',
    storageBlobDTOs: uploadedBlobs.value.map(b => ({ id: b.id }))
// ä¿å­˜æ–‡ä»¶å…³è”到知识库并触发向量化
const saveKnowledgeBaseFiles = async () => {
  await request.post('/knowledgeBase/file/save', {
    knowledgeBaseId: selectedKnowledgeBase.value,
    storageBlobIds: uploadedBlobs.value.map(b => b.id)
  })
  uploadedBlobs.value = []
}
@@ -409,7 +383,7 @@
}
const deleteFile = async (row) => {
  await request.delete('/storageAttachment/delete', { data: [row.id] })
  await request.delete('/knowledgeBase/file/delete', { data: [row.id] })
  ElMessage.success('删除成功')
  refreshFileList()
}
@@ -558,21 +532,26 @@
## ä¸ƒã€ä¸šåŠ¡æµç¨‹
### 7.1 æ–‡ä»¶ä¸Šä¼ æµç¨‹
### 7.1 æ–‡ä»¶ä¸Šä¼ æµç¨‹ï¼ˆé‡è¦ï¼‰
```
前端调用 /common/upload ä¸Šä¼ æ–‡ä»¶
    â†“
获取 StorageBlobVO åˆ—表(包含blobId、预览URL、下载URL)
    â†“
调用 /storageAttachment/add å…³è”文件到知识库
调用 /knowledgeBase/file/save å…³è”文件到知识库
    â†“
后端监听附件保存事件 â†’ æå–文件文本 â†’ åˆ‡ç‰‡ â†’ å‘量化
后端创建向量记录 â†’ å¼‚步触发向量化处理
    â†“
存入 Pinecone å‘量库(命名空间: kb-{knowledgeBaseId})
异步任务:提取文件文本 â†’ åˆ‡ç‰‡ â†’ è°ƒç”¨Embedding模型生成向量 â†’ å­˜å…¥Pinecone
    â†“
更新 knowledge_base_vector è¡¨çŠ¶æ€ä¸ºå·²å®Œæˆ
```
**关键点**:
- å¿…须调用 `/knowledgeBase/file/save` æŽ¥å£æ‰èƒ½è§¦å‘向量化
- å‘量化是异步处理的,不会阻塞请求
- å‰ç«¯å¯é€šè¿‡è½®è¯¢ `/knowledgeBase/vector/status/{knowledgeBaseId}` æŸ¥çœ‹å¤„理进度
### 7.2 çŸ¥è¯†åº“问答流程
@@ -598,16 +577,9 @@
每个知识库使用独立命名空间:`kb-{knowledgeBaseId}`
### 8.3 é™„件关联参数
| å‚æ•° | å€¼ | è¯´æ˜Ž |
|------|-----|------|
| recordType | `knowledge_base` | ä½¿ç”¨å·²æœ‰æžšä¸¾ |
| application | `rag_file` | æ ‡è¯†ä¸ºRAG文件 |
## ä¹ã€æ³¨æ„äº‹é¡¹
1. æ–‡ä»¶ä¸Šä¼ ä½¿ç”¨ç³»ç»Ÿå·²æœ‰çš„ `/common/upload` å’Œ `/storageAttachment/add` æŽ¥å£
1. **文件上传必须调用 `/knowledgeBase/file/save` è§¦å‘向量化**
2. åˆ é™¤æ–‡ä»¶æ—¶åŒæ­¥åˆ é™¤å‘量库中的相关切片
3. å¤§æ–‡ä»¶å‘量化可能耗时较长,前端需轮询状态或显示进度
4. çŸ¥è¯†åº“问答依赖向量检索质量,建议优化切片策略
src/main/java/com/ruoyi/RuoYiApplication.java
@@ -3,6 +3,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
@@ -12,6 +13,7 @@
 */
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
@EnableScheduling
@EnableAsync
public class RuoYiApplication
{
    public static void main(String[] args)
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java
@@ -46,13 +46,15 @@
    private static final int CHUNK_OVERLAP = 100;
    @Override
    @Async
    @Async("threadPoolTaskExecutor")
    public void processVectorAsync(Long vectorId) {
        log.info("开始异步向量化处理: vectorId={}, thread={}", vectorId, Thread.currentThread().getName());
        processVector(vectorId);
    }
    @Override
    public void processVector(Long vectorId) {
        log.info("开始处理向量化: vectorId={}", vectorId);
        KnowledgeBaseVector vector = knowledgeBaseVectorService.getById(vectorId);
        if (vector == null) {
            log.error("向量记录不存在: {}", vectorId);
@@ -61,30 +63,38 @@
        try {
            // æ›´æ–°çŠ¶æ€ä¸ºå¤„ç†ä¸­
            log.info("更新状态为处理中: vectorId={}", vectorId);
            knowledgeBaseVectorService.updateVectorStatus(vectorId,
                    KnowledgeBaseVector.STATUS_PROCESSING, null, null);
            // èŽ·å–æ–‡ä»¶å†…å®¹
            log.info("获取文件信息: storageBlobId={}", vector.getStorageBlobId());
            StorageBlob blob = storageBlobService.getById(vector.getStorageBlobId());
            if (blob == null) {
                throw new RuntimeException("文件不存在: " + vector.getStorageBlobId());
            }
            File file = getFile(blob);
            log.info("文件路径: {}, æ˜¯å¦å­˜åœ¨: {}", file.getAbsolutePath(), file.exists());
            // ç›´æŽ¥è¯»å–文件内容,不使用 MultipartFile åŒ…装
            log.info("提取文件内容: fileName={}", vector.getFileName());
            String content = extractFileContent(file, vector.getFileName(), blob.getContentType());
            log.info("文件内容长度: {}", content != null ? content.length() : 0);
            if (content == null || content.trim().isEmpty()) {
                throw new RuntimeException("文件内容为空");
            }
            // æ–‡æœ¬åˆ‡ç‰‡
            log.info("开始文本切片");
            List<TextSegment> chunks = splitText(content, vector);
            log.info("切片完成,共 {} ä¸ªå—", chunks.size());
            // æ‰¹é‡ç”ŸæˆåµŒå…¥å‘量并存储
            int chunkCount = 0;
            for (TextSegment chunk : chunks) {
                log.debug("处理第 {} ä¸ªå—", chunkCount + 1);
                Embedding embedding = embeddingModel.embed(chunk).content();
                embeddingStore.add(embedding, chunk);
                chunkCount++;
src/main/java/com/ruoyi/approve/controller/KnowledgeBaseController.java
@@ -4,14 +4,13 @@
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.approve.dto.KnowledgeBaseVectorVO;
import com.ruoyi.approve.pojo.KnowledgeBase;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import com.ruoyi.approve.service.KnowledgeBaseService;
import com.ruoyi.approve.service.KnowledgeBaseVectorService;
import com.ruoyi.basic.dto.StorageAttachmentDTO;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.pojo.StorageAttachment;
import com.ruoyi.basic.pojo.StorageBlob;
import com.ruoyi.basic.service.StorageAttachmentService;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.basic.service.StorageBlobService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
@@ -31,6 +30,7 @@
    private KnowledgeBaseService knowledgeBaseService;
    private KnowledgeBaseVectorService knowledgeBaseVectorService;
    private StorageAttachmentService storageAttachmentService;
    private StorageBlobService storageBlobService;
    /**
     * èŽ·å–åˆ—è¡¨
@@ -128,18 +128,10 @@
        // åˆ›å»ºå‘量记录并触发向量化
        for (Long blobId : dto.getStorageBlobIds()) {
            // èŽ·å–æ–‡ä»¶ä¿¡æ¯
            var blob = storageAttachmentService.getBaseMapper()
                    .selectOne(com.baomidou.mybatisplus.core.toolkit.Wrappers.<StorageAttachment>lambdaQuery()
                            .eq(StorageAttachment::getStorageBlobId, blobId)
                            .eq(StorageAttachment::getRecordType, "knowledge_base")
                            .eq(StorageAttachment::getRecordId, dto.getKnowledgeBaseId())
                            .last("limit 1"));
            StorageBlob blob = storageBlobService.getById(blobId);
            if (blob != null) {
                // èŽ·å–æ–‡ä»¶åï¼Œéœ€è¦ä»Ž storage_blob è¡¨èŽ·å–
                // è¿™é‡Œç®€åŒ–处理,实际需要查询 storage_blob è¡¨
                String fileName = "file_" + blobId;
                String fileType = "unknown";
                String fileName = blob.getOriginalFilename();
                String fileType = getFileExtension(fileName);
                knowledgeBaseVectorService.createVectorRecord(
                        dto.getKnowledgeBaseId(),
@@ -153,6 +145,13 @@
        return AjaxResult.success();
    }
    private String getFileExtension(String fileName) {
        if (fileName == null || !fileName.contains(".")) {
            return "unknown";
        }
        return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
    }
    /**
     * åˆ é™¤çŸ¥è¯†åº“文件
     */
src/main/java/com/ruoyi/framework/config/AsyncConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.ruoyi.framework.config;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
 * å¼‚步任务配置
 * ç”¨äºŽé…ç½® @Async æ³¨è§£ä½¿ç”¨çš„线程池
 */
@Configuration
public class AsyncConfig implements AsyncConfigurer {
    @Autowired
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;
    @Override
    public Executor getAsyncExecutor() {
        return threadPoolTaskExecutor;
    }
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            System.err.println("异步任务执行异常: " + method.getName() + " å‚æ•°: " + params);
            throwable.printStackTrace();
        };
    }
}