SpringAI+DeepSeek大模型应用开发——5 ChatPDF

ChatPDF 知识库

RAG检索增强

由于训练大模型非常耗时,再加上训练语料本身比较滞后,所以大模型存在知识限制问题:

  • 知识数据比较落后,往往是几个月之前的;不包含太过专业领域 或者企业私有的数据;

  • 为了解决这些问题,就需要用到RAG了。

RAG原理

RAG 的核心原理是将检索技术与生成模型 相结合,结合外部知识库来检索相关信息来增强模型的输出,其实就是给大模型挂一个知识库

其核心工作流程分为三个阶段:

  1. 接收请求: 首先,系统接收到用户的请求(例如提出一个问题)
  2. 信息检索® : 系统从一个大型文档库中检索出与查询最相关的文档片段。这一步的目标是找到那些可能包含答案或相关信息的文档。这里不一定是从向量数据库中检索,但是向量数据库能反应相似度最高的几个文档(比如说法不同,意思相同),而不是精确查找
  3. 生成增强(A): 将检索到的文档片段与原始查询一起输入到大模型(如chatGPT)中,注意使用合适的提示词,比如原始的问题是XXX,检索到的信息是YY,给大模型的输入应该类似于: 请基于YYY回答XXXX。
  4. 输出生成(G): 大模型LLM 基于输入的査询和检索到的文档片段生成最终的文本答案,并返回给用户

注意:知识库不能写在提示词中,因为通常知识库数据量都是非常大的,而大模型的上下文是有大小限制的,那怎么办呢?

只要想办法从庞大的知识库中找到与用户问题相关的一小部分,组装成提示词,发送给大模型就可以了;那么该如何从知识库中找到与用户问题相关的内容呢?

  • 全文检索?但在这里是不合适的,因为全文检索是文字匹配,而这里要求的是内容上的相似度;
  • 而要从内容相似度来判断,这就不得不提到向量模型的知识了。

向量模型

向量是空间中有方向和长度的量,空间可以是二维,也可以是多维;向量既然是在空间中,那么两个向量之间就一定能计算距离

向量之间的距离一般有两种计算方法:

欧几里得距离

在n维空间中,两点间的直线距离。它是两点间最直接的距离测量方式。很适合用于RGB色彩空间中衡量两种颜色之间的差异

颜色可以用 RGB 值表示,然后通过计算两种颜色 RGB 值之间的欧几里得距离来判断它们的相似度。

  • R G B: 两个颜色的 RGB 分量(红色、绿色、蓝色)
  • d: 两个颜色之间的欧几里得距离。
  • 距离越小,表示颜色越相似; 距离越大,表示颜色越不同
余弦相似度

通过比较两个向量之间的夹角余弦值来衡量它们的方向是否相似,如果夹角余弦值越小,说明它们越相似,但这种方法不能考虑到向量的大小。

在颜色分析中,它可以用来比较颜色 色调的相似性,但是它对于亮度和饱和度的变化不敏感。

综上,如果能把文本转为向量 ,就可以通过向量距离来判断文本的相似度了;

现在有不少的专门的向量模型 ,就可以实现将文本向量化。一个好的向量模型,就是要尽可能让文本含义相似的向量,在空间中距离更近

阿里云百炼平台就提供了这样的模型,用于将文本向量化:

这里选择通用文本向量-v3,这个模型兼容OpenAI,所以我们依然采用OpenAI的配置;修改yml配置

yaml 复制代码
spring:
  application:
    name: chart-robot
  ai:
    ollama:
      # Ollama服务地址
      base-url: http://localhost:11434
      chat:
        # 模型名称,可更改
        model: deepseek-r1:14b
        options:
          # 模型温度,值越大,输出结果越随机
          temperature: 0.8
    openai:
      base-url: https://dashscope.aliyuncs.com/compatible-mode
      api-key: ${OPENAI_API_KEY} #API key
      chat:
        options:
          # 可选择的模型列表 https://help.aliyun.com/zh/model-studio/getting-started/models
          model: qwen-plus
      embedding:
        options:
          model: text-embedding-v3  #通用文本向量-v3
          dimensions: 1024
