SpringAI + DeepSeek大模型应用开发 - 进阶篇(下)

发布于:2025-06-23 ⋅ 阅读:(14) ⋅ 点赞:(0)

三、SpringAI

 4. ChatPDF

4.1 RAG原理

要解决大模型的知识限制问题,其实并不复杂。

解决的思路就是给大模型外挂一个知识库,可以是专业领域知识,也可以是企业私有的数据。

不过,知识库不能简单的直接拼接在提示词中。因为通常知识库数据量非常大的,而大模型的上下文是有大小限制的,早期的GPT上下文不能超过2000 token,因此知识库不能直接写在提示词中。

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

那么问题来了,我们该如何从知识库中找到与用户问题相关的内容呢?可能会有同学会想到全文检索,但是在这里是不合适的,因为全文检索是文字匹配,这里我们要求的是内容上的相似度。而要从内容相似度来判断,就不得不提到向量模型的知识了。

4.2 向量模型 

(1)向量相似度

以二维向量为例,向量之间的距离有两种计算方法:

通常,两个向量之间的欧式距离越近,我们认为两个向量的相似度越高(余弦距离相反,越大相似度越高)。

所以,如果我们能把文本转为向量,就可以通过向量距离来判断文本的相似度了。

通过计算两个向量之间的距离,可以判断向量相似度。欧式距离越小,相似度越高;余弦距离越大,相似度越高。

向量模型:将文档向量化,保证内容越相似的文本,在向量空间中距离越近

(2)向量模型

①引入依赖

        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        </dependency>

②配置向量模型 - application.yaml

spring:
  application:
    name: heima-ai
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: deepseek-r1:7b
    openai:
      base-url: https://dashscope.aliyuncs.com/compatible-mode
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: qwen-max-latest # 模型名称
          temperature: 0.8 # 模型温度,值越大,输出结果越随机
      embedding:
        options:
          model: text-embedding-v4 # 向量模型名称
          dimensions: 1024 # 向量维度

③使用EmbeddingModel

  • 新增VectorDistanceUtils,计算向量的欧式距离、余弦距离
package com.itheima.ai.utils;

public class VectorDistanceUtils {
    
    // 防止实例化
    private VectorDistanceUtils() {}

    // 浮点数计算精度阈值
    private static final double EPSILON = 1e-12;

    /**
     * 计算欧氏距离
     * @param vectorA 向量A(非空且与B等长)
     * @param vectorB 向量B(非空且与A等长)
     * @return 欧氏距离
     * @throws IllegalArgumentException 参数不合法时抛出
     */
    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);
    }

    /**
     * 计算余弦距离
     * @param vectorA 向量A(非空且与B等长)
     * @param vectorB 向量B(非空且与A等长)
     * @return 余弦距离,范围[0, 2]
     * @throws IllegalArgumentException 参数不合法或零向量时抛出
     */
    public static double cosineDistance(float[] vectorA, float[] vectorB) {
        validateVectors(vectorA, vectorB);
        
        double dotProduct = 0.0;
        double normA = 0.0;
        double normB = 0.0;
        
        for (int i = 0; i < vectorA.length; i++) {
            dotProduct += vectorA[i] * vectorB[i];
            normA += vectorA[i] * vectorA[i];
            normB += vectorB[i] * vectorB[i];
        }
        
        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);
        
        return similarity;
    }

    // 参数校验统一方法
    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");
        }
    }
}
  • 编写测试类 - HeimaAiApplicationTests
package com.itheima.ai;

import com.itheima.ai.utils.VectorDistanceUtils;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Arrays;
import java.util.List;

@SpringBootTest
class HeimaAiApplicationTests {

    @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));
        }
    }
}
  • 添加运行配置

  • 测试结果(欧式距离越小,相似度越高;余弦距离越大,相似度越高)

0.0
1.277985806334919
1.217696088331691

1.3344384543780141
1.3342534594876638
1.3400395683070097
------------------
1.0
0.18337628987446866
0.25860824145232714

0.1096371227131696
0.10988406960580344
0.10214705075234658

4.3 向量数据库

向量模型是帮我们生成向量的,如此庞大的知识库,谁来帮我们从中比较和检索数据呢?这就需要用到向量数据库了。

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

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

