feat(ai): 优化AI模块路由,支持文档分片管理功能

- 将AI对话与知识库控制器基础路径改为/api/v1/前缀
- 修改ChatRequest中sessionId类型为String,统一会话ID处理
- 新增DocumentChunkVO用于表示文档分片实体
- 在AiKnowledgeBaseService及实现类中添加获取文档分片列表及详情接口
- 在AiKnowledgeBaseController新增两个REST API:获取文档分片列表和获取分片详情
- AiDocumentMapper新增数据库操作方法selectDocumentChunks和selectChunkById
- 修改向量存储切片保存逻辑,确保分片含完整项目和文档属性元数据
- 优化文档分片持久化时更新原始
This commit is contained in:
2026-03-30 18:12:37 +08:00
parent 4ca9c63302
commit 4399550418
10 changed files with 235 additions and 42 deletions

View File

@@ -25,7 +25,7 @@ import java.util.UUID;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/ai/chat") @RequestMapping("/api/v1/ai/chat")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "AI对话", description = "AI智能问答相关接口") @Tag(name = "AI对话", description = "AI智能问答相关接口")
public class AiChatController { public class AiChatController {

View File

@@ -3,6 +3,7 @@ package cn.yinlihupo.controller.ai;
import cn.yinlihupo.common.core.BaseResponse; import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.util.ResultUtils; import cn.yinlihupo.common.util.ResultUtils;
import cn.yinlihupo.common.util.SecurityUtils; import cn.yinlihupo.common.util.SecurityUtils;
import cn.yinlihupo.domain.vo.DocumentChunkVO;
import cn.yinlihupo.domain.vo.KbDocumentVO; import cn.yinlihupo.domain.vo.KbDocumentVO;
import cn.yinlihupo.service.ai.AiKnowledgeBaseService; import cn.yinlihupo.service.ai.AiKnowledgeBaseService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -20,7 +21,7 @@ import java.util.List;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/ai/kb") @RequestMapping("/api/v1/ai/kb")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "AI知识库", description = "AI知识库文档管理相关接口") @Tag(name = "AI知识库", description = "AI知识库文档管理相关接口")
public class AiKnowledgeBaseController { public class AiKnowledgeBaseController {
@@ -134,4 +135,53 @@ public class AiKnowledgeBaseController {
return ResultUtils.error("重新索引失败: " + e.getMessage()); return ResultUtils.error("重新索引失败: " + e.getMessage());
} }
} }
/**
* 获取文档分片列表
*
* @param docId 文档UUID
* @return 分片列表
*/
@GetMapping("/document/{docId}/chunks")
@Operation(summary = "获取文档分片列表", description = "获取指定文档的所有分片信息")
public BaseResponse<List<DocumentChunkVO>> getDocumentChunks(@PathVariable String docId) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
return ResultUtils.error("用户未登录");
}
try {
List<DocumentChunkVO> chunks = knowledgeBaseService.getDocumentChunks(docId);
return ResultUtils.success("查询成功", chunks);
} catch (Exception e) {
log.error("获取文档分片失败: {}", e.getMessage(), e);
return ResultUtils.error("获取文档分片失败: " + e.getMessage());
}
}
/**
* 获取分片详情
*
* @param chunkId 分片ID
* @return 分片详情
*/
@GetMapping("/chunk/{chunkId}")
@Operation(summary = "获取分片详情", description = "获取指定分片的详细信息")
public BaseResponse<DocumentChunkVO> getChunkDetail(@PathVariable String chunkId) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
return ResultUtils.error("用户未登录");
}
try {
DocumentChunkVO chunk = knowledgeBaseService.getChunkDetail(chunkId);
if (chunk == null) {
return ResultUtils.error("分片不存在");
}
return ResultUtils.success("查询成功", chunk);
} catch (Exception e) {
log.error("获取分片详情失败: {}", e.getMessage(), e);
return ResultUtils.error("获取分片详情失败: " + e.getMessage());
}
}
} }

View File

