Browse Source

first commit

zhenghao 7 months ago
commit
6b33c70ced

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+target/
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 jianyuan1991
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 83 - 0
README.md

@@ -0,0 +1,83 @@
+# Spring AI+Ollama+pgvector实现本地RAG
+1、数据准备,将待文本数据通过embedding模型转成文本向量,并存储到向量数据库中。
+
+2、用户提问,将用户提出的文本通过embedding模型转成问题文本向量,并在向量库中进行搜索,搜索得到一些文本段落,将搜索到的文本段落组装成prompt去调用大模型来获得答案。
+
+## 环境准备
+```text
+jdk 17+
+postgresql-12+ (linux环境)
+ollama (linux环境)
+```
+
+###创建maven配置文件(maven.xml)
+```xml
+<settings>
+  <localRepository>存储文件目录(如D:/repo)</localRepository>
+  <mirrors>
+    <mirror>
+      <id>central</id>
+      <url>https://maven.aliyun.com/repository/central</url>
+      <mirrorOf>central,!bladex</mirrorOf>
+    </mirror>
+  </mirrors>
+  <servers>
+    <server>
+      <id>bladex</id>
+      <configuration>
+        <httpHeaders>
+          <property>
+            <name>Authorization</name>
+            <value>token c02fc9fe46326c7706fb65c233ac2c7ba1af6dfe</value>
+          </property>
+        </httpHeaders>
+      </configuration>
+    </server>
+  </servers>
+</settings>
+```
+###配置idea maven环境
+```text
+ctrl + alt + s 打开设置
+搜索 maven
+找到 User settings file 
+勾选Override
+找到配置的maven.xml
+```
+
+### 数据库插件
+```shell
+(centos7) sudo yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
+(centos8) sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
+(centos8) sudo dnf -qy module disable postgresql
+yum -y install postgresql[pg版本]-server postgresql[pg版本]
+yum -y install pgvector_[pg版本]
+yum -y install postgresql[pg版本]-contrib
+```
+
+### Ollama和模型
+
+####下载ollama
+```shell
+curl -o ~/ollama https://hub.whtrys.space/ollama/ollama/releases/download/v0.2.5/ollama-linux-amd64
+```
+
+####启动ollama
+````shell
+nohup ~/ollama serve > ~/ollama.log 2>&1 &
+````
+
+####下载qwen2:7b:
+```shell
+~/ollama pull qwen2:7b
+```
+
+####下载embedding模型:
+```shell
+~/ollama pull mofanke/dmeta-embedding-zh
+```
+
+####开启会话qwen2:7b
+```shell
+~/ollama run qwen2:7b
+```

+ 107 - 0
pom.xml

@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project>
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.springframework.boot</groupId>
+		<artifactId>spring-boot-starter-parent</artifactId>
+		<version>3.3.1</version>
+		<relativePath/>
+	</parent>
+	<groupId>cn.jlsxwkj</groupId>
+	<artifactId>ragchat</artifactId>
+	<name>ragchat</name>
+	<description>rag chat for Spring Boot</description>
+	<properties>
+		<java.version>17</java.version>
+		<spring-ai.version>1.0.0-M1</spring-ai.version>
+	</properties>
+	<dependencies>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-web</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.ai</groupId>
+			<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.ai</groupId>
+			<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.ai</groupId>
+			<artifactId>spring-ai-pdf-document-reader</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.ai</groupId>
+			<artifactId>spring-ai-tika-document-reader</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>com.fasterxml.jackson.dataformat</groupId>
+			<artifactId>jackson-dataformat-avro</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-aop</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.postgresql</groupId>
+			<artifactId>postgresql</artifactId>
+			<scope>runtime</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.projectlombok</groupId>
+			<artifactId>lombok</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.mybatis.spring.boot</groupId>
+			<artifactId>mybatis-spring-boot-starter</artifactId>
+			<version>3.0.3</version>
+		</dependency>
+		<dependency>
+			<groupId>com.github.xiaoymin</groupId>
+			<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
+			<version>4.4.0</version>
+		</dependency>
+		<dependency>
+			<groupId>cn.hutool</groupId>
+			<artifactId>hutool-all</artifactId>
+			<version>5.8.15</version>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.commons</groupId>
+			<artifactId>commons-lang3</artifactId>
+			<version>3.6</version>
+		</dependency>
+	</dependencies>
+	<dependencyManagement>
+		<dependencies>
+			<dependency>
+				<groupId>org.springframework.ai</groupId>
+				<artifactId>spring-ai-bom</artifactId>
+				<version>${spring-ai.version}</version>
+				<type>pom</type>
+				<scope>import</scope>
+			</dependency>
+		</dependencies>
+	</dependencyManagement>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+			</plugin>
+		</plugins>
+	</build>
+	<repositories>
+		<repository>
+			<id>spring-milestones</id>
+			<name>Spring Milestones</name>
+			<url>https://repo.spring.io/milestone</url>
+			<snapshots>
+				<enabled>false</enabled>
+			</snapshots>
+		</repository>
+	</repositories>
+</project>

