Vue 3+Spring Boot RAG智能知识库实战指南
把大模型从实验室搬到真实的业务场景里,总会遇到一个尴尬的现实:那些通用的基础大模型,基本没法直接用。它们啊,就像一个知识渊博但信息过时的教授,面对每天都在变化的特定业务问题,肚子里那点“存货”往往派不上用场。
举个例子,早期的ChatGPT,它的知识库只更新到2021年。你要是问它2022年发生了什么大事,它大概率会开始一本正经地胡说八道。这就是大模型面临的“知识更新困境”——模型的知识就像一潭死水,流动不起来。
到了2023年11月,OpenAI发布的GPT-4 Turbo,知识更新也只到2023年4月。要知道,给大模型更新知识库可不是更新手机App那么简单。首先,你得保证海量预训练数据的质量;其次,更新完知识库后整个模型基本要重新训一遍,最麻烦的是,不能只喂新数据,必须按比例混着旧数据一起训,否则模型就会“灾难性遗忘”——学会了新知识,把老本全忘了。所以说,知识更新问题,始终是大模型绕不开的一个坎儿。
直到2020年,Facebook(也就是现在的Meta)在《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》这篇论文里,提出了一个叫**检索增强生成(RAG)**的框架,才算找到了一条靠谱的出路。这个框架的厉害之处在于,它能让大模型访问到训练数据之外的信息。也就是说,模型在每次回答问题时,都可以先查资料(检索),再根据查到的、更专业、更准确的信息来回答。这就像给一个记忆模糊的人配了一个无限容量的移动硬盘,瞬间解决了知识滞后和胡编乱造的顽疾。
一、RAG及核心原理
(一) 什么是RAG?
RAG的核心思想其实很简单,就是解决大模型“爱编造”和“知识落后”两大毛病。如果用个比喻来描述:
- 传统的大语言模型(LLM):像是“闭卷考试”,全靠训练时的死记硬背,遇到不会的就只能瞎编。
- RAG:像是“开卷考试”,允许你一边翻书(检索),一边写答案(生成)。
所以,RAG的基本流程可以概括为一个简洁的公式:
RAG = LLM(大模型) + 外部知识库
它把传统的生成式大模型和实时信息检索技术结合到一起,给大模型源源不断地补充外部数据和上下文。这样一来,模型生成的内容就不再依赖训练时那点“死知识”,而是有了实时、个性化的鲜活数据做支撑,结果自然就更丰富、更准确、更可信。
(二) RAG的核心工作流程
一个完整的RAG流程,通常要经历三个主要阶段:数据准备、数据召回、答案生成。
- 数据准备:说白了就是识别你的数据都在哪,把它们提取出来,再洗洗干净,最后存到“仓库”里。
- 数据召回:当用户问了个问题,系统会根据这个问题的意思,从“仓库”里把最相关的“存货”給翻出来。
- 答案生成:这时候大模型上场,它揣着刚翻到的那几页“参考资料”和你提的问题,去组织答案。这个环节的质量高低,完全取决于你前面的“资料”(数据质量)和“翻书技巧”(检索策略)怎么样了。
2.1 数据准备阶段——打好地基,建好知识库
企业里的数据,多数是没规没矩的非结构化的文件,散落在PDF、Word、TXT的各个角落。这个阶段的核心工作,就是把这些杂乱的原始材料——解析、清洗、切成合适大小的块(Chunking),然后妥妥地存进数据库里。
你可能会问,干嘛不直接把原文件扔进去,非要费这么大劲?
原因很简单,这就像你直接把一本又厚又没目录的书扔给一个一目十行但只能读几页的人,让他回答你书里的问题,效果可想而知。
这么折腾,主要是为了应付三个“硬伤”:
大模型的“记忆”限制(上下文窗口)
大语言模型一次能处理的文本量是有限的,这个限制就叫“上下文窗口”。你不可能把整本几百页的PDF或者整个公司的知识库一次全塞给它。
所以,核心是分块。大模型有上下文窗口限制(比如8k, 32k, 128k tokens),而且检索也需要细粒度。因此,必须把长文档切成小的“知识片段”。当用户提问时,系统只要找出最相关的3-5个片段喂给模型就行。既在模型的能力范围内,又能保证信息的精准度。
检索的“精度”与“语义”难题
传统的数据库(比如MySQL)处理表格数据是专家,但它完全理解不了段落中的“弦外之音”和语义。
因此,需要对原始数据进行解析和清洗。原始文档里满是页眉、页脚、页码、广告这些“噪音”,不清洗掉,它们会严重干扰检索结果,让系统找到一堆不相干的东西。解析还能顺便帮我们留住文档的标题、列表这些宝贵的信息结构。
分块的目的在于,检索的基本单位是“块”,而不是整篇文章或单个句子。块太大了,查到的结果里大量无关信息;块太小了,又会丢掉必要的上下文。好的分块就是在“信息聚焦”和“语义完整”之间找到平衡点。
另外,RAG能进行语义检索,全靠“向量化(Embedding)”技术。它通过一个模型把文本块转换成一串高维数字(向量),让语义相近的文本在数学空间里也挨得更近。比如“服务器宕机”和“机器无响应”字面不同,但向量表示会很接近。这种转换,必须在干净、语义完整的文本块上进行,才能保证检索的准确性。
数据库类型的“错配”
我们平时说的“数据库”主要指关系型数据库,它并不是为了存储和检索非结构化文本的语义而生。
所以,RAG系统最终会把处理好的文本块和它们的向量表示,存进专门为这类数据设计的“向量数据库”里,而不是传统的关系型数据库。可以把它想象成一个高效的“语义地图”,能根据用户问题的“语义指纹”,在毫秒级别就找到知识库里最相关的几个文本块。
数据处理的关键步骤
数据采集与加载
这是数据进入系统的入口。系统会用不同的“加载器”来读取不同格式的文件:PDF、Word、Markdown、网页、数据库记录……把各种来源的原始数据都收进来。
数据清洗
目标是去除所有对核心语义无用的“噪声”。比如页眉页脚、HTML标签、乱码、无关广告、重复内容……不清洗,这些噪音就会像病毒一样污染后面的所有流程。
文档分块
把长文档切成语义完整的“知识片段”。原因前面说过了,一是因为模型内存有限,二是因为检索精度要求高——精准的知识块能让模型更快定位答案,减少无关信息干扰。
常见的分块策略有两种:
一种是固定长度切分,按固定字符数或Token数切,通常会设置一点重叠,防止切断完整的句子。
另一种是语义切分,根据段落、章节标题、句子边界这类自然语言结构来切,能最大程度保持语义的完整性。
向量化
这是把人类语言翻译成机器能懂的数学语言的过程。用一个预训练的Embedding模型,把每个文本块(Chunk)都转换成一个高维向量。这个向量就像是文本的“语义身份证”,语义相似的文本,它们的身份证在数学空间里距离也更近。比如“猫咪喜欢玩毛线球”和“猫爱玩绒线球”,它们的向量表示会非常接近。
索引与存储
最后,把每个文本块的向量、原始文本和来源、标题、作者这类元数据,一股脑存进专门的向量数据库(比如Milvus, Chroma等)里。向量数据库会为这些向量建立索引,这样才能在极端时间内完成海量数据的语义搜索,这也是整个系统能跑得飞快的核心。
2.2 数据召回阶段——如何高效地“翻书”
这个环节的目标,就是从海量的向量数据库里,又快又准地找出与用户问题最相关的Top-K个文本片段。怎么做到的呢?
语义检索
这是RAG区别于传统搜索引擎的最大优势。它会把用户的自然语言问题也通过Embedding模型变成向量,然后在向量空间里计算该向量和所有知识块向量的“距离”(通常用余弦相似度来衡量距离的远近)。这样,即使字面不同,只要意思相近,系统也能找到。比如用户搜索“车坏了怎么办”,传统搜索可能找不到“汽车维修”的文章,但语义检索能理解“车”和“汽车”、“坏了”和“维修”是近义词,从而召回正确内容。
多路召回
只靠一种检索策略容易有盲区。所以现代RAG系统通常会采用“多路召回”,同时用好几种策略去搜,然后把结果合并起来。比如同时使用向量检索(管语义)、全文检索(管精确匹配,比如订单号、型号)和元数据过滤(管权限、时间、类别)。这样既能保证语义的广度,又能保证关键词的精准度,防止漏检。
混合打分与重排序
初选出来的结果里难免有噪音,需要二次筛选。系统会对向量检索得分和关键词检索得分进行加权计算(比如用Reciprocal Rank Fusion算法),然后用一个专门的交叉编码器模型,对初筛出的Top-50个片段进行精细打分和重新排序,把真正最相关的内容排在最前面。
上下文窗口管理
这一步会根据大模型的上下文窗口限制,动态地截取或拼接召回的文本块,在保证信息完整性的前提下,最大化利用模型的输入长度,避免Token溢出或浪费。
2.3 答案生成——把资料变成智慧的最后一步
这是整个RAG流程的最后一环,也是最考验功力的一步。如果说前面的召回是“开卷考试时翻书找资料”,那生成环节就是考生合上书本,根据刚看到的内容和理解来组织语言、输出答案。
这个环节主要依赖大语言模型,但它不是简单的问答,而是基于特定上下文的“阅读理解”。
提示词工程与上下文组装
这是答案生成的入口,也是最能体现设计能力的地方。系统需要把用户问题、检索回的Top-K个文本片段,以及预设的系统指令,按模板拼成一个完整的提示词(Prompt)。它的作用是为模型划定边界——明确告诉模型:“只准根据我给你的材料回答,如果材料里没有,就说你不知道。”这能极大程度地限制模型胡编乱造。
信息抽取与综合
大模型在这里扮演了“阅读者”和“总结者”的角色。它会阅读多个文本片段(这些片段可能来自不同文档,甚至有些矛盾),然后提取出与问题相关的核心事实,把碎片化的信息整合成一段通顺的逻辑,直接给你结论。用户不必自己去读5个不同的文档,模型帮他完成了“阅读-理解-总结”的全过程。
风格控制与格式化
通过系统提示词,可以控制生成内容的语气、长度和格式,以适应不同的业务场景。比如客服场景要求语气亲切、回答简练;法律/医疗场景要求用词严谨、引用准确;代码生成场景则要求输出Markdown格式的代码块。
来源溯源与引用
对企业级RAG系统来说,这是个不可或缺的功能。系统在生成答案的同时,会标记出每一句话或每个观点是参考了哪个文本片段,并在前端展示对应的文档链接或角标。这能极大地建立用户信任——用户可以点开引用源去核对原文,确认AI没有撒谎,这对严肃的业务场景至关重要。
二、开发一个RAG系统
(一) 技术栈选型
聊完原理,咱们该上手实操了。一个能跑起来的RAG系统,大概长这样:
| 模块 | 技术选型 | 说明 |
|---|---|---|
| 前端 | Vue 3 + TypeScript + Vite | 现代化前端框架,类型安全 |
| UI 组件库 | Element Plus / Ant Design Vue | 快速构建界面 |
| 后端 | Spring Boot 3 + JDK 17+ | 核心业务逻辑 |
| AI 框架 | LangChain4j | Ja va生态中最强大的LLM编排框架 |
| 向量数据库 | In-Memory (演示) / PostgreSQL (PgVector) / Milvus | 存储向量数据 |
| 大模型 | DeepSeek / OpenAI / Qwen | 提供推理能力 |
为了快速演示效果,我们这次不真的去建一个向量数据库,而是使用内存模式(In-Memory),直接把数据存在程序的RAM里。好处是方便快捷,坏处是每次重启服务数据都会清空,不过做演示足够了。
大模型方面,我们用的是DeepSeek和阿里云的大模型,在官网上注册个账号,拿到API Key就能用。
(二) 整体架构概览
一个完整的RAG系统核心架构如下:
- 前端 (Vue3):用来跟用户交互,包括知识库管理、文件上传和对话聊天。
- 后端 (Spring Boot):业务逻辑中心,处理文件上传、调用AI服务、管理数据。
- AI 服务层 (Spring AI + LLM):
- Embedding 模型:把文本转换成机器能理解的向量。
- 大语言模型 (LLM):根据检索到的上下文生成最终答案。 - 向量数据库 (Vector Store):存着文档的向量表示,用来快速进行语义检索。这里先用内存插件实现效果。
(三) 环境搭建和后端配置
先创建一个Spring Boot项目,这部分不太赘述。
3.1 配置文件 pom.xml
在项目的pom.xml里加上必要的依赖,主要是这几个:
dev.langchain4j
langchain4j
0.35.0
dev.langchain4j
langchain4j-open-ai
0.35.0
dev.langchain4j
langchain4j-chroma
0.35.0
org.apache.poi
poi-ooxml
5.2.5
org.apache.pdfbox
pdfbox
2.0.27
这几个依赖各司其职:
| 依赖 | 说明 |
|---|---|
| langchain4j | 核心库,提供大模型集成、提示工程、记忆管理等核心功能。它定义了抽象接口,让你的业务代码不依赖于具体的模型,以后想从OpenAI换到Ollama或Azure,改个配置就行,不用重写代码。 |
| langchain4j-open-ai | 用来接入兼容OpenAI协议的模型,比如DeepSeek、Ollama、阿里云百炼等。 |
| langchain4j-chroma | 集成ChromaDB向量数据库,负责存储和检索嵌入向量。当你提问时,LangChain4j会通过这个组件去Chroma里找与你问题最相似的文档片段,当作背景知识喂给AI。 |
| poi-ooxml | 处理Word和Excel文件。 |
| pdfbox | 处理PDF文件。 |
3.2 配置 application.properties
接下来在配置文件中设置应用、数据库和AI模型的信息:
# --- AI 配置 (使用了两个AI,deepSeek和阿里云百炼 可以动态切换)---
# 当前激活的AI(aliyun 或 deepseek)
ai.active-provider=aliyun
# DeepSeek 配置
ai.providers.deepseek.api-key=sk-youToken
ai.providers.deepseek.base-url=https://api.deepseek.com/v1
ai.providers.deepseek.chat-model=deepseek-chat
ai.providers.deepseek.embedding-model=text-embedding-3-small
# 阿里云配置
ai.providers.aliyun.api-key=sk-youToken
ai.providers.aliyun.base-url=https://dashscope.aliyuncs.com/compatible-mode/v1
ai.providers.aliyun.chat-model=qwen-plus
ai.providers.aliyun.embedding-model=text-embedding-v1
(四) 后端核心功能实现
根据之前的RAG工作流程来看,后端要做的主要是两件事:
- 第一步(文档入库):接收前端上传的各种格式文档,解析、清洗、分块后存入数据库。
- 第二步(问答检索):接收前端的问题,通过Embedding模型转成向量,然后从知识库里检索出相似的知识片段,再把这些片段和问题一起发给大模型,最后把模型返回的信息回传给前端展示。
4.1 构建实体类和配置类
实体类 ChatRequest
先建一个实体类 ChatRequest.ja va,用来接收前端送来的各类参数:
package org.seaPack.dto;
import lombok.Data;
import ja va.util.List;
@Data
public class ChatRequest {
/** * 命名空间:用来指定在哪个知识库里检索 */
private String namespace;
/** * 用户问题:需要RAG系统回答的问题 */
private String question;
/** * 消息列表:用于维护对话历史 */
private List messages;
@Data
public static class MessageDTO {
private String role; // 角色:user 或 assistant
private String content; // 消息内容
}
}
字段说明:
- namespace:设定在不同的知识库之间做隔离,常用于多租户或者多知识库场景。系统里如果有多个独立的知识库,通过这个参数来指定查哪一个。
- question:用户提出的问题,是RAG系统检索和生成答案的核心依据。
- messages:前端传来的消息列表,用于在连续对话中传递历史内容,确保上下文连贯。
内部类 MessageDTO 用来描述每一条消息:
- role:标识消息发送者,比如"user"、"assistant"、"system"。
- content:消息的实际文本内容。
配置类 AIProperties
再创建一个配置类 AIProperties,用来支持在多个AI提供商(如DeepSeek、阿里云)之间动态切换。利用OpenAI协议的兼容性,可以做到“一次代码,多处运行”。
package org.seaPack.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import ja va.util.Map;
@Component
@ConfigurationProperties(prefix = "ai")
public class AIProperties {
/** * 当前激活的AI提供商名称,如 deepseek, aliyun */
private String activeProvider;
/** * 所有AI提供商的配置集合 */
private Map providers;
@Data
public static class ProviderConfig {
private String apiKey; // API Key
private String baseUrl; // 接口基础地址
private String chatModel; // 聊天模型名称
private String embeddingModel; // 向量化模型名称
}
}
这里把 @ConfigurationProperties(prefix = "ai") 注解加上,Spring Boot就会自动把配置文件里以 ai.* 开头的配置项绑定到这个类上。
4.2 文档入库
这一步主要是实现文件的上传和解析。我们会拆分为几个小步骤:
- 创建一个工具类 FileParserUtil,解析不同类型的文件。
- 创建 RagService 服务,获取AI配置,初始化向量模型。
- 解析文档,把解析后的数据存入向量化数据库。
- 创建 RAG控制器 RagController,提供文件上传接口。
- 在 RagService 中开发命名空间功能,把不同的文档隔离起来,避免互相干扰。
创建工具类 FileParserUtil.ja va
这个工具类的核心功能就是根据文件后缀,自动解析TXT、PDF、DOCX格式的文件,提取出纯文本内容。
package org.seaPack.components;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.springframework.stereotype.Component;
import ja va.io.ByteArrayOutputStream;
import ja va.io.InputStream;
import ja va.util.List;
import ja va.util.stream.Collectors;
@Component
@Slf4j
public class FileParserUtil {
/** * 对外提供的核心方法,根据文件后缀解析文件内容 */
public static String parseFile(InputStream inputStream, String fileName) throws Exception {
String lowerName = fileName.toLowerCase();
try {
if (lowerName.endsWith(".txt")) { return parseTxt(inputStream); }
else if (lowerName.endsWith(".pdf")) { return parsePdf(inputStream); }
else if (lowerName.endsWith(".docx")) { return parseDocx(inputStream); }
else { throw new IllegalArgumentException("不支持的文件格式: " + fileName); }
} catch (Exception e) {
log.error("文件解析失败: {}", fileName, e);
throw new Exception("文件解析失败: " + e.getMessage());
}
}
private static String parseTxt(InputStream inputStream) throws Exception {
ByteArrayOutputStream result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) { result.write(buffer, 0, length); }
return result.toString("UTF-8");
}
private static String parsePdf(InputStream inputStream) throws Exception {
try (PDDocument document = PDDocument.load(inputStream)) {
if (document.getNumberOfPages() == 0) { return ""; }
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true);
String text = stripper.getText(document);
if (text != null) {
text = text.replaceAll("\\?{2,}", " ");
text = text.replaceAll("[\\p{Cntrl}&&[^\\r\\n\\t]]", "");
text = text.replaceAll("\\n\\s*\\n", "\\n");
}
return text;
}
}
private static String parseDocx(InputStream inputStream) throws Exception {
try (XWPFDocument document = new XWPFDocument(inputStream)) {
List texts = document.getParagraphs().stream()
.map(XWPFParagraph::getText)
.filter(text -> text != null && !text.trim().isEmpty())
.collect(Collectors.toList());
return String.join("\n", texts);
}
}
}
目前只支持.txt、.docx、.pdf三种格式,如果有其他格式需求,后续可以再往里加。
获取AI配置并初始化向量模型
在RagService里做初始化操作,构建向量化模型,并在本地内存里存向量数据。
@Service
public class RagService {
private final AIProperties aiProperties;
private EmbeddingModel embeddingModel;
public RagService(AIProperties aiProperties) {
this.aiProperties = aiProperties;
}
@PostConstruct
public void init() {
// 1. 获取当前激活的提供商配置
String providerName = aiProperties.getActiveProvider();
AIProperties.ProviderConfig config = aiProperties.getProviders().get(providerName);
if (config == null) {
throw new RuntimeException("未找到 AI 提供商配置: " + providerName);
}
System.out.println("正在初始化 RAG 服务,使用提供商: " + providerName);
// 2. 构建向量化模型
this.embeddingModel = OpenAiEmbeddingModel.builder()
.apiKey(config.getApiKey())
.baseUrl(config.getBaseUrl())
.modelName(config.getEmbeddingModel())
.build();
System.out.println("RAG 服务初始化完成。");
}
}
这里的 @PostConstruct 注解保证了 init 方法会在Spring Bean启动时自动执行。逻辑很简单,就两步:先读配置拿到当前激活的AI提供商信息,然后用 OpenAiEmbeddingModel 构建一个向量化模型。
解析文档并存入向量数据库
这一步的核心是“数据入库”——把解析好的文本“吃进去”,转化成向量,再存到内存数据库里。主要完成了文本加载、文本分割、向量化、存储这一标准流程。而且我们通过命名空间的概念,实现了不同知识库之间的隔离。
@Service
public class RagService {
// ... 之前的内容 ...
private EmbeddingModel embeddingModel;
private final Map> namespaceStore = new ConcurrentHashMap<>();
/** * 文档入库方法:将文本转换为向量并存储 */
public void ingestText(String namespace, String text) {
// 获取或创建该命名空间对应的向量存储库
EmbeddingStore store = namespaceStore.computeIfAbsent(namespace, k -> new InMemoryEmbeddingStore<>());
// 将文本转换为Document对象
Document document = new Document(text);
// 创建文档分割器:按段落分割,每段最大500字符,重叠100字符
DocumentByParagraphSplitter splitter = new DocumentByParagraphSplitter(500, 100);
// 分割文档为多个文本片段
List segments = splitter.split(document);
System.out.println("[RagService] ingestText namespace=" + namespace + ", segments=" + segments.size());
// 遍历每个文本片段,进行向量化并存储
for (TextSegment segment : segments) {
Embedding embedding = embeddingModel.embed(segment.text()).content();
store.add(embedding, segment);
}
}
// ...
}
这段代码的逻辑很清晰:
1. 获取或创建存储实例(懒加载):namespaceStore.computeIfAbsent(namespace, k -> new InMemoryEmbeddingStore<>()); 这行代码检查当前命名空间是否已有存储实例,如果没有,就新建一个内存型的向量库并放入Map中。目前我们用内存模型,服务重启数据会丢失。
2. 文本包装与分割:先把字符串包装成Document对象,然后用 DocumentByParagraphSplitter 切片,500代表每个片段最大500字符,100代表片段间有100字符的重叠。重叠是用来保留上下文,防止切断了句子的意思,提高检索效果。
3. 向量化与存储循环:为每个文本片段调用模型生成向量,然后把(向量, 原始文本片段)这对数据存入store实例。
创建RAG控制器,提供文件上传接口
创建一个 RagController 类,提供一个文件上传接口 /rag/ingest-file,它利用 FileParserUtil 工具类来处理不同格式的文件,然后调用 RagService 的 ingestText 方法把数据入库。
@RestController
@RequestMapping("/rag")
public class RagController {
private final RagService ragService;
public RagController(RagService ragService) {
this.ragService = ragService;
}
@PostMapping("/ingest-file")
public Result ingestFile(@RequestParam("namespace") String namespace,
@RequestParam("file") MultipartFile file) {
System.out.println(">>> 正在接收文件: " + file.getOriginalFilename() + " 到空间: [" + namespace + "]");
if (file.isEmpty()) { return Result.error("文件为空"); }
try {
String text = FileParserUtil.parseFile(file.getInputStream(), file.getOriginalFilename());
ragService.ingestText(namespace, text);
return Result.success();
} catch (Exception e) {
e.printStackTrace();
return Result.error("文件解析失败: " + e.getMessage());
}
}
}
到此,一个支持多知识库隔离、基于内存的、简单的文本入库接口就完成了。它利用LangChain4j的标准组件,实现了从文本到向量存储的闭环。
命名空间开发,隔离不同文本
前面的入库代码里,ingestText 方法的第一行就是根据不同的命名空间创建不同的向量存储库,完成了文本隔离。在此基础上,我们还要提供命名空间的查询与删除接口,方便管理。
在 RagService 里加上命名空间管理的操作:
@Service
public class RagService {
private final Map> namespaceStore = new ConcurrentHashMap<>();
// ... (ingestText方法) ...
/** * 清除指定命名空间的所有文档 */
public void clearNamespace(String namespace) {
namespaceStore.remove(namespace);
}
/** * 获取所有已存在的命名空间列表 */
public List getNamespaces() {
return List.copyOf(namespaceStore.keySet());
}
}
再在 RagController 里加上对应的接口:
@RestController
@RequestMapping("/rag")
public class RagController {
private final RagService ragService;
public RagController(RagService ragService) {
this.ragService = ragService;
}
@DeleteMapping("/namespace/{namespace}")
public Result clearNamespace(@PathVariable String namespace) {
ragService.clearNamespace(namespace);
return Result.success();
}
@GetMapping("/namespaces")
public Result> getNamespaces() {
return Result.success(ragService.getNamespaces());
}
}
4.3 问答检索
这阶段的流程是:接收前端的问题 -> 通过Embedding模型转化成向量 -> 从知识库中检索出相似的知识片段 -> 把知识片段和问题一起发给大模型 -> 把大模型返回的信息回传给前端。
这个流程的操作相对简单,大体上分两步:
1. 根据问题在向量模型库中检索相关信息。
2. 把问题和检索到的信息发送给远程大模型。
在向量模型库中检索相关信息
在 RagService 里添加一个 getRelevantContext 方法,它接收命名空间和问题两个参数,根据问题在向量库里搜索相关信息,然后拼成一段文本返回:
@Service
public class RagService {
private EmbeddingModel embeddingModel;
private final Map> namespaceStore = new ConcurrentHashMap<>();
/** * 仅检索上下文,不生成回答 */
public String getRelevantContext(String namespace, String question) {
EmbeddingStore store = namespaceStore.get(namespace);
if (store == null) { return null; }
// 把用户问题转换为向量
Embedding queryEmbedding = embeddingModel.embed(question).content();
// 在向量库里查找最相似的Top-3片段,相似度阈值0.5
List> matches = store.findRelevant(queryEmbedding, 3, 0.5);
if (matches.isEmpty()) { return null; }
// 拼接文档片段
return matches.stream()
.map(match -> match.embedded().text())
.collect(Collectors.joining("\n\n"));
}
}
这段代码的主要逻辑:
1. 根据命名空间参数找到对应的向量存储库,如果没有则返回null。
2. 把用户的问题转成向量。
3. 在库里查找与问题向量最相似的片段。
4. 把找到的几个片段拼成一段完整的文本返回。
将检索信息和问题发给大模型
这一步是把知识库的检索信息连同用户的问题一起发给对应的大模型。我们在 ChatController 里添加一个与大模型对话的接口 /chat/aiModel。
@RestController
@RequestMapping("/chat")
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class ChatController {
private final AIProperties aiProperties;
private final RestTemplate restTemplate = new RestTemplate();
@Autowired
private RagService ragService;
@PostMapping("/aiModel")
public ResponseEntity chat(@RequestBody ChatRequest request) {
// 1. 获取配置
String providerName = aiProperties.getActiveProvider();
AIProperties.ProviderConfig config = aiProperties.getProviders().get(providerName);
if (config == null) {
throw new RuntimeException("AI 配置错误:未找到提供商 [" + providerName + "]");
}
// 2. 构建URL
String url = config.getBaseUrl().replaceAll("/+$", "") + "/chat/completions";
// 3. RAG核心增强逻辑
List messagesToSent = new ArrayList<>();
if (request.getNamespace() != null && !request.getNamespace().trim().isEmpty()) {
// 取用户最后一条消息作为问题
String lastUserQuestion = "";
if (request.getMessages() != null && !request.getMessages().isEmpty()) {
lastUserQuestion = request.getMessages().get(request.getMessages().size() - 1).getContent();
} else { lastUserQuestion = "你好"; }
// 从知识库检索上下文
String context = ragService.getRelevantContext(request.getNamespace(), lastUserQuestion);
if (context != null && !context.isEmpty()) {
String systemPrompt = "你是一个智能助手。请根据以下检索到的上下文信息回答问题:\n\n" +
"------ 上下文开始 ------\n" + context + "\n" +
"------ 上下文结束 ------\n\n" +
"如果上下文中没有答案,请根据你的通用知识回答。";
ChatRequest.MessageDTO systemMsg = new ChatRequest.MessageDTO();
systemMsg.setRole("system");
systemMsg.setContent(systemPrompt);
messagesToSent.add(systemMsg);
}
}
// 4. 追加前端传来的历史消息
if (request.getMessages() != null) { messagesToSent.addAll(request.getMessages()); }
// 5. 构建请求体
Map body = new HashMap<>();
body.put("model", config.getChatModel());
List messagesBody = new ArrayList<>();
if (messagesToSent != null && !messagesToSent.isEmpty()) { messagesBody.addAll(messagesToSent); }
if (request.getMessages() != null && !request.getMessages().isEmpty()) { messagesBody.addAll(request.getMessages()); }
body.put("messages", messagesBody);
body.put("stream", true); // 开启流式返回
// 6. 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + config.getApiKey());
// 7. 构建请求实体
HttpEntity
这段代码的主要逻辑是:
1. 获取AI配置。
2. 构建URL,准备连接远程大模型。
3. 从知识库中检索相关片段,这是RAG增强的核心。
4. 把检索到的信息和前端传来的历史消息拼在一起。
5. 构建请求体,开启流式输出,让前端能一个字一个字地显示。
6. 根据API Key构建请求头。
7. 构建最终的请求实体。
8. 进行流式转发。
9. 返回SSE响应。
至此,后端的功能就全部完成了。
(五)前端实现(Vue3 + TS)
前端主要负责与用户交互,并把请求发送给后端。
5.1 安装依赖
需要几个核心依赖:markdown-it 用来渲染AI对话中常见的Markdown格式显示;pinia 用来缓存数据;element-plus 用来绘制界面。
npm install markdown-it pinia element-plus
5.2 实现API调用
先创建一个 api/rag.ts 文件,封装与后端的通信。
定义请求和响应的类型:
export interface ChatRequest {
namespace: string; // 命名空间
question: string; // 问题
}
export interface IngestRequest {
namespace: string;
file: File;
}
export interface ApiResponse {
code: number;
msg: string;
data: T;
}
然后定义具体的API接口,主要包括文件上传、获取命名空间列表和删除命名空间:
import { request } from "@/utils/axios";
import { ApiResponse } from '@/api/ai/types/rag.ts';
const USER_BASE_URL = "/api";
export const ragApi = {
// 索引文件
ingestFile: (data: FormData) => {
return request>(`${USER_BASE_URL}/rag/ingest-file`, {
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
})
},
// 获取命名空间列表
getNamespaces: () => {
return request(`${USER_BASE_URL}/rag/namespaces`, { method: 'GET' }) as Promise
},
// 清空命名空间
clearNamespace: (namespace: string) => {
return request>(`${USER_BASE_URL}/rag/clear-namespace/${namespace}`, { method: 'POST' })
}
}
再创建一个 chat 接口文件,包含AI对话的SSE流式调用:
const USER_BASE_URL = "/api";
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export async function streamChat(
messages: ChatMessage[],
onChunk: (text: string) => void,
onDone: () => void,
onError: (e: Error) => void,
namespace?: string,
) {
try {
const response = await fetch(`${USER_BASE_URL}/chat/aiModel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, namespace, stream: true })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'API 请求失败');
}
if (!response.body) { throw new Error('响应体为空,无法读取流'); }
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) { onDone(); break; }
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('data:')) {
const jsonString = trimmedLine.replace(/^data:\s*/, '');
if (jsonString === '[DONE]') { onDone(); return; }
try {
const parsed = JSON.parse(jsonString);
const content = parsed.choices?.[0]?.delta?.content;
if (content) { onChunk(content); }
} catch (e) {
console.warn('解析失败:', e, jsonString);
}
}
}
}
} catch (err) {
onError(err as Error);
}
}
这个 streamChat 函数的核心作用,就是向后端AI模型发起请求,然后实时读取、解析服务器返回的流式数据(一个字一个字往外蹦的AI回复),并通过回调函数实时更新到前端界面上。
参数解释:
- messages:当前完整的对话历史。
- onChunk:每当收到AI生成的增量文本时触发,用于实时渲染界面。
- onDone:对话结束时触发。
- onError:发生异常时触发。
- namespace:可选的命名空间参数,用于区分不同的业务场景或模型路由。
代码的核心逻辑:
1. 流式请求:用 fetch 发起POST请求,并在请求体中显式设置 stream: true,告诉后端要以流式方式返回数据。
2. 流式读取:使用 response.body.getReader() 获取底层流读取器,并用 TextDecoder 把二进制流解码为字符串。同时定义了一个 buffer 缓冲区来处理网络传输中的“粘包”或“半包”问题。
3. SSE协议解析:遍历每一行,遵循标准的SSE协议格式,提取JSON数据,从 delta.content 中获取增量文本。
5.3 构建用户界面
用户界面主要拆分为两个核心组件:
- KnowledgeBase.vue:侧边栏,包含文件上传(
el-upload)和知识库列表。 - ChatInterface.vue:主区域,包含消息列表(
el-card)、输入框(el-input)和发送按钮。
核心逻辑:
- 文件上传:监听 el-upload 的 on-change 事件,调用 ragApi.ingestFile。
- 消息发送:用户在输入框提问后点击发送,调用 ragApi.chat,并把返回的答案和用户的提问一起渲染到消息列表中。
- Markdown渲染:使用 markdown-it 把LLM返回的Markdown格式文本渲染为HTML。
KnowledgeBase.vue 侧边栏组件:
我的知识库
拖拽文件到这里,或 点击选择
支持格式:TXT, PDF, DOC, DOCX
知识库空间:
暂无数据
{{ ns }}
<script setup lang="ts">
import { Folder, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElScrollbar, UploadInstance, UploadProps } from 'element-plus'
import { useChatStore } from '@/store/modules/chat'
import { ragApi } from '@/api/ai/rag'
import emitter from '@/utils/bus';
const uploadRef = ref()
const store = useChatStore()
const currentNamespace = ref('')
const namespaceList = ref([])
const uploading = ref(false)
// 文件上传
const onFileChange: UploadProps['onChange'] = async (file) => {
if (!currentNamespace.value.trim()) {
ElMessage.warning('请先输入或选择“知识库空间”')
uploadRef.value?.clearFiles()
return
}
if (!file.raw) {
ElMessage.error('文件读取失败,请重新选择')
return
}
uploading.value = true
const formData = new FormData()
formData.append('file', file.raw!)
formData.append('namespace', currentNamespace.value)
try {
await ragApi.ingestFile(formData);
ElMessage.success(`《${file.name}》上传成功!`)
store.addMessage({
role: 'assistant',
content: `✅ **文件入库成功**\n\n文件名:${file.name}\n空间:${currentNamespace.value}\n\n您可以开始提问了。`
})
uploadRef.value?.clearFiles()
fetchNamespaces()
} catch (err: any) {
ElMessage.error(`上传失败: ${err.message}`)
} finally {
uploading.value = false
}
}
const handleExceed: UploadProps['onExceed'] = () => {
ElMessageBox.alert('只允许同时上传一个文件,请先删除再上传新文件。', '警告', { type: 'warning' })
}
const fetchNamespaces = async () => {
namespaceList.value = await ragApi.getNamespaces();
};
const selectNamespace = (ns: string) => {
currentNamespace.value = ns
ElMessage.info(`切换到空间: ${ns}`)
}
watch(() => currentNamespace.value, (newVal) => {
emitter.emit('update-namespace', newVal)
})
onMounted(() => { fetchNamespaces() })
</script>
ChatInterface.vue 主聊天区域组件:
AI大模型 RAG 助手
清空会话
正在检索知识库并思考...
{{ store.loading ? '生成中...' : '发送提问' }}
<script setup lang="ts">
import { Delete } from '@element-plus/icons-vue'
import { useChatStore } from '@/store/modules/chat'
import { streamChat } from '@/api/ai/index.ts'
import MarkdownIt from 'markdown-it'
import emitter from '@/utils/bus';
const namespace = ref('');
const md = new MarkdownIt({ html: true, linkify: true, typographer: true })
const store = useChatStore()
const inputText = ref('')
const messageContainer = ref(null);
const renderMarkdown = (text: string) => { return md.render(text); }
const scrollToBottom = () => {
nextTick(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
})
}
watch(() => store.messages.length, scrollToBottom);
const handleEnter = (e: KeyboardEvent) => {
if (!e.shiftKey) { handleSend() }
}
const handleSend = async () => {
const text = inputText.value.trim();
if (!text || store.loading) return;
store.addMessage({ role: "user", content: text });
inputText.value = "";
store.loading = true;
store.addMessage({ role: "assistant", content: "" });
const history = store.messages.slice(0, -1);
await streamChat(
history,
(text) => store.updateLastMessage(text),
() => { store.loading = false },
(err) => {
store.updateLastMessage(`\n\n[错误: ${err.message}]`);
store.loading = false
},
namespace.value,
)
}
const handleClear = () => {
store.clearHistory()
ElMessageBox.confirm('会话已清空', '提示', { type: 'info' })
}
onMounted(() => { emitter.on('update-namespace', setNamespace); })
onUnmounted(() => { emitter.off('update-namespace', setNamespace); })
</script>
三、效果演示
来看看实际效果。
首先提问:“小明是谁,什么职业?”
(图片占位:大模型回答截图)
可以看到,模型由于没有相关资料,回答得很模糊。
接着,我们创建一个txt文件,里面写上小明的详细信息。比如:小明,今年28岁,是一名高级软件工程师,目前在阿里巴巴工作。
(图片占位:txt文件内容截图)
然后把这个文件上传到知识库,再次提问同样的问题:
(图片占位:上传文件后再次提问的截图)
(图片占位:上传后大模型回答的截图)
瞧,这次大模型就准确地回答了“小明是谁,什么职业”,答案正是来自我们上传的txt文件。这就是RAG的魅力——让大模型真正“读懂”你的私有知识。