Explorar el Código

feat(rag): 实现 RAG 向量化存储功能

- 新增 pgvector 数据表(text_chunks, vector_embeddings)
- 实现文本分块服务(智能句子边界切分)
- 实现 Ollama Embedding 向量化服务
- 实现 pgvector 向量相似度检索
- 实现 DeepSeek API 客户端
- 实现 RAG 核心服务(索引、检索、问答)
- 新增 RAG API Controller
- 集成到 TextStorageService 和 ParseService(自动索引)
何文松 hace 1 mes
padre
commit
f8b3530b1e
Se han modificado 20 ficheros con 1761 adiciones y 18 borrados
  1. 8 1
      backend/ai-service/pom.xml
  2. 125 0
      backend/ai-service/src/main/java/com/lingyue/ai/client/DeepSeekClient.java
  3. 59 0
      backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatMessage.java
  4. 59 0
      backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatRequest.java
  5. 72 0
      backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatResponse.java
  6. 16 1
      backend/graph-service/pom.xml
  7. 50 0
      backend/graph-service/src/main/java/com/lingyue/graph/config/VectorConfig.java
  8. 150 0
      backend/graph-service/src/main/java/com/lingyue/graph/controller/RAGController.java
  9. 49 0
      backend/graph-service/src/main/java/com/lingyue/graph/entity/TextChunk.java
  10. 34 0
      backend/graph-service/src/main/java/com/lingyue/graph/entity/VectorEmbedding.java
  11. 50 0
      backend/graph-service/src/main/java/com/lingyue/graph/repository/TextChunkRepository.java
  12. 93 0
      backend/graph-service/src/main/java/com/lingyue/graph/repository/VectorEmbeddingRepository.java
  13. 159 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/OllamaEmbeddingService.java
  14. 229 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/RAGService.java
  15. 179 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/TextChunkService.java
  16. 32 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/TextStorageService.java
  17. 124 0
      backend/graph-service/src/main/java/com/lingyue/graph/service/VectorSearchService.java
  18. 137 0
      backend/lingyue-starter/src/main/resources/application.properties
  19. 9 16
      backend/parse-service/src/main/java/com/lingyue/parse/service/ParseService.java
  20. 127 0
      backend/sql/rag_tables.sql

+ 8 - 1
backend/ai-service/pom.xml

@@ -24,6 +24,12 @@
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
         
+        <!-- Spring WebFlux (用于 WebClient) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-webflux</artifactId>
+        </dependency>
+        
         <!-- MyBatis Plus -->
         <dependency>
             <groupId>com.baomidou</groupId>
@@ -36,10 +42,11 @@
             <artifactId>postgresql</artifactId>
         </dependency>
         
-        <!-- Nacos Service Discovery -->
+        <!-- Nacos Service Discovery (单体应用设为 optional) -->
         <dependency>
             <groupId>com.alibaba.cloud</groupId>
             <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
+            <optional>true</optional>
         </dependency>
         
         <!-- Common Module -->

+ 125 - 0
backend/ai-service/src/main/java/com/lingyue/ai/client/DeepSeekClient.java