Vector Databases
Azure AI Service OpenSearch
Azure Cosmos DB Oracle
Apache Cassandra Vector Store PGvector
Chroma Pinecone
Elasticsearch Qdrant
GemFire Redis(企业版)
MariaDB Vector Store SAP Hana
Milvus Typesense
MongoDB Atlas Weaviate
Neo4j SimpleVectorStore

这些库都实现了统一的接口:VectorStore,因此操作方式一样。

Redis

可参考:SpringAI版本更新:向量数据库不可用的解决方案! - 磊哥|www.javacn.site - 博客园

步骤①:引入依赖(仅作介绍,项目中实际没用)

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

②配置向量数据库

spring:
  application:
    name: heima-ai
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: deepseek-r1:7b
    openai:
      base-url: https://dashscope.aliyuncs.com/compatible-mode
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: qwen-max-latest # 模型名称
          temperature: 0.8 # 模型温度,值越大,输出结果越随机
      embedding:
        options:
          model: text-embedding-v4 # 向量模型名称
          dimensions: 1024 # 向量维度
    vectorstore:
      redis:
        index: spring_ai_index # 向量库索引名
        initialize-schema: true # 是否初始化向量库索引结构
        prefix: "doc:" # 向量库key前缀
  data:
    redis:
      host: 192.168.200.130 # 改为你自己的地址
  • 使用Docker安装Redis:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
  • 安装完成后,可以通过命令行访问:
docker exec -it redis-stack redis-cli
  • 也可以通过浏览器访问控制台:http://192.168.200.130:8001,注意,这里的IP要换成你自己的

③读写数据

@Autowired
private VectorStore vectorStore;

// 添加向量数据
vectorStore.add(List.of(new Document("I like Spring Boot"), new Document("I love Java")));

// 相似性搜索
List<Document> results = vectorStore.similaritySearch("Java");

4.4 PDF处理

 SimpleVectorStore

SimpleVectorStore向量库是基于内存实现的,是一个专门用来测试、教学用的库(在Spring AI 1.0.0-M7版本中已移除)。

以下是VectorStore接口中声明的方法:

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) { ... };
    // 根据条件检索文档
    List<Document> similaritySearch(String query);
    // 根据条件检索文档
    List<Document> similaritySearch(SearchRequest request);

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

可以看到,VectorStore操作向量化的基本单位是Document,我们在使用时需要将自己的知识库分割转换为一个个的Document,然后写入VectorStore。在SpringAI中提供了各种文档读取的工具:可以参考官网:ETL Pipeline :: Spring AI Reference

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

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

①引入依赖(以读取PDF为例)

        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pdf-document-reader</artifactId>
        </dependency>

②修改CommonConfiguration,增加越高VectorStore的Bean

    @Bean
    public VectorStore vectorStore(OpenAiEmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

③读写和拆分文档(单元测试)

package com.itheima.ai;

import com.itheima.ai.utils.VectorDistanceUtils;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

import java.util.Arrays;
import java.util.List;

@SpringBootTest
class HeimaAiApplicationTests {

    @Autowired
    private OpenAiEmbeddingModel embeddingModel;

    @Autowired
    private VectorStore vectorStore;

    @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)  // 只返回相似度最高的一条数据
                .similarityThreshold(0.6)  // 相似度阈值
                .filterExpression("file_name == '中二知识笔记.pdf'")
                .build();
        List<Document> docs = vectorStore.similaritySearch(request);
        if (docs.isEmpty()) {
            System.out.println("没有搜索到任何内容");
            return;
        }
        for (Document doc : docs) {
            System.out.println(doc.getId());
            System.out.println(doc.getScore());
            System.out.println(doc.getText());
        }
    }
}
  • 注意:运行之前添加API_KEY

RAG原理总结

现在我们有了这些工具:

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

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

  • 要解决大模型的知识限制问题,需要外挂知识库
  • 受到大模型上下文限制,知识库不能简单的直接拼接在提示词中
  • 我们需要从庞大的知识库中找到与用户问题相关的一小部分,再组装成提示词
  • 这些可以利用文档读取器、向量大模型、向量数据库来解决

所以,RAG要做的事情就是将知识库分割,然后利用向量模型做向量化,存入向量数据库,然后查询的时候去检索:

第一阶段(存储知识库)

  • 将知识库内容切片,分为一个个片段;
  • 将每个片段都利用向量模型向量化
  • 将所有向量化后的片段写入向量数据库