@@ -2,8 +2,6 @@ package cn.yinlihupo.domain.dto;
import lombok.Data; import lombok.Data;
import java.util.UUID;
/** /**
* AI对话请求DTO * AI对话请求DTO
*/ */
@@ -13,7 +11,7 @@ public class ChatRequest {
/** /**
* 会话ID为空则新建会话 * 会话ID为空则新建会话
*/ */
private UUID sessionId; private String sessionId;
/** /**
* 项目ID必填 * 项目ID必填

View File

@@ -0,0 +1,55 @@
package cn.yinlihupo.domain.vo;
import lombok.Data;
/**
* 文档分片VO
*/
@Data
public class DocumentChunkVO {
/**
* 分片ID
*/
private String id;
/**
* 原始文档ID
*/
private String docId;
/**
* 分片内容
*/
private String content;
/**
* 分片序号
*/
private Integer chunkIndex;
/**
* 总分片数
*/
private Integer chunkTotal;
/**
* 文档标题
*/
private String title;
/**
* 文档类型
*/
private String docType;
/**
* 来源类型
*/
private String sourceType;
/**
* 状态
*/
private String status;
}

View File

@@ -1,6 +1,7 @@
package cn.yinlihupo.mapper; package cn.yinlihupo.mapper;
import cn.yinlihupo.domain.entity.AiDocument; import cn.yinlihupo.domain.entity.AiDocument;
import cn.yinlihupo.domain.vo.DocumentChunkVO;
import cn.yinlihupo.domain.vo.KbDocumentVO; import cn.yinlihupo.domain.vo.KbDocumentVO;
import cn.yinlihupo.domain.vo.ReferencedDocVO; import cn.yinlihupo.domain.vo.ReferencedDocVO;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@@ -88,4 +89,21 @@ public interface AiDocumentMapper extends BaseMapper<AiDocument> {
* @return 影响行数 * @return 影响行数
*/ */
int incrementQueryCount(@Param("id") String id); int incrementQueryCount(@Param("id") String id);
/**
* 查询文档分片列表
* 通过 metadata 中的 doc_id 字段查询关联的分片
*
* @param docId 文档ID
* @return 分片列表
*/
List<DocumentChunkVO> selectDocumentChunks(@Param("docId") String docId);
/**
* 查询文档分片详情
*
* @param chunkId 分片ID
* @return 分片详情
*/
DocumentChunkVO selectChunkById(@Param("chunkId") String chunkId);
} }

View File

@@ -1,5 +1,6 @@
package cn.yinlihupo.service.ai; package cn.yinlihupo.service.ai;
import cn.yinlihupo.domain.vo.DocumentChunkVO;
import cn.yinlihupo.domain.vo.KbDocumentVO; import cn.yinlihupo.domain.vo.KbDocumentVO;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -57,4 +58,20 @@ public interface AiKnowledgeBaseService {
* @param docId 文档ID * @param docId 文档ID
*/ */
void processDocumentAsync(String docId); void processDocumentAsync(String docId);
/**
* 获取文档分片列表
*
* @param docId 文档ID
* @return 分片列表
*/
List<DocumentChunkVO> getDocumentChunks(String docId);
/**
* 获取分片详情
*
* @param chunkId 分片ID
* @return 分片详情
*/
DocumentChunkVO getChunkDetail(String chunkId);
} }

View File