+ 19 - 0
sql/pg.sql

@@ -0,0 +1,19 @@
+
+
+DROP TABLE IF EXISTS log_error;
+CREATE TABLE log_error (
+    id bigserial NOT NULL,
+    exception text,
+    message text,
+    error_info text,
+    error_stack_trace text,
+    create_time timestamp(0) DEFAULT now(),
+    CONSTRAINT log_error_pkey PRIMARY KEY (id)
+);
+
+COMMENT ON COLUMN log_error.id IS '主键';
+COMMENT ON COLUMN log_error."exception" IS '异常';
+COMMENT ON COLUMN log_error.message IS '异常消息';
+COMMENT ON COLUMN log_error.error_info IS '异常详细消息';
+COMMENT ON COLUMN log_error.error_stack_trace IS '异常堆栈';
+COMMENT ON COLUMN log_error.create_time IS '创建时间';

+ 17 - 0
src/main/java/cn/jlsxwkj/RagChatApplication.java

@@ -0,0 +1,17 @@
+package cn.jlsxwkj;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author zh
+ */
+@SpringBootApplication
+public class RagChatApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(RagChatApplication.class, args);
+		System.out.println("Started Application");
+	}
+
+}

+ 24 - 0
src/main/java/cn/jlsxwkj/common/R/ErrorHandler.java

@@ -0,0 +1,24 @@
+package cn.jlsxwkj.common.R;
+
+import lombok.Data;
+
+/**
+ *  @author zh
+ *  异常封装类
+ */
+@Data
+public class ErrorHandler {
+    /**
+     * 异常的状态码
+     */
+    private Integer status;
+    /**
+     * 异常的消息
+     */
+    private String message;
+    /**
+     * 异常的名字
+     */
+    private String exception;
+
+}

+ 51 - 0
src/main/java/cn/jlsxwkj/common/R/GlobalRestExceptionHandler.java

@@ -0,0 +1,51 @@
+package cn.jlsxwkj.common.R;
+
+import cn.jlsxwkj.common.utils.Log;
+import cn.jlsxwkj.moudles.logerror.LogError;
+import cn.jlsxwkj.moudles.logerror.LogErrorService;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import jakarta.annotation.Resource;
+import org.springframework.web.ErrorResponse;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Arrays;
+
+/**
+ * @author zh
+ */
+@RestControllerAdvice
+public class GlobalRestExceptionHandler {
+
+    @Resource
+    private LogErrorService logErrorService;
+
+    /**
+     * 捕捉全局异常
+     * @param e 异常
+     * @return 封装异常对象
+     */
+    @ExceptionHandler(Throwable.class)
+    public ErrorHandler exception(Exception e) {
+        var errorHandler = new ErrorHandler();
+        errorHandler.setStatus(HttpResponseStatus.INTERNAL_SERVER_ERROR.code());
+        errorHandler.setMessage(HttpResponseStatus.INTERNAL_SERVER_ERROR.reasonPhrase());
+        errorHandler.setException(e.getClass().getName());
+        if (e instanceof ErrorResponse) {
+            errorHandler.setStatus(((ErrorResponse)e).getStatusCode().value());
+            errorHandler.setMessage(e.getMessage());
+        }
+        LogError errorHandlerToLogError = new LogError().castErrorHandlerToLogError(errorHandler);
+        errorHandlerToLogError.setErrorInfo(e.getMessage());
+        errorHandlerToLogError.setErrorStackTrace(Arrays.toString(e.getStackTrace()));
+        logErrorService.insertOne(errorHandlerToLogError);
+        Log.error(e.getClass(),
+                "\nCode ----> {}\nException ----> {}\nMessage ----> {}\nErrInfo ----> {}\n",
+                errorHandler.getStatus(),
+                errorHandler.getException(),
+                errorHandler.getMessage(),
+                e.getMessage()
+        );
+        return errorHandler;
+    }
+}

