大家好,我是java1234_小锋老师,分享一套基于Spring AI 2.0的RAG企业内部知识库问答(AI大模型 SpringBoot4+Vue3+Ollama) 。
项目简介
随着人工智能技术的飞速发展,大语言模型(LLM)已经成为企业数字化转型的重要驱动力。然而,通用大模型由于训练语料的截止时间和领域局限性,在直接回答企业内部专业问题时常常出现"幻觉"现象,难以准确利用企业沉淀的内部知识。检索增强生成(Retrieval-Augmented Generation,RAG)技术通过将外部知识库与大语言模型相结合,能够有效缓解上述问题,提升回答的准确性、时效性和可解释性,因而成为企业落地大模型应用的主流方案。
本文围绕"基于Spring AI 2.0的RAG企业内部知识库问答系统"的设计与实现展开研究。系统采用前后端分离架构,后端基于Spring Boot 3.x与Spring AI 2.0框架,前端采用Vue 3 + TypeScript + Element Plus构建,数据层采用MySQL存储业务数据,PGVector存储文档向量,MinIO存储原始文件,Redis用于缓存登录态及热点数据。系统实现了用户管理、知识库管理、文档上传与向量化、智能问答(RAG检索+流式回答+引用展示)、对话历史管理、系统管理等核心功能。
系统设计上,本文使用Spring AI 2.0提供的ChatClient、EmbeddingModel、VectorStore等抽象,对文档解析、文本切分、向量化、相似度检索、Prompt组装、流式生成等RAG核心流程进行了完整实现,并结合企业实际需求设计了权限控制、引用溯源、模型可插拔等机制。经过功能测试与性能测试,系统运行稳定,问答准确率较通用大模型直接问答有显著提升,能够满足中小企业内部知识检索与问答的实际需求。
源码下载
链接: pan.baidu.com/s/1347t_Ys9…
提取码: 1234
相关截图
核心代码
package com.java1234.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.java1234.dto.ChatAskRequest;
import com.java1234.dto.ChatAskResult;
import com.java1234.entity.ChatMessage;
import com.java1234.entity.ChatSession;
import com.java1234.exception.BusinessException;
import com.java1234.mapper.ChatMessageMapper;
import com.java1234.mapper.ChatSessionMapper;
import com.java1234.service.ChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* {@link com.java1234.service.ChatService} 实现。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatServiceImpl implements ChatService {
private static final int RAG_TOP_K = 10;
/**
* 系统提示:要求仅依据上下文、Markdown 输出。
*/
public static final String SYSTEM_PROMPT = """
你是「Java1234 RAG 企业知识库」的智能助手。请严格根据检索到的上下文回答问题。
若上下文不足以回答,请明确说明「知识库中未找到相关信息」,不要编造。
回答请使用清晰的 Markdown(可适当使用标题、列表)。结尾可简要列出依据的文档标题。
""";
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final ChatSessionMapper chatSessionMapper;
private final ChatMessageMapper chatMessageMapper;
private final ObjectMapper objectMapper;
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ChatAskResult ask(Long userId, ChatAskRequest req) throws Exception {
Long sessionId = req.getSessionId();
if (sessionId == null) {
ChatSession s = new ChatSession();
s.setUserId(userId);
String t = req.getQuestion().trim();
s.setTitle(t.length() > 30 ? t.substring(0, 30) + "…" : t);
chatSessionMapper.insert(s);
sessionId = s.getId();
}
else {
ChatSession exist = chatSessionMapper.selectById(sessionId);
if (exist == null || !exist.getUserId().equals(userId)) {
throw new BusinessException("会话不存在或无权限");
}
}
long t0 = System.nanoTime();
List<Document> cited = retrieveForCategories(req.getQuestion(), req.getCategoryIds());
long retrievalMs = (System.nanoTime() - t0) / 1_000_000L;
log.info("RAG 向量检索完成 sessionId={} 命中块数={} 耗时={}ms", sessionId, cited.size(), retrievalMs);
String userTurn = buildRagUserMessage(req.getQuestion(), cited);
t0 = System.nanoTime();
String answer = chatClient.prompt().system(SYSTEM_PROMPT).user(userTurn).call().content();
long llmMs = (System.nanoTime() - t0) / 1_000_000L;
log.info("LLM 生成完成 sessionId={} 耗时={}ms(SimpleLoggerAdvisor 将打出请求/响应摘要)", sessionId, llmMs);
List<Map<String, Object>> refs = toRefs(cited);
String refsJson = objectMapper.writeValueAsString(refs);
ChatMessage um = new ChatMessage();
um.setSessionId(sessionId);
um.setRole("USER");
um.setContent(req.getQuestion());
um.setRefs(null);
chatMessageMapper.insert(um);
ChatMessage am = new ChatMessage();
am.setSessionId(sessionId);
am.setRole("ASSISTANT");
am.setContent(answer);
am.setRefs(refsJson);
chatMessageMapper.insert(am);
chatSessionMapper.touchUpdateTime(sessionId);
ChatAskResult res = new ChatAskResult();
res.setSessionId(sessionId);
res.setAnswer(answer);
res.setReferences(refs);
return res;
}
/**
* 有分类时先带 {@code categoryId} 过滤检索;无命中或异常时与无分类相同,做全库无条件检索兜底。
*/
private List<Document> retrieveForCategories(String question, List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return vectorSimilaritySearch(question, null);
}
Set<String> keys = categoryIds.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (keys.isEmpty()) {
return vectorSimilaritySearch(question, null);
}
try {
Filter.Expression expr = buildCategoryIdFilter(keys);
List<Document> filtered = vectorSimilaritySearch(question, expr);
if (filtered != null && !filtered.isEmpty()) {
return filtered;
}
log.info("限定 categoryId {} 向量检索无命中,降级为全库无条件检索", keys);
return vectorSimilaritySearch(question, null);
}
catch (Exception ex) {
log.warn("categoryId 过滤向量检索失败,降级为全库无条件检索:{}", ex.toString());
return vectorSimilaritySearch(question, null);
}
}
/**
* SimpleVectorStore 内存向量检索:无多余参数;{@code filter} 为 null 表示全库。
*/
private List<Document> vectorSimilaritySearch(String question, Filter.Expression filter) {
SearchRequest.Builder b = SearchRequest.builder()
.query(question)
.topK(RAG_TOP_K)
.similarityThreshold(0.0);
if (filter != null) {
b.filterExpression(filter);
}
List<Document> docs = vectorStore.similaritySearch(b.build());
return docs != null ? docs : Collections.emptyList();
}
private static Filter.Expression buildCategoryIdFilter(Set<String> categoryIdsAsString) {
FilterExpressionBuilder fb = new FilterExpressionBuilder();
if (categoryIdsAsString.size() == 1) {
return fb.eq("categoryId", categoryIdsAsString.iterator().next()).build();
}
List<Object> values = new ArrayList<>(categoryIdsAsString);
return fb.in("categoryId", values).build();
}
/**
* 将检索结果拼成单条 user 消息,等价于一次 RAG 上下文注入(避免 Advisor 内二次检索)。
*/
private static String buildRagUserMessage(String question, List<Document> cited) {
if (cited == null || cited.isEmpty()) {
return """
(知识库检索未命中足够相关的片段,请直接依据系统说明作答。)
用户问题:
""" + question;
}
StringBuilder sb = new StringBuilder();
sb.append("以下是检索到的知识片段,请严格据此回答;片段相互冲突时优先采纳与问题最直接相关的表述。\n\n");
int i = 1;
for (Document d : cited) {
Map<String, Object> meta = d.getMetadata();
String title = meta != null && meta.get("title") != null ? String.valueOf(meta.get("title")) : "(无标题)";
sb.append("### 片段 ").append(i++).append(" · ").append(title).append("\n");
String text = d.getText();
if (text != null) {
sb.append(text.strip()).append("\n\n");
}
}
sb.append("---\n用户问题:\n").append(question.strip());
return sb.toString();
}
/**
* {@inheritDoc}
*/
@Override
public List<ChatSession> listSessions(Long userId) {
return chatSessionMapper.listByUserId(userId);
}
/**
* {@inheritDoc}
*/
@Override
public List<ChatMessage> listMessages(Long userId, Long sessionId) {
ChatSession s = chatSessionMapper.selectById(sessionId);
if (s == null || !s.getUserId().equals(userId)) {
throw new BusinessException("会话不存在或无权限");
}
return chatMessageMapper.listBySessionId(sessionId);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteSession(Long userId, Long sessionId) {
ChatSession s = chatSessionMapper.selectById(sessionId);
if (s == null || !s.getUserId().equals(userId)) {
throw new BusinessException("会话不存在或无权限");
}
chatMessageMapper.deleteBySessionId(sessionId);
chatSessionMapper.deleteById(sessionId);
}
private static List<Map<String, Object>> toRefs(List<Document> docs) {
List<Map<String, Object>> refs = new ArrayList<>();
for (Document d : docs) {
Map<String, Object> m = new LinkedHashMap<>();
Map<String, Object> meta = d.getMetadata();
m.put("title", meta != null ? meta.get("title") : null);
m.put("docId", meta != null ? meta.get("docId") : null);
m.put("categoryId", meta != null ? meta.get("categoryId") : null);
String tx = d.getText();
if (tx != null && tx.length() > 240) {
tx = tx.substring(0, 240) + "…";
}
m.put("snippet", tx);
refs.add(m);
}
return refs;
}
}
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { listCategories, saveCategory, deleteCategory } from '../../api/category'
import { formatDateTime } from '../../utils/date'
const loading = ref(false)
const list = ref([])
const dlg = ref(false)
const form = ref({ id: null, name: '', description: '', icon: 'Document', sortOrder: 0 })
async function load() {
loading.value = true
try {
const res = await listCategories()
list.value = res.data
} finally {
loading.value = false
}
}
function openCreate() {
form.value = { id: null, name: '', description: '', icon: 'Folder', sortOrder: 0 }
dlg.value = true
}
function openEdit(row) {
form.value = { ...row, sortOrder: row.sortOrder ?? 0 }
dlg.value = true
}
async function save() {
await saveCategory(form.value)
dlg.value = false
load()
}
async function del(row) {
await ElMessageBox.confirm(`删除分类「${row.name}」?`, '提示')
await deleteCategory(row.id)
load()
}
onMounted(load)
</script>
<template>
<div>
<div class="page-title">知识分类</div>
<el-card shadow="hover" class="box">
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="openCreate">新增分类</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="90" />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dlg" title="分类" width="460px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" rows="3" /></el-form-item>
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="Element 图标名" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
margin-bottom: 12px;
}
.box {
border-radius: 16px;
}
</style>