分享一套锋哥原创的基于Spring AI 2.0的RAG企业内部知识库问答系统(AI大模型 SpringBoot4+Vue3+Ollama)
大家好我是Java1234_小锋老师分享一套锋哥原创的基于Spring AI 2.0的RAG企业内部知识库问答系统(AI大模型 SpringBoot4Vue3Ollama)。项目介绍随着人工智能技术的飞速发展大语言模型LLM已经成为企业数字化转型的重要驱动力。然而通用大模型由于训练语料的截止时间和领域局限性在直接回答企业内部专业问题时常常出现幻觉现象难以准确利用企业沉淀的内部知识。检索增强生成Retrieval-Augmented GenerationRAG技术通过将外部知识库与大语言模型相结合能够有效缓解上述问题提升回答的准确性、时效性和可解释性因而成为企业落地大模型应用的主流方案。本文围绕基于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核心流程进行了完整实现并结合企业实际需求设计了权限控制、引用溯源、模型可插拔等机制。经过功能测试与性能测试系统运行稳定问答准确率较通用大模型直接问答有显著提升能够满足中小企业内部知识检索与问答的实际需求。源码下载链接https://pan.baidu.com/s/1347t_Ys9deQ72vhVVK4HEg?pwd1234提取码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(); ListDocument 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{} 耗时{}msSimpleLoggerAdvisor 将打出请求/响应摘要, sessionId, llmMs); ListMapString, 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 ListDocument retrieveForCategories(String question, ListLong categoryIds) { if (categoryIds null || categoryIds.isEmpty()) { return vectorSimilaritySearch(question, null); } SetString 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); ListDocument 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 ListDocument 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); } ListDocument docs vectorStore.similaritySearch(b.build()); return docs ! null ? docs : Collections.emptyList(); } private static Filter.Expression buildCategoryIdFilter(SetString categoryIdsAsString) { FilterExpressionBuilder fb new FilterExpressionBuilder(); if (categoryIdsAsString.size() 1) { return fb.eq(categoryId, categoryIdsAsString.iterator().next()).build(); } ListObject values new ArrayList(categoryIdsAsString); return fb.in(categoryId, values).build(); } /** * 将检索结果拼成单条 user 消息等价于一次 RAG 上下文注入避免 Advisor 内二次检索。 */ private static String buildRagUserMessage(String question, ListDocument cited) { if (cited null || cited.isEmpty()) { return 知识库检索未命中足够相关的片段请直接依据系统说明作答。 用户问题 question; } StringBuilder sb new StringBuilder(); sb.append(以下是检索到的知识片段请严格据此回答片段相互冲突时优先采纳与问题最直接相关的表述。\n\n); int i 1; for (Document d : cited) { MapString, 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 ListChatSession listSessions(Long userId) { return chatSessionMapper.listByUserId(userId); } /** * {inheritDoc} */ Override public ListChatMessage 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 ListMapString, Object toRefs(ListDocument docs) { ListMapString, Object refs new ArrayList(); for (Document d : docs) { MapString, Object m new LinkedHashMap(); MapString, 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 classpage-title知识分类/div el-card shadowhover classbox div classtoolbar el-button typeprimary :iconPlus clickopenCreate新增分类/el-button /div el-table :datalist v-loadingloading stripe el-table-column propid labelID width70 / el-table-column propname label名称 / el-table-column propdescription label描述 show-overflow-tooltip / el-table-column propsortOrder label排序 width90 / el-table-column label创建时间 width170 template #default{ row }{{ formatDateTime(row.createTime) }}/template /el-table-column el-table-column label操作 width160 fixedright template #default{ row } el-button link typeprimary clickopenEdit(row)编辑/el-button el-button link typedanger clickdel(row)删除/el-button /template /el-table-column /el-table /el-card el-dialog v-modeldlg title分类 width460px el-form :modelform label-width80px el-form-item label名称el-input v-modelform.name //el-form-item el-form-item label描述el-input v-modelform.description typetextarea rows3 //el-form-item el-form-item label图标el-input v-modelform.icon placeholderElement 图标名 //el-form-item el-form-item label排序el-input-number v-modelform.sortOrder //el-form-item /el-form template #footer el-button clickdlg false取消/el-button el-button typeprimary clicksave保存/el-button /template /el-dialog /div /template style scoped .toolbar { margin-bottom: 12px; } .box { border-radius: 16px; } /style