向量模型测试

文本向量化以后,就可以通过向量之间的距离来判断文本相似度;接下来,我们来测试下阿里百炼提供的向量大模型;

在项目中写一个工具类,用以计算向量之间的欧氏距离 和**余弦距离。**新建一个ai.util包,在其中新建一个VectorDistanceUtils类:

JAVA 复制代码
public class VectorDistanceUtils {

    // 私有构造函数:防止该工具类被实例化。
    private VectorDistanceUtils() {}
    // 浮点数计算精度阈值,用于判断浮点数是否接近零。
    private static final double EPSILON = 1e-12;

    /**
     * 计算欧氏距离(Euclidean Distance)
     * 欧氏距离是两个向量之间的直线距离,常用于衡量多维空间中两点的距离。
     * @param vectorA 向量A(非空且与B等长)
     * @param vectorB 向量B(非空且与A等长)
     */
    public static double euclideanDistance(float[] vectorA, float[] vectorB) {
        // 校验输入向量的合法性
        validateVectors(vectorA, vectorB);

        double sum = 0.0; // 用于累加差值平方
        for (int i = 0; i < vectorA.length; i++) {
            double diff = vectorA[i] - vectorB[i]; // 计算对应维度上的差值
            sum += diff * diff; // 累加差值的平方
        }
        return Math.sqrt(sum); // 返回平方和的平方根,即欧氏距离
    }

    /**
     * 计算余弦距离(Cosine Distance)
     * 余弦距离基于余弦相似度计算,表示两个向量在方向上的差异。距离范围为[0, 2],
     * 其中0表示完全相同,2表示完全相反。
     */
    public static double cosineDistance(float[] vectorA, float[] vectorB) {
        // 校验输入向量的合法性
        validateVectors(vectorA, vectorB);

        double dotProduct = 0.0; // 点积
        double normA = 0.0;      // 向量A的模
        double normB = 0.0;      // 向量B的模

        // 遍历向量的每个维度,计算点积和模的平方
        for (int i = 0; i < vectorA.length; i++) {
            dotProduct += vectorA[i] * vectorB[i]; // 点积累加
            normA += vectorA[i] * vectorA[i];     // A模的平方累加
            normB += vectorB[i] * vectorB[i];     // B模的平方累加
        }

        // 计算向量的模
        normA = Math.sqrt(normA);
        normB = Math.sqrt(normB);

        // 如果任意一个向量为零向量,则无法计算余弦距离,抛出异常
        if (normA < EPSILON || normB < EPSILON) {
            throw new IllegalArgumentException("Vectors cannot be zero vectors");
        }

        // 计算余弦相似度,确保结果在[-1, 1]范围内(处理浮点误差)
        double similarity = dotProduct / (normA * normB);
        similarity = Math.max(Math.min(similarity, 1.0), -1.0);

        // 余弦距离 = 1 - 相似度,范围为[0, 2]
        return 1.0 - similarity;
    }

    /**
     * 参数校验统一方法
     * 确保输入向量满足以下条件:
     * 1. 不为空(null);
     * 2. 长度相等;
     * 3. 非空数组。
     */
    private static void validateVectors(float[] a, float[] b) {
        if (a == null || b == null) {
            throw new IllegalArgumentException("Vectors cannot be null");
        }
        if (a.length != b.length) {
            throw new IllegalArgumentException("Vectors must have same dimension");
        }
        if (a.length == 0) {
            throw new IllegalArgumentException("Vectors cannot be empty");
        }
    }
}

由于SpringBoot的自动装配能力,刚才配置的向量模型可以直接使用;

