人工智能(七)- RAG 搭建企业知识库问答机器人

1 阅读15分钟

人工智能(六)- Spring AI 调用Ollama本地大模型

一、RAG 介绍

RAG(检索增强生成,Retrieval-Augmented Generation)是一种通过在生成答案前先从外部知识库检索相关信息,来优化大语言模型(LLM)输出的技术。它解决了模型知识滞后、产生幻觉(一本正经地胡说八道)以及缺乏私有领域数据的问题,能以低成本实现更准确、实时、可信的内容生成。

RAG 并不是重新训练模型,而是在模型回答问题时,给它外挂一个“图书馆”。流程如下:

  • 检索(Retrieval): 当用户输入查询时,系统会搜索向量数据库或其他知识库,查找相关的文档片段。
  • 增强(Augmented): 系统将检索到的相关知识与用户的问题组合成一个富含上下文的提示(Prompt)。
  • 生成(Generation): 大模型根据这个提示生成最终答案,确保内容有事实依据。
1.1 为什么需要 RAG?
  • 缓解模型幻觉: 通过引用外部权威知识源,大幅减少AI虚构事实的概率。
  • 实时与专有知识: 模型不需要重新训练就能获取最新信息,或使用企业内部私有数据。
  • 高性价比: 相比于对大模型进行微调(Fine-tuning),RAG 更快、更经济,适合快速调整模型以适应特定业务场景。
  • 可解释性: 生成的回答通常可以附带信息来源,便于用户核查。
1.2 典型应用场景
  • 企业内部知识库问答: 基于公司内部文档、规章制度回答员工或客户问题。
  • 专业客服机器人: 提供准确的产品指南、售后咨询。
  • 专业研究与检索: 如医疗、法律领域,辅助查找案例或论文证据。

总之,RAG 是将通用大模型连接到特定、实时和私有数据的桥梁,使其在专业领域更可靠、更实用。


二、向量数据库

向量数据库(Vector Database)是一种专为存储、索引和查询高维向量嵌入(Embedding)而设计的数据库,主要用于处理文本、图像、音视频等非结构化数据的语义相似性搜索。它基于“近似最近邻搜索”(ANN)技术,帮助大型语言模型(LLM)理解上下文,实现快速检索、推荐系统及知识库扩充(RAG)。

2.1 核心概念
  • 向量嵌入 (Embedding):机器学习模型将图像、文本等非结构化数据转化为数字向量,其中语义相似的数据在向量空间中距离更近。
  • 相似性搜索 (Similarity Search):不同于传统数据库的精确匹配,向量数据库根据计算后的相似度(如余弦相似度、欧氏距离)找出最接近的结果。
  • 近似最近邻 (ANN) 算法:在高维数据中快速查找,能在毫秒级从百万级数据中找到相似项。
2.2 向量数据库与传统数据库的区别
特性传统数据库 (Relational DB)向量数据库 (Vector DB)
数据类型结构化数据 (行/列)非结构化数据转化为高维向量
查询方式精确查找 (SQL, 关键词)近似搜索 (基于相似度/语义)
应用场景事务处理、业务记录AI 语义搜索、推荐、知识检索
查找准确性100% 匹配前 K 个最相似结果 (Top-K)
2.3 主要应用场景
  • 检索增强生成 (RAG):将企业自有知识库转化为向量存储,LLM 根据查询检索相关文档以生成更准确的回答,减少“幻觉”。
  • 语义搜索引擎:基于含义而非关键字匹配(例如:搜索“手机”能检索到“移动设备”)。
  • 推荐系统:根据用户画像向量和商品向量的距离,推荐相似内容。
  • 多模态搜索:通过文字查找图片,或查找相似的视频片段。
2.4 主流向量数据库
  • 开源/专用:Milvus (蚂蚁/Zilliz), Pinecone, Qdrant, Weaviate, Faiss (Facebook)。
  • 集成式:Elasticsearch, Redis, Tencent Cloud VectorDB, PostgreSQL (pgvector)。

向量数据库在生成式 AI 时代是连接向量模型与实际应用数据的关键桥梁,它使得机器不仅能存储数据,还能理解数据。


三、获取API Key