@@ -0,0 +1,125 @@
+package com.lingyue.ai.client;
+
+import com.lingyue.ai.dto.ChatMessage;
+import com.lingyue.ai.dto.ChatRequest;
+import com.lingyue.ai.dto.ChatResponse;
+import com.lingyue.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.util.List;
+
+/**
+ * DeepSeek API 客户端
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Component
+public class DeepSeekClient {
+
+    private final WebClient webClient;
+
+    @Value("${deepseek.api.model:deepseek-chat}")
+    private String defaultModel;
+
+    public DeepSeekClient(
+            @Value("${deepseek.api.url:https://api.deepseek.com}") String apiUrl,
+            @Value("${deepseek.api.key:}") String apiKey
+    ) {
+        this.webClient = WebClient.builder()
+                .baseUrl(apiUrl)
+                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
+                .build();
+    }
+
+    /**
+     * 发送 Chat Completion 请求
+     *
+     * @param request 请求对象
+     * @return 响应对象
+     */
+    public ChatResponse chat(ChatRequest request) {
+        try {
+            ChatResponse response = webClient
+                    .post()
+                    .uri("/v1/chat/completions")
+                    .bodyValue(request)
+                    .retrieve()
+                    .bodyToMono(ChatResponse.class)
+                    .block();
+
+            if (response == null) {
+                throw new ServiceException("DeepSeek 返回空响应");
+            }
+
+            log.debug("DeepSeek 调用成功: model={}, tokens={}",
+                    response.getModel(),
+                    response.getUsage() != null ? response.getUsage().getTotal_tokens() : "N/A");
+
+            return response;
+
+        } catch (Exception e) {
+            log.error("DeepSeek API 调用失败: {}", e.getMessage(), e);
+            throw new ServiceException("AI 服务调用失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 简单文本生成
+     *
+     * @param prompt 提示词
+     * @return 生成的文本
+     */
+    public String complete(String prompt) {
+        ChatRequest request = ChatRequest.builder()
+                .model(defaultModel)
+                .messages(List.of(ChatMessage.user(prompt)))
+                .build();
+
+        ChatResponse response = chat(request);
+        return response.getFirstContent();
+    }
+
+    /**
+     * 带系统提示的文本生成
+     *
+     * @param systemPrompt 系统提示
+     * @param userPrompt   用户提示
+     * @return 生成的文本
+     */
+    public String complete(String systemPrompt, String userPrompt) {
+        ChatRequest request = ChatRequest.builder()
+                .model(defaultModel)
+                .messages(List.of(
+                        ChatMessage.system(systemPrompt),
+                        ChatMessage.user(userPrompt)
+                ))
+                .build();
+
+        ChatResponse response = chat(request);
+        return response.getFirstContent();
+    }
+
+    /**
+     * 多轮对话
+     *
+     * @param messages 消息列表
+     * @return 生成的回复
+     */
+    public String chat(List<ChatMessage> messages) {
+        ChatRequest request = ChatRequest.builder()
+                .model(defaultModel)
+                .messages(messages)
+                .build();
+
+        ChatResponse response = chat(request);
+        return response.getFirstContent();
+    }
+}

+ 59 - 0
backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatMessage.java

@@ -0,0 +1,59 @@
+package com.lingyue.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Chat 消息
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChatMessage {
+
+    /**
+     * 角色:system, user, assistant
+     */
+    private String role;
+
+    /**
+     * 消息内容
+     */
+    private String content;
+
+    /**
+     * 创建系统消息
+     */
+    public static ChatMessage system(String content) {
+        return ChatMessage.builder()
+                .role("system")
+                .content(content)
+                .build();
+    }
+
+    /**
+     * 创建用户消息
+     */
+    public static ChatMessage user(String content) {
+        return ChatMessage.builder()
+                .role("user")
+                .content(content)
+                .build();
+    }
+
+    /**
+     * 创建助手消息
+     */
+    public static ChatMessage assistant(String content) {
+        return ChatMessage.builder()
+                .role("assistant")
+                .content(content)
+                .build();
+    }
+}

+ 59 - 0
backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatRequest.java

@@ -0,0 +1,59 @@
+package com.lingyue.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * DeepSeek Chat API 请求
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChatRequest {
+
+    /**
+     * 模型名称
+     */
+    private String model;
+
+    /**
+     * 消息列表
+     */
+    private List<ChatMessage> messages;
+
+    /**
+     * 温度参数(0-2,默认1)
+     */
+    @Builder.Default
+    private Double temperature = 0.7;
+
+    /**
+     * 最大生成 Token 数
+     */
+    @Builder.Default
+    private Integer max_tokens = 2048;
+
+    /**
+     * 是否流式返回
+     */
+    @Builder.Default
+    private Boolean stream = false;
+
+    /**
+     * 快速创建请求
+     */
+    public static ChatRequest of(String model, List<ChatMessage> messages) {
+        return ChatRequest.builder()
+                .model(model)
+                .messages(messages)
+                .build();
+    }
+}

+ 72 - 0
backend/ai-service/src/main/java/com/lingyue/ai/dto/ChatResponse.java

@@ -0,0 +1,72 @@
+package com.lingyue.ai.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * DeepSeek Chat API 响应
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Data
+public class ChatResponse {
+
+    /**
+     * 响应ID
+     */
+    private String id;
+
+    /**
+     * 对象类型
+     */
+    private String object;
+
+    /**
+     * 创建时间戳
+     */
+    private Long created;
+
+    /**
+     * 使用的模型
+     */
+    private String model;
+
+    /**
+     * 选择列表
+     */
+    private List<Choice> choices;
+
+    /**
+     * Token 使用统计
+     */
+    private Usage usage;
+
+    /**
+     * 获取第一个回复内容
+     */
+    public String getFirstContent() {
+        if (choices != null && !choices.isEmpty()) {
+            Choice choice = choices.get(0);
+            if (choice.getMessage() != null) {
+                return choice.getMessage().getContent();
+            }
+        }
+        return null;
+    }
+
+    @Data
+    public static class Choice {
+        private Integer index;
+        private ChatMessage message;
+        private String finish_reason;
+    }
+
+    @Data
+    public static class Usage {
+        private Integer prompt_tokens;
+        private Integer completion_tokens;
+        private Integer total_tokens;
+    }
+}

+ 16 - 1
backend/graph-service/pom.xml

@@ -24,6 +24,20 @@
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
         
+        <!-- Spring WebFlux (用于 WebClient) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-webflux</artifactId>
+        </dependency>
+        
+        <!-- AI Service (用于 DeepSeek 客户端) -->
+        <dependency>
+            <groupId>com.lingyue</groupId>
+            <artifactId>ai-service</artifactId>
+            <version>${project.version}</version>
+            <optional>true</optional>
+        </dependency>
+        
         <!-- MyBatis Plus -->
         <dependency>
             <groupId>com.baomidou</groupId>
@@ -36,10 +50,11 @@
             <artifactId>postgresql</artifactId>
         </dependency>
         
-        <!-- Nacos Service Discovery -->
+        <!-- Nacos Service Discovery (单体应用设为 optional) -->
         <dependency>
             <groupId>com.alibaba.cloud</groupId>
             <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
+            <optional>true</optional>
         </dependency>
         
         <!-- Common Module -->

+ 50 - 0
backend/graph-service/src/main/java/com/lingyue/graph/config/VectorConfig.java

@@ -0,0 +1,50 @@
+package com.lingyue.graph.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.reactive.function.client.ExchangeStrategies;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * 向量化服务配置
+ * 配置 Ollama WebClient
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Configuration
+public class VectorConfig {
+
+    @Value("${ollama.url:http://localhost:11434}")
+    private String ollamaUrl;
+
+    @Value("${ollama.timeout:60000}")
+    private int ollamaTimeout;
+
+    /**
+     * Ollama WebClient Bean
+     * 用于调用 Ollama Embedding API
+     */
+    @Bean
+    public WebClient ollamaWebClient() {
+        log.info("初始化 Ollama WebClient: url={}", ollamaUrl);
+
+        // 增加缓冲区大小以处理大向量
+        ExchangeStrategies strategies = ExchangeStrategies.builder()
+                .codecs(configurer -> configurer
+                        .defaultCodecs()
+                        .maxInMemorySize(10 * 1024 * 1024)) // 10MB
+                .build();
+
+        return WebClient.builder()
+                .baseUrl(ollamaUrl)
+                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+                .exchangeStrategies(strategies)
+                .build();
+    }
+}

+ 150 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/RAGController.java

@@ -0,0 +1,150 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.entity.TextChunk;
+import com.lingyue.graph.service.RAGService;
+import com.lingyue.graph.service.TextChunkService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * RAG API 控制器
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/rag")
+@RequiredArgsConstructor
+@Tag(name = "RAG接口", description = "RAG向量检索与问答")
+public class RAGController {
+
+    private final RAGService ragService;
+    private final TextChunkService textChunkService;
+
+    /**
+     * 索引文档(分块+向量化)
+     */
+    @PostMapping("/index")
+    @Operation(summary = "索引文档", description = "将文档进行分块和向量化存储")
+    public AjaxResult<IndexResponse> indexDocument(@RequestBody IndexRequest request) {
+        log.info("索引文档请求: documentId={}", request.getDocumentId());
+
+        int chunkCount = ragService.indexDocument(
+                request.getDocumentId(),
+                request.getTextStorageId(),
+                request.getText()
+        );
+
+        IndexResponse response = new IndexResponse();
+        response.setDocumentId(request.getDocumentId());
+        response.setChunkCount(chunkCount);
+        response.setMessage("索引完成");
+
+        return AjaxResult.success(response);
+    }
+
+    /**
+     * RAG 问答
+     */
+    @PostMapping("/query")
+    @Operation(summary = "RAG问答", description = "基于向量检索的智能问答")
+    public AjaxResult<RAGService.RAGResult> query(@RequestBody QueryRequest request) {
+        log.info("RAG查询请求: question={}", request.getQuestion());
+
+        RAGService.RAGResult result = ragService.query(
+                request.getQuestion(),
+                request.getDocumentId(),
+                request.getTopK()
+        );
+
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 获取文档分块
+     */
+    @GetMapping("/chunks/{documentId}")
+    @Operation(summary = "获取文档分块", description = "获取指定文档的所有文本分块")
+    public AjaxResult<List<TextChunk>> getChunks(
+            @PathVariable @Parameter(description = "文档ID") String documentId) {
+
+        List<TextChunk> chunks = textChunkService.getChunksByDocumentId(documentId);
+        return AjaxResult.success(chunks);
+    }
+
+    /**
+     * 删除文档索引
+     */
+    @DeleteMapping("/index/{documentId}")
+    @Operation(summary = "删除文档索引", description = "删除指定文档的所有分块和向量")
+    public AjaxResult<Void> deleteIndex(
+            @PathVariable @Parameter(description = "文档ID") String documentId) {
+
+        ragService.deleteIndex(documentId);
+        return AjaxResult.success("索引删除成功", null);
+    }
+
+    /**
+     * 获取文档分块统计
+     */
+    @GetMapping("/stats/{documentId}")
+    @Operation(summary = "获取文档分块统计", description = "获取指定文档的分块数量")
+    public AjaxResult<StatsResponse> getStats(
+            @PathVariable @Parameter(description = "文档ID") String documentId) {
+
+        int chunkCount = textChunkService.countByDocumentId(documentId);
+
+        StatsResponse response = new StatsResponse();
+        response.setDocumentId(documentId);
+        response.setChunkCount(chunkCount);
+
+        return AjaxResult.success(response);
+    }
+
+    // ==================== 请求/响应 DTO ====================
+
+    @lombok.Data
+    public static class IndexRequest {
+        @Parameter(description = "文档ID", required = true)
+        private String documentId;
+
+        @Parameter(description = "文本存储ID")
+        private String textStorageId;
+
+        @Parameter(description = "文档文本内容", required = true)
+        private String text;
+    }
+
+    @lombok.Data
+    public static class QueryRequest {
+        @Parameter(description = "用户问题", required = true)
+        private String question;
+
+        @Parameter(description = "文档ID(可选,不传则全局检索)")
+        private String documentId;
+
+        @Parameter(description = "检索数量,默认3")
+        private Integer topK;
+    }
+
+    @lombok.Data
+    public static class IndexResponse {
+        private String documentId;
+        private Integer chunkCount;
+        private String message;
+    }
+
+    @lombok.Data
+    public static class StatsResponse {
+        private String documentId;
+        private Integer chunkCount;
+    }
+}

+ 49 - 0
backend/graph-service/src/main/java/com/lingyue/graph/entity/TextChunk.java

@@ -0,0 +1,49 @@
+package com.lingyue.graph.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import com.lingyue.common.domain.entity.SimpleModel;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Map;
+
+/**
+ * 文本分块实体
+ * 用于存储文档分块后的文本片段,支持 RAG 检索
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@TableName(value = "text_chunks", autoResultMap = true)
+@Schema(description = "文本分块实体")
+public class TextChunk extends SimpleModel {
+
+    @Schema(description = "文档ID")
+    @TableField("document_id")
+    private String documentId;
+
+    @Schema(description = "文本存储ID")
+    @TableField("text_storage_id")
+    private String textStorageId;
+
+    @Schema(description = "分块索引(在文档中的顺序)")
+    @TableField("chunk_index")
+    private Integer chunkIndex;
+
+    @Schema(description = "分块文本内容")
+    @TableField("content")
+    private String content;
+
+    @Schema(description = "估算的Token数量")
+    @TableField("token_count")
+    private Integer tokenCount;
+
+    @Schema(description = "元数据(页码、段落位置等)")
+    @TableField(value = "metadata", typeHandler = JacksonTypeHandler.class)
+    private Map<String, Object> metadata;
+}

+ 34 - 0
backend/graph-service/src/main/java/com/lingyue/graph/entity/VectorEmbedding.java

@@ -0,0 +1,34 @@
+package com.lingyue.graph.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.lingyue.common.domain.entity.SimpleModel;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 向量嵌入实体
+ * 存储文本块的向量表示,用于相似度检索
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@TableName("vector_embeddings")
+@Schema(description = "向量嵌入实体")
+public class VectorEmbedding extends SimpleModel {
+
+    @Schema(description = "关联的文本分块ID")
+    @TableField("chunk_id")
+    private String chunkId;
+
+    @Schema(description = "向量嵌入(pgvector格式字符串)")
+    @TableField("embedding")
+    private String embedding;
+
+    @Schema(description = "使用的嵌入模型名称")
+    @TableField("model_name")
+    private String modelName = "nomic-embed-text";
+}

+ 50 - 0
backend/graph-service/src/main/java/com/lingyue/graph/repository/TextChunkRepository.java

@@ -0,0 +1,50 @@
+package com.lingyue.graph.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.lingyue.graph.entity.TextChunk;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 文本分块数据访问层
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Mapper
+public interface TextChunkRepository extends BaseMapper<TextChunk> {
+
+    /**
+     * 根据文档ID查询所有分块
+     */
+    @Select("SELECT * FROM text_chunks WHERE document_id = #{documentId} ORDER BY chunk_index")
+    List<TextChunk> findByDocumentId(@Param("documentId") String documentId);
+
+    /**
+     * 根据文本存储ID查询所有分块
+     */
+    @Select("SELECT * FROM text_chunks WHERE text_storage_id = #{textStorageId} ORDER BY chunk_index")
+    List<TextChunk> findByTextStorageId(@Param("textStorageId") String textStorageId);
+
+    /**
+     * 根据文档ID删除所有分块
+     */
+    @Delete("DELETE FROM text_chunks WHERE document_id = #{documentId}")
+    int deleteByDocumentId(@Param("documentId") String documentId);
+
+    /**
+     * 根据文本存储ID删除所有分块
+     */
+    @Delete("DELETE FROM text_chunks WHERE text_storage_id = #{textStorageId}")
+    int deleteByTextStorageId(@Param("textStorageId") String textStorageId);
+
+    /**
+     * 统计文档的分块数量
+     */
+    @Select("SELECT COUNT(*) FROM text_chunks WHERE document_id = #{documentId}")
+    int countByDocumentId(@Param("documentId") String documentId);
+}

+ 93 - 0
backend/graph-service/src/main/java/com/lingyue/graph/repository/VectorEmbeddingRepository.java

@@ -0,0 +1,93 @@
+package com.lingyue.graph.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.lingyue.graph.entity.VectorEmbedding;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 向量嵌入数据访问层
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Mapper
+public interface VectorEmbeddingRepository extends BaseMapper<VectorEmbedding> {
+
+    /**
+     * 根据分块ID查询向量
+     */
+    @Select("SELECT * FROM vector_embeddings WHERE chunk_id = #{chunkId}")
+    VectorEmbedding findByChunkId(@Param("chunkId") String chunkId);
+
+    /**
+     * 根据分块ID删除向量
+     */
+    @Delete("DELETE FROM vector_embeddings WHERE chunk_id = #{chunkId}")
+    int deleteByChunkId(@Param("chunkId") String chunkId);
+
+    /**
+     * 向量相似度检索(按文档ID筛选)
+     * 使用 pgvector 的余弦距离操作符
+     *
+     * @param queryEmbedding 查询向量(格式:"[0.1, 0.2, ...]")
+     * @param documentId     文档ID
+     * @param limit          返回数量
+     * @return 相似文本块列表
+     */
+    @Select("""
+            SELECT 
+                tc.id AS chunk_id,
+                tc.document_id,
+                tc.content,
+                tc.chunk_index,
+                tc.token_count,
+                1 - (ve.embedding <=> #{queryEmbedding}::vector) AS similarity
+            FROM text_chunks tc
+            JOIN vector_embeddings ve ON tc.id = ve.chunk_id
+            WHERE tc.document_id = #{documentId}
+            ORDER BY ve.embedding <=> #{queryEmbedding}::vector
+            LIMIT #{limit}
+            """)
+    List<Map<String, Object>> searchSimilarByDocument(
+            @Param("queryEmbedding") String queryEmbedding,
+            @Param("documentId") String documentId,
+            @Param("limit") int limit
+    );
+
+    /**
+     * 全局向量相似度检索(不限制文档)
+     *
+     * @param queryEmbedding 查询向量
+     * @param limit          返回数量
+     * @return 相似文本块列表
+     */
+    @Select("""
+            SELECT 
+                tc.id AS chunk_id,
+                tc.document_id,
+                tc.content,
+                tc.chunk_index,
+                tc.token_count,
+                1 - (ve.embedding <=> #{queryEmbedding}::vector) AS similarity
+            FROM text_chunks tc
+            JOIN vector_embeddings ve ON tc.id = ve.chunk_id
+            ORDER BY ve.embedding <=> #{queryEmbedding}::vector
+            LIMIT #{limit}
+            """)
+    List<Map<String, Object>> searchSimilarGlobal(
+            @Param("queryEmbedding") String queryEmbedding,
+            @Param("limit") int limit
+    );
+
+    /**
+     * 检查分块是否已有向量
+     */
+    @Select("SELECT COUNT(*) > 0 FROM vector_embeddings WHERE chunk_id = #{chunkId}")
+    boolean existsByChunkId(@Param("chunkId") String chunkId);
+}

+ 159 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/OllamaEmbeddingService.java

@@ -0,0 +1,159 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.common.exception.ServiceException;
+import com.lingyue.graph.entity.TextChunk;
+import com.lingyue.graph.entity.VectorEmbedding;
+import com.lingyue.graph.repository.VectorEmbeddingRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.util.*;
+
+/**
+ * Ollama Embedding 服务
+ * 调用 Ollama API 进行文本向量化
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OllamaEmbeddingService {
+
+    private final VectorEmbeddingRepository vectorEmbeddingRepository;
+    private final WebClient ollamaWebClient;
+
+    @Value("${ollama.embedding.model:nomic-embed-text}")
+    private String embeddingModel;
+
+    /**
+     * 对单个文本进行向量化
+     *
+     * @param text 待向量化的文本
+     * @return 向量(浮点数数组)
+     */
+    public float[] embed(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            throw new ServiceException("文本不能为空");
+        }
+
+        try {
+            // 构建请求体
+            Map<String, Object> request = new HashMap<>();
+            request.put("model", embeddingModel);
+            request.put("prompt", text);
+
+            // 调用 Ollama API
+            @SuppressWarnings("unchecked")
+            Map<String, Object> response = ollamaWebClient
+                    .post()
+                    .uri("/api/embeddings")
+                    .bodyValue(request)
+                    .retrieve()
+                    .bodyToMono(Map.class)
+                    .block();
+
+            if (response == null || !response.containsKey("embedding")) {
+                throw new ServiceException("Ollama 返回结果无效");
+            }
+
+            // 解析向量
+            @SuppressWarnings("unchecked")
+            List<Double> embeddingList = (List<Double>) response.get("embedding");
+            float[] vector = new float[embeddingList.size()];
+            for (int i = 0; i < embeddingList.size(); i++) {
+                vector[i] = embeddingList.get(i).floatValue();
+            }
+
+            log.debug("文本向量化完成,维度: {}", vector.length);
+            return vector;
+
+        } catch (Exception e) {
+            log.error("Ollama 向量化失败: {}", e.getMessage(), e);
+            throw new ServiceException("向量化失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 将向量数组转换为 pgvector 格式字符串
+     */
+    public String vectorToString(float[] vector) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < vector.length; i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append(vector[i]);
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+    /**
+     * 对文本分块进行向量化并保存
+     *
+     * @param chunk 文本分块
+     * @return 向量嵌入实体
+     */
+    @Transactional
+    public VectorEmbedding embedAndSave(TextChunk chunk) {
+        // 检查是否已存在
+        if (vectorEmbeddingRepository.existsByChunkId(chunk.getId())) {
+            log.debug("分块 {} 已有向量,跳过", chunk.getId());
+            return vectorEmbeddingRepository.findByChunkId(chunk.getId());
+        }
+
+        // 向量化
+        float[] vector = embed(chunk.getContent());
+        String embeddingString = vectorToString(vector);
+
+        // 保存
+        VectorEmbedding embedding = new VectorEmbedding();
+        embedding.setId(UUID.randomUUID().toString().replace("-", ""));
+        embedding.setChunkId(chunk.getId());
+        embedding.setEmbedding(embeddingString);
+        embedding.setModelName(embeddingModel);
+        embedding.setCreateTime(new Date());
+
+        vectorEmbeddingRepository.insert(embedding);
+        log.debug("分块 {} 向量化完成并保存", chunk.getId());
+
+        return embedding;
+    }
+
+    /**
+     * 批量向量化文本分块
+     *
+     * @param chunks 文本分块列表
+     * @return 向量嵌入列表
+     */
+    @Transactional
+    public List<VectorEmbedding> embedBatch(List<TextChunk> chunks) {
+        List<VectorEmbedding> embeddings = new ArrayList<>();
+
+        for (TextChunk chunk : chunks) {
+            try {
+                VectorEmbedding embedding = embedAndSave(chunk);
+                embeddings.add(embedding);
+            } catch (Exception e) {
+                log.error("分块 {} 向量化失败: {}", chunk.getId(), e.getMessage());
+                // 继续处理其他分块
+            }
+        }
+
+        log.info("批量向量化完成,成功 {}/{} 块", embeddings.size(), chunks.size());
+        return embeddings;
+    }
+
+    /**
+     * 根据分块ID删除向量
+     */
+    public void deleteByChunkId(String chunkId) {
+        vectorEmbeddingRepository.deleteByChunkId(chunkId);
+    }
+}

+ 229 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/RAGService.java

@@ -0,0 +1,229 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.ai.client.DeepSeekClient;
+import com.lingyue.common.exception.ServiceException;
+import com.lingyue.graph.entity.TextChunk;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * RAG(检索增强生成)服务
+ * 核心业务逻辑:索引文档、向量检索、问答生成
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RAGService {
+
+    private final TextChunkService textChunkService;
+    private final OllamaEmbeddingService ollamaEmbeddingService;
+    private final VectorSearchService vectorSearchService;
+    private final DeepSeekClient deepSeekClient;
+
+    @Value("${rag.search.top-k:3}")
+    private int defaultTopK;
+
+    /**
+     * 索引文档(分块 + 向量化)
+     *
+     * @param documentId    文档ID
+     * @param textStorageId 文本存储ID
+     * @param text          文档文本内容
+     * @return 索引的分块数量
+     */
+    @Transactional
+    public int indexDocument(String documentId, String textStorageId, String text) {
+        if (text == null || text.trim().isEmpty()) {
+            log.warn("文本为空,跳过索引: documentId={}", documentId);
+            return 0;
+        }
+
+        log.info("开始索引文档: documentId={}, textLength={}", documentId, text.length());
+
+        // 1. 文本分块
+        List<TextChunk> chunks = textChunkService.chunkText(documentId, textStorageId, text);
+
+        if (chunks.isEmpty()) {
+            log.warn("文档分块为空: documentId={}", documentId);
+            return 0;
+        }
+
+        // 2. 向量化并保存
+        ollamaEmbeddingService.embedBatch(chunks);
+
+        log.info("文档索引完成: documentId={}, chunks={}", documentId, chunks.size());
+        return chunks.size();
+    }
+
+    /**
+     * 从文件路径索引文档
+     *
+     * @param documentId    文档ID
+     * @param textStorageId 文本存储ID
+     * @param filePath      文本文件路径
+     * @return 索引的分块数量
+     */
+    @Transactional
+    public int indexDocumentFromFile(String documentId, String textStorageId, String filePath) {
+        try {
+            String text = Files.readString(Path.of(filePath), StandardCharsets.UTF_8);
+            return indexDocument(documentId, textStorageId, text);
+        } catch (IOException e) {
+            log.error("读取文件失败: {}", filePath, e);
+            throw new ServiceException("读取文件失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 删除文档索引
+     *
+     * @param documentId 文档ID
+     */
+    @Transactional
+    public void deleteIndex(String documentId) {
+        // 删除分块会级联删除向量(外键约束)
+        int count = textChunkService.deleteByDocumentId(documentId);
+        log.info("删除文档索引: documentId={}, chunks={}", documentId, count);
+    }
+
+    /**
+     * RAG 问答
+     *
+     * @param question   用户问题
+     * @param documentId 文档ID(可选,为null时全局检索)
+     * @param topK       检索数量
+     * @return RAG 回答结果
+     */
+    public RAGResult query(String question, String documentId, Integer topK) {
+        if (question == null || question.trim().isEmpty()) {
+            throw new ServiceException("问题不能为空");
+        }
+
+        int k = topK != null ? topK : defaultTopK;
+
+        log.info("RAG 查询: question='{}', documentId={}, topK={}",
+                question.substring(0, Math.min(50, question.length())), documentId, k);
+
+        // 1. 向量检索
+        List<VectorSearchService.SearchResult> searchResults =
+                vectorSearchService.search(question, documentId, k);
+
+        if (searchResults.isEmpty()) {
+            return RAGResult.builder()
+                    .question(question)
+                    .answer("未找到相关信息,请尝试其他问题。")
+                    .chunks(List.of())
+                    .build();
+        }
+
+        // 2. 构建上下文
+        String context = buildContext(searchResults);
+
+        // 3. 构建 Prompt 并调用 LLM
+        String prompt = buildPrompt(context, question);
+        String answer = deepSeekClient.complete(prompt);
+
+        // 4. 构建结果
+        List<RAGResult.ChunkInfo> chunkInfos = searchResults.stream()
+                .map(r -> RAGResult.ChunkInfo.builder()
+                        .chunkId(r.getChunkId())
+                        .documentId(r.getDocumentId())
+                        .content(r.getContent())
+                        .similarity(r.getSimilarity())
+                        .build())
+                .collect(Collectors.toList());
+
+        RAGResult result = RAGResult.builder()
+                .question(question)
+                .answer(answer)
+                .chunks(chunkInfos)
+                .build();
+
+        log.info("RAG 查询完成: chunks={}, answerLength={}",
+                searchResults.size(), answer != null ? answer.length() : 0);
+
+        return result;
+    }
+
+    /**
+     * 构建上下文(拼接检索到的文本块)
+     */
+    private String buildContext(List<VectorSearchService.SearchResult> results) {
+        StringBuilder sb = new StringBuilder();
+
+        for (int i = 0; i < results.size(); i++) {
+            VectorSearchService.SearchResult result = results.get(i);
+            sb.append(String.format("【片段%d】(相似度: %.2f)\n%s\n\n",
+                    i + 1, result.getSimilarity(), result.getContent()));
+        }
+
+        return sb.toString();
+    }
+
+    /**
+     * 构建 Prompt
+     */
+    private String buildPrompt(String context, String question) {
+        return String.format("""
+                你是一个专业的文档分析助手。请基于以下文档内容回答用户问题。
+
+                ## 文档内容
+                %s
+
+                ## 用户问题
+                %s
+
+                ## 回答要求
+                1. 仅基于文档内容回答,不要编造信息
+                2. 如果文档中没有相关信息,请明确说明"文档中未找到相关信息"
+                3. 回答要准确、简洁、专业
+                4. 如果需要引用文档内容,请标注来源片段
+
+                请回答:
+                """, context, question);
+    }
+
+    /**
+     * RAG 查询结果
+     */
+    @lombok.Data
+    @lombok.Builder
+    public static class RAGResult {
+        /**
+         * 用户问题
+         */
+        private String question;
+
+        /**
+         * AI 回答
+         */
+        private String answer;
+
+        /**
+         * 检索到的文本块
+         */
+        private List<ChunkInfo> chunks;
+
+        @lombok.Data
+        @lombok.Builder
+        public static class ChunkInfo {
+            private String chunkId;
+            private String documentId;
+            private String content;
+            private Double similarity;
+        }
+    }
+}

+ 179 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/TextChunkService.java

@@ -0,0 +1,179 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.graph.entity.TextChunk;
+import com.lingyue.graph.repository.TextChunkRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+/**
+ * 文本分块服务
+ * 将长文本分割成适合向量化的小块
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TextChunkService {
+
+    private final TextChunkRepository textChunkRepository;
+
+    @Value("${rag.chunk.size:500}")
+    private int chunkSize;
+
+    @Value("${rag.chunk.overlap:50}")
+    private int chunkOverlap;
+
+    /**
+     * 对文本进行分块
+     *
+     * @param documentId    文档ID
+     * @param textStorageId 文本存储ID
+     * @param text          待分块的文本
+     * @return 分块列表
+     */
+    @Transactional
+    public List<TextChunk> chunkText(String documentId, String textStorageId, String text) {
+        if (text == null || text.trim().isEmpty()) {
+            log.warn("文本为空,跳过分块: documentId={}", documentId);
+            return Collections.emptyList();
+        }
+
+        // 先删除已有的分块
+        textChunkRepository.deleteByDocumentId(documentId);
+
+        List<TextChunk> chunks = new ArrayList<>();
+        int start = 0;
+        int chunkIndex = 0;
+
+        while (start < text.length()) {
+            int end = Math.min(start + chunkSize, text.length());
+
+            // 尝试在句子边界分割(避免截断句子)
+            if (end < text.length()) {
+                int sentenceEnd = findSentenceBoundary(text, start, end);
+                if (sentenceEnd > start) {
+                    end = sentenceEnd;
+                }
+            }
+
+            String chunkContent = text.substring(start, end).trim();
+
+            // 跳过空白分块
+            if (chunkContent.isEmpty()) {
+                start = end;
+                continue;
+            }
+
+            // 创建分块
+            TextChunk chunk = new TextChunk();
+            chunk.setId(UUID.randomUUID().toString().replace("-", ""));
+            chunk.setDocumentId(documentId);
+            chunk.setTextStorageId(textStorageId);
+            chunk.setChunkIndex(chunkIndex++);
+            chunk.setContent(chunkContent);
+            chunk.setTokenCount(estimateTokenCount(chunkContent));
+
+            // 添加元数据
+            Map<String, Object> metadata = new HashMap<>();
+            metadata.put("start_pos", start);
+            metadata.put("end_pos", end);
+            metadata.put("length", chunkContent.length());
+            chunk.setMetadata(metadata);
+
+            chunk.setCreateTime(new Date());
+            chunk.setUpdateTime(new Date());
+
+            textChunkRepository.insert(chunk);
+            chunks.add(chunk);
+
+            // 移动起始位置(考虑重叠)
+            start = end - chunkOverlap;
+            if (start >= text.length() || start <= 0) {
+                break;
+            }
+        }
+
+        log.info("文档 {} 分块完成,共 {} 块", documentId, chunks.size());
+        return chunks;
+    }
+
+    /**
+     * 寻找句子边界
+     * 从end位置向前搜索,找到最近的句子结束符
+     */
+    private int findSentenceBoundary(String text, int start, int end) {
+        // 向前搜索最多100个字符
+        int searchStart = Math.max(start, end - 100);
+
+        for (int i = end; i > searchStart; i--) {
+            char c = text.charAt(i - 1);
+            // 中文和英文的句子结束符
+            if (c == '。' || c == '!' || c == '?' || c == ';' ||
+                    c == '.' || c == '!' || c == '?' || c == '\n') {
+                return i;
+            }
+        }
+
+        // 如果没找到句子边界,尝试在逗号或空格处分割
+        for (int i = end; i > searchStart; i--) {
+            char c = text.charAt(i - 1);
+            if (c == ',' || c == ',' || c == ' ' || c == '\t') {
+                return i;
+            }
+        }
+
+        return end;
+    }
+
+    /**
+     * 估算 Token 数量
+     * 中文约 1.5-2 字符/token,英文约 4 字符/token
+     * 这里使用混合估算
+     */
+    private int estimateTokenCount(String text) {
+        int chineseCount = 0;
+        int otherCount = 0;
+
+        for (char c : text.toCharArray()) {
+            if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
+                chineseCount++;
+            } else {
+                otherCount++;
+            }
+        }
+
+        // 中文按 1.5 字符/token,其他按 4 字符/token
+        return (int) (chineseCount / 1.5 + otherCount / 4.0);
+    }
+
+    /**
+     * 根据文档ID获取所有分块
+     */
+    public List<TextChunk> getChunksByDocumentId(String documentId) {
+        return textChunkRepository.findByDocumentId(documentId);
+    }
+
+    /**
+     * 根据文档ID删除所有分块
+     */
+    @Transactional
+    public int deleteByDocumentId(String documentId) {
+        int count = textChunkRepository.deleteByDocumentId(documentId);
+        log.info("删除文档 {} 的分块,共 {} 块", documentId, count);
+        return count;
+    }
+
+    /**
+     * 统计文档的分块数量
+     */
+    public int countByDocumentId(String documentId) {
+        return textChunkRepository.countByDocumentId(documentId);
+    }
+}

+ 32 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/TextStorageService.java

@@ -5,9 +5,11 @@ import com.lingyue.graph.entity.TextStorage;
 import com.lingyue.graph.repository.TextStorageRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.io.File;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.security.MessageDigest;
@@ -25,6 +27,10 @@ import java.util.UUID;
 public class TextStorageService {
     
     private final TextStorageRepository textStorageRepository;
+    private final RAGService ragService;
+    
+    @Value("${rag.auto-index.enabled:true}")
+    private boolean autoIndexEnabled;
     
     /**
      * 保存文本存储记录
@@ -79,6 +85,32 @@ public class TextStorageService {
         return textStorageRepository.findByDocumentId(documentId);
     }
     
+    /**
+     * 保存文本并自动建立 RAG 索引
+     * 
+     * @param documentId 文档ID
+     * @param filePath TXT文件路径
+     * @return 文本存储记录
+     */
+    public TextStorage saveAndIndex(String documentId, String filePath) {
+        // 1. 保存文本存储记录
+        TextStorage textStorage = saveTextStorage(documentId, filePath);
+        
+        // 2. 自动建立 RAG 索引
+        if (autoIndexEnabled) {
+            try {
+                String text = Files.readString(Path.of(filePath), StandardCharsets.UTF_8);
+                int chunkCount = ragService.indexDocument(documentId, textStorage.getId(), text);
+                log.info("自动建立 RAG 索引完成: documentId={}, chunks={}", documentId, chunkCount);
+            } catch (Exception e) {
+                log.warn("自动建立 RAG 索引失败,不影响主流程: documentId={}", documentId, e);
+                // 索引失败不影响主流程
+            }
+        }
+        
+        return textStorage;
+    }
+    
     /**
      * 计算文件MD5校验和
      */

+ 124 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/VectorSearchService.java

@@ -0,0 +1,124 @@
+package com.lingyue.graph.service;
+
+import com.lingyue.graph.entity.TextChunk;
+import com.lingyue.graph.repository.VectorEmbeddingRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 向量检索服务
+ * 基于 pgvector 实现相似度检索
+ *
+ * @author lingyue
+ * @since 2026-01-15
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class VectorSearchService {
+
+    private final VectorEmbeddingRepository vectorEmbeddingRepository;
+    private final OllamaEmbeddingService ollamaEmbeddingService;
+
+    @Value("${rag.search.top-k:3}")
+    private int defaultTopK;
+
+    /**
+     * 向量相似度检索(按文档ID筛选)
+     *
+     * @param query      查询文本
+     * @param documentId 文档ID(可选,为null时全局检索)
+     * @param topK       返回前K个结果
+     * @return 相似文本块列表(包含相似度分数)
+     */
+    public List<SearchResult> search(String query, String documentId, Integer topK) {
+        if (query == null || query.trim().isEmpty()) {
+            log.warn("查询文本为空");
+            return new ArrayList<>();
+        }
+
+        int limit = topK != null ? topK : defaultTopK;
+
+        try {
+            // 1. 将查询文本向量化
+            float[] queryVector = ollamaEmbeddingService.embed(query);
+            String queryEmbedding = ollamaEmbeddingService.vectorToString(queryVector);
+
+            // 2. 执行向量检索
+            List<Map<String, Object>> results;
+            if (documentId != null && !documentId.isEmpty()) {
+                results = vectorEmbeddingRepository.searchSimilarByDocument(
+                        queryEmbedding, documentId, limit);
+            } else {
+                results = vectorEmbeddingRepository.searchSimilarGlobal(
+                        queryEmbedding, limit);
+            }
+
+            // 3. 转换结果
+            List<SearchResult> searchResults = new ArrayList<>();
+            for (Map<String, Object> row : results) {
+                SearchResult result = new SearchResult();
+                result.setChunkId((String) row.get("chunk_id"));
+                result.setDocumentId((String) row.get("document_id"));
+                result.setContent((String) row.get("content"));
+                result.setChunkIndex(((Number) row.get("chunk_index")).intValue());
+                result.setSimilarity(((Number) row.get("similarity")).doubleValue());
+
+                if (row.get("token_count") != null) {
+                    result.setTokenCount(((Number) row.get("token_count")).intValue());
+                }
+
+                searchResults.add(result);
+            }
+
+            log.info("向量检索完成: query='{}', documentId={}, 结果数={}",
+                    query.substring(0, Math.min(50, query.length())),
+                    documentId, searchResults.size());
+
+            return searchResults;
+
+        } catch (Exception e) {
+            log.error("向量检索失败: {}", e.getMessage(), e);
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * 全局向量检索(不限制文档)
+     */
+    public List<SearchResult> searchGlobal(String query, Integer topK) {
+        return search(query, null, topK);
+    }
+
+    /**
+     * 检索结果
+     */
+    @lombok.Data
+    public static class SearchResult {
+        private String chunkId;
+        private String documentId;
+        private String content;
+        private Integer chunkIndex;
+        private Integer tokenCount;
+        private Double similarity;
+
+        /**
+         * 转换为 TextChunk 实体
+         */
+        public TextChunk toTextChunk() {
+            TextChunk chunk = new TextChunk();
+            chunk.setId(chunkId);
+            chunk.setDocumentId(documentId);
+            chunk.setContent(content);
+            chunk.setChunkIndex(chunkIndex);
+            chunk.setTokenCount(tokenCount);
+            return chunk;
+        }
+    }
+}

+ 137 - 0
backend/lingyue-starter/src/main/resources/application.properties

@@ -0,0 +1,137 @@
+# ============================================
+# Lingyue Starter - 单体应用统一配置
+# ============================================
+
+# 引入公共配置
+spring.config.import=classpath:application-common.properties,classpath:application-infra.properties
+
+# 服务端口
+server.port=8000
+server.servlet.context-path=/
+server.tomcat.uri-encoding=UTF-8
+server.tomcat.accept-count=1000
+server.tomcat.threads.max=800
+server.tomcat.threads.min-spare=10
+
+# 应用名称
+spring.application.name=lingyue-zhibao
+app.name=灵越智报
+app.version=2.0.0
+app.copyrightYear=2024
+
+# 允许 Bean 定义覆盖(单体应用可能有重复的 Bean)
+spring.main.allow-bean-definition-overriding=true
+
+# 文件上传配置
+spring.servlet.multipart.max-file-size=20MB
+spring.servlet.multipart.max-request-size=100MB
+app.uploadBaseDir=/tmp/lingyue-zhibao
+
+# 国际化配置
+spring.messages.basename=i18n/messages
+
+# 热部署配置
+spring.devtools.restart.enabled=true
+
+# MVC配置
+spring.mvc.pathmatch.matching-strategy=ant-path-matcher
+
+# 激活的配置文件
+spring.profiles.active=dev
+
+# MyBatis Plus配置(覆盖公共配置)
+mybatis-plus.type-aliases-package=com.lingyue.**.entity
+mybatis-plus.config-location=classpath:mybatis/mybatis-config.xml
+
+# JWT配置
+jwt.secret=${JWT_SECRET:lingyue-zhibao-secret-key-2024-please-change-in-production}
+jwt.expiration=604800000
+jwt.refresh-expiration=2592000000
+
+# Token配置
+token.header=Authorization
+token.secret=${JWT_SECRET:lingyue-zhibao-secret-key-2024-please-change-in-production}
+token.expireTime=604800
+
+# 用户配置
+user.password.maxRetryCount=5
+user.password.lockTime=10
+
+# SpringDoc OpenAPI配置(如遇到问题可临时禁用)
+springdoc.api-docs.enabled=false
+springdoc.swagger-ui.enabled=false
+# springdoc.api-docs.path=/api-docs
+# springdoc.swagger-ui.path=/swagger-ui.html
+
+# WebSocket配置
+websocket.enabled=true
+websocket.path=/ws
+websocket.allowedOrigins=*
+
+# PaddleOCR配置
+paddleocr.server-url=${PADDLEOCR_SERVER_URL:http://localhost:8866}
+paddleocr.timeout=30000
+
+# DeepSeek API配置
+deepseek.api.url=https://api.deepseek.com
+deepseek.api.key=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+deepseek.api.model=deepseek-chat
+deepseek.api.timeout=60000
+
+# ============================================
+# RAG 向量化配置
+# ============================================
+
+# Ollama 配置
+ollama.url=${OLLAMA_URL:http://localhost:11434}
+ollama.embedding.model=${OLLAMA_EMBEDDING_MODEL:nomic-embed-text}
+ollama.timeout=60000
+
+# RAG 分块配置
+rag.chunk.size=500
+rag.chunk.overlap=50
+
+# RAG 检索配置
+rag.search.top-k=3
+
+# RAG 自动索引配置
+rag.auto-index.enabled=true
+
+# XSS防护配置
+xss.enabled=true
+xss.excludes=/auth/register,/auth/login
+xss.urlPatterns=/documents/*,/parse/*,/ai/*,/graphs/*
+
+# 日志配置(覆盖公共配置)
+logging.level.root=INFO
+logging.level.com.lingyue=INFO
+logging.level.org.springframework=WARN
+logging.level.org.springframework.web=INFO
+
+# Nacos配置(单体应用禁用服务发现)
+spring.cloud.nacos.discovery.enabled=false
+
+# 禁用 Feign 和 SpringDoc 自动配置(单体应用不需要)
+spring.cloud.openfeign.enabled=false
+spring.autoconfigure.exclude=\
+  org.springframework.cloud.openfeign.FeignAutoConfiguration,\
+  org.springdoc.core.configuration.SpringDocConfiguration,\
+  org.springdoc.webmvc.ui.SwaggerConfig
+
+# 数据库配置
+spring.datasource.druid.url=jdbc:postgresql://localhost:5432/lingyue_zhibao
+spring.datasource.druid.username=lingyue
+spring.datasource.druid.password=123123
+
+# Redis配置
+spring.data.redis.host=localhost
+spring.data.redis.port=6379
+
+# RabbitMQ配置
+spring.rabbitmq.host=localhost
+spring.rabbitmq.port=5672
+spring.rabbitmq.username=admin
+spring.rabbitmq.password=admin123
+
+# JWT配置
+jwt.secret=your-jwt-secret-key

+ 9 - 16
backend/parse-service/src/main/java/com/lingyue/parse/service/ParseService.java

@@ -33,7 +33,8 @@ public class ParseService {
     private final OcrResultParser ocrResultParser;
     private final LayoutAnalysisService layoutAnalysisService;
     private final FileStorageProperties fileStorageProperties;
-    private final com.lingyue.parse.client.GraphServiceClient graphServiceClient;
+    // 单体应用直接注入 Service,不使用 Feign Client
+    private final com.lingyue.graph.service.TextStorageService textStorageService;
 
     /**
      * 根据ID获取解析任务
@@ -297,28 +298,20 @@ public class ParseService {
     }
 
     /**
-     * 记录文本存储路径到数据库
+     * 记录文本存储路径到数据库并自动建立 RAG 索引
+     * 单体应用模式:直接调用 Service 层
      * 
      * @param documentId 文档ID
      * @param textFilePath 文本文件路径
      */
     private void recordTextStorage(String documentId, String textFilePath) {
         try {
-            java.util.Map<String, Object> request = new java.util.HashMap<>();
-            request.put("documentId", documentId);
-            request.put("filePath", textFilePath);
-            
-            com.lingyue.common.domain.AjaxResult<?> result = graphServiceClient.saveTextStorage(request);
-            
-            if (result != null && result.getCode() == 200) {
-                log.info("文本存储路径记录成功: documentId={}, filePath={}", documentId, textFilePath);
-            } else {
-                log.warn("文本存储路径记录失败: documentId={}, filePath={}, result={}", 
-                        documentId, textFilePath, result);
-            }
+            // 使用 saveAndIndex 方法,保存文本的同时自动建立 RAG 索引
+            textStorageService.saveAndIndex(documentId, textFilePath);
+            log.info("文本存储路径记录并建立索引成功: documentId={}, filePath={}", documentId, textFilePath);
         } catch (Exception e) {
-            log.error("调用graph-service记录文本存储路径异常", e);
-            throw e;
+            log.error("记录文本存储路径异常: documentId={}, filePath={}", documentId, textFilePath, e);
+            // 记录失败不影响主流程,只记录日志
         }
     }
 

+ 127 - 0
backend/sql/rag_tables.sql

@@ -0,0 +1,127 @@
+-- ============================================
+-- RAG 向量化存储相关表
+-- 灵越智报 v2.0
+-- ============================================
+
+-- 启用 pgvector 扩展(需要先安装:apt install postgresql-15-pgvector)
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- ============================================
+-- 1. 文本分块表 (text_chunks)
+-- ============================================
+CREATE TABLE IF NOT EXISTS text_chunks (
+    id VARCHAR(32) PRIMARY KEY,
+    document_id VARCHAR(32) NOT NULL,
+    text_storage_id VARCHAR(32),
+    chunk_index INTEGER NOT NULL,
+    content TEXT NOT NULL,
+    token_count INTEGER,
+    metadata JSONB DEFAULT '{}',
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 索引
+CREATE INDEX IF NOT EXISTS idx_text_chunks_document_id ON text_chunks(document_id);
+CREATE INDEX IF NOT EXISTS idx_text_chunks_text_storage_id ON text_chunks(text_storage_id);
+CREATE INDEX IF NOT EXISTS idx_text_chunks_chunk_index ON text_chunks(document_id, chunk_index);
+
+-- 更新时间触发器
+CREATE TRIGGER update_text_chunks_updated_at BEFORE UPDATE ON text_chunks
+    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- ============================================
+-- 2. 向量嵌入表 (vector_embeddings)
+-- ============================================
+CREATE TABLE IF NOT EXISTS vector_embeddings (
+    id VARCHAR(32) PRIMARY KEY,
+    chunk_id VARCHAR(32) NOT NULL REFERENCES text_chunks(id) ON DELETE CASCADE,
+    embedding vector(768),  -- nomic-embed-text 维度为 768
+    model_name VARCHAR(100) DEFAULT 'nomic-embed-text',
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 普通索引
+CREATE INDEX IF NOT EXISTS idx_vector_embeddings_chunk_id ON vector_embeddings(chunk_id);
+CREATE INDEX IF NOT EXISTS idx_vector_embeddings_model ON vector_embeddings(model_name);
+
+-- HNSW 向量索引(用于高效相似度检索)
+-- 使用余弦距离操作符
+CREATE INDEX IF NOT EXISTS idx_vector_embeddings_hnsw ON vector_embeddings 
+    USING hnsw (embedding vector_cosine_ops);
+
+-- ============================================
+-- 3. 辅助函数:向量相似度检索
+-- ============================================
+
+-- 按文档ID检索相似文本块
+CREATE OR REPLACE FUNCTION search_similar_chunks(
+    query_embedding vector(768),
+    target_document_id VARCHAR(32),
+    result_limit INTEGER DEFAULT 3
+)
+RETURNS TABLE (
+    chunk_id VARCHAR(32),
+    document_id VARCHAR(32),
+    content TEXT,
+    chunk_index INTEGER,
+    similarity FLOAT
+) AS $$
+BEGIN
+    RETURN QUERY
+    SELECT 
+        tc.id AS chunk_id,
+        tc.document_id,
+        tc.content,
+        tc.chunk_index,
+        1 - (ve.embedding <=> query_embedding) AS similarity
+    FROM text_chunks tc
+    JOIN vector_embeddings ve ON tc.id = ve.chunk_id
+    WHERE tc.document_id = target_document_id
+    ORDER BY ve.embedding <=> query_embedding
+    LIMIT result_limit;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 全局检索相似文本块(不限制文档)
+CREATE OR REPLACE FUNCTION search_similar_chunks_global(
+    query_embedding vector(768),
+    result_limit INTEGER DEFAULT 5
+)
+RETURNS TABLE (
+    chunk_id VARCHAR(32),
+    document_id VARCHAR(32),
+    content TEXT,
+    chunk_index INTEGER,
+    similarity FLOAT
+) AS $$
+BEGIN
+    RETURN QUERY
+    SELECT 
+        tc.id AS chunk_id,
+        tc.document_id,
+        tc.content,
+        tc.chunk_index,
+        1 - (ve.embedding <=> query_embedding) AS similarity
+    FROM text_chunks tc
+    JOIN vector_embeddings ve ON tc.id = ve.chunk_id
+    ORDER BY ve.embedding <=> query_embedding
+    LIMIT result_limit;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ============================================
+-- 4. 注释
+-- ============================================
+COMMENT ON TABLE text_chunks IS '文本分块表,存储文档分块后的文本片段';
+COMMENT ON COLUMN text_chunks.document_id IS '关联的文档ID';
+COMMENT ON COLUMN text_chunks.text_storage_id IS '关联的文本存储ID';
+COMMENT ON COLUMN text_chunks.chunk_index IS '分块在文档中的顺序索引';
+COMMENT ON COLUMN text_chunks.content IS '分块文本内容';
+COMMENT ON COLUMN text_chunks.token_count IS '估算的Token数量';
+COMMENT ON COLUMN text_chunks.metadata IS '元数据(如页码、段落位置等)';
+
+COMMENT ON TABLE vector_embeddings IS '向量嵌入表,存储文本块的向量表示';
+COMMENT ON COLUMN vector_embeddings.chunk_id IS '关联的文本分块ID';
+COMMENT ON COLUMN vector_embeddings.embedding IS '768维向量嵌入(nomic-embed-text)';
+COMMENT ON COLUMN vector_embeddings.model_name IS '使用的嵌入模型名称';