java 复制代码
@SpringBootTest
...
// 自动注入向量模型
@Autowired
private OpenAiEmbeddingModel embeddingModel;
@Test
void contextLoads() {
    // 1.测试数据
    // 1.1.用来查询的文本,国际冲突
    String query = "global conflicts";

    // 1.2.用来做比较的文本
    String[] texts = new String[]{
        "哈马斯称加沙下阶段停火谈判仍在进行 以方尚未做出承诺",
        "土耳其、芬兰、瑞典与北约代表将继续就瑞典"入约"问题进行谈判",
        "日本航空基地水井中检测出有机氟化物超标",
        "国家游泳中心(水立方):恢复游泳、嬉水乐园等水上项目运营",
        "我国首次在空间站开展舱外辐射生物学暴露实验",
    };
    // 2.向量化
    // 2.1.先将查询文本向量化
    float[] queryVector = embeddingModel.embed(query);

    // 2.2.再将比较文本向量化,放到一个数组
    List<float[]> textVectors = embeddingModel.embed(Arrays.asList(texts));

    // 3.比较欧氏距离
    // 3.1.把查询文本自己与自己比较,肯定是相似度最高的
    System.out.println(VectorDistanceUtils.euclideanDistance(queryVector, queryVector));
    
    // 3.2.把查询文本与其它文本比较
    for (float[] textVector : textVectors) {
        System.out.println(VectorDistanceUtils.euclideanDistance(queryVector, textVector));
    }
    System.out.println("------------------");

    // 4.比较余弦距离
    // 4.1.把查询文本自己与自己比较,肯定是相似度最高的
    System.out.println(VectorDistanceUtils.cosineDistance(queryVector, queryVector));
    // 4.2.把查询文本与其它文本比较
    for (float[] textVector : textVectors) {
        System.out.println(VectorDistanceUtils.cosineDistance(queryVector, textVector));
    }
}

运行结果:

可以看到,向量相似度确实符合我们的预期。有了比较文本相似度的办法,知识库的问题就可以解决了;前面说了,知识库数据量很大,无法全部写入提示词,而且庞大的知识库中与用户问题相关的其实并不多

所以,我们需要想办法从庞大的知识库中找到与用户问题相关的一小部分,组装成提示词,发送给大模型就可以了

现但是新的问题来了:向量模型是生成向量的,如此庞大的知识库,谁来从中比较和检索数据呢? 这就需要用到向量数据库

向量数据库
文本向量化

由于需要将已拆分的知识片段文本存储向量库,以便后续可以进行检索,而向量库存储的数据是向量不是文本

因此需要将文本进行向量化,即将一个字符串转换为一个N维数组 ,这个过程在自然语言处理(NLP)领域称为文本嵌入

不同的LLM对于文本嵌入的实现是不同的,ChatGPT的实现是基于transformer架构的,相关实现存储在服务端,每次嵌入都需要访问OpenAI的HTTP接口。

通过下面的例子可以看到OpenAi使用的模型是:text-embedding-ada-002,向量的维度是:1536

java 复制代码
OpenAiEmbeddingModel embeddingModel = new OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder().apiKey(API_KEY).baseUrl(BASE_URL).build();
log.info("当前的模型是: {}", embeddingModel.modelName());
String text = "两只眼睛";
Embedding embedding = embeddingModel.embed(text).content();
log.info("文本:{}的嵌入结果是:\n{}", text, embedding.vectorAsList());
log.info("它是{}维的向量", embedding.dimension());
向量库存储

向量数据库,也称为向量存储或向量搜索引擎,是一种专门设计用于存储和管理向量(固定长度的数字列表)及其他数据项的数据库。

这些向量是数据点在高维空间中的数学表示,其中每个维度对应数据的一个特征。向量数据库的主要目的是通过近似最近邻(ANN)算法实现高效的相似性搜索。

向量数据库的主要作用有两个:

  • 存储向量数据
  • 基于相似度检索数据

SpringAI支持很多向量数据库,并且都进行了封装,可以用统一的API去访问:

这些库都实现了统一的接口:VectorStore,因此操作方式一模一样,只要学会任意一个,其它就都不是问题;

注意:除了最后一个库,其它所有向量数据库都是需要安装部署的,而且每个企业用的向量库都不一样。

SimpleVectorStore
  • 最后一个SimpleVectorStore向量库是基于内存实现,是一个专门用来测试、教学用的库,非常适合此处案例的使用;
  • 修改CommonConfiguration,添加一个VectorStore的Bean
java 复制代码
@Bean
public VectorStore vectorStore(OpenAiEmbeddingModel embeddingModel) {
    return SimpleVectorStore.builder(embeddingModel).build();
}
VectorStore接口
java 复制代码
public interface VectorStore extends DocumentWriter {
    default String getName() {
                return this.getClass().getSimpleName();
        }
    // 保存文档到向量库
    void add(List<Document> documents);
    // 根据文档id删除文档
    void delete(List<String> idList);

    void delete(Filter.Expression filterExpression);

     default void delete(String filterExpression) {
        SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
        Filter.Expression textExpression = searchRequest.getFilterExpression();
        Assert.notNull(textExpression, "Filter expression must not be null");
        this.delete(textExpression);
    }
    // 根据条件检索文档
    @Nullable
    List<Document> similaritySearch(String query);
    // 根据条件检索文档
    @Nullable
    List<Document> similaritySearch(SearchRequest request);

    default <T> Optional<T> getNativeClient() {
                return Optional.empty();
        }
}

注意,VectorStore操作向量化的基本单位是Document,在使用时需要将自己的知识库分割转换为一个个的Document,然后写入VectorStore

那么问题来了,该如何把各种不同的知识库文件转为Document呢?

文件读取和转换

由于知识库太大,所以要将知识库拆分成文档片段,然后再做向量化。而且SpringAI中向量库接收的是Document类型的文档,即我们处理文档还要转成Document格式

不过,文档读取、拆分、转换的动作并不需要我们亲自完成。在SpringAI中提供了各种文档读取的工具,可以参考官网:Spring AI Reference

比如PDF文档读取和拆分,SpringAI提供了两种默认的拆分原则:

  • PagePdfDocumentReader:按页拆分,推荐使用;
  • ParagraphPdfDocumentReader :按pdf的目录拆分,不推荐,因为很多PDF不规范,没有章节标签;

此处选择使用PagePdfDocumentReader。首先,在pom.xml中引入依赖:

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>

然后就可以利用工具把PDF文件读取并处理成Document了;

编写一个单元测试

JAVA 复制代码
@Test
public void testVectorStore(){
    Resource resource = new FileSystemResource("中二知识笔记.pdf");
    // 1.创建PDF的读取器
    PagePdfDocumentReader reader = new PagePdfDocumentReader(
        resource, // 文件源
        PdfDocumentReaderConfig.builder()
        .withPageExtractedTextFormatter(ExtractedTextFormatter.defaults())
        .withPagesPerDocument(1) // 每1页PDF作为一个Document
        .build()
    );
    // 2.读取PDF文档,拆分为Document
    List<Document> documents = reader.read();
    // 3.写入向量库
    vectorStore.add(documents);
    // 4.构建一个搜索请求
    SearchRequest request = SearchRequest.builder()
        .query("论语中教育的目的是什么")
        .topK(1)  //返回最相关的前 1 个结果
        .similarityThreshold(0.6) //只有相似度大于等于 0.6 的结果才会被返回
        .filterExpression("file_name == '中二知识笔记.pdf'")
        .build();
    List<Document> docs = vectorStore.similaritySearch(request);  //搜索
    if (docs == null) {
        System.out.println("没有搜索到任何内容");
        return;
    }
    //遍历搜索结果,打印每个文档的相关信息
    for (Document doc : docs) {
        System.out.println(doc.getId());
        System.out.println(doc.getScore());
        System.out.println(doc.getText());
    }
}