+ 58 - 0
src/main/java/cn/jlsxwkj/common/R/Response.java

@@ -0,0 +1,58 @@
+package cn.jlsxwkj.common.R;
+
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import lombok.Data;
+
+/**
+ * @Author zh
+ */
+@Data
+public class Response {
+    /**
+     * 标识返回状态
+     */
+    private Integer code;
+    /**
+     * 标识返回内容
+     */
+    private Object data;
+    /**
+     * 标识返回消息
+     */
+    private String message;
+    /**
+     * 标识返回异常
+     */
+    private String exception;
+
+    /**
+     * 禁止构造对象
+     */
+    private Response () {}
+
+    /**
+     * 成功返回
+     *
+     */
+    public static Response data(Object data){
+        var r = new Response();
+        r.setCode(HttpResponseStatus.OK.code());
+        r.setMessage(HttpResponseStatus.OK.reasonPhrase());
+        r.setData(data);
+        return r;
+    }
+
+    /**
+     * 失败返回
+     *
+     */
+    public static Response error(Integer code, String message, String exception){
+        var r = new Response();
+        r.setCode(code);
+        r.setMessage(message);
+        r.setException(exception);
+        return r;
+    }
+}
+

+ 55 - 0
src/main/java/cn/jlsxwkj/common/R/ResultResponseHandler.java

