章节五:智能 BI 报表问答系统

一、系统概述与场景介绍

1.1 项目背景

在企业日常运营中,数据分析和报表生成是不可或缺的核心环节。然而,传统的 BI(Business Intelligence)系统存在以下痛点:

  • 技术门槛高:业务人员需要掌握 SQL 或专业的 BI 工具才能进行数据查询
  • 响应周期长:每次数据需求都需要提工单给技术团队,等待排期和开发
  • 灵活性差:固定报表难以满足多变的业务分析需求
  • 数据利用率低:企业数据"存而易,用而难"的现象普遍存在

1.2 系统目标

本章节将开发一个自然语言到数据查询/分析的交互平台,让业务人员能够用"说人话"的方式获取数据洞察。系统的核心目标包括:

  • 降低数据获取门槛:业务人员通过自然语言描述需求即可获取数据
  • 自动化报表生成:系统自动生成 Excel 报表并通过邮件发送
  • 智能 SQL 生成:利用大模型的推理能力,将非结构化需求转换为结构化查询
  • 数据洞察自动化:对查询结果进行归纳、总结和洞察分析

1.3 系统架构设计

┌─────────────────────────────────────────────────────────────────┐
│                        用户交互层                                │
│              (自然语言输入:"帮我统计本月各门店销售额")              │
└──────────────────────────┬──────────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────────┐
│                      Graph 智能编排层                            │
│  ┌─────────────┐    ┌──────────────┐    ┌─────────────────┐     │
│  │  SQL生成节点 │───▶│ SQL执行节点  │───▶│  报表/邮件节点   │     │
│  │  (翻译官)   │    │  (执行者)    │    │  (分析师)       │     │
│  └─────────────┘    └──────────────┘    └─────────────────┘     │
│         ▲                    │                    │              │
│         │              ┌─────▼─────┐              │              │
│         └──────────────│  MySQL    │◀─────────────┘              │
│                        │ 数据库    │                               │
│                        └───────────┘                               │
│                         ▲                                        │
│              ┌──────────┴──────────┐                              │
│              │   向量数据库(Redis)   │                              │
│              │   (表结构知识库)      │                              │
│              └─────────────────────┘                              │
└─────────────────────────────────────────────────────────────────┘

系统角色分工:

角色 组件 职责
翻译官 Graph + LLM 将用户的自然语言问题,翻译成结构化的数据查询逻辑
执行者 Java 后端 接收 Graph 生成的查询逻辑,安全地执行查询并返回结果
分析师 Graph + LLM 对查询结果进行归纳、总结和洞察分析

1.4 核心亮点

  • 主导开发基于 Spring AI 的 ERP 自然语言交互查询系统,显著降低了业务人员的数据获取门槛
  • 利用 Graph 的推理能力,将非结构化需求转换为结构化查询,实现数据洞察的自动化生成
  • 深入解决企业数据"存而易,用而难"的普遍问题,让数据真正服务于业务决策

二、开发前置准备工作 — 表结构设计与数据初始化

2.1 数据库选型

本系统采用 MySQL 8.0 作为业务数据库,结合经典的星型模型进行数据仓库设计。星型模型由一个中心事实表和多个维度表组成,是数据仓库中最常用的建模方式。

2.2 维度表设计

维度表(Dimension Table)用于描述业务实体的属性信息,是数据分析的"角度"和"视角"。

2.2.1 商品维度表(dim_product)
-- =====================================================
-- 维度表:商品维度(dim_product)
-- =====================================================
DROP TABLE IF EXISTS dim_product;
CREATE TABLE dim_product (
  product_id BIGINT PRIMARY KEY COMMENT '商品唯一 ID',
  product_name VARCHAR(255) NOT NULL COMMENT '商品名称',
  category_id BIGINT NULL COMMENT '商品分类 ID',
  category_name VARCHAR(255) NULL COMMENT '商品分类名称',
  brand VARCHAR(255) NULL COMMENT '品牌',
  cost_price DECIMAL(10,2) NULL COMMENT '成本价',
  retail_price DECIMAL(10,2) NULL COMMENT '建议零售价'
) COMMENT='商品维度表';

设计说明:

字段 类型 说明
product_id BIGINT 商品唯一标识,作为主键
product_name VARCHAR(255) 商品名称,用于展示
category_id BIGINT 分类 ID,支持多级分类
category_name VARCHAR(255) 分类名称,冗余存储便于查询
brand VARCHAR(255) 品牌名称,用于品牌维度分析
cost_price DECIMAL(10,2) 成本价,用于利润分析
retail_price DECIMAL(10,2) 建议零售价,用于定价分析
2.2.2 门店维度表(dim_store)
-- =====================================================
-- 维度表:门店维度(dim_store)
-- =====================================================
DROP TABLE IF EXISTS dim_store;
CREATE TABLE dim_store (
  store_id BIGINT PRIMARY KEY COMMENT '门店唯一 ID',
  store_name VARCHAR(255) NOT NULL COMMENT '门店名称',
  province VARCHAR(100) NULL COMMENT '所在省份',
  city VARCHAR(100) NULL COMMENT '所在城市',
  address VARCHAR(255) NULL COMMENT '门店详细地址',
  open_date DATE NULL COMMENT '开业日期'
) COMMENT='门店维度表';
2.2.3 时间维度表(dim_date)
-- =====================================================
-- 维度表:时间维度(dim_date)
-- =====================================================
DROP TABLE IF EXISTS dim_date (
  date_id DATE PRIMARY KEY COMMENT '日期ID',
  year INT NOT NULL,
  quarter INT NOT NULL,
  month INT NOT NULL,
  day INT NOT NULL,
  weekday INT NOT NULL
) COMMENT='时间维度表';