注意:启动测试之前,要将中二知识笔记.pdf文件放到工程目录结构下;结果如下

RAG原理总结

目前已经有了以下这些工具

  • PDFReader:读取文档并拆分为片段;
  • 向量大模型:将文本片段向量化;
  • 向量数据库:存储向量,检索向量;

接下来梳理一下要解决的问题和解决思路:

  1. 要解决大模型的知识限制问题,需要外挂知识库
  2. 受到大模型上下文限制,知识库不能直接拼接在提示词中;
  3. 需要从庞大的外挂知识库中找到与用户问题相关的一小部分,再组装成提示词;
  4. 这些可以利用文档读取器、向量大模型、向量数据库来解决;
  5. RAG要做的事情就是将知识库分割==>利用向量模型做向量化==>存入向量数据库==>查询的时候去检索;
  6. 每当用户询问AI时,将用户问题向量化==>拿着问题向量==>去向量数据库检索最相关的片段
  7. 对话大模型:将检索到的片段、用户的问题一起拼接为提示词==> 发送提示词给大模型,得到响应。

目标

接下来就来实现一个非常火爆的个人知识库AI应用------ChatPDF,原网站如下:

这个网站其实就是把个人的PDF文件作为知识库,让AI基于PDF内容来回答问题,对于大学生、研究人员、专业人士来说,非常方便。

PDF上传下载向量化

既然是ChatPDF,即所有知识库都是PDF形式的,由用户提交给服务器。所以,需要先实现一个上传PDF的接口,在接口中实现下列功能:

  • 校验文件格式是否为PDF;
  • 保存文件信息;
    • 保存文件(可以是oss或本地保存);
    • 保存会话ID和文件路径的映射关系(方便查询会话历史的时候再次读取文件);
  • 文档拆分和向量化(文档太大,需要拆分为一个个片段,分别向量化);

另外,将来用户查询会话历史,还需要返回pdf文件给前端用于预览,所以需要实现一个下载PDF接口,包含下面功能:

  • 读取文件
  • 返回文件给前端

PDF文件管理

由于将来要实现PDF下载功能,就需要记住每一个chatId对应的PDF文件名称

所以定义一个类,记录chatId与pdf文件的映射关系,同时实现基本的文件保存功能。在repository包中定义FileRepository接口

java 复制代码
public interface FileRepository {
    /**
     * 保存文件,还要记录chatId与文件的映射关系
     * @param chatId 会话id
     * @param resource 文件
     * @return 上传成功,返回true; 否则返回false
     */
    boolean save(String chatId, Resource resource);