要调用阿里千问大模型API,首先需要完成阿里云账号的注册、百炼服务开通及API Key获取。

3.1 注册阿里云账号

若你尚未拥有阿里云账号,需先前往阿里云注册页面完成注册,建议使用企业或个人常用手机号/邮箱注册,方便后续账号管理与服务开通。

3.2 开通阿里云百炼服务

注册并登录阿里云账号后,前往阿里云百炼大模型服务平台,按照页面指引开通百炼服务。开通过程中需确认服务协议,无需额外付费即可享受新人专属免费额度。

3.3 获取API Key

API Key是调用千问大模型的身份凭证,获取步骤如下:

  1. 登录阿里云百炼控制台,前往密钥管理页面
  2. 点击「创建API Key」按钮,系统会自动生成API Key;
  3. 保存好生成的API Key,后续后端代码中需使用该密钥进行身份验证,建议妥善保管,避免泄露。
3.4 模型与计费说明

阿里云百炼不仅支持阿里千问系列大模型(如qwen-plus、qwen-max等),还兼容DeepSeek、Kimi、GLM、MiniMax等第三方知名大模型,可根据业务需求[灵活选择]

3.5 计费规则

首次开通百炼服务时,平台会自动发放各模型的新人专属免费额度,有效期通常为30~90天;免费额度耗尽或过期后,继续使用模型推理服务将按实际调用量计费,具体计费标准可参考阿里云百炼官方定价文档


四、安装 Milvus 向量数据库

Milvus 提供三种部署模式:

  1. Milvus Lite 是一个 Python 库,可以轻松集成到您的应用程序中。作为 Milvus 的轻量级版本,它非常适合快速原型设计或在资源有限的边缘设备上运行。
  2. Milvus Standalone 是单机服务器部署,所有组件都捆绑到一个 Docker 镜像中,部署方便。
  3. Milvus Distributed 可以部署在 Kubernetes 集群上,采用云原生架构,专为数十亿甚至更大的场景而设计。此体系结构可确保关键组件的冗余。

本文安装的是 Milvus Lite,参考文章本地运行 Milvus Lite


五、工程搭建:基于 Spring Boot 的后端开发

5.1 创建Maven工程

使用IntelliJ IDEA创建一个Maven工程,完成工程初始化。

5.2 引入核心 pom 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.13</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>org.devpotato</groupId>
    <artifactId>chat-service</artifactId>
    <version>1.0-SNAPSHOT</version>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <packaging>jar</packaging>

    <name>ChatService</name>
    <url>http://maven.apache.org</url>

    <properties>
        <java.version>17</java.version>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>

        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jackson.version>2.15.0</jackson.version>
        <spring-ai.version>1.0.0-M1</spring-ai.version>
    </properties>

    <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>

    <dependencies>
        <!--        spring-boot        -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring AI -->
        <!-- Source: https://mvnrepository.com/artifact/org.springframework.ai/spring-ai-ollama -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-ollama</artifactId>
            <version>${spring-ai.version}</version>
            <scope>compile</scope>
        </dependency>

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

        <!-- PDF 解析 -->
        <dependency>
            <groupId>org.apache.pdfbox</groupId>
            <artifactId>pdfbox</artifactId>
            <version>2.0.30</version>
        </dependency>

        <!-- Milvus Java SDK -->
        <dependency>
            <groupId>io.milvus</groupId>
            <artifactId>milvus-sdk-java</artifactId>
            <version>2.4.0</version>
        </dependency>

        <!--        jackson        -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
            <version>${jackson.version}</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>${jackson.version}</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>${jackson.version}</version>
        </dependency>

        <!--        guava        -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>21.0</version>
        </dependency>

        <!--        lombok        -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <!--        openai        -->
        <dependency>
            <groupId>com.openai</groupId>
            <artifactId>openai-java</artifactId>
            <version>4.28.0</version>
            <scope>compile</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-stdlib-jdk8</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.openai</groupId>
            <artifactId>openai-java-client-okhttp</artifactId>
            <version>4.28.0</version>
            <scope>compile</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-stdlib-jdk8</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib-jdk8</artifactId>
            <version>2.3.20-RC3</version>
            <scope>compile</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-stdlib</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- Source: https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib -->
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>2.3.20-RC3</version>
            <scope>compile</scope>
        </dependency>

    </dependencies>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <releases>
                <enabled>false</enabled>
            </releases>
        </repository>
    </repositories>