时间维度表的重要性: 时间维度是数据分析中最常用的维度之一。通过预先生成时间维度表,可以大大简化按年、季度、月、周进行聚合查询的复杂度。

2.2.4 客户维度表(dim_customer)
-- =====================================================
-- 维度表:客户维度(dim_customer)
-- =====================================================
DROP TABLE IF EXISTS dim_customer;
CREATE TABLE dim_customer (
  customer_id BIGINT PRIMARY KEY COMMENT '客户ID',
  customer_name VARCHAR(255) NOT NULL,
  gender VARCHAR(10) NULL,
  age INT NULL,
  city VARCHAR(100) NULL,
  province VARCHAR(100) NULL
) COMMENT='客户维度表';

2.3 事实表设计

事实表(Fact Table)用于存储业务过程中的度量数据(如销售额、数量等),是数据分析的核心。

2.3.1 销售事实表(fact_sales)
-- =====================================================
-- 事实表:销售事实表(fact_sales)
-- =====================================================
DROP TABLE IF EXISTS fact_sales;
CREATE TABLE fact_sales (
  sales_id BIGINT PRIMARY KEY COMMENT '销售记录ID',
  product_id BIGINT NOT NULL COMMENT '商品ID',
  store_id BIGINT NOT NULL COMMENT '门店ID',
  customer_id BIGINT NULL COMMENT '客户ID',
  date_id DATE NOT NULL COMMENT '销售日期',
  quantity INT NOT NULL COMMENT '销售数量',
  sales_amount DECIMAL(10,2) NOT NULL COMMENT '销售金额',
  discount DECIMAL(10,2) NULL COMMENT '折扣金额',
  FOREIGN KEY (product_id) REFERENCES dim_product(product_id),
  FOREIGN KEY (store_id) REFERENCES dim_store(store_id),
  FOREIGN KEY (customer_id) REFERENCES dim_customer(customer_id),
  FOREIGN KEY (date_id) REFERENCES dim_date(date_id)
) COMMENT='销售事实表';

销售事实表的结构特点:

字段 类型 角色 说明
sales_id BIGINT 代理键 销售记录唯一标识
product_id BIGINT 外键 关联商品维度
store_id BIGINT 外键 关联门店维度
customer_id BIGINT 外键 关联客户维度
date_id DATE 外键 关联时间维度
quantity INT 度量 销售数量
sales_amount DECIMAL(10,2) 度量 销售金额
discount DECIMAL(10,2) 度量 折扣金额
2.3.2 库存事实表(fact_inventory)
-- =====================================================
-- 事实表:库存事实表(fact_inventory)
-- =====================================================
DROP TABLE IF EXISTS fact_inventory;
CREATE TABLE fact_inventory (
  inventory_id BIGINT PRIMARY KEY COMMENT '库存记录ID',
  product_id BIGINT NOT NULL COMMENT '商品ID',
  store_id BIGINT NOT NULL COMMENT '门店ID',
  date_id DATE NOT NULL COMMENT '库存日期',
  quantity INT NOT NULL COMMENT '库存数量',
  FOREIGN KEY (product_id) REFERENCES dim_product(product_id),
  FOREIGN KEY (store_id) REFERENCES dim_store(store_id),
  FOREIGN KEY (date_id) REFERENCES dim_date(date_id)
) COMMENT='库存事实表';

2.4 数据模型关系图

                    ┌──────────────┐
                    │   dim_date   │
                    │  (时间维度)   │
                    └──────┬───────┘
                           │
         ┌─────────────────┼─────────────────┐
         │                 │                 │
┌────────▼──────┐  ┌───────▼───────┐  ┌─────▼────────┐
│  dim_product  │  │   dim_store   │  │ dim_customer │
│  (商品维度)    │  │  (门店维度)    │  │  (客户维度)  │
└───────┬───────┘  └───────┬───────┘  └──────┬───────┘
        │                  │                  │
        │    ┌─────────────┼──────────────────┘
        │    │             │
        │    │    ┌────────▼────────┐
        │    │    │   fact_sales    │
        │    │    │   (销售事实表)   │
        │    │    └─────────────────┘
        │    │
        │    │    ┌─────────────────┐
        └────┼───▶│ fact_inventory  │
             │    │  (库存事实表)    │
             │    └─────────────────┘
             │

星型模型设计原则: 事实表位于中心,通过外键关联各个维度表。这种设计使得聚合查询高效且直观,非常适合 OLAP(联机分析处理)场景。


三、工程搭建

3.1 子模块 POM 配置