第二阶段(检索知识库)

  • 每当用户询问AI时,将用户问题向量化;
  • 拿着问题向量去向量数据库检索最相关的片段;

第三阶段(对话大模型)

  • 将检索到的片段、用户的问题一起拼接为提示词;
  • 发送给大模型,得到响应。

4.5 ChatPDF

需求:模仿chatpdf.com网站,实现个人知识库功能

功能列表:

  • 文件上传并导入向量库
  • 文件下载
  • AI对话

步骤①:定义FileRepository 接口

package com.itheima.ai.repository;

import org.springframework.core.io.Resource;

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);
}

②添加实现类LocalPdfFileRepository 

package com.itheima.ai.repository;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Properties;

@Slf4j
@Component
@RequiredArgsConstructor
public class LocalPdfFileRepository implements FileRepository{
    private final VectorStore vectorStore;
    // 会话id与文件名的对应关系,方便查询会话历史时重新加载文件
    private final Properties chatFiles = new Properties();  // 自带持久化存储能力,继承自HashTable

    /**
     * 保存文件,还有记录chatId与文件的映射关系
     * @param chatId 会话id
     * @param resource 文件
     * @return 上传成功返回true,否则返回false
     */
    @Override
    public boolean save(String chatId, Resource resource) {
        String filename = resource.getFilename();
        // 1. 保存到磁盘
        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;
    }

    /**
     * 根据chatId获取文件
     * @param chatId 会话id
     * @return 找到的文件
     */
    @Override
    public Resource getFile(String chatId) {
        return new FileSystemResource(chatFiles.getProperty(chatId));
    }

    @PostConstruct
    private void init() {
        // 加载会话-文件映射关系
        FileSystemResource pdfResource = new FileSystemResource("chat-pdf.properties");
        if (pdfResource.exists()) {
            try {
                chatFiles.load(new BufferedReader(new InputStreamReader(pdfResource.getInputStream(), StandardCharsets.UTF_8)));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        // 加载向量存储数据
        FileSystemResource vectorResource = new FileSystemResource("chat-pdf.json");
        if (vectorResource.exists()) {
            SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore;
            simpleVectorStore.load(vectorResource);
        }
    }

    @PreDestroy
    private void persistent() {
        try {
            // 保存会话-文件映射关系
            chatFiles.store(new FileWriter("chat-pdf.properties"), LocalDateTime.now().toString());
            // 保存向量存储数据
            SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore;
            simpleVectorStore.save(new File("chat-pdf.json"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

💡注意:

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

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

③添加一个Result类,用于返回响应结果

package com.itheima.ai.entity.vo;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class Result {
    private Integer ok;
    private String msg;

    public 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);
    }
}

④创建一个PdfController,实现文件的上传和下载

package com.itheima.ai.controller;

import com.itheima.ai.entity.vo.Result;
import com.itheima.ai.repository.FileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai/pdf")
public class PdfController {
    private final FileRepository fileRepository;
    private final VectorStore vectorStore;


    /**
     * 文件上传
     * @param chatId
     * @param file
     * @return
     */
    @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());

            // 4. 结果返回
            return Result.ok();
        } catch (Exception e) {
            log.error("Failed to upload PDF: ", e);
            return Result.fail("上传文件失败!");
        }
    }

    /**
     * 文件下载
     * @param chatId
     * @return
     */
    @GetMapping("/file/{chatId}")
    public ResponseEntity<Resource> download(@PathVariable("chatId") String chatId) {
        // 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);
    }

    /**
     * 写入向量库
     * @param 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);
    }
}

⑤修改application.yaml,添加配置,限制文件上传大小(最大10M)

spring:
  application:
    name: heima-ai
  servlet:
    multipart:
      max-file-size: 104857600
      max-request-size: 104857600

⑥修改CORS配置,暴露响应头

默认情况下,跨域请求的响应头是不暴露的,这样前端就拿不到下载的文件名。所以我们需要修改CORS配置,暴露响应头:

package com.itheima.ai.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .exposedHeaders("Content-Disposition");  // 暴露响应头
    }
}

⑦配置ChatClient。在CommonConfiguration中配置RAG Advisor

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

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

不过,SpringAI同样基于AOP技术帮我们完成了全部流程,用到的是一个名为QuestionAnswerAdvisor的Advisor。我们只需要把VectorStore配置到Advisor即可。

    @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();
    }

⑧对话和检索 - PdfController

package com.itheima.ai.controller;

import com.itheima.ai.entity.vo.Result;
import com.itheima.ai.repository.ChatHistoryRepository;
import com.itheima.ai.repository.FileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;

import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor.FILTER_EXPRESSION;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai/pdf")
public class PdfController {
    private final FileRepository fileRepository;
    private final VectorStore vectorStore;
    private final ChatClient pdfChatClient;
    private final ChatHistoryRepository chatHistoryRepository;

    /**
     * PDF聊天
     * @param prompt
     * @param chatId
     * @return
     */
    @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();
    }
}

⑨测试

5. 多模态

模态是指表达或感知事物的方式,例如视觉、听觉、嗅觉。对应的信息传递媒介可以是文本、语音、图片、视频等。多模态就是从多个模态表达或感知事物。

步骤①:修改CommonConfiguration的Bean,自定义模型配置(局部)

