分享一套锋哥原创的基于Spring AI 2.0的RAG医疗健康知识智能问答系统(AI大模型 SpringBoot4+Vue3+Ollama)

0 阅读4分钟

大家好,我是java1234_小锋老师,分享一套基于Spring AI 2.0的RAG医疗健康知识智能问答系统(AI大模型 SpringBoot4+Vue3+Ollama)  。

图7.jpg

项目简介

随着人工智能技术的迅猛发展,大语言模型(Large Language Model, LLM)在自然语言理解与生成方面表现出色,但通用大模型缺乏垂直领域的专业知识,存在"幻觉"问题,难以直接应用于医疗健康等专业性强、容错率低的场景。检索增强生成(Retrieval-Augmented Generation, RAG)技术通过将外部知识库与大模型相结合,在生成回答前先检索相关知识片段作为上下文,能够显著提升回答的准确性、时效性与可溯源性。

本文以"基于 Spring AI 2.0 的 RAG 医疗健康知识智能问答系统"为研究课题,采用 Spring Boot 4 + Spring AI 2.0 + MySQL 8.0 + 向量数据库 + Vue 3 等技术,设计并实现了一套面向普通用户与管理员的医疗健康知识智能问答平台。系统分为用户端、管理端和 RAG 核心三大模块,支持医疗文档上传、自动切分、向量化、相似度检索、提示词增强、流式问答、引用回溯、用户反馈以及健康档案管理等功能。

本文首先调研了 RAG 技术的国内外研究现状,分析了 Spring AI 2.0 的统一抽象与核心 API;其次完成了系统的需求分析、总体架构设计、功能模块设计、数据库 E-R 设计和接口设计;在实现层面,重点阐述了基于 Spring AI 2.0 的 ChatClient、EmbeddingModel、VectorStore 等核心 API 的应用,以及文档解析、TokenTextSplitter 切分、 向量入库、相似度检索、Prompt 模板拼装与流式 SSE 响应等关键技术细节。最后对系统进行了功能测试与 RAG 准确性评估,验证了系统的可用性与有效性。

测试结果表明,本系统能够在 2 秒内完成首 Token 响应,知识检索准确率达到 87% 以上,可在医疗咨询场景下提供条理化、可追溯的专业解答,对于普通用户健康知识查询、轻问诊辅助具有较强的实用价值。

源码下载

链接: pan.baidu.com/s/13h560RmY…

提取码: 1234

相关截图

图1.jpg

图2.jpg

图3.jpg

图4.jpg

图5.jpg

图6.jpg

图8.jpg

核心代码


package com.java1234.controller;

import com.java1234.common.PageResult;
import com.java1234.common.R;
import com.java1234.entity.KbDocument;
import com.java1234.service.KbDocumentService;
import com.java1234.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
 * 知识文档上传与管理。
 */
@RestController
@RequestMapping("/api/documents")
@RequiredArgsConstructor
public class DocumentController {

    private final KbDocumentService kbDocumentService;

    /**
     * 上传并向量化(管理员)。
     */
    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public R<KbDocument> upload(@RequestPart("file") MultipartFile file,
                               @RequestParam Long categoryId,
                               @RequestParam(required = false) String title) throws Exception {
        var u = SecurityUtils.requireUser();
        return R.ok(kbDocumentService.upload(file, categoryId, title, u.getUserId()));
    }

    /**
     * 文档分页(管理员)。
     */
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/page")
    public R<PageResult<KbDocument>> page(@RequestParam(defaultValue = "") String keyword,
                                          @RequestParam(required = false) Long categoryId,
                                          @RequestParam(defaultValue = "1") int page,
                                          @RequestParam(defaultValue = "10") int size) {
        return R.ok(kbDocumentService.page(keyword, categoryId, page, size));
    }

    /**
     * 删除文档及向量(管理员)。
     */
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public R<Void> delete(@PathVariable Long id) throws Exception {
        kbDocumentService.delete(id);
        return R.ok();
    }
}

<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { User, Document, Cpu, ChatDotRound } from '@element-plus/icons-vue'
import StatCard from '../../components/StatCard.vue'
import { fetchOverview } from '../../api/stats'

const loading = ref(true)
const overview = ref({})

let charts = []

async function load() {
  loading.value = true
  try {
    const res = await fetchOverview()
    overview.value = res.data
    await nextTick()
    renderCharts()
  } finally {
    loading.value = false
  }
}