在父项目 carl-ai-agent 下创建子模块 ai-bi-helper,其 pom.xml 配置如下:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>com.carl</groupId>

    <artifactId>carl-ai-agent</artifactId>

    <version>1.0-SNAPSHOT</version>

  </parent>

  <artifactId>ai-bi-helper</artifactId>

  <packaging>jar</packaging>

  <name>ai-bi-helper</name>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

    <mysql.version>8.0.32</mysql.version>

  </properties>

  <dependencies>
    <!-- Spring Boot Web -->
    <dependency>
      <groupId>org.springframework.boot</groupId>

      <artifactId>spring-boot-starter-web</artifactId>

    </dependency>

    <!-- Spring Boot Mail -->
    <dependency>
      <groupId>org.springframework.boot</groupId>

      <artifactId>spring-boot-starter-mail</artifactId>

    </dependency>

    <!-- Spring Boot JDBC -->
    <dependency>
      <groupId>org.springframework.boot</groupId>

      <artifactId>spring-boot-starter-jdbc</artifactId>

    </dependency>

    <!-- MySQL 驱动 -->
    <dependency>
      <groupId>mysql</groupId>

      <artifactId>mysql-connector-java</artifactId>

      <version>${mysql.version}</version>

    </dependency>

    <!-- Redisson -->
    <dependency>
      <groupId>org.redisson</groupId>

      <artifactId>redisson</artifactId>

      <version>3.20.0</version>

    </dependency>

    <!-- Spring AI Tika 文档读取器 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>

      <artifactId>spring-ai-tika-document-reader</artifactId>

    </dependency>

    <!-- Spring Boot Test -->
    <dependency>
      <groupId>org.springframework.boot</groupId>

      <artifactId>spring-boot-starter-test</artifactId>

      <scope>test</scope>

    </dependency>

    <!-- Spring AI OpenAI 自动配置 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>

      <artifactId>spring-ai-autoconfigure-model-openai</artifactId>

    </dependency>

    <!-- Spring AI Chat Client 自动配置 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>

      <artifactId>spring-ai-autoconfigure-model-chat-client</artifactId>

    </dependency>

    <!-- Spring AI Redis 向量存储 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>

      <artifactId>spring-ai-starter-vector-store-redis</artifactId>

    </dependency>

    <!-- Spring AI RAG -->
    <dependency>
      <groupId>org.springframework.ai</groupId>

      <artifactId>spring-ai-rag</artifactId>

    </dependency>

    <!-- Spring AI Alibaba Graph Core -->
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>

      <artifactId>spring-ai-alibaba-graph-core</artifactId>

    </dependency>

  </dependencies>

</project>

依赖说明:

依赖 用途
spring-boot-starter-web Web 服务基础框架
spring-boot-starter-mail 邮件发送功能
spring-boot-starter-jdbc JDBC 数据库访问
mysql-connector-java MySQL 数据库驱动
redisson Redis 客户端,用于 Graph 状态持久化
spring-ai-tika-document-reader 文档解析,支持多种格式上传
spring-ai-autoconfigure-model-openai OpenAI 模型自动配置
spring-ai-starter-vector-store-redis Redis 向量数据库支持
spring-ai-rag RAG(检索增强生成)功能
spring-ai-alibaba-graph-core AI 工作流图引擎核心

四、项目配置

4.1 application.yml

spring:
  mail:
    host: smtp.qq.com
    port: 587
    username: 1525761478@qq.com
    password: efwvvempkglygcjg
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true

  application:
    name: ai-bi-helper

  data:
    redis:
      host: 127.0.0.1
      port: 6379
      database: 0

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/bi-helper?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
    username: root
    password: 123456

  ai:
    openai:
      api-key: sk-e836ecd744a14caca23c9d06b9bd1f1f
      base-url: https://dashscope.aliyuncs.com/compatible-mode
      embedding:
        options:
          model: text-embedding-v4
      chat:
        options:
          model: qwen3-max
          temperature: 0.6

    vectorstore:
      redis:
        initialize-schema: true
        index-name: custom-index
        prefix: custom-prefix

server:
  port: 8877

logging:
  file:
    path: ./logs
  level:
    root: info
    com.carl.ai.bi.helper: info

配置要点说明:

邮件配置:

配置项 说明
host SMTP 服务器地址,这里使用 QQ 邮箱
port SMTP 端口,587 为 TLS 加密端口
auth 开启身份验证
starttls 开启 TLS 加密传输

数据源配置:

配置项 说明
driver-class-name MySQL 8.0 驱动类
url 数据库连接地址,bi-helper 为数据库名
username/password 数据库认证信息

AI 模型配置:

配置项 说明
api-key 阿里云 DashScope API Key
base-url 阿里云兼容模式接口地址
embedding.model 嵌入模型 text-embedding-v4,用于文档向量化
chat.model 聊天模型 qwen3-max,用于 SQL 生成
temperature 温度参数 0.6,平衡创造性和确定性

向量数据库配置:

配置项 说明
initialize-schema 自动初始化向量索引结构
index-name 向量索引名称
prefix Redis Key 前缀

五、向量数据库与文档上传(RAG 技术实现)

5.1 RAG 技术概述

RAG(Retrieval-Augmented Generation,检索增强生成)是本系统的核心技术之一。其工作原理是:

  1. 文档上传与向量化:将数据库表结构文档上传,通过 Embedding 模型转换为向量,存储到向量数据库
  2. 查询时检索:当用户提出问题时,系统将问题也转换为向量,从向量数据库中检索最相关的表结构信息
  3. 上下文增强:将检索到的表结构信息作为上下文,提供给大模型生成 SQL

为什么需要 RAG? 大模型本身不知道企业具体的表结构,通过 RAG 技术,我们可以让大模型在生成 SQL 时"看到"正确的表结构信息,从而提高 SQL 的准确率。

5.2 启动 Redis 向量数据库

使用 Docker 启动 redis-stack-server,它内置了 RedisSearch 模块,支持向量搜索:

docker run --name redis-stack -d -p 6379:6379 redis/redis-stack-server

redis-stack-server 是 Redis 的增强版本,内置 RediSearch、RedisJSON、RedisVector 等模块,支持向量相似度搜索,适合作为轻量级向量数据库使用。

5.3 文档上传接口

