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

- 将文件关联接口从 /storageAttachment/add 更改为 /knowledgeBase/file/save
- 修改请求参数格式,使用 knowledgeBaseId 和 storageBlobIds 数组
- 重命名文件列表查询接口为查询向量化状态接口
- 更新删除接口路径为 /knowledgeBase/file/delete
- 添加文件扩展名获取方法并优化文件信息获取逻辑
- 配置异步任务线程池支持 @Async 注解
- 添加详细的异步处理日志记录
- 更新前端文件关联触发逻辑以匹配新接口
- 优化向量化处理流程和错误处理机制
```
已修改5个文件
96 ■■■■ 文件已修改
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java 86 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/mapper/KnowledgeBaseVectorMapper.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/collaborativeApproval/service/impl/MeetingServiceImpl.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/collaborativeApproval/vo/SearchMeetingUseVo.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/EmbeddingStoreConfig.java
@@ -25,7 +25,7 @@
        return PineconeEmbeddingStore.builder()
                .apiKey("pcsk_4SJLnh_tNB3wSLJU8tc4E5P28PcXX8eCLdURqZpVhg1FMV8CRYxjneWdzqRdB5Ftqooi9")
                .index("xiaozhi-index")//如果指定的索引不存在,将创建一个新的索引
                .nameSpace("xiaozhi-namespace") //如果指定的名称空间不存在,将创建一个新的名称 空间
                .nameSpace("knowledge-base") //使用自定义命名空间,避免使用 __default__
                .createIndex(PineconeServerlessIndexConfig.builder()
                        .cloud("AWS") //指定索引部署在 AWS 云服务上。
                        .region("us-east-1") //指定索引所在的 AWS 区域为 us-east-1。
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java
@@ -1,6 +1,5 @@
package com.ruoyi.ai.service.impl;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.service.KnowledgeRagService;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import com.ruoyi.approve.service.KnowledgeBaseVectorService;
@@ -10,7 +9,6 @@
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.EmbeddingStore;
@@ -20,6 +18,8 @@
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
@@ -37,13 +37,22 @@
    private final KnowledgeBaseVectorService knowledgeBaseVectorService;
    private final StorageBlobService storageBlobService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;
    private final FileProperties fileProperties;
    private static final int CHUNK_SIZE = 500;
    private static final int CHUNK_OVERLAP = 100;
    /**
     * 文件大小阈值,超过此值才进行切片
     * 80MB = 80 * 1024 * 1024 字节
     */
    private static final long CHUNK_THRESHOLD_BYTES = 80L * 1024 * 1024;
    /**
     * Embedding 模型最大输入长度限制
     * 阿里云 DashScope 限制为 8192 字符
     */
    private static final int EMBEDDING_MAX_LENGTH = 8000;
    @Override
    @Async("threadPoolTaskExecutor")
@@ -76,10 +85,12 @@
            File file = getFile(blob);
            log.info("文件路径: {}, 是否存在: {}", file.getAbsolutePath(), file.exists());
            long fileSize = file.length();
            log.info("文件大小: {} 字节", fileSize);
            // 直接读取文件内容,不使用 MultipartFile 包装
            log.info("提取文件内容: fileName={}", vector.getFileName());
            String content = extractFileContent(file, vector.getFileName(), blob.getContentType());
            String content = extractFileContent(file, vector.getFileName());
            log.info("文件内容长度: {}", content != null ? content.length() : 0);
            if (content == null || content.trim().isEmpty()) {
@@ -87,9 +98,17 @@
            }
            // 文本切片
            log.info("开始文本切片");
            List<TextSegment> chunks = splitText(content, vector);
            log.info("切片完成,共 {} 个块", chunks.size());
            List<TextSegment> chunks;
            boolean needChunk = fileSize > CHUNK_THRESHOLD_BYTES || content.length() > EMBEDDING_MAX_LENGTH;
            if (needChunk) {
                log.info("开始切片: fileSize={}, contentLength={}", fileSize, content.length());
                chunks = splitText(content, vector);
                log.info("切片完成,共 {} 个块", chunks.size());
            } else {
                log.info("文件较小且内容长度{}不超过{},不进行切片", content.length(), EMBEDDING_MAX_LENGTH);
                Map<String, Object> metadata = buildMetadata(vector);
                chunks = List.of(TextSegment.from(content, new dev.langchain4j.data.document.Metadata(metadata)));
            }
            // 批量生成嵌入向量并存储
            int chunkCount = 0;
@@ -158,12 +177,12 @@
    /**
     * 提取文件内容
     */
    private String extractFileContent(File file, String fileName, String contentType) throws Exception {
    private String extractFileContent(File file, String fileName) throws Exception {
        String ext = getFileExtension(fileName);
        // 根据文件类型提取内容
        if (isPlainText(ext)) {
            return Files.readString(file.toPath());
            return readFileWithEncoding(file);
        }
        if ("docx".equals(ext)) {
@@ -179,7 +198,54 @@
        }
        // 默认尝试读取文本
        return Files.readString(file.toPath());
        return readFileWithEncoding(file);
    }
    /**
     * 自动检测文件编码并读取内容
     * 优先尝试 UTF-8,失败则尝试 GBK
     */
    private String readFileWithEncoding(File file) throws Exception {
        byte[] bytes = Files.readAllBytes(file.toPath());
        // 先尝试 UTF-8
        String utf8Content = new String(bytes, StandardCharsets.UTF_8);
        if (isValidUtf8(utf8Content)) {
            log.debug("文件编码: UTF-8");
            return utf8Content;
        }
        // 尝试 GBK
        try {
            Charset gbk = Charset.forName("GBK");
            String gbkContent = new String(bytes, gbk);
            log.debug("文件编码: GBK");
            return gbkContent;
        } catch (Exception e) {
            log.warn("编码检测失败,使用 UTF-8");
            return utf8Content;
        }
    }
    /**
     * 检查 UTF-8 解码是否有效
     */
    private boolean isValidUtf8(String decoded) {
        // 检查是否包含替换字符(说明 UTF-8 解码失败)
        if (decoded.contains("�")) {
            return false;
        }
        // 检查是否有过多的非打印字符(乱码特征)
        int invalidCount = 0;
        for (int i = 0; i < Math.min(decoded.length(), 1000); i++) {
            char c = decoded.charAt(i);
            // 检查私有使用区域或异常的控制字符
            if ((c >= '' && c <= '') || (c < ' ' && c != '\n' && c != '\r' && c != '\t')) {
                invalidCount++;
            }
        }
        // 如果无效字符超过 5%,认为是编码错误
        return invalidCount < Math.min(decoded.length(), 1000) * 0.05;
    }
    private String getFileExtension(String fileName) {
src/main/java/com/ruoyi/approve/mapper/KnowledgeBaseVectorMapper.java
@@ -34,6 +34,6 @@
    /**
     * 统计知识库的总切片数量
     */
    @Select("SELECT SUM(chunk_count) FROM knowledge_base_vector WHERE knowledge_base_id = #{knowledgeBaseId} AND vector_status = 2")
    @Select("SELECT COALESCE(SUM(chunk_count), 0) FROM knowledge_base_vector WHERE knowledge_base_id = #{knowledgeBaseId} AND vector_status = 2")
    int sumChunkCountByKnowledgeBaseId(@Param("knowledgeBaseId") Long knowledgeBaseId);
}
src/main/java/com/ruoyi/collaborativeApproval/service/impl/MeetingServiceImpl.java
@@ -238,9 +238,9 @@
                    .or()
                    .eq(MeetApplication::getApplicationType, "notification");
        });
        if (Objects.nonNull(vo.getMeetingDate())) {
        if (Objects.nonNull(vo.getDate())) {
            alWrapper.and(wrapper -> {
                wrapper.eq(MeetApplication::getMeetingDate, vo.getMeetingDate());
                wrapper.like(MeetApplication::getMeetingDate, vo.getDate());
            });
        }
        alWrapper.orderByAsc(MeetApplication::getStartTime);
src/main/java/com/ruoyi/collaborativeApproval/vo/SearchMeetingUseVo.java
@@ -16,4 +16,6 @@
    @JsonFormat(pattern = "yyyy-MM-dd")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date meetingDate;
    private String date;
}