</project>

5.3 工程配置文件

在src/main/resources目录下创建application.yml文件

server:
  port: 8080

# Milvus 配置
milvus:
  uri: http://localhost:19530
  token: root:Milvus
  collection-name: pdf_rag_collection
  vector-dim: 1024  # Embedding向量维度
5.4 解决跨域问题

由于前端页面与后端服务可能存在跨域(CORS)问题,导致前端无法正常调用后端API,因此需要添加跨域过滤器。创建MyFilter类,实现Filter接口,具体代码如下:

import org.springframework.stereotype.Component;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class MyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 解决Chrome等浏览器访问本地服务的跨域问题
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        // 允许所有域名跨域访问(生产环境建议指定具体域名,提升安全性)
        httpResponse.setHeader("Access-Control-Allow-Origin", "*");
        // 允许的请求方式
        httpResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        // 允许的请求头
        httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type");
        // 继续执行过滤链
        chain.doFilter(request, response);
    }
}
5.5 PDF 读取工具类

提取 PDF 纯文本内容,自动去除空白行、格式冗余内容

package org.devpotato.rag;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;

import java.io.File;
import java.io.IOException;

public class PdfReaderUtil {

    /**
     * 读取PDF文件为纯文本
     */
    public static String readPdfToString(String pdfPath) throws IOException {
        try (PDDocument document = PDDocument.load(new File(pdfPath))) {
            PDFTextStripper stripper = new PDFTextStripper();
            // 按顺序读取文本
            stripper.setSortByPosition(true);
            return stripper.getText(document).trim();
        }
    }
}
5.6 文本分块工具类

按固定长度分块(512 字符 / 块),保留语义完整性

package org.devpotato.rag;

import java.util.ArrayList;
import java.util.List;

public class TextSplitterUtil {

    /**
     * 文本分块(固定长度,无重叠)
     *
     * @param text      原始文本
     * @param chunkSize 每块大小
     * @return 分块后的文本列表
     */
    public static List<String> splitText(String text, int chunkSize) {
        List<String> chunks = new ArrayList<>();
        int length = text.length();
        int start = 0;

        while (start < length) {
            int end = Math.min(start + chunkSize, length);
            chunks.add(text.substring(start, end).trim());
            start = end;
        }
        return chunks;
    }

    // 默认分块大小:512字符
    public static List<String> splitText(String text) {
        return splitText(text, 512);
    }
}
5.7 千问 API 工具类

实现文本向量化和大模型问答两个核心功能

package org.devpotato.rag;

import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.chat.completions.ChatCompletionMessage;
import com.openai.models.embeddings.CreateEmbeddingResponse;
import com.openai.models.embeddings.Embedding;
import com.openai.models.embeddings.EmbeddingCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
public class QianWenUtil {
    private static final String API_KEY = "xxx";
    private static final String BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";

    /**
     * 使用的模型名称
     */
    private static final String LLM_MODEL = "qwen-plus";

    /**
     * 系统提示词
     */
    private static final String SYSTEM_PROMPT = "作为一个专业的电商售后支持客服,你具备深厚的问题解决能力。请针对用户提出的问题,提供详细的解答步骤或有效的解决方案,并且考虑到用户的水平可能有所不同,请尽可能地简化语言。";


    @Value("${milvus.vector-dim}")
    private int vectorDim;

    OpenAIClient client = OpenAIOkHttpClient.builder()
            .apiKey(API_KEY)
            .baseUrl(BASE_URL)
            .build();