    /**
     * 根据chatId获取文件
     * @param chatId 会话id
     * @return 找到的文件
     */
    Resource getFile(String chatId);
}
java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class LocalPdfFileRepository implements FileRepository {

    private final VectorStore vectorStore; // 向量存储组件

    // 会话id 与 文件名的对应关系,方便查询会话历史时重新加载文件
    private final Properties chatFiles = new Properties();

    /**
     * 保存资源到本地磁盘,并记录会话 ID 与文件名的映射关系。
     */
    @Override
    public boolean save(String chatId, Resource resource) {
        // 1. 获取文件名并检查是否已存在
        String filename = resource.getFilename();
        File target = new File(Objects.requireNonNull(filename));
        if (!target.exists()) {
            try {
                // 将资源内容复制到目标文件
                Files.copy(resource.getInputStream(), target.toPath());
            } catch (IOException e) {
                log.error("Failed to save PDF resource.", e);
                return false;
            }
        }
        // 2. 保存会话 ID 与文件名的映射关系
        chatFiles.put(chatId, filename);
        return true;
    }

    /**
     * 根据会话 ID 获取对应的文件资源。
     */
    @Override
    public Resource getFile(String chatId) {
        // 根据会话 ID 查找文件名
        String filename = chatFiles.getProperty(chatId);
        if (filename == null) {
            log.warn("No file found for chatId: {}", chatId);
            return null;
        }
        return new FileSystemResource(filename);
    }

    /**
     * 初始化方法,在 Spring 容器启动时执行。
     * 加载 `chat-pdf.properties` 文件中的会话 ID 映射关系,
     * 并加载 `chat-pdf.json` 中的向量数据。
     */
    @PostConstruct
    private void init() {
        // 加载会话 ID 映射关系
        FileSystemResource pdfResource = new FileSystemResource("chat-pdf.properties");
        if (pdfResource.exists()) {  //如果文件存在
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(pdfResource.getInputStream(), StandardCharsets.UTF_8))) {
                chatFiles.load(reader);  //加载chatFiles到本地文件
            } catch (IOException e) {
                throw new RuntimeException("Failed to load chat-pdf.properties", e);
            }
        }
        // 加载向量存储数据
        FileSystemResource vectorResource = new FileSystemResource("chat-pdf.json");
        if (vectorResource.exists()) {
            SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore;
            try {
                simpleVectorStore.load(vectorResource);
            } catch (Exception e) {
                throw new RuntimeException("Failed to load chat-pdf.json", e);
            }
        }
    }

    /**
     * 销毁方法,在 Spring 容器关闭时执行。
     * 持久化会话 ID 映射关系和向量存储数据到磁盘。
     */
    @PreDestroy
    private void persistent() {
        try {
            // 持久化会话 ID 映射关系
            try (FileWriter writer = new FileWriter("chat-pdf.properties")) {
                chatFiles.store(writer, "Persisted at " + LocalDateTime.now());
            }

            // 持久化向量存储数据
            SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore;
            simpleVectorStore.save(new File("chat-pdf.json"));
        } catch (IOException e) {
            throw new RuntimeException("Failed to persist data", e);
        }
    }
}

此处选择了基于内存的SimpleVectorStore,重启就会丢失向量数据。所以这里是将pdf文件与chatId的对应关系、VectorStore都持久化到了磁盘;

实际开发中,如果选择了RedisVectorStore,或者CassandraVectorStore,则无需自己持久化。但是chatId和PDF文件之间的对应关系,还是需要自己维护的。

上传文件相应结果

由于前端文件上传给后端后,后端需要返回响应结果,在ai.entity.vo中定义一个Result类:

java 复制代码
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class Result {
    private Integer ok;
    private String msg;
    private Result(Integer ok, String msg) {
        this.ok = ok;
        this.msg = msg;
    }
    public static Result ok() {
        return new Result(1, "ok");
    }

    public static Result fail(String msg) {
        return new Result(0, msg);
    }
}

文件上传下载

ai.controller中创建一个PdfController

java 复制代码
@Slf4j
@RequiredArgsConstructor  //配合final实现自动注入
@RestController
@RequestMapping("/ai/pdf")
public class PdfController {

    private final FileRepository fileRepository;  //文件存储组件
    private final VectorStore vectorStore; //向量存储组件
    private final ChatClient pdfChatClient;  //问答模型客户端
    private final ChatHistoryRepository chatHistoryRepository; //会话历史记录

    /**
     * 对话
     */
    @RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
    public Flux<String> chat(String prompt, String chatId) {
        // 1.找到会话文件
        Resource file = fileRepository.getFile(chatId);
        if (!file.exists()) {
            // 文件不存在,不回答
            throw new RuntimeException("会话文件不存在!");
        }
        // 2.保存会话id
        chatHistoryRepository.save("pdf", chatId);
        // 3.请求模型
        return pdfChatClient.prompt()
                .user(prompt)
                .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
                .advisors(a -> a.param(FILTER_EXPRESSION, "file_name == '" + file.getFilename() + "'"))
                .stream()
                .content();
    }