@RestController
@RequestMapping(value = "/document")
@AllArgsConstructor
public class DocumentController {

    @Autowired
    private VectorStore vectorStore;

    @GetMapping("/upload")
    public R upload(MultipartFile file) {
        // 1. 使用 Tika 读取文档内容
        TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
        List<Document> documents = documentReader.get();

        // 2. 使用标题分割器对文档进行分块
        HeadingTextSplitter headingTextSplitter = new HeadingTextSplitter();
        List<Document> split = headingTextSplitter.split(documents);

        // 3. 将分块后的文档存入向量数据库
        vectorStore.add(split);

        return R.success();
    }
}

接口工作流程:

用户上传文档 ──▶ TikaDocumentReader 解析 ──▶ HeadingTextSplitter 分割 ──▶ VectorStore 存储
                                      │
                                      ▼
                              文本块 ──▶ Embedding 模型 ──▶ 向量 ──▶ Redis
步骤 组件 作用
文档解析 TikaDocumentReader 支持 Word、PDF、TXT 等多种格式
文本分割 HeadingTextSplitter 按标题层级分割,保留语义完整性
向量存储 VectorStore 自动调用 Embedding 模型并存储到 Redis

5.4 文档模板示例

上传的文档需要包含完整的表结构说明,以下是一个标准模板:

企业智能 BI 数据库表结构说明文档

1. 文档目的
本文件用于描述企业智能 BI 系统使用的数据库表结构,包括事实表、维度表及其字段说明,供后续 RAG 系统及 AI SQL Agent 使用。

2. 维度表结构

2.1 商品维度(dim_product)
字段名 | 类型 | 主键 | 描述
product_id | bigint | YES | 商品唯一 ID
product_name | varchar(255) |  | 商品名称
category_id | bigint | | 商品分类 ID
category_name | varchar(255) | | 商品分类名称
brand | varchar(255) | | 品牌
cost_price | decimal(10,2) | | 成本价
retail_price | decimal(10,2) | | 建议零售价

2.2 门店维度(dim_store)
字段名 | 类型 | 主键 | 描述
store_id | bigint | YES | 门店唯一 ID
store_name | varchar(255) | | 门店名称
province | varchar(100) | | 所在省份
city | varchar(100) | | 所在城市
address | varchar(255) | | 门店详细地址
open_date | date | | 开业日期

(其他维度表和事实表结构类似,详见完整文档)

六、自定义文本分割器

文本分割(Text Splitting)是 RAG 系统的关键环节。分割质量直接影响向量检索的召回准确度。本节实现两种自定义分割器。

6.1 HeadingTextSplitter — 标题分割器

基于文档标题层级进行分割,适用于结构化的 Word 文档。

public class HeadingTextSplitter extends TextSplitter {

    // 匹配 Word 文档的所有数字编号标题:1、1.1、2.3.4 等
    private static final Pattern HEADING_PATTERN =
            Pattern.compile("^\\d+(?:\\.\\d+)*\\s+.*$", Pattern.MULTILINE);

    @Override
    protected List<String> splitText(String text) {
        List<String> blocks = new ArrayList<>();

        if (text == null || text.trim().isEmpty()) {
            return blocks;
        }

        Matcher matcher = HEADING_PATTERN.matcher(text);
        List<Integer> starts = new ArrayList<>();

        // 1. 找出所有标题的位置
        while (matcher.find()) {
            starts.add(matcher.start());
        }

        // 2. 如果没有找到标题,整块返回
        if (starts.isEmpty()) {
            blocks.add(text.trim());
            return blocks;
        }

        // 3. 处理标题前的内容(文档前言部分)
        int firstStart = starts.get(0);
        if (firstStart > 0) {
            String preamble = text.substring(0, firstStart).trim();
            if (!preamble.isEmpty()) {
                blocks.add(preamble);
            }
        }

        // 4. 按标题分割文档
        for (int i = 0; i < starts.size(); i++) {
            int start = starts.get(i);
            int end = (i + 1 < starts.size()) ? starts.get(i + 1) : text.length();
            String block = text.substring(start, end).trim();
            if (!block.isEmpty()) {
                blocks.add(block);
            }
        }

        return blocks;
    }
}

分割原理:

输入文本:                          分割结果:
1. 概述                            ["1. 概述\n这是概述内容",
这是概述内容                       "1.1 子标题\n子标题内容",
1.1 子标题                         "2. 总结\n总结内容"]
子标题内容
2. 总结
总结内容

正则表达式说明:

模式 含义
^ 行首
\d+ 一个或多个数字
(?:\.\d+)* 零个或多个 .数字 组合(如 .1.2.3
\s+ 一个或多个空白字符
.*$ 任意字符到行尾
Pattern.MULTILINE 多行模式,^$ 匹配每行开头和结尾

6.2 ChineseTextSpliter — 通用中文文档分割器

基于中文标点符号进行句子级分割,适用于通用的中文文本内容。

public class ChineseTextSpliter extends TextSplitter {

    // 匹配中文句子结束标点:。!?;
    private static final Pattern SENTENCE_SPLIT_PATTERN =
            Pattern.compile("(?<=[。!?;])");

    private final int chunkSize;      // 每个块的最大字符数
    private final int chunkOverlap;   // 相邻块之间的重叠字符数

    public ChineseTextSpliter(int chunkSize, int chunkOverlap) {
        this.chunkSize = chunkSize;
        this.chunkOverlap = chunkOverlap;
    }

    @Override
    protected List<String> splitText(String text) {
        List<String> segments = new ArrayList<>();

        if (text == null || text.isBlank()) {
            return segments;
        }

        // 1. 按中文标点符号分割为句子
        String[] sentences = SENTENCE_SPLIT_PATTERN.split(text);
        StringBuilder currentChunk = new StringBuilder();

        // 2. 按 chunkSize 组合句子
        for (String sentence : sentences) {
            if (currentChunk.length() + sentence.length() > chunkSize) {
                // 当前块已满,保存
                segments.add(currentChunk.toString().trim());

                // 保留重叠部分,确保语义连续性
                String chunk = currentChunk.toString();
                if (chunk.length() > chunkOverlap) {
                    currentChunk = new StringBuilder(
                            chunk.substring(chunk.length() - chunkOverlap));
                } else {
                    currentChunk = new StringBuilder();
                }
            }
            currentChunk.append(sentence);
        }

        // 3. 处理最后一块
        if (currentChunk.length() > 0) {
            segments.add(currentChunk.toString().trim());
        }

        return segments;
    }
}

参数说明:

参数 说明 建议值
chunkSize 每个文本块的最大字符数 500-1000
chunkOverlap 相邻块之间的重叠字符数 50-100

分割效果示意:

原文:sentence1。sentence2!sentence3。sentence4;sentence5。
      └─────────────────────────────────────┘ chunkSize
                              └─────────────┘ overlap

为什么需要重叠(overlap)? 重叠可以确保跨块的语义信息不会丢失。例如,如果一个完整的句子恰好被分割在两个块中,重叠区域可以保证检索时至少有一个块包含完整上下文。


七、SQL 生成节点开发

7.1 GenSqlNode — SQL 生成节点

GenSqlNode 是整个系统的核心节点,负责将用户的自然语言问题转换为可执行的 SQL 语句。

@Slf4j
public class GenSqlNode implements NodeAction {

    private final ChatClient.Builder chatClientBuilder;
    private final VectorStore vectorStore;

    public GenSqlNode(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        this.chatClientBuilder = chatClientBuilder;
        this.vectorStore = vectorStore;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        String userInput = state.value("userInput", "");

        // 1. 构建查询重写器
        RewriteQueryTransformer queryTransformer = RewriteQueryTransformer.builder()
                .chatClientBuilder(chatClientBuilder)
                .build();

        // 2. 构建 RAG 检索增强顾问
        RetrievalAugmentationAdvisor retrievalAugmentationAdvisor =
                RetrievalAugmentationAdvisor.builder()
                        .queryTransformers(queryTransformer)
                        .documentRetriever(VectorStoreDocumentRetriever.builder()
                                .vectorStore(vectorStore)
                                .build())
                        .queryAugmenter(ContextualQueryAugmenter.builder()
                                .allowEmptyContext(true)
                                .build())
                        .build();

        // 3. 构建 ChatClient 并配置系统提示词
        ChatClient chatClient = chatClientBuilder.build();
        Flux<String> content = chatClient.prompt()
                .advisors(retrievalAugmentationAdvisor)
                .system("""
                    你是一名熟练的 SQL 专家,负责根据企业数据表结构生成 SQL 查询。用户将以自然语言提出数据需求。
                    严格基于用户提供的数据库表结构和表之间的关系来分析。
                    你有能力访问企业数据库表结构和表之间的关系(这些信息通过向量数据库检索得到)。请严格遵循以下规则:
                    1. 仅生成可执行 SQL,不输出任何与 SQL 无关的文字或解释。
                    2. 每个查询字段必须使用 AS 指定一个中文别名,中文别名应尽量简短、清晰、描述字段含义。例如:`user_name AS '用户名'`
                    3. 在生成 SQL 前,首先理解用户需求和检索到的表结构信息。
                    4. 根据表结构和关系选择合适的表和字段,生成可执行 SQL。
                    5. 输出 SQL 时,禁止使用 markdown 格式 ```sql 来输出。
                    6. 如果存在多种实现方式,优先选择最简洁、性能较优的写法。
                    7. 不要输出与 SQL 无关的文本或解释。
                    8. 不可凭空虚构数据,若数据不足,请返回空字符串。
                    """)
                .user(userInput)
                .stream().content();

        // 4. 收集流式响应
        StringBuilder sb = new StringBuilder();
        content.doOnNext(co -> sb.append(co))
               .doOnError(Throwable::printStackTrace)
               .blockLast();

        String ragResult = sb.toString();
        log.info("ragResult=[{}]", ragResult);

        return Map.of("ragResult", ragResult);
    }
}

7.2 节点工作流程

用户输入 ──▶ RewriteQueryTransformer 重写查询 ──▶ VectorStore 检索相关表结构
                                                      │
                                                      ▼
                                              相关表结构上下文
                                                      │
用户输入 + 表结构上下文 ──▶ ChatClient (LLM) ──▶ 生成 SQL
                                                      │
                                                      ▼
                                              ragResult 存入 State

7.3 核心组件解析

7.3.1 RewriteQueryTransformer

RewriteQueryTransformer 用于优化用户的原始查询,使其更适合向量检索。例如:

原始查询 重写后的查询
“帮我查一下” “查询销售数据和门店信息”
“上个月的情况” “2025年1月销售统计”
7.3.2 VectorStoreDocumentRetriever

从 Redis 向量数据库中检索与查询最相关的表结构文档片段。

7.3.3 ContextualQueryAugmenter

将检索到的表结构信息与原始查询合并,形成增强后的完整提示词。

7.4 System Prompt 设计要点

System Prompt 是指导大模型生成 SQL 的核心指令,包含以下关键约束:

规则编号 规则内容 目的
1 仅生成 SQL,不输出其他文字 确保输出纯净,便于后续解析
2 字段必须使用 AS 指定中文别名 提升报表可读性
3 理解需求和表结构后生成 确保 SQL 符合实际表结构
4 选择合适的表和字段 避免关联错误
5 禁止使用 markdown 格式 避免解析失败
6 优先选择简洁、性能优的写法 提升查询效率
7 不输出无关文本 保持输出纯净
8 不可虚构数据 确保数据准确性

八、SQL 执行节点与自动化报表生成

8.1 ExecSqlAndCreateExcelNode — SQL 执行与 Excel 生成节点

该节点负责执行 Graph 生成的 SQL 查询,并将结果转换为 Excel 报表文件。

@Slf4j
public class ExecSqlAndCreateExcelNode implements NodeAction {

    private final JdbcTemplate jdbcTemplate;

    public ExecSqlAndCreateExcelNode(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        // 1. 从 State 中获取生成的 SQL
        String sql = state.value("ragResult", "");

        // 2. 执行 SQL 查询
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
        log.info("rows=[{}]", JSONUtil.toJsonStr(rows));

        // 3. 生成 Excel 文件
        File excelFile = generateExcelFile(rows);

        return Map.of("excelFile", excelFile);
    }
}

8.2 generateExcelFile — Excel 文件生成方法

private File generateExcelFile(List<Map<String, Object>> rows) throws Exception {
    if (rows == null || rows.isEmpty()) {
        throw new RuntimeException("SQL 查询结果为空,无法生成 Excel");
    }

    XSSFWorkbook workbook = new XSSFWorkbook();
    XSSFSheet sheet = workbook.createSheet("result");

    // 1. 创建表头
    Map<String, Object> firstRow = rows.get(0);
    List<String> columns = new ArrayList<>(firstRow.keySet());
    Row header = sheet.createRow(0);
    for (int i = 0; i < columns.size(); i++) {
        header.createCell(i).setCellValue(columns.get(i));
    }

    // 2. 填充数据行
    for (int r = 0; r < rows.size(); r++) {
        Row row = sheet.createRow(r + 1);
        Map<String, Object> data = rows.get(r);
        for (int c = 0; c < columns.size(); c++) {
            Object value = data.get(columns.get(c));
            row.createCell(c).setCellValue(value == null ? "" : value.toString());
        }
    }

    // 3. 写入临时文件
    File file = File.createTempFile("report_", ".xlsx");
    try (FileOutputStream out = new FileOutputStream(file)) {
        workbook.write(out);
    }

    return file;
}

Excel 生成流程:

SQL 查询结果(List<Map<String, Object>>)
           │
           ▼
┌─────────────────────┐
│ 1. 提取列名(表头)  │
│    从第一行 Map 的   │
│    keySet 获取       │
└──────────┬──────────┘
           ▼
┌─────────────────────┐
│ 2. 创建表头行       │
│    第 0 行写入列名   │
└──────────┬──────────┘
           ▼
┌─────────────────────┐
│ 3. 逐行写入数据      │
│    遍历每行 Map 的   │
│    value 写入单元格  │
└──────────┬──────────┘
           ▼
┌─────────────────────┐
│ 4. 保存为临时文件    │
│    report_xxx.xlsx   │
└─────────────────────┘

技术要点: 使用 Apache POI 的 XSSFWorkbook 类创建 .xlsx 格式的 Excel 文件,支持大数据量。createTempFile 方法在系统临时目录创建文件,程序结束后可由系统清理。


九、邮件发送节点开发

9.1 SendEmailNode — 邮件发送节点

public class SendEmailNode implements NodeAction {

    private final EmailService emailService;

    public SendEmailNode(EmailService emailService) {
        this.emailService = emailService;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        // 1. 从 State 中获取 Excel 文件
        File excelFile = state.value("excelFile", File.class).get();

        // 2. 发送邮件(带附件)
        emailService.sendEmailWithAttachment("xxx@qq.com", excelFile);

        return Map.of();
    }
}

9.2 邮件发送流程

Excel 文件
    │
    ▼
┌──────────────────────┐
│ EmailService         │
│                      │
│ 1. 构建 MimeMessage  │
│ 2. 添加收件人地址     │
│ 3. 设置邮件主题       │
│ 4. 添加正文内容       │
│ 5. 添加 Excel 附件    │
│ 6. 发送邮件           │
└──────────────────────┘

十、图结构定义与配置

10.1 GraphConfig — 图配置类

Graph 配置类负责定义整个 AI 工作流的节点和边,以及状态持久化配置。

@Configuration
public class GraphConfig {

    @Resource
    private VectorStore vectorStore;

    @Resource
    private JdbcTemplate jdbcTemplate;

    @Resource
    private EmailService emailService;

    @Resource
    private RedissonClient redissonClient;

    @Bean
    public CompiledGraph stateGraph(ChatClient.Builder chatClientBuilder)
            throws GraphStateException {

        // 1. 定义 State 的 Key 更新策略
        KeyStrategyFactory keyStrategyFactory = () -> Map.of(
                "userInput", new ReplaceStrategy(),
                "ragResult", new ReplaceStrategy()
        );

        // 2. 创建状态图
        StateGraph helperGraph = new StateGraph("biHelperGraph", keyStrategyFactory);

        // 3. 添加节点
        helperGraph.addNode("genSqlNode",
                AsyncNodeAction.node_async(
                        new GenSqlNode(chatClientBuilder, vectorStore)));

        helperGraph.addNode("execSqlAndCreateExcelNode",
                AsyncNodeAction.node_async(
                        new ExecSqlAndCreateExcelNode(jdbcTemplate)));

        helperGraph.addNode("sendEmailNode",
                AsyncNodeAction.node_async(
                        new SendEmailNode(emailService)));

        // 4. 定义边的关系(工作流顺序)
        helperGraph.addEdge(StateGraph.START, "genSqlNode");
        helperGraph.addEdge("genSqlNode", "execSqlAndCreateExcelNode");
        helperGraph.addEdge("execSqlAndCreateExcelNode", "sendEmailNode");
        helperGraph.addEdge("sendEmailNode", StateGraph.END);

        // 5. 配置状态持久化(使用 Redis)
        SaverConfig saverConfig = SaverConfig.builder()
                .register(SaverEnum.REDIS.getValue(), new RedisSaver(redissonClient))
                .build();

        // 6. 编译图
        return helperGraph.compile(CompileConfig.builder()
                .saverConfig(saverConfig)
                .build());
    }
}

10.2 图的执行流程

                     ┌──────────────┐
                     │    START     │
                     └──────┬───────┘
                            │
                            ▼
                     ┌──────────────┐
                     │   genSqlNode  │  ◀── 生成 SQL
                     │  (SQL生成节点) │
                     └──────┬───────┘
                            │
                            ▼
              ┌─────────────────────────────┐
              │  execSqlAndCreateExcelNode  │  ◀── 执行SQL + 生成Excel
              │    (SQL执行与报表生成节点)    │
              └─────────────┬───────────────┘
                            │
                            ▼
                     ┌──────────────┐
                     │  sendEmailNode │  ◀── 发送邮件
                     │  (邮件发送节点) │
                     └──────┬───────┘
                            │
                            ▼
                     ┌──────────────┐
                     │     END      │
                     └──────────────┘

10.3 状态持久化说明

SaverConfig saverConfig = SaverConfig.builder()
        .register(SaverEnum.REDIS.getValue(), new RedisSaver(redissonClient))
        .build();

使用 Redis 作为 Graph 状态的持久化存储,支持以下功能:

功能 说明
断点续传 工作流执行中断后可以从上次位置恢复
状态回溯 可以查看历史执行状态和中间结果
并发控制 通过 Redis 锁机制保证状态一致性

10.4 Redisson 配置

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Value("${spring.data.redis.database}")
    private int database;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 使用 JSON 序列化
        config.setCodec(new JsonJacksonCodec());
        // 配置单节点模式
        config.useSingleServer()
                .setAddress("redis://" + redisHost + ":" + redisPort)
                .setDatabase(database);
        return Redisson.create(config);
    }
}