function renderCharts() {
  disposeCharts()
  const qa = overview.value.qaByDay || []
  const reg = overview.value.userRegByDay || []
  const cats = overview.value.categoryDocShare || []

  const elLine = document.getElementById('chart-line')
  const elBar = document.getElementById('chart-bar')
  const elPie = document.getElementById('chart-pie')
  const elHBar = document.getElementById('chart-hbar')

  if (elLine) {
    const c = echarts.init(elLine)
    c.setOption({
      tooltip: { trigger: 'axis' },
      grid: { left: 40, right: 20, top: 28, bottom: 28 },
      xAxis: { type: 'category', data: qa.map((x) => x.date), axisLine: { lineStyle: { color: '#94a3b8' } } },
      yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed', color: '#e2e8f0' } } },
      series: [
        {
          name: '问答次数',
          type: 'line',
          smooth: true,
          data: qa.map((x) => x.count),
          areaStyle: { color: 'rgba(99,102,241,0.12)' },
          lineStyle: { width: 3, color: '#6366f1' },
        },
      ],
    })
    charts.push(c)
  }

  if (elBar) {
    const c = echarts.init(elBar)
    c.setOption({
      tooltip: { trigger: 'axis' },
      grid: { left: 40, right: 20, top: 28, bottom: 28 },
      xAxis: { type: 'category', data: reg.map((x) => x.date) },
      yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
      series: [
        {
          name: '新用户',
          type: 'bar',
          data: reg.map((x) => x.count),
          itemStyle: {
            borderRadius: [8, 8, 0, 0],
            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
              { offset: 0, color: '#a855f7' },
              { offset: 1, color: '#6366f1' },
            ]),
          },
        },
      ],
    })
    charts.push(c)
  }

  if (elPie) {
    const c = echarts.init(elPie)
    c.setOption({
      tooltip: { trigger: 'item' },
      series: [
        {
          type: 'pie',
          radius: '68%',
          data: cats.map((x) => ({ name: x.name, value: x.value })),
          label: { formatter: '{b}\n{d}%' },
        },
      ],
    })
    charts.push(c)
  }

  if (elHBar) {
    const c = echarts.init(elHBar)
    c.setOption({
      tooltip: { trigger: 'axis' },
      grid: { left: 100, right: 28, top: 20, bottom: 20 },
      xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
      yAxis: { type: 'category', data: cats.map((x) => x.name), axisLine: { show: false } },
      series: [
        {
          type: 'bar',
          data: cats.map((x) => x.value),
          itemStyle: {
            borderRadius: [0, 8, 8, 0],
            color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
              { offset: 0, color: '#6366f1' },
              { offset: 1, color: '#ec4899' },
            ]),
          },
        },
      ],
    })
    charts.push(c)
  }

  window.addEventListener('resize', resizeAll)
}

function resizeAll() {
  charts.forEach((c) => c.resize())
}

function disposeCharts() {
  window.removeEventListener('resize', resizeAll)
  charts.forEach((c) => c.dispose())
  charts = []
}

onMounted(load)
onBeforeUnmount(disposeCharts)
</script>

<template>
  <div class="dash" v-loading="loading">
    <div class="page-title">数据概览</div>
    <p class="sub">Java1234 RAG 医疗健康知识智能问答系统 · 实时监控</p>

    <el-row :gutter="16" class="cards">
      <el-col :xs="24" :sm="12" :md="6">
        <StatCard
          label="用户总数"
          :value="overview.userTotal ?? 0"
          :icon="User"
          gradient="linear-gradient(135deg,#6366f1,#8b5cf6)"
        />
      </el-col>
      <el-col :xs="24" :sm="12" :md="6">
        <StatCard
          label="知识文档"
          :value="overview.documentTotal ?? 0"
          :icon="Document"
          gradient="linear-gradient(135deg,#0ea5e9,#6366f1)"
        />
      </el-col>
      <el-col :xs="24" :sm="12" :md="6">
        <StatCard
          label="向量片段"
          :value="overview.vectorTotal ?? 0"
          :icon="Cpu"
          gradient="linear-gradient(135deg,#10b981,#0ea5e9)"
        />
      </el-col>
      <el-col :xs="24" :sm="12" :md="6">
        <StatCard
          label="今日问答"
          :value="overview.qaToday ?? 0"
          :icon="ChatDotRound"
          gradient="linear-gradient(135deg,#f59e0b,#ec4899)"
        />
      </el-col>
    </el-row>

    <el-row :gutter="16" class="charts">
      <el-col :xs="24" :lg="12">
        <el-card shadow="hover" class="chart-card">
          <template #header><span>近 7 日 · 助手回答次数</span></template>
          <div id="chart-line" class="chart-box"></div>
        </el-card>
      </el-col>
      <el-col :xs="24" :lg="12">
        <el-card shadow="hover" class="chart-card">
          <template #header><span>近 7 日 · 新注册用户</span></template>
          <div id="chart-bar" class="chart-box"></div>
        </el-card>
      </el-col>
      <el-col :xs="24" :lg="12">
        <el-card shadow="hover" class="chart-card">
          <template #header><span>分类文档数量占比</span></template>
          <div id="chart-pie" class="chart-box"></div>
        </el-card>
      </el-col>
      <el-col :xs="24" :lg="12">
        <el-card shadow="hover" class="chart-card">
          <template #header><span>分类文档数量(条形)</span></template>
          <div id="chart-hbar" class="chart-box"></div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>

<style scoped>
.dash {
  padding: 4px 4px 32px;
}
.sub {
      color: #64748b;
      margin: 6px 0 20px;
}
.cards {
  margin-bottom: 8px;
}
.chart-card {
  border-radius: 16px;
      margin-bottom: 16px;
}
.chart-box {
  height: 280px;
  width: 100%;
}
</style>