    public List<Float> textToEmbedding(String text) {
        // 创建向量化请求参数
        EmbeddingCreateParams params = EmbeddingCreateParams.builder()
                .model("text-embedding-v4")
                .input(EmbeddingCreateParams.Input.ofString(text))
                // 指定向量维度(仅 text-embedding-v3及 text-embedding-v4支持该参数)
                .dimensions(vectorDim)
                .build();

        try {
            // 发送请求并获取响应
            CreateEmbeddingResponse response = client.embeddings().create(params);
            System.out.println(response);
            List<Embedding> embeddingList = response.data();
            List<Float> vector = new ArrayList<>();
            for (Embedding embedding : embeddingList) {
                vector.addAll(embedding.embedding());
            }
            return vector;
        } catch (Exception e) {
            System.err.println("请求出错,请查看错误码对照网页:");
            System.err.println("https://help.aliyun.com/zh/model-studio/faq-about-alibaba-cloud-model-studio?spm=a2c4g.11186623.help-menu-2400256.d_0_17_0.18733a66lTrcHv#1c38f58abfcml");
            System.err.println("错误详情:" + e.getMessage());
            e.printStackTrace();
        }

        return new ArrayList<>();
    }

    public String llmChat(String prompt) {
        ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
                .addSystemMessage(SYSTEM_PROMPT)
                .addUserMessage(prompt)
                .model(LLM_MODEL)
                .build();

        ChatCompletion chatCompletion = client.chat().completions().create(params);
        ChatCompletionMessage completionMessage = chatCompletion.choices().get(0).message();
        return completionMessage.content().orElse("");
    }
}

5.8 Milvus 向量数据库操作类

实现创建集合、插入向量、相似度检索核心功能

package org.devpotato.rag;

import com.alibaba.fastjson.JSONObject;
import io.milvus.v2.client.ConnectConfig;
import io.milvus.v2.client.MilvusClientV2;
import io.milvus.v2.common.DataType;
import io.milvus.v2.common.IndexParam;
import io.milvus.v2.service.collection.request.AddFieldReq;
import io.milvus.v2.service.collection.request.CreateCollectionReq;
import io.milvus.v2.service.collection.request.GetLoadStateReq;
import io.milvus.v2.service.vector.request.InsertReq;
import io.milvus.v2.service.vector.request.SearchReq;
import io.milvus.v2.service.vector.response.InsertResp;
import io.milvus.v2.service.vector.response.SearchResp;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Component
public class MilvusUtil {
    @Value("${milvus.uri}")
    private String uri;

    @Value("${milvus.token}")
    private String token;

    @Value("${milvus.collection-name}")
    private String collectionName;

    @Value("${milvus.vector-dim}")
    private int vectorDim;

    private MilvusClientV2 milvusClient;

    /**
     * 初始化 Milvus 客户端
     */
    @PostConstruct
    public void init() {
        // 1. Connect to Milvus server
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(uri)
                .token(token)
                .build();

        milvusClient = new MilvusClientV2(connectConfig);

        createCollection();
    }

    /**
     * 创建向量集合(表)
     */
    private void createCollection() {
        // 3. Create a collection in customized setup mode

        // 3.1 Create schema
        CreateCollectionReq.CollectionSchema schema = milvusClient.createSchema();

        // 3.2 Add fields to schema
        // 字段1:ID(主键)
        schema.addField(AddFieldReq.builder()
                .fieldName("id")
                .dataType(DataType.Int64)
                .isPrimaryKey(true)
                .autoID(true)
                .build());

        // 字段2:文本内容
        schema.addField(AddFieldReq.builder()
                .fieldName("text")
                .dataType(DataType.VarChar)
                .maxLength(2000)
                .build());

        // 字段3:向量
        schema.addField(AddFieldReq.builder()
                .fieldName("vector")
                .dataType(DataType.FloatVector)
                .dimension(vectorDim)
                .build());

        // 3.3 Prepare index parameters(主键字段无需手动创建索引,Milvus会自动处理)
        IndexParam indexParamForVectorField = IndexParam.builder()
                .fieldName("vector")
                .indexType(IndexParam.IndexType.AUTOINDEX)
                .metricType(IndexParam.MetricType.COSINE)
                .build();

        List<IndexParam> indexParams = new ArrayList<>();
        indexParams.add(indexParamForVectorField);


        // 3.4 Create a collection with schema and index parameters
        CreateCollectionReq customizedSetupReq = CreateCollectionReq.builder()
                .collectionName(collectionName)
                .collectionSchema(schema)
                .indexParams(indexParams)
                .build();

        milvusClient.createCollection(customizedSetupReq);

        // 3.5 Get load state of the collection
        GetLoadStateReq customSetupLoadStateReq1 = GetLoadStateReq.builder()
                .collectionName(collectionName)
                .build();

        Boolean loaded = milvusClient.getLoadState(customSetupLoadStateReq1);
        System.out.println(loaded);
    }