配置说明:

配置项 说明
JsonJacksonCodec 使用 JSON 格式进行序列化,便于调试和查看
useSingleServer() 单节点 Redis 模式
setAddress() Redis 服务器地址
setDatabase() 使用的数据库编号

十一、业务代码与测试

11.1 TestController — 测试控制器

@RestController
@AllArgsConstructor
@RequestMapping("/test")
public class TestController {

    @Resource
    private CompiledGraph stateGraph;

    @GetMapping("/test1")
    public R<Map<String, Object>> test1() {
        // 1. 构建运行配置
        RunnableConfig runnableConfig = RunnableConfig.builder()
                .threadId(IdUtil.simpleUUID())
                .build();

        // 2. 调用 Graph,传入用户输入
        Optional<OverAllState> overAllState = stateGraph.call(
                Map.of("userInput",
                        "请帮我统计每个门店在 2025 年 1 月份的销售总金额和销售商品数量。"),
                runnableConfig);

        // 3. 获取执行结果
        Map<String, Object> stringObjectMap =
                overAllState.map(OverAllState::data).orElse(Map.of());

        return R.success(stringObjectMap);
    }
}

11.2 调用流程解析

HTTP GET /test/test1
       │
       ▼
┌─────────────────────────────┐
│ 1. 生成唯一 threadId        │
│    用于标识本次对话会话      │
└─────────────┬───────────────┘
              │
              ▼