    /**
     * 文件上传
     */
    @RequestMapping("/upload/{chatId}")
    public Result uploadPdf(@PathVariable String chatId, @RequestParam("file") MultipartFile file) {
        try {
            // 1. 校验文件是否为PDF格式
            if (!Objects.equals(file.getContentType(), "application/pdf")) {
                return Result.fail("只能上传PDF文件!");
            }
            // 2.保存文件
            boolean success = fileRepository.save(chatId, file.getResource());
            if (!success) {
                return Result.fail("保存文件失败!");
            }
            // 3.写入向量库
            this.writeToVectorStore(file.getResource());
            return Result.ok();
        } catch (Exception e) {
            log.error("Failed to upload PDF.", e);
            return Result.fail("上传文件失败!");
        }
    }

    /**
     * 文件下载
     */
    @GetMapping("/file/{chatId}")
    public ResponseEntity<Resource> download(@PathVariable("chatId") String chatId) throws IOException {
        // 1.读取文件
        Resource resource = fileRepository.getFile(chatId);
        if (!resource.exists()) {
            return ResponseEntity.notFound().build();
        }
        // 2.文件名编码,写入响应头
        String filename = URLEncoder.encode(Objects.requireNonNull(resource.getFilename()), StandardCharsets.UTF_8);
        // 3.返回文件
        return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
                .body(resource);
    }
    /**
     * 写入向量库
     */
    private void writeToVectorStore(Resource resource) {
        // 1.创建PDF的读取器
        PagePdfDocumentReader reader = new PagePdfDocumentReader(
                resource, // 文件源
                PdfDocumentReaderConfig.builder()
                        .withPageExtractedTextFormatter(ExtractedTextFormatter.defaults())
                        .withPagesPerDocument(1) // 每1页PDF作为一个Document
                        .build()
        );
        // 2.读取PDF文档,拆分为Document
        List<Document> documents = reader.read();
        // 3.写入向量库
        vectorStore.add(documents);
    }
}

上传大小限制

SpringMVC有默认的文件大小限制,只有10M,很多知识库文件都会超过这个值,所以我们需要修改配置,增加文件上传允许的上限;

修改application.yaml文件,添加配置:

yaml 复制代码
spring:
  servlet:
    multipart:
      # 单个文件的最大大小为100MB
      max-file-size: 104857600
      # 整个请求的最大大小为100MB
      max-request-size: 104857600

配置ChatClient

理论上来说,每次与AI对话的完整流程是这样的:

  1. 将用户的问题利用向量大模型做向量化OpenAiEmbeddingModel
  2. 去向量数据库检索相关的文档 VectorStore
  3. 拼接提示词,发送给大模型;
  4. 解析响应结果;

    不过,SpringAI同样基于AOP技术帮我们完成了全部流程,用的是一个名为QuestionAnswerAdvisor的Advisor。我们只需要把VectorStore配置到Advisor即可。在CommonConfiguration类中给ChatPDF也单独定义一个ChatClient:
JAVA 复制代码
@Bean
public ChatClient pdfChatClient(OpenAiChatModel model, ChatMemory chatMemory, VectorStore vectorStore) {
    return ChatClient
        .builder(model)
        .defaultSystem("请根据上下文回答问题,遇到上下文没有的问题,不要随意编造。")
        .defaultAdvisors(
        new SimpleLoggerAdvisor(),
        new MessageChatMemoryAdvisor(chatMemory),  // 会话记忆
        new QuestionAnswerAdvisor(  
            vectorStore,    // 向量库
            SearchRequest.builder()  // 向量检索的请求参数
            .similarityThreshold(0.6)   // 相似度阈值
            .topK(2)     // 返回的文档片段数量
            .build()
        )
    )
        .build();
}

也可以自己自定义RAG查询的流程,不使用Advisor,具体可参考官网

对话接口

最后,对接前端与大模型对话。修改PdfController,添加一个接口:

java 复制代码
/**
 * 对话
*/
@RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
public Flux<String> chat(String prompt, String chatId) {
    // 1.找到会话文件
    Resource file = fileRepository.getFile(chatId);
    if (!file.exists()) {
        // 文件不存在,不回答
        throw new RuntimeException("会话文件不存在!");
    }
    // 2.保存会话id
    chatHistoryRepository.save("pdf", chatId);
    // 3.请求模型
    return pdfChatClient.prompt()
        .user(prompt)
        .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
        .advisors(a -> a.param(FILTER_EXPRESSION, "file_name == '" + file.getFilename() + "'"))
        .stream()
        .content();
}

测试

持久化VectorStore

SpringAI提供了很多持久化的VectorStore,下面以其中两个为例来介绍:

  • RedisVectorStore : 目前测试metafiled过滤有异常;
  • CassandraVectorStore。

RedisVectorStore

  • 需要安装一个Redis Stack,这是Redis官方提供的拓展版本,其中有向量库的功能;
  • 可以使用Docker安装:
shell 复制代码
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
#通过命令行访问
docker exec -it redis-stack redis-cli
#也可以通过浏览器访问控制台:http://localhost:8001  ip换成自己配置的

在项目中引入RedisVectorStore的依赖:

复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-redis-store-spring-boot-starter</artifactId>
</dependency>

application.yml配置Redis:

YML 复制代码
spring:
  ai:
    vectorstore:
      redis:
        index: spring_ai_index # 向量库索引名
        initialize-schema: true # 是否初始化向量库索引结构
        prefix: "doc:" # 向量库key前缀
  data:
    redis:
      host: XXX # redis地址

接下来,无需声明bean,直接就可以直接使用VectorStore了。

CassandraVectorStore

首先,需要安装一个Cassandra访问,使用Docker安装:

SHELL 复制代码
docker run -d --name cas -p 9042:9042  cassandra

在项目中添加cassandra依赖:

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-cassandra-store-spring-boot-starter</artifactId>
</dependency>

配置Cassandra地址:

YML 复制代码
spring:
  cassandra:
    contact-points: xxx:9042
    local-datacenter: datacenter1

配置VectorStore:

java 复制代码
public CassandraVectorStore vectorStore(OpenAiEmbeddingModel embeddingModel, CqlSession cqlSession) {
    return CassandraVectorStore.builder(embeddingModel)
            .session(cqlSession)
            .addMetadataColumn(
                    new CassandraVectorStore.SchemaColumn("file_name", DataTypes.TEXT, CassandraVectorStore.SchemaColumnTags.INDEXED)
            )
            .build();
}
相关推荐
佩奇的技术笔记18 分钟前
Java学习手册:Web 应用架构概述
java
Miraitowa_cheems1 小时前
[Java EE] Spring 配置 和 日志
java·spring·java-ee
SuperherRo3 小时前
Web开发-JavaEE应用&原生和FastJson反序列化&URLDNS链&JDBC链&Gadget手搓
java·java-ee·jdbc·fastjson·反序列化·urldns
xxjiaz4 小时前
二分查找-LeetCode
java·数据结构·算法·leetcode
nofaluse4 小时前
JavaWeb开发——文件上传
java·spring boot
爱的叹息5 小时前
【java实现+4种变体完整例子】排序算法中【插入排序】的详细解析,包含基础实现、常见变体的完整代码示例,以及各变体的对比表格
java·算法·排序算法
爱的叹息5 小时前
【java实现+4种变体完整例子】排序算法中【快速排序】的详细解析,包含基础实现、常见变体的完整代码示例,以及各变体的对比表格
java·算法·排序算法
6v6-博客5 小时前
2024年网站开发语言选择指南:PHP/Java/Node.js/Python如何选型?
java·开发语言·php
Miraitowa_cheems5 小时前
[Java EE] Spring AOP 和 事务
java·java-ee·aop·spring 事务
光头小小强0076 小时前
致远OA——自定义开发rest接口
java·经验分享·spring·tomcat