    /**
     * 插入文本+向量到Milvus
     */
    public void insertData(String text, List<Float> vector) {
        List<JSONObject> data = new ArrayList<>();
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("text", text);
        jsonObject.put("vector", vector);
        data.add(jsonObject);

        InsertReq insertReq = InsertReq.builder()
                .collectionName(collectionName)
                .data(data)
                .build();

        InsertResp insertResp = milvusClient.insert(insertReq);
        System.out.println(insertResp);
    }

    /**
     * 相似度检索(返回最相似的3条文本)
     */
    public List<String> searchSimilarText(List<Float> queryVector) {
        SearchReq searchReq = SearchReq.builder()
                .collectionName(collectionName)
                .data(Collections.singletonList(queryVector))
                .annsField("vector")
                .topK(3)
                .outputFields(Collections.singletonList("text")) // 使用输出字段
                .build();

        SearchResp searchResp = milvusClient.search(searchReq);

        List<List<SearchResp.SearchResult>> searchResults = searchResp.getSearchResults();
        List<String> resultTextList = new ArrayList<>();
        for (List<SearchResp.SearchResult> results : searchResults) {
            System.out.println("TopK results:");
            for (SearchResp.SearchResult result : results) {
                System.out.println(result);
                resultTextList.add(result.getEntity().get("text").toString());
            }
        }
        return resultTextList;
    }
}

5.9 RAG 核心服务类

整合PDF 读取→分块→向量化→入库→检索→问答全流程

package org.devpotato.rag;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class RagService {
    private final QianWenUtil qianWenUtil;

    private final MilvusUtil milvusUtil;

    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 上传PDF并向量化入库(初始化数据)
     */
    public void uploadPdfAndInitData(String pdfPath) throws Exception {
        // 1. 读取PDF
        String pdfText = PdfReaderUtil.readPdfToString(pdfPath);

        // 2. 文本分块
        List<String> chunks = TextSplitterUtil.splitText(pdfText);

        // 3. 逐块向量化+存入Milvus
        for (String chunk : chunks) {
            List<Float> vector = qianWenUtil.textToEmbedding(chunk);
            milvusUtil.insertData(chunk, vector);
        }
    }

    /**
     * RAG 问答核心逻辑
     */
    public String ragChat(String question) throws Exception {
        // 1. 用户问题向量化
        List<Float> queryVector = qianWenUtil.textToEmbedding(question);

        // 2. 向量库检索相似文本
        List<String> similarTexts = milvusUtil.searchSimilarText(queryVector);

        // 3. 拼接提示词(RAG核心)
        String prompt = "基于以下文档内容回答问题,不要编造答案:\n" +
                "文档内容:" + String.join("\n", similarTexts) +
                "\n用户问题:" + question;

        // 4. 调用大模型生成答案
        return qianWenUtil.llmChat(prompt);
    }
}

5.10 核心对话控制器

对外提供PDF 上传初始化和RAG 问答接口

package org.devpotato.rag;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class RagController {
    private final RagService ragService;

    /**
     * 初始化PDF数据(调用一次即可)
     * 参数:PDF文件本地路径,例如 D:/docs/spring-boot-doc.pdf
     */
    @GetMapping("/init")
    public String initPdfData(@RequestParam("path") String pdfPath) {
        try {
            ragService.uploadPdfAndInitData(pdfPath);
            return "PDF初始化完成,文本已向量化存入Milvus!";
        } catch (Exception e) {
            return "初始化失败:" + e.getMessage();
        }
    }

    /**
     * RAG 问答接口
     */
    @RequestMapping(path = "", method = RequestMethod.POST)
    public String chat(@RequestBody Map<String, Object> map) throws Exception {
        String message = map.get("message").toString();

        return ragService.ragChat(message);
    }
}