┌─────────────────────────────┐
│ 2. stateGraph.call()        │
│    传入 userInput 和配置    │
│    触发完整工作流执行        │
└─────────────┬───────────────┘
              │
              ▼
┌─────────────────────────────┐
│ 3. 工作流执行               │
│    genSqlNode ──▶           │
│    execSqlAndCreateExcelNode │
│    ──▶ sendEmailNode        │
└─────────────┬───────────────┘
              │
              ▼
┌─────────────────────────────┐
│ 4. 返回执行结果             │
│    包含 ragResult 等状态数据 │
└─────────────────────────────┘

11.3 测试示例

接口调用示例:

curl http://localhost:8877/test/test1

预期执行效果:

  1. SQL 生成:Graph 根据 RAG 检索到的表结构,生成如下 SQL:
SELECT
    ds.store_name AS '门店名称',
    SUM(fs.sales_amount) AS '销售总金额',
    SUM(fs.quantity) AS '销售商品数量'
FROM fact_sales fs
JOIN dim_store ds ON fs.store_id = ds.store_id
JOIN dim_date dd ON fs.date_id = dd.date_id
WHERE dd.year = 2025 AND dd.month = 1
GROUP BY ds.store_name
  1. SQL 执行:JdbcTemplate 执行 SQL 并获取结果集
  2. Excel 生成:将结果集写入 report_xxx.xlsx
  3. 邮件发送:将 Excel 文件作为附件发送到指定邮箱

