AI BI Helper 开发实录 01:文本向量与 Milvus 向量数据库集成
跟着课程《Java转AI高薪领域必备——从0到1打通生产级AI Agent开发》做的实战记录。技术栈:Spring AI Alibaba 1.1.2.0 + Milvus 3.0 + Redis(备选)+ MySQL 8.x。项目代号 ai-bi-helper,这是第 01 期,聚焦文本向量技术,打通文档上传、自定义分割、向量存储全流程。
AI BI Helper 功能分为两大部分:一是文本向量,用于文档语义检索;二是Graph 工作流编排,用于实现智能数据分析和决策。本期先搞定第一部分——文本向量,把文档上传、自定义标题分割、向量存储(Redis → Milvus)这套流程跑通。
一. 基础环境搭建
1.1 新建项目 ivy-service-ai-bi-helper-bootstrap
在 ivy-services 中新增 ivy-service-ai-bi-helper-bootstrap 项目。这次项目比较简单,就不再新建 models 模块了,代码都放在一起,结构更紧凑。
1.2 添加 pom 依赖
核心依赖包括:数据库支持、Graph 工作流、阿里百炼大模型。
<dependencies>
<dependency>
<groupId>vip.wayhua.ivy.ai</groupId>
<artifactId>ivy-starter-core-db</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-starter-model-ollama</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<!-- 阿里百炼大模型服务平台 -->
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
</dependencies>
补充说明:Ivy 项目已经封装了很多常用 starter,比如
ivy-starter-core-db包含了 MySQL 驱动、tk-mybatis、Druid 连接池等,开箱即用。这也是我一直强调的——一定要把常用的东西封装成 starter,避免重复造轮子。
1.3 新建 Spring Boot 启动类
package vip.wayhua.ivy.ai.bi;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;
import tk.mybatis.spring.annotation.MapperScan;
import vip.wayhua.ivy.ai.core.utils.StringUtils;
@MapperScan("vip.wayhua.ivy.ai.*.dao")
@SpringBootApplication(scanBasePackages = "vip.wayhua.ivy")
public class BIHelperApplication {
private static final Logger log= LoggerFactory.getLogger(BIHelperApplication.class);
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(BIHelperApplication.class, args);
ConfigurableEnvironment env = run.getEnvironment();
String appName = "AI BI Helper后台接口";
String port = env.getProperty("server.port");
String slog = StringUtils.banner(appName, port, true);
log.error(slog);
}
}
1.4 application.yml 配置
server:
port: 8910
logging: # SpringBoot日志配置
level:
# ChatClient 监控日志的等级
org.springframework.ai.chat.client.advisor: debug
ivy:
mysql:
ip: 192.168.55.140
port: 3306
driverClassName: com.mysql.cj.jdbc.Driver
database: ai-agent
username: root
password: Ivy@2024
spring:
ai:
dashscope:
# 阿里百炼大模型服务平台 API-Key申请文档
# https://help.aliyun.com/zh/model-studio/get-api-key
api-key: sk-b956cd85b6cd****
chat:
options:
model: qwen3.7-max
# ollama:
# base-url: http://192.168.55.130:11434
# chat:
## model: deepseek-r1:7b
# model: qwen3.5:2b
datasource:
url: jdbc:mysql://${ivy.mysql.ip}:${ivy.mysql.port}/${ivy.mysql.database}?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false&autoReconnect=true&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&nullCatalogMeansCurrent=true
username: ${ivy.mysql.username}
password: ${ivy.mysql.password}
db-type: mysql
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: ${ivy.mysql.driverClassName}
druid:
initial-size: 10
max-active: 100
min-idle: 10
max-wait: 60000
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: true
test-on-return: false
db-type: mysql
mail:
host: smtp.126.com
port: 465
username: wayhua@126.com
password: CDnDp94-***
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
socketFactory:
port: 465
class: javax.net.ssl.SSLSocketFactory
fallback: false
mybatis:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapperLocations: classpath*:mapper/*Mapper.xml
# 开启接口文档
nextdoc4j:
enabled: true
1.5 测试 Controller
package vip.wayhua.ivy.ai.bi.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.wayhua.ivy.ai.core.dto.R;
@Tag(name = "测试Controller")
@RestController
@RequestMapping("/test")
public class TestController {
@Operation(summary = "测试", description = "测试")
@GetMapping("/test")
public R test() {
return R.success();
}
}
1.6 运行测试
启动服务后访问接口文档地址:
http://127.0.0.1:8910/doc.html#/document/all/%E6%B5%8B%E8%AF%95Controller/test
1.7 小结
基础环境搭建比较顺利,能复用的都封装过了,直接引入即可。这也是我一直强调的:一定要将常用的东西进行封装,形成自己的 starter 体系,这样新项目才能快速上手。
二. 文本向量 - Redis 版本
2.1 什么是文本向量
先搞清楚核心概念,引用豆包的解释:
文本向量:把自然语言(句子、段落、词语)转换成固定长度的浮点数数组,让计算机能理解文字语义。
文字本身是字符串,机器无法直接计算相似度;转为向量后,就可以用数学方法衡量语义关系。
简单说就是:把文字变成数字数组,方便计算机做语义匹配和相似度计算。
2.2 向量模型选择
本来想使用硅基流动的向量服务(申请了代金券),但一直无法正常使用,最终选择了阿里百炼的文本向量模型:
模型名称:qwen3.7-text-embedding
2.3 配置 Embedding
在 application.yml 中添加向量模型配置:
spring:
ai:
embedding:
api-key: sk-b956cd85b6**
options:
model: qwen3.7-text-embedding
dimensions: 1024
补充说明:
dimensions: 1024指定向量维度为 1024,这是该模型的默认维度。维度越高,语义表达越精细,但存储和计算成本也越高。
2.4 添加 Redis 向量存储依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
</dependency>
2.5 配置 Redis 向量存储
spring:
ai:
vectorstore:
redis:
index-name: bi-helper
prefix: bi-helper-prefix
initialize-schema: true
2.6 增加文档上传接口
package vip.wayhua.ivy.ai.bi.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import vip.wayhua.ivy.ai.core.dto.R;
@Tag(name = "文档上传Controller")
@RestController
@RequestMapping("/document")
public class DocumentController {
@Autowired
DocumentService documentService;
@PostMapping("/upload")
public R upload(@RequestParam("file") MultipartFile file) {
documentService.saveInVector(file);
return R.success();
}
}
2.7 DocumentServiceImpl 实现
2.7.1 添加 Tika 文档解析依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
补充说明:Tika 是 Apache 的文档解析工具,支持 PDF、Word、Excel 等多种格式,Spring AI 对其做了封装,使用非常方便。
2.7.2 代码实现(基础版本)
package vip.wayhua.ivy.ai.bi.service.impl;
import jakarta.annotation.Resource;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import vip.wayhua.ivy.ai.bi.service.DocumentService;
import java.util.List;
@Service
public class DocumentServiceImpl implements DocumentService {
@Resource
VectorStore vectorStore;
/****
* 业务逻辑
* 1.读取文档
* 2.进行分档的拆分,拆成不同的块
* 3.借助向量大模型,将文本转成向量
* 4.入库
* @param file
*/
@Override
public void saveInVector(MultipartFile file) {
// 1.读取文档,2.进行分档的拆分,拆成不同的块
TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
List<Document> documents = documentReader.get();
//4.入库
vectorStore.add(documents);
}
}
2.7.3 测试
查看 Redis 中的向量数据:
踩坑记录:使用 Redis Plus 查看向量数据会报错,建议使用 Another Redis Desktop Manager:
2.7.4 问题发现
默认的文本拆分策略是按固定字符数拆分,导致所有文档内容混在一起,语义不连贯。比如一个标题下的内容被拆到了两个块里,后续检索时上下文就不完整了。
2.8 自定义标题分隔器 HeadingBasedSplitter
针对上述问题,自定义一个基于标题的文本分割器,按文档中的数字标题(如 "1."、"1.1"、"2.3.1")进行拆分,确保每个块都是一个完整的章节。
package vip.wayhua.ivy.ai.bi.splitter;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 基于标题的文本分割器
* <p>
* 继承 Spring AI 的 {@link org.springframework.ai.transformer.splitter.TextSplitter},通过重写 {@link #splitText(String)} 方法,
* 识别文本中形如 "1."、"1.1"、"2.3.1" 的数字标题行,并按照最低层级标题将文档切分为独立的块。
* <p>
* 主要特性:
* <ul>
* <li>自动识别多级数字标题(例如:1.、1.1、2.1.3)。</li>
* <li>通过选项 {@code prependHeadingPath} 可将当前标题的完整层级路径(如 "1. 概述 > 1.1 背景")
* 前置到每个块的内容开头,帮助后续检索理解上下文。</li>
* <li>可配置最小块大小,过滤掉内容过短的空章节。</li>
* </ul>
* <p>
* 注意:此拆分器仅处理数字编号的标题,不支持中文数字(如"一、")或其他自定义格式,
* 如有需要可自行扩展匹配模式。
**/
public class HeadingBasedSplitter extends TextSplitter {
/**
* 用于匹配标题行的正则表达式。
* <ul>
* <li>{@code ^} —— 行首</li>
* <li>{@code (\d+(?:\.\d+)*)} —— 数字编号,如 "1"、"1.1"、"2.3.1"</li>
* <li>{@code \s+} —— 至少一个空白字符(分隔编号与标题文字)</li>
* <li>{@code (.*)$} —— 标题文字,直到行尾</li>
* </ul>
* 使用 {@link Pattern#MULTILINE} 模式,使 ^ 和 $ 匹配每一行的开头和结尾。
*/
private static final Pattern HEADING_PATTERN =
Pattern.compile("^(\\d+(?:\\.\\d+)*)\\s+(.*)$", Pattern.MULTILINE);
/**
* 是否将标题路径前置到每个块内容的最前面
*/
private final boolean prependHeadingPath;
/**
* 允许的最小块字符数,小于此值的章节将被忽略
*/
private final int minChunkSize;
/**
* 构造一个标题拆分器。
*
* @param prependHeadingPath 是否在块内容中前置完整的标题路径(例如 "1. 概述 > 1.2 背景")
* @param minChunkSize 最小块大小,低于此大小的标题小节将被丢弃
*/
public HeadingBasedSplitter(boolean prependHeadingPath, int minChunkSize) {
this.prependHeadingPath = prependHeadingPath;
this.minChunkSize = minChunkSize;
}
/**
* 对单段文本进行基于标题的拆分。
* <p>
* 实现逻辑:
* <ol>
* <li>扫描全文,找出所有匹配标题模式的位置。</li>
* <li>若无任何标题,且整个文本长度不少于最小块大小,则原样作为一个块返回。</li>
* <li>若存在标题,则遍历每个标题:
* <ul>
* <li>维护一个路径栈(双端队列),根据当前标题的层级深度调整栈中内容。</li>
* <li>提取从前一个标题结束到下一个标题开始之间的正文部分。</li>
* <li>根据配置决定是否将标题路径前置到正文前。</li>
* <li>过滤掉正文长度小于最小块大小的章节。</li>
* </ul>
* </li>
* </ol>
*
* @param text 待切分的原始文本
* @return 切分后的字符串列表,每个元素对应一个标题下的内容块
*/
@Override
protected List<String> splitText(String text) {
if (!StringUtils.hasText(text)) {
return List.of();
}
// 收集所有标题匹配的位置信息
Matcher matcher = HEADING_PATTERN.matcher(text);
List<HeadingMatch> matches = new ArrayList<>();
while (matcher.find()) {
String numberStr = matcher.group(1); // 如 "1.1"
String titleText = matcher.group(2) != null ? matcher.group(2).trim() : "";
matches.add(new HeadingMatch(matcher.start(), matcher.end(), numberStr, titleText));
}
// 没有标题:整个文本视为一个块(需满足最小长度)
if (matches.isEmpty()) {
return text.trim().length() >= minChunkSize ? List.of(text.trim()) : List.of();
}
List<String> chunks = new ArrayList<>();
// 使用双端队列维护当前标题的完整路径
Deque<String> pathStack = new LinkedList<>();
for (int i = 0; i < matches.size(); i++) {
HeadingMatch match = matches.get(i);
int currentDepth = match.numberStr.split("\\.").length; // 层级深度 = 数字编号中小数点数量 + 1
String headingFull = match.numberStr + " " + match.titleText;
// 1. 更新路径栈:弹出层级大于等于当前深度的旧标题,保证栈顶为直接上级
while (pathStack.size() >= currentDepth) {
pathStack.pollLast();
}
pathStack.offerLast(headingFull);
// 2. 确定正文范围:当前标题结束位置 到 下一个标题开始位置(或文档结尾)
int bodyStart = match.endIndex;
int bodyEnd = (i + 1 < matches.size()) ? matches.get(i + 1).startIndex : text.length();
String body = text.substring(bodyStart, bodyEnd).trim();
// 3. 过滤掉内容过短的章节(如纯标题段)
if (body.length() < minChunkSize) {
// 但仍需保持路径栈已更新(因为它可能作为后续更深层标题的上级)—— 此处通过 continue 可以保留栈状态
continue;
}
// 4. 组装最终块内容
String headingPath = String.join(" > ", pathStack); // 如 "1. 概述 > 1.1 背景"
String chunkContent = prependHeadingPath ? headingPath + "\n" + body : body;
chunks.add(chunkContent);
}
return chunks;
}
/**
* 内部类,记录单个标题匹配的关键信息。
*/
private static class HeadingMatch {
/**
* 标题在原文中的起始索引
*/
final int startIndex;
/**
* 标题结束索引(即正文开始位置)
*/
final int endIndex;
/**
* 纯数字编号,例如 "1.2"
*/
final String numberStr;
/**
* 标题文字部分,不包含编号
*/
final String titleText;
HeadingMatch(int start, int end, String numberStr, String title) {
this.startIndex = start;
this.endIndex = end;
this.numberStr = numberStr;
this.titleText = title;
}
}
}
2.9 集成自定义分隔器
修改 DocumentServiceImpl,引入自定义分割器:
public void saveInVector(MultipartFile file) {
// 1.读取文档,2.进行分档的拆分,拆成不同的块
TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
List<Document> documents = documentReader.get();
//3.
HeadingBasedSplitter splitter = new HeadingBasedSplitter(true,50);
List<Document> split = splitter.split(documents);
//4.入库
vectorStore.add(split);
}
注意事项:查看源码发现标题格式要求是 数字 + 空格 + 标题名称(如 "1 概述"、"1.1 背景"),这样才能被正则匹配到。
2.10 测试带分隔的效果
三. 文本向量 - Milvus 数据库版本
Redis 作为向量数据库适合小规模场景,生产环境推荐使用专业的向量数据库。这里选择 Milvus 3.0,它是一款开源的云原生向量数据库,性能优异,社区活跃。
3.1 移除 Redis 向量存储依赖
将 Redis 向量存储的引用注释掉(application.yml 中的配置可以保留,不会产生影响):
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-starter-vector-store-redis</artifactId>-->
<!-- </dependency>-->
3.2 安装 Milvus 3.0
参考官方文档:milvus.io/docs/zh/qui…
3.2.1 下载 docker-compose.yml
mkdir milvus
cd milvus/
yum install wget -y
wget https://github.com/milvus-io/milvus/releases/download/v3.0-beta/milvus-standalone-docker-compose.yml -O docker-compose.yml
踩坑记录:GitHub 有时无法访问,如果下载失败,可直接使用下面的配置内容。
3.2.2 docker-compose.yml 配置
version: '3.5'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.25
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2024-12-18T13-15-44Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v3.0-beta
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
MQ_TYPE: woodpecker
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
networks:
default:
name: milvus
3.2.3 启动 Milvus
docker-compose up -d
3.2.4 权限问题处理
启动后遇到权限错误:
错误信息:
["failed to mkdir"] [localStoragePath=/var/lib/milvus/data/] [error="mkdir /var/lib/milvus/data/: permission denied"]
解决方案:Milvus 默认以 UID 1000 运行,需要给数据目录设置正确的权限:
# milvus 默认运行uid:1000
mkdir -p /data/milvus
chmod -R 777 /export/server/milvus/volumes/milvus/
# 或者指定uid
chown -R 1000:1000 /export/server/milvus/volumes/milvus/
最好是在当前目录执行:
chmod -R 777 ./volumes/milvus/
# 或者指定uid
chown -R 1000:1000 ./volumes/milvus/
3.2.5 重启 Milvus
docker-compose stop
./autorun.sh
访问 Milvus Web UI:
http://192.168.55.130:9091/webui/
3.3 添加 Milvus 向量存储依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency>
3.4 配置 Milvus
3.4.1 初始配置
Spring:
ai:
vectorstore:
milvus:
client:
host: 192.168.55.130
port: 19530
collection-name: bi-helper
database-name: default
3.4.2 踩坑记录:Collection 名称格式错误
启动时报错:
Fail to describe collection 'bi-helper' in database 'default' failed, error code: 1100, reason: Invalid collection name: bi-helper. collection name can only contain numbers, letters and underscores: invalid parameter
[18:22:37.955] ERROR i.m.c.AbstractMilvusGrpcClient - InsertRequest collectionName:bi-helper failed! Exception:{}
io.milvus.exception.ServerException: Invalid collection name: bi-helper. collection name can only contain numbers, letters and underscores: invalid parameter
问题分析:Milvus 的 collection 名称只能包含数字、字母和下划线,不能包含连字符 -。
解决方案:将 bi-helper 改为 biHelper,并添加自动建表配置:
spring:
ai:
vectorstore:
milvus:
client:
host: 192.168.55.130
port: 19530
collection-name: biHelper
database-name: default
id-field-name: id
auto-id: false
embedding-dimension: 1024
initialize-schema: true
补充说明:
embedding-dimension: 1024:与 Embedding 模型的维度保持一致initialize-schema: true:自动创建 collection schema,无需手动建表
3.5 测试 Milvus 向量存储
已成功插入 8 条向量数据!
3.6 安装 Milvus 可视化工具 Attu
Milvus 3.0 默认没有自带图形化管理界面,需要单独安装 Attu:
重要提示:尽可能使用正式版本(如 2.6.5),beta 版本可能会有访问问题。正式版本可以正常查询和管理数据。
四. 数据库准备
为后续的 BI 分析和 Graph 工作流准备业务数据。
4.1 创建数据库
创建数据库 bi-helper:
4.2 创建数据库表
-- ============================================
-- 维度表:商品维度 (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='商品维度表';
-- ============================================
-- 维度表:门店维度 (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='门店维度表';
-- ============================================
-- 维度表:时间维度 (dim_date)
-- ============================================
DROP TABLE IF EXISTS dim_date;
CREATE TABLE 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='时间维度表';
-- ============================================
-- 维度表:客户维度 (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='客户维度表';
-- ============================================
-- 事实表:销售事实表 (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='销售事实表';
-- ============================================
-- 事实表:库存事实表 (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='库存事实表';
4.3 创建数据
-- 扩展商品维度数据
INSERT INTO dim_product (product_id, product_name, category_id, category_name, brand, cost_price, retail_price)
VALUES (6, 'iPad Air', 102, '平板电脑', 'Apple', 3500.00, 4599.00),
(7, '小米平板6', 102, '平板电脑', 'Xiaomi', 1800.00, 2499.00),
(8, '索尼电视 75寸', 103, '电视', 'Sony', 5200.00, 6999.00),
(9, '海尔冰箱 450L', 104, '家电', 'Haier', 2600.00, 3299.00),
(10, '美的洗衣机 10kg', 104, '家电', 'Midea', 2200.00, 2899.00),
(11, '荣耀 Magic6', 101, '手机', 'Honor', 3800.00, 4999.00),
(12, 'OPPO Find X7', 101, '手机', 'OPPO', 3600.00, 4799.00),
(13, 'vivo X200', 101, '手机', 'vivo', 3400.00, 4599.00),
(14, 'MacBook Air M3', 102, '笔记本', 'Apple', 7800.00, 9999.00),
(15, '惠普战X', 102, '笔记本', 'HP', 5200.00, 6999.00),
(16, '雷蛇游戏本 16', 102, '笔记本', 'Razer', 9000.00, 12999.00),
(17, '戴尔 XPS 13', 102, '笔记本', 'Dell', 6800.00, 8999.00),
(18, '海信电视 55寸', 103, '电视', 'Hisense', 2100.00, 2799.00),
(19, 'TCL 50寸电视', 103, '电视', 'TCL', 1800.00, 2499.00),
(20, '科沃斯扫地机器人 T10', 104, '家电', 'Ecovacs', 2400.00, 3299.00);
-- 扩展门店维度
INSERT INTO dim_store (store_id, store_name, province, city, address, open_date)
VALUES (1006, '南京新街口店', '江苏', '南京市', '新街口中央路18号', DATE ('2021-02-12')),
(1007, '成都春熙路店', '四川', '成都市', '锦江区春熙路66号', DATE ('2020-08-07')),
(1008, '武汉光谷店', '湖北', '武汉市', '东湖高新区光谷大道88号', DATE ('2021-10-03')),
(1009, '西安小寨店', '陕西', '西安市', '雁塔区小寨路88号', DATE ('2019-12-30')),
(1010, '重庆解放碑店', '重庆', '重庆市', '渝中区解放碑CBD', DATE ('2020-03-11')),
(1011, '苏州园区店', '江苏', '苏州市', '工业园区金鸡湖大道88号', DATE ('2022-01-15')),
(1012, '长沙五一广场店', '湖南', '长沙市', '芙蓉区五一大道66号', DATE ('2022-05-10')),
(1013, '天津滨江道店', '天津', '天津市', '和平区滨江道99号', DATE ('2020-06-18')),
(1014, '青岛万象城店', '山东', '青岛市', '市南区香港中路8号', DATE ('2021-09-28')),
(1015, '沈阳太原街店', '辽宁', '沈阳市', '和平区太原街66号', DATE ('2019-04-03'));
-- 扩展日期维度(2025-01-04 ~ 2025-01-31)
INSERT INTO dim_date (date_id, year, quarter, month, day, weekday)
VALUES ('2025-01-04', 2025, 1, 1, 4, 6),
('2025-01-05', 2025, 1, 1, 5, 7),
('2025-01-06', 2025, 1, 1, 6, 1),
('2025-01-07', 2025, 1, 1, 7, 2),
('2025-01-08', 2025, 1, 1, 8, 3),
('2025-01-09', 2025, 1, 1, 9, 4),
('2025-01-10', 2025, 1, 1, 10, 5),
('2025-01-11', 2025, 1, 1, 11, 6),
('2025-01-12', 2025, 1, 1, 12, 7),
('2025-01-13', 2025, 1, 1, 13, 1),
('2025-01-14', 2025, 1, 1, 14, 2),
('2025-01-15', 2025, 1, 1, 15, 3),
('2025-01-16', 2025, 1, 1, 16, 4),
('2025-01-17', 2025, 1, 1, 17, 5),
('2025-01-18', 2025, 1, 1, 18, 6),
('2025-01-19', 2025, 1, 1, 19, 7),
('2025-01-20', 2025, 1, 1, 20, 1),
('2025-01-21', 2025, 1, 1, 21, 2),
('2025-01-22', 2025, 1, 1, 22, 3),
('2025-01-23', 2025, 1, 1, 23, 4),
('2025-01-24', 2025, 1, 1, 24, 5),
('2025-01-25', 2025, 1, 1, 25, 6),
('2025-01-26', 2025, 1, 1, 26, 7),
('2025-01-27', 2025, 1, 1, 27, 1),
('2025-01-28', 2025, 1, 1, 28, 2),
('2025-01-29', 2025, 1, 1, 29, 3),
('2025-01-30', 2025, 1, 1, 30, 4),
('2025-01-31', 2025, 1, 1, 31, 5);
-- 扩展客户维度
INSERT INTO dim_customer (customer_id, customer_name, gender, age, city, province)
VALUES (4, '赵六', '男', 29, '深圳', '广东'),
(5, '孙七', '女', 24, '南京', '江苏'),
(6, '钱八', '男', 31, '成都', '四川'),
(7, '周九', '女', 27, '杭州', '浙江'),
(8, '吴十', '男', 35, '武汉', '湖北'),
(9, '郑一', '女', 22, '天津', '天津'),
(10, '王二', '男', 33, '重庆', '重庆'),
(11, '李三', '男', 30, '西安', '陕西'),
(12, '张四', '女', 28, '长沙', '湖南'),
(13, '陈五', '男', 40, '青岛', '山东'),
(14, '王小明', '男', 20, '广州', '广东'),
(15, '李丽', '女', 23, '北京', '北京'),
(16, '赵雪', '女', 26, '上海', '上海'),
(17, '周浩', '男', 34, '成都', '四川'),
(18, '钱娜', '女', 25, '深圳', '广东'),
(19, '吴峰', '男', 36, '南京', '江苏'),
(20, '孙美', '女', 29, '杭州', '浙江'),
(21, '丁强', '男', 31, '武汉', '湖北'),
(22, '贾玲', '女', 38, '天津', '天津'),
(23, '刘伟', '男', 41, '重庆', '重庆'),
(24, '王冬', '男', 27, '西安', '陕西'),
(25, '李雪', '女', 33, '长沙', '湖南'),
(26, '张亮', '男', 39, '青岛', '山东'),
(27, '赵婷', '女', 22, '广州', '广东'),
(28, '孙诚', '男', 24, '上海', '上海'),
(29, '周影', '女', 32, '北京', '北京'),
(30, '钱军', '男', 37, '深圳', '广东'),
(31, '吴倩', '女', 30, '成都', '四川'),
(32, '郑凯', '男', 35, '南京', '江苏');

五. 小结
本期内容主要聚焦于文本向量技术和Milvus 向量数据库集成,完成了以下工作:
已完成内容
- 基础环境搭建:新建
ivy-service-ai-bi-helper-bootstrap项目,配置数据库和大模型 - 文本向量核心功能:
- 集成阿里百炼
qwen3.7-text-embedding向量模型 - 实现文档上传接口,支持 PDF、Word 等多种格式
- 自定义
HeadingBasedSplitter标题分割器,按数字标题拆分文档 - 支持 Redis 和 Milvus 两种向量存储方案
- 集成阿里百炼
- Milvus 3.0 部署:
- Docker 部署 Milvus standalone 版本
- 解决权限问题(UID 1000)
- 安装 Attu 可视化工具
- 数据库准备:创建商品、仓库、库存、销售、调拨等业务表,并插入测试数据
踩坑记录
| 问题 | 解决方案 |
|---|---|
| Redis Plus 无法查看向量数据 | 使用 Another Redis Desktop Manager |
| Milvus 启动报权限错误 | chown -R 1000:1000 ./volumes/milvus/ |
Milvus Collection 名称包含 - | 改为 biHelper,只能包含数字、字母、下划线 |
| Attu beta 版本无法正常查询 | 使用正式版本 2.6.5 |
下期预告
AI BI Helper 的第二部分——Graph 工作流编排,将实现基于 AI 的智能数据分析和调拨建议生成。
本文是 AI BI Helper 开发实录的第一期,主要完成了文本向量和 Milvus 数据库的集成。感谢阅读!