5.11 启动类开发
package org.devpotato;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.function.context.config.ContextFunctionCatalogAutoConfiguration;

@SpringBootApplication(exclude = {
        ContextFunctionCatalogAutoConfiguration.class
})
public class StartServer {

    public static void main(String[] args) {
        SpringApplication.run(StartServer.class, args);

        System.out.println(">>> start");
    }
}


六、前端页面搭建(简易版)

前端采用简单的HTML+JavaScript搭建聊天界面,实现用户输入提问、展示客服回复的功能。创建index.html文件,代码如下(可直接在浏览器中打开使用):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>电商客服中心</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Microsoft YaHei', sans-serif;
        }

        body {
            background-color: #f5f7fa;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            padding: 20px;
        }

        .chat-container {
            width: 100%;
            max-width: 800px;
            height: 80vh;
            background-color: #fff;
            border-radius: 10px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .chat-header {
            background-color: #409eff;
            color: #fff;
            padding: 15px 20px;
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .chat-header img {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            background-color: #fff;
        }

        .chat-header h2 {
            font-size: 18px;
            font-weight: 600;
        }

        .chat-messages {
            flex: 1;
            padding: 20px;
            overflow-y: auto;
            background-color: #f9f9f9;
        }

        .message {
            margin-bottom: 15px;
            max-width: 70%;
            display: flex;
            animation: fadeIn 0.3s ease;
        }

        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .user-message {
            margin-left: auto;
            flex-direction: row-reverse;
        }

        .message-avatar {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            margin: 0 8px;
            flex-shrink: 0;
        }

        .user-message .message-content {
            background-color: #409eff;
            color: #fff;
            border-radius: 10px 10px 0 10px;
        }

        .bot-message .message-content {
            background-color: #fff;
            color: #333;
            border-radius: 10px 10px 10px 0;
            border: 1px solid #eee;
        }

        .message-content {
            padding: 10px 15px;
            word-wrap: break-word;
            line-height: 1.4;
        }

        /* 快捷按钮区域样式 */
        .quick-buttons {
            padding: 10px 15px;
            border-top: 1px solid #eee;
            background-color: #fafafa;
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
        }

        .quick-button {
            padding: 6px 15px;
            background-color: #e8f4ff;
            color: #409eff;
            border: 1px solid #d1e9ff;
            border-radius: 20px;
            cursor: pointer;
            font-size: 13px;
            transition: all 0.2s;
        }

        .quick-button:hover {
            background-color: #409eff;
            color: #fff;
            border-color: #409eff;
        }

        .chat-input {
            display: flex;
            padding: 15px;
            border-top: 1px solid #eee;
            background-color: #fff;
        }

        #message-input {
            flex: 1;
            padding: 12px 15px;
            border: 1px solid #ddd;
            border-radius: 25px;
            outline: none;
            font-size: 14px;
            resize: none;
            height: 45px;
            max-height: 120px;
        }

        #message-input:focus {
            border-color: #409eff;
            box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
        }

        #send-button {
            margin-left: 10px;
            padding: 0 20px;
            background-color: #409eff;
            color: #fff;
            border: none;
            border-radius: 25px;
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.2s;
        }

        #send-button:hover {
            background-color: #337ecc;
        }

        #send-button:disabled {
            background-color: #b3d8ff;
            cursor: not-allowed;
        }

        .loading {
            display: inline-block;
            width: 20px;
            height: 20px;
            border: 3px solid rgba(255,255,255,.3);
            border-radius: 50%;
            border-top-color: white;
            animation: spin 1s ease-in-out infinite;
        }

        @keyframes spin {
            to { transform: rotate(360deg); }
        }

        .empty-hint {
            text-align: center;
            color: #999;
            padding: 50px 0;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <div class="chat-container">
        <div class="chat-header">
            <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMjAiIGZpbGw9IiM0MDllZmYiLz4KPHBhdGggZD0iTTE1IDI1QzE1IDI3LjcxIDE2Ljk5IDI5IDE5IDI5QzIxLjAxIDI5IDIzIDI3LjcxIDIzIDI1QzIzIDIyLjc5IDIxLjAxIDIxIDE5IDIxQzE2Ljk5IDIxIDE1IDIyLjc5IDE1IDI1WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="客服图标">
            <h2>在线客服中心</h2>
        </div>
        <div class="chat-messages" id="chat-messages">
            <div class="empty-hint" id="empty-hint">欢迎咨询,我是您的专属客服😊</div>
        </div>
        
        <!-- 新增快捷按钮区域 -->
        <div class="quick-buttons" id="quick-buttons">
            <div class="quick-button" onclick="sendQuickMessage('查看订单')">查看订单</div>
            <div class="quick-button" onclick="sendQuickMessage('查看物流')">查看物流</div>
            <div class="quick-button" onclick="sendQuickMessage('申请退款')">申请退款</div>
            <div class="quick-button" onclick="sendQuickMessage('修改收货地址')">修改收货地址</div>
            <div class="quick-button" onclick="sendQuickMessage('商品质量问题')">商品质量问题</div>
        </div>
        
        <div class="chat-input">
            <textarea id="message-input" placeholder="请输入您想咨询的问题..." onkeydown="if(event.keyCode===13&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea>
            <button id="send-button" onclick="sendMessage()">发送</button>
        </div>
    </div>

    <script>
        // 获取DOM元素
        const messageInput = document.getElementById('message-input');
        const sendButton = document.getElementById('send-button');
        const chatMessages = document.getElementById('chat-messages');
        const emptyHint = document.getElementById('empty-hint');

        // 头像URL(使用base64编码的SVG,也可以替换为实际图片URL)
        const BOT_AVATAR = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTgiIGN5PSIxOCIgcj0iMTgiIGZpbGw9IiM0MDllZmYiLz4KPHBhdGggZD0iTTEzIDIyQzEzIDI0LjIxIDE0Ljk5IDI2IDE3IDI2QzE5LjAxIDI2IDIxIDI0LjIxIDIxIDIyQzIxIDE5Ljc5IDE5LjAxIDE4IDE3IDE4QzE0Ljk5IDE4IDEzIDE5Ljc5IDEzIDIyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==';
        const USER_AVATAR = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTgiIGN5PSIxOCIgcj0iMTgiIGZpbGw9IiNmZmYwMDAiLz4KPHBhdGggZD0iTTEyIDIwQzEyIDIyLjcxIDEzLjk5IDI1IDE2IDI1QzE4LjAxIDI1IDIwIDIyLjcxIDIwIDIwQzIwIDE3Ljc5IDE4LjAxIDE1IDE2IDE1QzEzLjk5IDE1IDEyIDE3Ljc5IDEyIDIwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTIwIDI5QzIwIDI5IDE3IDMwIDE3IDMwQzE0IDMwIDEyIDI5IDEyIDI5QzEyIDI5IDEyIDI3IDEyIDI3QzEyIDI3IDE0IDI2IDE2IDI2QzE4IDI2IDIwIDI3IDIwIDI3QzIwIDI3IDIwIDI5IDIwIDI5WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==';

        // 自动调整输入框高度
        messageInput.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = (this.scrollHeight > 45 ? this.scrollHeight : 45) + 'px';
        });

        // 快捷消息发送函数
        function sendQuickMessage(message) {
            // 将快捷消息填入输入框
            messageInput.value = message;
            messageInput.style.height = 'auto';
            messageInput.style.height = (messageInput.scrollHeight > 45 ? messageInput.scrollHeight : 45) + 'px';
            
            // 自动发送该消息
            sendMessage();
        }

        // 发送消息函数
        async function sendMessage() {
            const message = messageInput.value.trim();
            
            // 验证输入内容
            if (!message) {
                alert('请输入咨询内容!');
                return;
            }

            // 禁用发送按钮和输入框
            sendButton.disabled = true;
            messageInput.disabled = true;

            try {
                // 隐藏空提示
                emptyHint.style.display = 'none';

                // 添加用户消息到聊天窗口
                addMessageToChat(message, 'user');
                
                // 清空输入框并恢复高度
                messageInput.value = '';
                messageInput.style.height = '45px';

                // 添加加载状态
                const loadingId = addLoadingMessage();

                // 调用后端接口
                const response = await fetch('http://127.0.0.1:8080/chat', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ message: message })
                });

                // 移除加载状态
                removeLoadingMessage(loadingId);

                // 处理响应
                if (response.ok) {
                    const data = await response.text();
                    // 添加客服回复到聊天窗口
                    addMessageToChat(data, 'bot');
                } else {
                    addMessageToChat('抱歉,服务器暂时无法响应,请稍后再试!', 'bot');
                    console.error('接口请求失败:', response.status);
                }
            } catch (error) {
                // 移除加载状态
                const loadingElements = document.querySelectorAll('.loading-message');
                loadingElements.forEach(el => el.remove());
                
                addMessageToChat('网络错误,请检查您的网络连接!', 'bot');
                console.error('请求出错:', error);
            } finally {
                // 恢复发送按钮和输入框
                sendButton.disabled = false;
                messageInput.disabled = false;
                messageInput.focus();
                
                // 滚动到最新消息
                scrollToBottom();
            }
        }

        // 添加消息到聊天窗口
        function addMessageToChat(content, sender) {
            const messageDiv = document.createElement('div');
            messageDiv.className = `message ${sender}-message`;
            
            // 创建头像元素
            const avatarImg = document.createElement('img');
            avatarImg.className = 'message-avatar';
            avatarImg.src = sender === 'user' ? USER_AVATAR : BOT_AVATAR;
            avatarImg.alt = sender === 'user' ? '用户头像' : '客服头像';
            
            // 创建消息内容元素
            const contentDiv = document.createElement('div');
            contentDiv.className = 'message-content';
            contentDiv.textContent = content;
            
            // 组装消息元素
            messageDiv.appendChild(avatarImg);
            messageDiv.appendChild(contentDiv);
            
            chatMessages.appendChild(messageDiv);
            
            // 滚动到最新消息
            scrollToBottom();
        }

        // 添加加载中的消息
        function addLoadingMessage() {
            const loadingId = 'loading-' + Date.now();
            const loadingDiv = document.createElement('div');
            loadingDiv.id = loadingId;
            loadingDiv.className = 'message bot-message loading-message';
            
            // 创建客服头像
            const avatarImg = document.createElement('img');
            avatarImg.className = 'message-avatar';
            avatarImg.src = BOT_AVATAR;
            avatarImg.alt = '客服头像';
            
            // 创建加载内容
            const contentDiv = document.createElement('div');
            contentDiv.className = 'message-content';
            contentDiv.innerHTML = '<div class="loading"></div>';
            
            // 组装加载消息
            loadingDiv.appendChild(avatarImg);
            loadingDiv.appendChild(contentDiv);
            chatMessages.appendChild(loadingDiv);
            
            scrollToBottom();
            return loadingId;
        }

        // 移除加载中的消息
        function removeLoadingMessage(loadingId) {
            const loadingDiv = document.getElementById(loadingId);
            if (loadingDiv) {
                loadingDiv.remove();
            }
        }

        // 滚动到聊天底部
        function scrollToBottom() {
            chatMessages.scrollTop = chatMessages.scrollHeight;
        }

        // 监听输入框回车事件(兼容)
        messageInput.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = (this.scrollHeight > 45 ? this.scrollHeight : 45) + 'px';
        });
        
        messageInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendMessage();
            }
        });
    </script>
</body>
</html>

七、运行测试

7.1 没有 RAG 前的问答(推荐的是其他品牌的手机)

7.2 初始化

调用初始化接口,初始化知识库 pdf 文档。

http://127.0.0.1:8080/chat/init?path=/Users/xxx/Downloads/阿里云百炼系列手机产品介绍.pdf
7.3 RAG 后的问答

推荐的是我们 pdf 里的内容


八、RAG 流程说明(核心)

离线构建阶段 PDF 读取 → 文本清洗 → 文本分块 → Embedding 向量化 → 存入向量数据库

在线问答阶段 用户问题 → 问题向量化 → 向量库相似度检索 → 拼接上下文 → 大模型生成答案


九、优化方向

分块优化:使用语义分块替代固定长度分块,保留段落完整性。 检索优化:增加关键词检索 + 向量检索混合模式。