十二、常用 SQL 查询示例

以下 SQL 示例展示了 BI 系统中常见的分析场景,可作为系统测试用例:

12.1 类别销售统计

-- 统计 2024 年各产品类别的销售总金额,并按金额从高到低排序
SELECT
    p.category AS 产品类别,
    SUM(s.total_amount) AS 销售总金额
FROM Sales_Orders s
JOIN Products p ON s.product_id = p.product_id
WHERE YEAR(s.order_date) = 2024
GROUP BY p.category
ORDER BY 销售总金额 DESC;

12.2 客户消费分析

-- 查询某个客户 2024 年的订单数量及消费总额
SELECT
    c.customer_name AS 客户名称,
    COUNT(s.order_id) AS 订单数量,
    SUM(s.total_amount) AS 消费总额
FROM Sales_Orders s
JOIN Customers c ON s.customer_id = c.customer_id
WHERE c.customer_id = 1001 AND YEAR(s.order_date) = 2024
GROUP BY c.customer_name;

12.3 热销产品排行

-- 查询销量最高的前 10 个产品及其所属类别
SELECT
    p.product_name AS 产品名称,
    p.category AS 类别,
    SUM(s.quantity) AS 销量
FROM Sales_Orders s
JOIN Products p ON s.product_id = p.product_id
GROUP BY p.product_name, p.category
ORDER BY 销量 DESC
LIMIT 10;

12.4 月度销售趋势

-- 计算今年每个月的销售总额,并按月份升序排列
SELECT
    DATE_FORMAT(order_date, '%Y-%m') AS 月份,
    SUM(total_amount) AS 月销售额
FROM Sales_Orders
WHERE YEAR(order_date) = YEAR(CURDATE())
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY DATE_FORMAT(order_date, '%Y-%m');

12.5 大额订单查询

-- 查询订单金额超过 10,000 的大额订单
SELECT
    s.order_id AS 订单ID,
    c.customer_name AS 客户名称,
    p.product_name AS 产品名称,
    s.total_amount AS 订单金额,
    s.order_date AS 下单日期
FROM Sales_Orders s
JOIN Customers c ON s.customer_id = c.customer_id
JOIN Products p ON s.product_id = p.product_id
WHERE s.total_amount > 10000
ORDER BY s.total_amount DESC;

12.6 客户复购分析

-- 统计每个客户的复购次数(下单次数 >= 2)
SELECT
    c.customer_name AS 客户名称,
    COUNT(s.order_id) AS 订单次数
FROM Sales_Orders s
JOIN Customers c ON s.customer_id = c.customer_id
GROUP BY c.customer_name
HAVING COUNT(s.order_id) >= 2
ORDER BY 订单次数 DESC;

12.7 销量趋势分析(CTE 使用)

-- 查询近 30 天销量下降的产品
WITH last30 AS (
    SELECT product_id, SUM(quantity) AS qty
    FROM Sales_Orders
    WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
    GROUP BY product_id
),
prev30 AS (
    SELECT product_id, SUM(quantity) AS qty
    FROM Sales_Orders
    WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
      AND order_date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
    GROUP BY product_id
)
SELECT
    p.product_name AS 产品名称,
    last30.qty AS 最近30天销量,
    prev30.qty AS30天销量,
    (last30.qty - prev30.qty) AS 变化
FROM last30
JOIN prev30 ON last30.product_id = prev30.product_id
JOIN Products p ON p.product_id = last30.product_id
WHERE last30.qty < prev30.qty
ORDER BY 变化 ASC;

12.8 地区销售排名

-- 查看每个地区的客户数及该地区今年销售额排名
SELECT
    c.region AS 地区,
    COUNT(DISTINCT c.customer_id) AS 客户数量,
    SUM(s.total_amount) AS 销售额
FROM Customers c
JOIN Sales_Orders s ON c.customer_id = s.customer_id
WHERE YEAR(s.order_date) = YEAR(CURDATE())
GROUP BY c.region
ORDER BY 销售额 DESC;

12.9 移动平均分析

-- 查询每天 GMV 的 7 日移动平均值
SELECT
    stat_date AS 日期,
    total_amount AS 当日GMV,
    AVG(total_amount) OVER (
        ORDER BY stat_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS GMV_7日移动平均
FROM Sales_Daily_Stats
ORDER BY stat_date;

十三、系统总结与扩展方向

13.1 系统核心能力总结

能力 实现方式 效果
自然语言转 SQL Spring AI + RAG + LLM 业务人员无需懂 SQL
智能表结构匹配 向量数据库检索 自动找到正确的表和字段
自动化报表 Apache POI + 邮件 一键生成并发送报表
工作流编排 Spring AI Alibaba Graph 灵活的节点编排和持久化

13.2 可扩展方向

  1. SQL 评估与自纠正:增加 SQL 评估节点,对生成的 SQL 进行语法和逻辑校验,发现问题后循环修正
  2. 多数据源支持:扩展支持 Oracle、PostgreSQL、ClickHouse 等多种数据库
  3. 可视化图表:在 Excel 基础上增加 ECharts 等可视化图表生成
  4. 权限控制:增加数据权限管控,不同角色只能访问授权的数据
  5. 对话式交互:支持多轮对话,用户可以基于上次结果继续追问
  6. 数据洞察:增加 LLM 对查询结果的智能分析和洞察生成功能

更多推荐