@@ -0,0 +1,55 @@
+package cn.jlsxwkj.common.R;
+
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.lang.Nullable;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+
+/**
+ *
+ * @Author Joker DJ
+ * ControllerAdvice 绑定的Controller包的权限定名 例如:com.dj.Controller
+ */
+@ControllerAdvice(basePackages = "cn.jlsxwkj.moudles.ragchat")
+public class ResultResponseHandler implements ResponseBodyAdvice<Object> {
+
+    @Override
+    public boolean supports(@Nullable MethodParameter methodParameter,
+                            @Nullable Class<? extends HttpMessageConverter<?>> aClass) {
+        return true;
+    }
+
+    @Override
+    public Object beforeBodyWrite(Object o,
+                                  @Nullable MethodParameter methodParameter,
+                                  @Nullable MediaType mediaType,
+                                  @Nullable Class<? extends HttpMessageConverter<?>> aClass,
+                                  @Nullable ServerHttpRequest serverHttpRequest,
+                                  @Nullable ServerHttpResponse serverHttpResponse) {
+        // 对请求的结果在这里统一返回和处理
+        if (o instanceof ErrorHandler) {
+            // 1、如果返回的结果是一个异常的结果,就把异常返回的结构数据倒腾到R.fail里面即可
+            var errorHandler = (ErrorHandler) o;
+            return Response.error(errorHandler.getStatus(), errorHandler.getMessage(), errorHandler.getException());
+        }
+        if (o instanceof String) {
+            try {
+                // 2、因为springmvc数据转换器对String是有特殊处理 StringHttpMessageConverter
+                var objectMapper = new ObjectMapper();
+                var r = Response.data(o);
+                return objectMapper.writeValueAsString(r);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+        return Response.data(o);
+    }
+}
+
+

+ 29 - 0
src/main/java/cn/jlsxwkj/common/config/Consumer.java

@@ -0,0 +1,29 @@
+package cn.jlsxwkj.common.config;
+
+import cn.jlsxwkj.common.interceptor.Login;
+import jakarta.annotation.Resource;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * @author zh
+ */
+@Configuration
+public class Consumer implements WebMvcConfigurer {
+    @Resource
+    private Login logInterceptor;
+    /**
+     * 注册拦截器
+     * @param registry 注册
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(logInterceptor)
+        // 拦截
+        .addPathPatterns("/**")
+        // 放行
+        .excludePathPatterns("/login");
+        WebMvcConfigurer.super.addInterceptors(registry);
+    }
+}

+ 27 - 0
src/main/java/cn/jlsxwkj/common/filter/ExecTime.java

@@ -0,0 +1,27 @@
+package cn.jlsxwkj.common.filter;
+
+import cn.jlsxwkj.common.utils.Log;
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+/**
+ * @author zh
+ */
+@Component
+public class ExecTime implements Filter {
+
+    @Override
+    public void doFilter(ServletRequest servletRequest,
+                         ServletResponse servletResponse,
+                         FilterChain filterChain) throws IOException, ServletException {
+        var startTime = System.currentTimeMillis();
+        var url = ((HttpServletRequest) servletRequest).getRequestURL().toString();
+        filterChain.doFilter(servletRequest,servletResponse);
+        var execTime = System.currentTimeMillis() - startTime;
+        Log.warn(this.getClass(), "api: {}, exec_time ====> {}ms", url, execTime);
+    }
+
+}

+ 29 - 0
src/main/java/cn/jlsxwkj/common/interceptor/Login.java

@@ -0,0 +1,29 @@
+package cn.jlsxwkj.common.interceptor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.ModelAndView;
+import reactor.util.annotation.Nullable;
+
+/**
+ * @author zh
+ */
+@Component
+public class Login implements HandlerInterceptor {
+    @Override
+    public boolean preHandle(@Nullable HttpServletRequest request,
+                             @Nullable HttpServletResponse response,
+                             @Nullable Object handler) {
+        assert request != null;
+        return true;
+    }
+
+    @Override
+    public void postHandle(@Nullable HttpServletRequest request,
+                           @Nullable HttpServletResponse response,
+                           @Nullable Object handler,
+                           @Nullable ModelAndView modelAndView) {
+    }
+}

+ 98 - 0
src/main/java/cn/jlsxwkj/common/reader/ParagraphDocReader.java

@@ -0,0 +1,98 @@
+package cn.jlsxwkj.common.reader;
+
+import cn.jlsxwkj.common.utils.SplitDocument;
+import lombok.Data;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.document.DocumentReader;
+import org.springframework.ai.reader.ExtractedTextFormatter;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+import org.xml.sax.ContentHandler;
+
+import java.util.List;
+
+/**
+ * @author zh
+ * 文档读取
+ */
+@Data
+public class ParagraphDocReader implements DocumentReader {
+
+    private final AutoDetectParser parser;
+    private final ContentHandler handler;
+    private final Metadata metadata;
+    private final ParseContext context;
+    private final Resource resource;
+    private int windowSize;
+    private ExtractedTextFormatter textFormatter;
+
+    /**
+     * Constructor initializing the reader with a given resource URL.
+     * @param resourceUrl URL to the resource
+     */
+    public ParagraphDocReader(String resourceUrl,
+                              int windowSize) {
+        this(resourceUrl, ExtractedTextFormatter.defaults());
+        this.windowSize = windowSize;
+    }
+
+    /**
+     * Constructor initializing the reader with a given resource URL and a text formatter.
+     * @param resourceUrl URL to the resource
+     * @param textFormatter Formatter for the extracted text
+     */
+    public ParagraphDocReader(String resourceUrl,
+                              ExtractedTextFormatter textFormatter) {
+        this(new DefaultResourceLoader().getResource(resourceUrl), textFormatter);
+    }
+
+    /**
+     * Constructor initializing the reader with a resource and a text formatter. This
+     * constructor will create a BodyContentHandler that allows for reading large PDFs
+     * (constrained only by memory)
+     * @param resource Resource pointing to the document
+     * @param textFormatter Formatter for the extracted text
+     */
+    public ParagraphDocReader(Resource resource,
+                              ExtractedTextFormatter textFormatter) {
+        this(resource, new BodyContentHandler(-1), textFormatter);
+    }
+
+    /**
+     * Constructor initializing the reader with a resource, content handler, and a text
+     * formatter.
+     * @param resource Resource pointing to the document
+     * @param contentHandler Handler to manage content extraction
+     * @param textFormatter Formatter for the extracted text
+     */
+    public ParagraphDocReader(Resource resource,
+                              ContentHandler contentHandler,
+                              ExtractedTextFormatter textFormatter) {
+        this.textFormatter = textFormatter;
+        this.parser = new AutoDetectParser();
+        this.handler = contentHandler;
+        this.metadata = new Metadata();
+        this.context = new ParseContext();
+        this.resource = resource;
+    }
+
+    /**
+     * Extracts and returns the list of documents from the resource.
+     * @return List of extracted {@link Document}
+     */
+    @Override
+    public List<Document> get() {
+        try (var stream = this.resource.getInputStream()) {
+            this.parser.parse(stream, this.handler, this.metadata, this.context);
+            return SplitDocument.splitStringToListDocument(this.handler.toString(), this.windowSize, this.resource.getFilename());
+        }
+        catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+}

+ 59 - 0
src/main/java/cn/jlsxwkj/common/reader/ParagraphTextReader.java

@@ -0,0 +1,59 @@
+package cn.jlsxwkj.common.reader;
+
+
+import cn.jlsxwkj.common.utils.SplitDocument;
+import lombok.Data;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.document.DocumentReader;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.Resource;
+import org.springframework.util.StreamUtils;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author zh
+ * 文本读取
+ */
+@Data
+public class ParagraphTextReader implements DocumentReader {
+
+    private final Resource resource;
+    private final Charset charset = StandardCharsets.UTF_8;
+
+    /**
+     * 窗口大小,为段落的数量,用于滚动读取
+     */
+    private final int windowSize;
+
+    public ParagraphTextReader(String resourceUrl,
+                               int windowSize) {
+        this(new DefaultResourceLoader().getResource(resourceUrl), windowSize);
+    }
+
+    public ParagraphTextReader(Resource resource, int windowSize) {
+        Objects.requireNonNull(resource, "The Spring Resource must not be null");
+        this.resource = resource;
+        this.windowSize = windowSize;
+    }
+
+    /**
+     * 读取文本内容,并根据换行进行分段,采用窗口模式,窗口为段落的数量
+     *
+     * @return 文档信息列表
+     */
+    @Override
+    public List<Document> get() {
+        try {
+            var document = StreamUtils.copyToString(this.resource.getInputStream(), this.charset);
+            return SplitDocument.splitStringToListDocument(document, this.windowSize, this.resource.getFilename());
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+}

+ 27 - 0
src/main/java/cn/jlsxwkj/common/utils/Log.java

@@ -0,0 +1,27 @@
+package cn.jlsxwkj.common.utils;
+
+import cn.hutool.log.dialect.slf4j.Slf4jLog;
+
+/**
+ * @author zh
+ * 打印日志类
+ */
+public class Log {
+
+    public static void warn(Class<?> clazz, String format,
+                            Object... arguments) {
+        new Slf4jLog(clazz).warn(format, arguments);
+    }
+
+    @SuppressWarnings("unused")
+    public static void info(Class<?> clazz, String format,
+                            Object... arguments) {
+        new Slf4jLog(clazz).info(format, arguments);
+    }
+
+    @SuppressWarnings("unused")
+    public static void error(Class<?> clazz, String format,
+                             Object... arguments) {
+        new Slf4jLog(clazz).error(format, arguments);
+    }
+}

+ 64 - 0
src/main/java/cn/jlsxwkj/common/utils/MergeDocuments.java

@@ -0,0 +1,64 @@
+package cn.jlsxwkj.common.utils;
+
+import cn.hutool.core.util.ArrayUtil;
+import org.springframework.ai.document.Document;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author jay
+ */
+public class MergeDocuments {
+
+    /**
+     * 合并文档列表
+     *
+     * @param documentList  文档列表
+     * @return              合并后的文档列表
+     */
+    public static List<Document> mergeDocuments(List<Document> documentList) {
+        var mergeDocuments = new ArrayList<Document>();
+        //根据文档来源进行分组
+        var groupDoc = Collectors.groupingBy(
+                (Document document) -> (String) document.getMetadata().get(SplitDocument.FILE_NAME)
+        );
+        var documentMap = documentList.stream().collect(groupDoc);
+        for (var docListEntry : documentMap.entrySet()) {
+            //获取最大的段落结束编码
+            var endPage = Comparator.comparing(
+                    (Document document) -> (int) document.getMetadata().get(SplitDocument.END_PARAGRAPH_NUMBER)
+            );
+            var documents = docListEntry.getValue().stream().max(endPage);
+            if (documents.isEmpty()) {
+                continue;
+            }
+            var maxParagraphNum = (int) documents.get().getMetadata().get(SplitDocument.END_PARAGRAPH_NUMBER);
+            //根据最大段落结束编码构建一个用于合并段落的空数组
+            var paragraphs = new String[maxParagraphNum];
+            //用于获取最小段落开始编码
+            var minParagraphNum = maxParagraphNum;
+            for (Document document : docListEntry.getValue()) {
+                //文档内容根据回车进行分段
+                var tempPs = document.getContent().split("\n");
+                //获取文档开始段落编码
+                var startParagraphNumber = (int) document.getMetadata().get(SplitDocument.START_PARAGRAPH_NUMBER);
+                if (minParagraphNum > startParagraphNumber) {
+                    minParagraphNum = startParagraphNumber;
+                }
+                //将文档段落列表拷贝到合并段落数组中
+                System.arraycopy(tempPs, 0, paragraphs, startParagraphNumber - 1, tempPs.length);
+            }
+            //合并段落去除空值,并组成文档内容
+            var mergeDoc = new Document(ArrayUtil.join(ArrayUtil.removeNull(paragraphs), "\n"));
+            //合并元数据
+            mergeDoc.getMetadata().putAll(docListEntry.getValue().get(0).getMetadata());
+            //设置元数据:开始段落编码
+            mergeDoc.getMetadata().put(SplitDocument.START_PARAGRAPH_NUMBER, minParagraphNum);
+            //设置元数据:结束段落编码
+            mergeDoc.getMetadata().put(SplitDocument.END_PARAGRAPH_NUMBER, maxParagraphNum);
+            mergeDocuments.add(mergeDoc);
+        }
+        return mergeDocuments;
+    }
+}

+ 65 - 0
src/main/java/cn/jlsxwkj/common/utils/SplitDocument.java

@@ -0,0 +1,65 @@
+package cn.jlsxwkj.common.utils;
+
+import cn.hutool.core.collection.ListUtil;
+import org.springframework.ai.document.Document;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author jay
+ */
+public class SplitDocument {
+
+    public static final String START_PARAGRAPH_NUMBER = "startParagraphNumber";
+    public static final String END_PARAGRAPH_NUMBER = "endParagraphNumber";
+    public static final String FILE_NAME = "source";
+
+    /**
+     * 分割字符并转换为多个文档
+     *
+     * @param document      文档
+     * @param windowSize    窗口大小
+     * @param fileName      文件名
+     * @return              多个文档
+     */
+    public static List<Document> splitStringToListDocument(String document,
+                                                           int windowSize, String fileName) {
+        var readDocuments = new ArrayList<Document>();
+        var paragraphs = Arrays.stream(document.split("\n")).distinct().dropWhile(""::equals).collect(Collectors.toList());
+
+        //采用窗口滑动读取
+        var startIndex = 0;
+        var endIndex = startIndex + windowSize;
+        if (endIndex > paragraphs.size()) {
+            readDocuments.add(toDocument(paragraphs, fileName, startIndex + 1, paragraphs.size()));
+        } else {
+            for (; endIndex <= paragraphs.size(); startIndex++, endIndex++) {
+                readDocuments.add(toDocument(ListUtil.sub(paragraphs, startIndex, endIndex), fileName, startIndex + 1, endIndex));
+            }
+        }
+        return readDocuments;
+    }
+
+    /**
+     * 封装段落成文档
+     *
+     * @param paragraphList     段落内容列表
+     * @param startParagraphNum 开始段落编码
+     * @param endParagraphNum   结束段落编码
+     * @return                  文档信息
+     */
+    private static Document toDocument(List<String> paragraphList,
+                                       String fileName,
+                                       int startParagraphNum,
+                                       int endParagraphNum) {
+        var doc = new Document(String.join("\n", paragraphList));
+        doc.getMetadata().put(FILE_NAME, fileName);
+        doc.getMetadata().put(START_PARAGRAPH_NUMBER, startParagraphNum);
+        doc.getMetadata().put(END_PARAGRAPH_NUMBER, endParagraphNum);
+        return doc;
+    }
+
+}

+ 48 - 0
src/main/java/cn/jlsxwkj/moudles/chat/ChatController.java

@@ -0,0 +1,48 @@
+package cn.jlsxwkj.moudles.chat;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+
+import java.io.IOException;
+
+/**
+ * @author zh
+ */
+@RestController
+@RequestMapping("/api/ai/rag/")
+@Tag(name = "RAG chat")
+public class ChatController {
+
+	@Resource
+	private DocumentService documentService;
+
+	@Operation(summary = "上传文档")
+	@PostMapping("/upload")
+	public String upload(@RequestBody MultipartFile file) throws IOException {
+		return documentService.uploadDocument(file);
+	}
+
+	@Operation(summary = "搜索文档")
+	@PostMapping("/search")
+	public String searchDoc(@RequestParam String keyword) {
+		return documentService.search(keyword);
+	}
+
+
+	@Operation(summary = "问答文档流")
+	@PostMapping(value = "/chatStream", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
+	public Flux<String> chatStream(@RequestParam String message) {
+		return documentService.chatStream(message);
+	}
+
+	@Operation(summary = "问答文档")
+	@PostMapping(value = "/chat")
+	public String chat(@RequestParam String message) {
+		return documentService.chat(message);
+	}
+}

+ 144 - 0
src/main/java/cn/jlsxwkj/moudles/chat/DocumentService.java

@@ -0,0 +1,144 @@
+package cn.jlsxwkj.moudles.chat;
+
+import cn.hutool.crypto.digest.MD5;
+import cn.jlsxwkj.common.reader.ParagraphDocReader;
+import cn.jlsxwkj.common.reader.ParagraphTextReader;
+import cn.jlsxwkj.common.utils.Log;
+import cn.jlsxwkj.common.utils.MergeDocuments;
+import jakarta.annotation.Resource;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.document.DocumentReader;
+import org.springframework.ai.ollama.OllamaChatModel;
+import org.springframework.ai.vectorstore.SearchRequest;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import reactor.core.publisher.Flux;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+
+@Service
+public class DocumentService {
+
+	@Resource
+	private VectorStore vectorStore;
+	@Resource
+	private OllamaChatModel ollamaChatModel;
+	private static final MD5 md5 = MD5.create();
+	private static final String PATH = Objects.requireNonNull(
+			DocumentService.class.getClassLoader().getResource("")
+	).getPath() + "\\ai_doc\\";
+
+	/**
+	 * 使用spring ai解析txt文档并保存至 pg
+	 *
+	 * @param file 			文档
+	 * @throws IOException 	如果解析文档异常删除已保存文件
+	 */
+	public String uploadDocument(MultipartFile file) throws IOException {
+		var split = Objects.requireNonNull(file.getOriginalFilename()).split("\\.");
+		var fileType = split[split.length - 1].toLowerCase(Locale.ROOT);
+		var savePath = new File(PATH);
+		var saveFile = new File(PATH + DocumentService.md5.digestHex(file.getInputStream()) + "." + fileType);
+		var fileUrl = saveFile.toURI().toURL().toString();
+		if (!savePath.exists()) {
+			if (!savePath.mkdirs()) {
+				Log.warn(this.getClass(), "创建文件夹失败: " + PATH);
+				return "创建文件夹失败: " + PATH;
+			}
+		}
+		DocumentReader reader;
+		switch (fileType) {
+			case "txt" -> reader = new ParagraphTextReader(fileUrl, 5);
+			case "doc", "docx" -> reader = new ParagraphDocReader(fileUrl, 5);
+			default -> {
+				Log.warn(this.getClass(), "暂不支持的文件类型: " + fileType);
+				return "暂不支持的文件类型: " + fileType;
+			}
+		}
+		// 判断文件是否存在
+		if (!saveFile.exists()) {
+			try	{
+				file.transferTo(saveFile);
+				vectorStore.add(reader.get());
+			} catch (Exception e){
+				boolean delete = saveFile.delete();
+				if (!delete) {
+					Log.warn(this.getClass(), "删除文件失败: ", fileUrl, e);
+					return "删除文件失败: " + fileUrl;
+				}
+			}
+		}
+		return "未知错误";
+	}
+
+	/**
+	 * 根据关键词搜索向量库
+	 *
+	 * @param keyword 	关键词
+	 * @return 			文本内容
+	 */
+	public String search(String keyword) {
+		//提取文本内容
+		return MergeDocuments.mergeDocuments(
+				vectorStore.similaritySearch(SearchRequest.query(keyword).withSimilarityThreshold(0.5))
+		).stream().map(Document::getContent).collect(Collectors.joining("\n"));
+	}
+
+	/**
+	 * 问答流,根据输入内容回答
+	 *
+	 * @param message 	输入内容
+	 * @return 			回答内容
+	 */
+	public Flux<String> chatStream(String message) {
+		//查询获取文档信息
+		var content = search(message);
+		//封装prompt并调用大模型
+		return ollamaChatModel.stream(getChatPrompt2String(message, content));
+	}
+
+	/**
+	 * 问答,根据输入内容回答
+	 *
+	 * @param message 	输入内容
+	 * @return 			回答内容
+	 */
+	public String chat(String message) {
+		//查询获取文档信息
+		var content = search(message);
+
+		//封装prompt并调用大模型
+		return ollamaChatModel.call(
+				getChatPrompt2String(message, content)
+		);
+	}
+
+	/**
+	 * 获取prompt
+	 *
+	 * @param message 	提问内容
+	 * @param context 	上下文
+	 * @return 			提示词
+	 */
+	private String getChatPrompt2String(String message,
+										String context) {
+		var promptText = """
+							"%s"
+							请参考以上内容回答问题 "%s" , 
+							如内容不符可忽略
+							""";
+		if (context.isEmpty()) {
+			promptText = """
+     						%s
+							""";
+		}
+
+		return String.format(promptText, message, context);
+	}
+}

+ 24 - 0
src/main/java/cn/jlsxwkj/moudles/logerror/LogError.java

@@ -0,0 +1,24 @@
+package cn.jlsxwkj.moudles.logerror;
+
+import cn.jlsxwkj.common.R.ErrorHandler;
+import lombok.Data;
+
+/**
+ * @author zh
+ */
+@Data
+public class LogError {
+
+    private long id;
+    private String exception;
+    private String message;
+    private String errorInfo;
+    private String errorStackTrace;
+    private java.sql.Timestamp createTime;
+
+    public LogError castErrorHandlerToLogError(ErrorHandler errorHandler) {
+        this.setMessage(errorHandler.getMessage());
+        this.setException(errorHandler.getException());
+        return this;
+    }
+}

+ 31 - 0
src/main/java/cn/jlsxwkj/moudles/logerror/LogErrorMapper.java

@@ -0,0 +1,31 @@
+package cn.jlsxwkj.moudles.logerror;
+
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * @author zh
+ */
+@Mapper
+public interface LogErrorMapper {
+
+    /**
+     * 插入错误日志
+     * @param logError log实体
+     */
+    @Insert("""
+            insert into log_error(
+                message, 
+                exception, 
+                error_info,
+                error_stack_trace
+            ) values (
+                #{LogError.message}, 
+                #{LogError.exception},
+                #{LogError.errorInfo},
+                #{LogError.errorStackTrace}
+            )
+            """)
+    void insertOne(@Param("LogError") LogError logError);
+}

+ 18 - 0
src/main/java/cn/jlsxwkj/moudles/logerror/LogErrorService.java

@@ -0,0 +1,18 @@
+package cn.jlsxwkj.moudles.logerror;
+
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author zh
+ */
+@Service
+public class LogErrorService {
+
+    @Resource
+    private LogErrorMapper logErrorMapper;
+
+    public void insertOne(LogError logError) {
+        logErrorMapper.insertOne(logError);
+    }
+}

+ 16 - 0
src/main/resources/application-dev.yml

@@ -0,0 +1,16 @@
+spring:
+  datasource:
+    url: jdbc:postgresql://118.195.196.59:35432/test
+    username: postgres
+    password: xwkj2022@
+  ai:
+    vectorstore:
+      pgvector:
+        #embedding的向量维度,这里的768是根据nomic-embed-text返回的向量维度配置的
+        dimensions: 768
+    ollama:
+      base-url: http://118.195.196.59:31434
+      chat:
+        model: qwen2:7b
+      embedding:
+        model: mofanke/dmeta-embedding-zh

+ 32 - 0
src/main/resources/application.yml

@@ -0,0 +1,32 @@
+server:
+  port: 80
+  tomcat:
+    max-swallow-size: -1
+spring:
+  profiles:
+    default: dev
+  servlet:
+    multipart:
+      max-file-size: 500MB
+      enabled: true
+      max-request-size: 500MB
+# springdoc-openapi项目配置`
+springdoc:
+  swagger-ui:
+    path: /swagger-ui.html
+    tags-sorter: alpha
+    operations-sorter: alpha
+  api-docs:
+    path: /v3/api-docs
+  group-configs:
+    - group: 'default'
+      paths-to-match: '/**'
+      packages-to-scan: cn.jlsxwkj.moudles
+# knife4j的增强配置,不需要增强可以不配
+knife4j:
+  enable: true
+  setting:
+    language: zh_cn
+logging:
+  level:
+    root: INFO