分享一套锋哥原创的基于Spring AI 2.0的RAG企业内部知识库问答(AI大模型 SpringBoot4+Vue3+Ollama)

24 阅读5分钟

大家好,我是java1234_小锋老师,分享一套基于Spring AI 2.0的RAG企业内部知识库问答(AI大模型 SpringBoot4+Vue3+Ollama)  。

图7.jpg

项目简介

随着人工智能技术的飞速发展,大语言模型(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

相关截图

图1.jpg

图2.jpg

图3.jpg

图4.jpg

图5.jpg

图6.jpg

图8.jpg

图9.jpg

核心代码


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>