    @Bean
    public ChatClient chatClient(OpenAiChatModel model, ChatMemory chatMemory) {
        return ChatClient
                .builder(model)
                .defaultOptions(ChatOptions.builder().model("qwen-omni-turbo").build())  // 配置模型
                .defaultSystem("你是一个热心、可爱的智能助手,你的名字叫小团团,请以小团团的身份和语气回答问题。")
                .defaultAdvisors(
                        new SimpleLoggerAdvisor(),
                        new MessageChatMemoryAdvisor(chatMemory)  // 会话记忆
                )
                .build();
    }
  • 如果是"qwen-omni-turbo-realtime"模型,要更改base-url,比较麻烦

②修改ChatController,扩展之前的聊天机器人,以支持多模态聊天

package com.itheima.ai.controller;

import com.itheima.ai.repository.ChatHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.model.Media;
import org.springframework.util.MimeType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.Objects;

import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;

@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
public class ChatController {

    private final ChatClient chatClient;

    private final ChatHistoryRepository chatHistoryRepository;

    /**
     * 多模态模式
     * @param prompt
     * @param chatId
     * @param files
     * @return
     */
    @RequestMapping(value = "/chat", produces = "text/html;charset=utf-8")
    public Flux<String> chat(
            @RequestParam("prompt") String prompt,
            @RequestParam("chatId") String chatId,
            @RequestParam(value = "files", required = false) List<MultipartFile> files) {
        // 1.保存会话id
        chatHistoryRepository.save("chat", chatId);
        // 2.请求模型
        if (files == null || files.isEmpty()) {
            // 没有附件,纯文本聊天
            return textChat(prompt, chatId);
        } else {
            // 有附件,多模态聊天
            return multiModelChat(prompt, chatId, files);
        }
    }

    private Flux<String> multiModelChat(String prompt, String chatId, List<MultipartFile> files) {
        // 1.解析多媒体
        List<Media> medias = files.stream()
                .map(file -> new Media(
                                MimeType.valueOf(Objects.requireNonNull(file.getContentType())),
                                file.getResource()
                        )
                )
                .toList();

        // 2.请求模型
        return chatClient.prompt()
                .user(p -> p.text(prompt).media(medias.toArray(Media[]::new)))
                .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
                .stream()
                .content();
    }

    private Flux<String> textChat(String prompt, String chatId) {
        return chatClient.prompt()
                .user(prompt)
                .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId))
                .stream()
                .content();
    }
}

③之前的AlibabaOpenAiChatModel中对fromAudioData的代码进行了修改,以支持音频

注意:

在SpringAI的1.0.0-m6版本中,qwen-omni与SpringAI中的OpenAI模块的兼容性有问题,目前仅支持文本和图片两种模态。音频会有数据格式错误问题,视频完全不支持。

目前的解决方案有两种:

  • 一是使用spring-ai-alibaba来替代;
  • 二是重写OpenAIModel的实现

④同时chatClient这个Bean也改为使用我们自己写的AlibabaOpenAiChatModel

⑤测试(图片、语音)

如果想要支持视频,可以使用Alibaba的Spring AI Alibaba

注:如果侵权,请联系我删除!


网站公告

今日签到

点亮在社区的每一天
去签到