@@ -67,24 +67,24 @@ public class AiChatServiceImpl implements AiChatService {
@Override @Override
public void streamChat(ChatRequest request, Long userId, SseEmitter emitter) { public void streamChat(ChatRequest request, Long userId, SseEmitter emitter) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
UUID sessionId = request.getSessionId(); String sessionId = request.getSessionId();
boolean isNewSession = (sessionId == null); boolean isNewSession = (sessionId == null);
try { try {
// 1. 获取或创建会话 // 1. 获取或创建会话
if (isNewSession) { if (isNewSession) {
sessionId = UUID.randomUUID(); sessionId = UUID.randomUUID().toString();
String title = generateSessionTitle(request.getMessage()); String title = generateSessionTitle(request.getMessage());
createSession(userId, request.getProjectId(), request.getTimelineNodeId(), request.getMessage(), title); createSession(userId, request.getProjectId(), request.getTimelineNodeId(), request.getMessage(), title);
} else { } else {
// 验证会话权限 // 验证会话权限
if (!hasSessionAccess(sessionId, userId)) { if (!hasSessionAccess(UUID.fromString(sessionId), userId)) {
sendError(emitter, "无权访问该会话"); sendError(emitter, "无权访问该会话");
return; return;
} }
} }
final UUID finalSessionId = sessionId; final String finalSessionId = sessionId;
// 发送开始消息 // 发送开始消息
sendEvent(emitter, "start", Map.of( sendEvent(emitter, "start", Map.of(
@@ -107,7 +107,7 @@ public class AiChatServiceImpl implements AiChatService {
// 4. 构建Prompt // 4. 构建Prompt
String systemPrompt = buildSystemPrompt(request.getProjectId(), retrievedDocs); String systemPrompt = buildSystemPrompt(request.getProjectId(), retrievedDocs);
List<Message> messages = buildMessages(finalSessionId, request.getContextWindow(), List<Message> messages = buildMessages(UUID.fromString(finalSessionId) , request.getContextWindow(),
systemPrompt, request.getMessage()); systemPrompt, request.getMessage());
// 5. 流式调用LLM // 5. 流式调用LLM
@@ -331,15 +331,15 @@ public class AiChatServiceImpl implements AiChatService {
/** /**
* 保存消息 * 保存消息
*/ */
private Long saveMessage(UUID sessionId, Long userId, Long projectId, private Long saveMessage(String sessionId, Long userId, Long projectId,
Long timelineNodeId, String role, String content, Long timelineNodeId, String role, String content,
String referencedDocIds) { String referencedDocIds) {
// 获取当前最大序号 // 获取当前最大序号
Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(sessionId); Integer maxIndex = chatHistoryMapper.selectMaxMessageIndex(UUID.fromString(sessionId));
int nextIndex = (maxIndex != null ? maxIndex : 0) + 1; int nextIndex = (maxIndex != null ? maxIndex : 0) + 1;
AiChatMessage message = new AiChatMessage(); AiChatMessage message = new AiChatMessage();
message.setSessionId(sessionId); message.setSessionId(UUID.fromString(sessionId));
message.setUserId(userId); message.setUserId(userId);
message.setProjectId(projectId); message.setProjectId(projectId);
message.setTimelineNodeId(timelineNodeId); message.setTimelineNodeId(timelineNodeId);

View File

@@ -1,6 +1,7 @@
package cn.yinlihupo.service.ai.impl; package cn.yinlihupo.service.ai.impl;
import cn.yinlihupo.domain.entity.AiDocument; import cn.yinlihupo.domain.entity.AiDocument;
import cn.yinlihupo.domain.vo.DocumentChunkVO;
import cn.yinlihupo.domain.vo.KbDocumentVO; import cn.yinlihupo.domain.vo.KbDocumentVO;
import cn.yinlihupo.mapper.AiDocumentMapper; import cn.yinlihupo.mapper.AiDocumentMapper;
import cn.yinlihupo.service.ai.AiKnowledgeBaseService; import cn.yinlihupo.service.ai.AiKnowledgeBaseService;
@@ -209,4 +210,14 @@ public class AiKnowledgeBaseServiceImpl implements AiKnowledgeBaseService {
vo.setCreateTime(doc.getCreateTime()); vo.setCreateTime(doc.getCreateTime());
return vo; return vo;
} }
@Override
public List<DocumentChunkVO> getDocumentChunks(String docId) {
return documentMapper.selectDocumentChunks(docId);
}
@Override
public DocumentChunkVO getChunkDetail(String chunkId) {
return documentMapper.selectChunkById(chunkId);
}
} }

View File

@@ -14,6 +14,7 @@ import org.springframework.stereotype.Component;
import java.io.InputStream; import java.io.InputStream;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@@ -165,44 +166,51 @@ public class DocumentProcessor {
/** /**
* 存储切片到向量库 * 存储切片到向量库
* 每个分片都是独立记录,包含完整的项目属性用于检索
* *
* @param parentDoc 父文档 * @param doc 文档实体(包含项目属性)
* @param chunks 切片列表 * @param chunks 切片列表
*/ */
private void storeChunks(AiDocument parentDoc, List<String> chunks) { private void storeChunks(AiDocument doc, List<String> chunks) {
String parentId = parentDoc.getId(); String docId = doc.getId();
for (int i = 0; i < chunks.size(); i++) { for (int i = 0; i < chunks.size(); i++) {
String chunkContent = chunks.get(i); String chunkContent = chunks.get(i);
// 使用UUID生成唯一的chunk ID确保格式正确 // 使用UUID生成唯一的chunk ID确保格式正确
String chunkId = UUID.randomUUID().toString(); String chunkId = UUID.randomUUID().toString();
// 创建向量文档 // 创建向量文档,每个分片都包含完整的项目属性
Document vectorDoc = new Document( Map<String, Object> metadata = new HashMap<>();
chunkId, // 项目关联属性(用于检索过滤)
chunkContent, metadata.put("project_id", doc.getProjectId() != null ? doc.getProjectId().toString() : "");
Map.of( metadata.put("timeline_node_id", doc.getTimelineNodeId() != null ? doc.getTimelineNodeId().toString() : "");
"project_id", parentDoc.getProjectId() != null ? parentDoc.getProjectId().toString() : "", metadata.put("kb_id", doc.getKbId() != null ? doc.getKbId().toString() : "");
"timeline_node_id", parentDoc.getTimelineNodeId() != null ? parentDoc.getTimelineNodeId().toString() : "", // 文档来源信息
"chunk_index", i, metadata.put("source_type", doc.getSourceType() != null ? doc.getSourceType() : "");
"chunk_total", chunks.size(), metadata.put("source_id", doc.getSourceId() != null ? doc.getSourceId().toString() : "");
"chunk_parent_id", parentId, // 文档信息
"title", parentDoc.getTitle() != null ? parentDoc.getTitle() : "", metadata.put("doc_id", docId); // 原始文档ID用于关联查询
"source_type", parentDoc.getSourceType() != null ? parentDoc.getSourceType() : "", metadata.put("title", doc.getTitle() != null ? doc.getTitle() : "");
"status", "active" metadata.put("doc_type", doc.getDocType() != null ? doc.getDocType() : "");
) metadata.put("file_type", doc.getFileType() != null ? doc.getFileType() : "");
); // 分片信息
metadata.put("chunk_index", i);
metadata.put("chunk_total", chunks.size());
// 状态
metadata.put("status", "active");
Document vectorDoc = new Document(chunkId, chunkContent, metadata);
// 存储到向量库 // 存储到向量库
vectorStore.add(List.of(vectorDoc)); vectorStore.add(List.of(vectorDoc));
// 如果是第一个切片,更新父文档内容 log.debug("存储切片: {}/{}, docId: {}, chunkId: {}", i + 1, chunks.size(), docId, chunkId);
if (i == 0) {
parentDoc.setContent(chunkContent);
documentMapper.updateById(parentDoc);
} }
log.debug("存储切片: {}/{}, parentId: {}, chunkId: {}", i + 1, chunks.size(), parentId, chunkId); // 更新文档内容为第一个分片(用于预览)
if (!chunks.isEmpty()) {
doc.setContent(chunks.get(0));
documentMapper.updateById(doc);
} }
} }

View File

@@ -2,7 +2,7 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.yinlihupo.mapper.AiDocumentMapper"> <mapper namespace="cn.yinlihupo.mapper.AiDocumentMapper">
<!-- 获取项目文档列表 --> <!-- 获取项目文档列表(只查询原始文档,不包含分片) -->
<select id="selectProjectDocuments" resultType="cn.yinlihupo.domain.vo.KbDocumentVO"> <select id="selectProjectDocuments" resultType="cn.yinlihupo.domain.vo.KbDocumentVO">
SELECT SELECT
vs.id, vs.id,
@@ -20,7 +20,7 @@
LEFT JOIN sys_user su ON vs.create_by = su.id LEFT JOIN sys_user su ON vs.create_by = su.id
WHERE vs.project_id = #{projectId} WHERE vs.project_id = #{projectId}
AND vs.deleted = 0 AND vs.deleted = 0
AND vs.chunk_parent_id IS NULL AND (vs.metadata->>'doc_id') IS NULL
ORDER BY vs.create_time DESC ORDER BY vs.create_time DESC
</select> </select>
@@ -58,11 +58,11 @@
AND deleted = 0 AND deleted = 0
</select> </select>
<!-- 获取文档的分数量 --> <!-- 获取文档的分数量 -->
<select id="selectChunkCount" resultType="java.lang.Integer"> <select id="selectChunkCount" resultType="java.lang.Integer">
SELECT COUNT(*) SELECT COUNT(*)
FROM vector_store FROM vector_store
WHERE chunk_parent_id = #{docId} WHERE metadata->>'doc_id' = #{docId}
AND deleted = 0 AND deleted = 0
</select> </select>
@@ -98,4 +98,40 @@
WHERE id = #{id} WHERE id = #{id}
</update> </update>
<!-- 查询文档分片列表 -->
<select id="selectDocumentChunks" resultType="cn.yinlihupo.domain.vo.DocumentChunkVO">
SELECT
id,
metadata->>'doc_id' as docId,
content,
CAST(metadata->>'chunk_index' AS INTEGER) as chunkIndex,
CAST(metadata->>'chunk_total' AS INTEGER) as chunkTotal,
metadata->>'title' as title,
metadata->>'doc_type' as docType,
metadata->>'source_type' as sourceType,
metadata->>'status' as status
FROM vector_store
WHERE metadata->>'doc_id' = #{docId}
AND deleted = 0
ORDER BY CAST(metadata->>'chunk_index' AS INTEGER)
</select>
<!-- 查询分片详情 -->
<select id="selectChunkById" resultType="cn.yinlihupo.domain.vo.DocumentChunkVO">
SELECT
id,
metadata->>'doc_id' as docId,
content,
CAST(metadata->>'chunk_index' AS INTEGER) as chunkIndex,
CAST(metadata->>'chunk_total' AS INTEGER) as chunkTotal,
metadata->>'title' as title,
metadata->>'doc_type' as docType,
metadata->>'source_type' as sourceType,
metadata->>'status' as status
FROM vector_store
WHERE id = #{chunkId}
AND deleted = 0
LIMIT 1
</select>
</mapper> </mapper>