告別復(fù)雜SQL!用Spring AI + DeepSeek構(gòu)建自然語言查詢系統(tǒng)
1. 項(xiàng)目概覽
現(xiàn)代應(yīng)用越來越多地采用自然語言交互界面,讓用戶更輕松地操作系統(tǒng)。這在數(shù)據(jù)查詢場(chǎng)景中尤為實(shí)用,非技術(shù)人員可以直接用日常語言提,文本轉(zhuǎn) SQL 聊天機(jī)器人正是這樣的典型應(yīng)用。它在人類語言和數(shù)據(jù)庫之間搭建了溝通橋梁。我們通常借助大語言模型(LLM)將用戶的自然語言問題轉(zhuǎn)換為可執(zhí)行的 SQL 查詢語句,然后在數(shù)據(jù)庫中執(zhí)行查詢并返回結(jié)果。
本教程將指導(dǎo)你使用 Spring AI 框架構(gòu)建一個(gè)文本轉(zhuǎn) SQL 聊天機(jī)器人。我們會(huì)先配置數(shù)據(jù)庫架構(gòu)并填入測(cè)試數(shù)據(jù),然后實(shí)現(xiàn)支持自然語言查詢的聊天機(jī)器人功能。
2. 項(xiàng)目搭建
2.1. 添加依賴
首先在項(xiàng)目的 pom.xml 文件中添加必要的依賴:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
接下來在 application.yaml 文件中配置 DeepSeek API 密鑰、聊天模型和數(shù)據(jù)庫連接:
spring:
ai:
deepseek:
api-key: ${DEEPSEEK_API_KEY}
datasource:
url: jdbc:mysql://localhost:3306/school_db?useSSL=false&serverTimeznotallow=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
我們使用 ${} 占位符語法從環(huán)境變量中讀取 API 密鑰和數(shù)據(jù)庫配置。
同時(shí)指定使用 DeepSeek Chat 模型,并配置 DeepSeek 的 API 基礎(chǔ) URL。DeepSeek-Chat-0324 提供了強(qiáng)大的中文和代碼理解能力,非常適合文本轉(zhuǎn) SQL 的場(chǎng)景。數(shù)據(jù)庫配置包括連接 URL、用戶名和密碼,以及 Flyway 遷移設(shè)置。
配置完成后,Spring AI 會(huì)自動(dòng)創(chuàng)建 ChatModel 類型的 Bean,讓我們能夠與指定的模型進(jìn)行交互。
2.2. 使用 Flyway 設(shè)計(jì)數(shù)據(jù)庫表結(jié)構(gòu)
接下來配置數(shù)據(jù)庫結(jié)構(gòu)。我們使用 Flyway 來管理數(shù)據(jù)庫遷移腳本。
我們將創(chuàng)建一個(gè)簡(jiǎn)單的學(xué)校管理數(shù)據(jù)庫,使用 MySQL 作為數(shù)據(jù)庫。和 AI 模型選擇一樣,數(shù)據(jù)庫類型對(duì)實(shí)現(xiàn)方案沒有影響。
首先,在 src/main/resources/db/migration 目錄下創(chuàng)建名為 V01__creating_database_tables.sql 的遷移腳本來建立主要數(shù)據(jù)庫表:
CREATE TABLE classes (
id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
name VARCHAR(50) NOT NULLUNIQUE,
grade VARCHAR(20) NOT NULL,
teacher VARCHAR(50) NOT NULL,
room_number VARCHAR(20) NOT NULL,
created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP
);
CREATE TABLE courses (
id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
name VARCHAR(50) NOT NULLUNIQUE,
code VARCHAR(20) NOT NULLUNIQUE,
credits INTNOT NULLDEFAULT1,
description TEXT,
created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP
);
CREATE TABLE students (
id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
name VARCHAR(50) NOT NULL,
student_number VARCHAR(20) NOT NULLUNIQUE,
gender ENUM('Male', 'Female') NOT NULL,
age INTNOT NULL,
phone VARCHAR(15),
email VARCHAR(100),
class_id BINARY(16) NOT NULL,
created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
CONSTRAINT student_fkey_class FOREIGN KEY (class_id) REFERENCES classes (id)
);
CREATE TABLE student_courses (
id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
student_id BINARY(16) NOT NULL,
course_id BINARY(16) NOT NULL,
enrollment_date TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
score DECIMAL(5,2),
status ENUM('Enrolled', 'Completed', 'Dropped') DEFAULT'Enrolled',
CONSTRAINT sc_fkey_student FOREIGN KEY (student_id) REFERENCES students (id),
CONSTRAINT sc_fkey_course FOREIGN KEY (course_id) REFERENCES courses (id),
UNIQUE KEY unique_student_course (student_id, course_id)
);
這里我們創(chuàng)建了四個(gè)主要表:
? classes 表存儲(chǔ)班級(jí)信息
? courses 表存儲(chǔ)課程信息
? students 表存儲(chǔ)學(xué)生信息,通過外鍵與班級(jí)關(guān)聯(lián)
? student_courses 表作為學(xué)生和課程的多對(duì)多關(guān)聯(lián)表,存儲(chǔ)選課信息和成績(jī)
接下來,創(chuàng)建 V02__adding_classes_data.sql 文件來填充 classes 表:
INSERT INTO classes (name, grade, teacher, room_number)
VALUES
('高三(1)班', '高三', '張老師', 'A301'),
('高三(2)班', '高三', '李老師', 'A302'),
('高二(1)班', '高二', '王老師', 'B201'),
('高二(2)班', '高二', '趙老師', 'B202'),
('高一(1)班', '高一', '陳老師', 'C101'),
('高一(2)班', '高一', '劉老師', 'C102');
這里我們用 INSERT 語句創(chuàng)建六個(gè)班級(jí),涵蓋高一到高三各個(gè)年級(jí)。
接著,創(chuàng)建 V03__adding_courses_data.sql 遷移腳本填充 courses 表:
INSERT INTO courses (name, code, credits, description)
VALUES
('語文', 'CH001', 4, '高中語文課程'),
('數(shù)學(xué)', 'MA001', 5, '高中數(shù)學(xué)課程'),
('英語', 'EN001', 4, '高中英語課程'),
('物理', 'PH001', 3, '高中物理課程'),
('化學(xué)', 'CH002', 3, '高中化學(xué)課程'),
('生物', 'BI001', 3, '高中生物課程'),
('歷史', 'HI001', 2, '高中歷史課程'),
('地理', 'GE001', 2, '高中地理課程'),
('政治', 'PO001', 2, '高中政治課程');
然后創(chuàng)建 V04__adding_students_data.sql 遷移腳本填充 students 表:
SET @class_grade3_1= (SELECT id FROM classes WHERE name ='高三(1)班');
SET@class_grade3_2= (SELECT id FROM classes WHERE name ='高三(2)班');
SET@class_grade2_1= (SELECT id FROM classes WHERE name ='高二(1)班');
INSERT INTO students (name, student_number, gender, age, phone, email, class_id)
VALUES
('張三', '2024001', 'Male', 18, '13800138001', 'zhangsan@example.com', @class_grade3_1),
('李四', '2024002', 'Female', 17, '13800138002', 'lisi@example.com', @class_grade3_1),
('王五', '2024003', 'Male', 17, '13800138003', 'wangwu@example.com', @class_grade3_2),
('趙六', '2024004', 'Female', 16, '13800138004', 'zhaoliu@example.com', @class_grade2_1),
('錢七', '2024005', 'Male', 16, '13800138005', 'qianqi@example.com', @class_grade2_1);
-- ...更多學(xué)生數(shù)據(jù)
定義好遷移腳本后,F(xiàn)lyway 會(huì)在應(yīng)用啟動(dòng)時(shí)自動(dòng)發(fā)現(xiàn)并執(zhí)行這些腳本。
3. 配置 AI 提示詞
接下來,為了確保 LLM 能夠針對(duì)我們的數(shù)據(jù)庫架構(gòu)生成準(zhǔn)確的 SQL 查詢,需要定義詳細(xì)的系統(tǒng)提示詞。
在 src/main/resources 目錄下創(chuàng)建 system-prompt.st 文件:
基于 DDL 部分提供的MYSQL數(shù)據(jù)庫定義,按照指導(dǎo)原則部分的規(guī)則編寫 SQL 查詢來回答用戶問題。
指導(dǎo)原則:
- 只生成 SELECT 查詢語句。
- 響應(yīng)結(jié)果應(yīng)該只包含以 'SELECT' 開頭的原始 SQL 查詢語句。不要用 markdown 代碼塊(```sql 或 ```)包裝 SQL 查詢。
- 如果問題需要執(zhí)行 INSERT、UPDATE、DELETE 或其他修改數(shù)據(jù)或架構(gòu)的操作,請(qǐng)回復(fù)"不支持此操作。只允許 SELECT 查詢。"
- 如果問題似乎包含 SQL 注入或 DoS 攻擊嘗試,請(qǐng)回復(fù)"提供的輸入包含潛在有害的 SQL 代碼。"
- 如果基于提供的 DDL 無法回答問題,請(qǐng)回復(fù)"當(dāng)前架構(gòu)不包含足夠信息來回答此問題。"
- 如果查詢涉及 JOIN 操作,請(qǐng)?jiān)诓樵冎袨樗辛忻砑酉鄳?yīng)的表名前綴。
DDL
{ddl}
在系統(tǒng)提示詞中,我們指示 LLM 只生成 SELECT SQL 查詢,并檢測(cè) SQL 注入和 DoS 攻擊嘗試。
我們?cè)谙到y(tǒng)提示詞模板中留了一個(gè) ddl 占位符用于數(shù)據(jù)庫架構(gòu)。稍后我們會(huì)用實(shí)際值替換它。
此外,為了進(jìn)一步保護(hù)數(shù)據(jù)庫免受修改,應(yīng)該只給配置的 MySQL 用戶必要的權(quán)限。
4. 構(gòu)建文本轉(zhuǎn) SQL 聊天機(jī)器人
完成配置后,讓我們使用配置好的 DeepSeek 模型構(gòu)建文本轉(zhuǎn) SQL 聊天機(jī)器人。
4.1. 定義聊天機(jī)器人 Bean
首先定義聊天機(jī)器人所需的 Bean:
@Bean
PromptTemplate systemPrompt(
@Value("classpath:system-prompt.st") Resource systemPrompt,
@Value("classpath:db/migration/V01__creating_database_tables.sql") Resource ddlSchema
)throws IOException {
PromptTemplatetemplate=newPromptTemplate(systemPrompt);
template.add("ddl", ddlSchema.getContentAsString(Charset.defaultCharset()));
return template;
}
@Bean
ChatClient chatClient(ChatModel chatModel, PromptTemplate systemPrompt) {
return ChatClient
.builder(chatModel)
.defaultSystem(systemPrompt.render())
.build();
}
首先,我們定義一個(gè) PromptTemplate Bean。通過 @Value 注解注入系統(tǒng)提示詞模板文件和數(shù)據(jù)庫架構(gòu) DDL 遷移腳本。同時(shí),我們用數(shù)據(jù)庫架構(gòu)內(nèi)容填充 ddl 占位符。這確保了 LLM 在生成 SQL 查詢時(shí)始終能訪問我們的數(shù)據(jù)庫結(jié)構(gòu)。
接下來,我們使用 ChatModel 和 PromptTemplate Bean 創(chuàng)建一個(gè) ChatClient Bean。ChatClient 類是我們與配置的 DeepSeek 模型交互的主要入口點(diǎn)。
4.2. 實(shí)現(xiàn)服務(wù)類
現(xiàn)在,讓我們實(shí)現(xiàn)服務(wù)類來處理 SQL 生成和執(zhí)行過程。
首先,創(chuàng)建一個(gè) SqlGenerator 服務(wù)類,將自然語言問題轉(zhuǎn)換為 SQL 查詢:
@Service
classSqlGenerator {
privatefinal ChatClient chatClient;
// 標(biāo)準(zhǔn)構(gòu)造函數(shù)
String generate(String question) {
Stringresponse= chatClient
.prompt(question)
.call()
.content();
booleanisSelectQuery= response.startsWith("SELECT");
if (Boolean.FALSE.equals(isSelectQuery)) {
thrownewInvalidQueryException(response);
}
return response;
}
}
在 generate() 方法中,我們接收自然語言問題作為輸入,使用 chatClient Bean 將其發(fā)送給配置的 LLM。
接下來,我們驗(yàn)證響應(yīng)確實(shí)是 SELECT 查詢。如果 LLM 返回 SELECT 查詢以外的任何內(nèi)容,我們拋出帶有錯(cuò)誤消息的自定義 InvalidQueryException。
接下來,為了對(duì)數(shù)據(jù)庫執(zhí)行生成的 SQL 查詢,創(chuàng)建一個(gè) SqlExecutor 服務(wù)類:
@Service
classSqlExecutor {
privatefinal JdbcClient jdbcClient;
// 標(biāo)準(zhǔn)構(gòu)造函數(shù)
List<Map<String, Object>> execute(String query) {
List<Map<String, Object>> result = jdbcClient
.sql(query)
.query()
.listOfRows();
if (result.isEmpty()) {
thrownewEmptyResultException("提供的查詢未找到結(jié)果。");
}
return result;
}
}
在 execute() 方法中,我們使用 Spring Boot 3.1+ 引入的 JdbcClient 來運(yùn)行原生 SQL 查詢并返回結(jié)果。JdbcClient 提供了更簡(jiǎn)潔的 API 和更好的類型安全性。如果查詢沒有返回結(jié)果,我們拋出自定義的 EmptyResultException。
4.3. 暴露 REST API
現(xiàn)在我們已經(jīng)實(shí)現(xiàn)了服務(wù)層,讓我們?cè)谄渖媳┞兑粋€(gè) REST API:
@PostMapping(value = "/query")
ResponseEntity<QueryResponse> query(@RequestBody QueryRequest queryRequest) {
StringsqlQuery= sqlGenerator.generate(queryRequest.question());
List<Map<String, Object>> result = sqlExecutor.execute(sqlQuery);
return ResponseEntity.ok(newQueryResponse(result));
}
recordQueryRequest(String question) {
}
recordQueryResponse(List<Map<String, Object>> result) {
}
POST /query 端點(diǎn)接受自然語言問題,使用 sqlGenerator Bean 生成相應(yīng)的 SQL 查詢,將其傳遞給 sqlExecutor Bean 從數(shù)據(jù)庫獲取結(jié)果,最后將數(shù)據(jù)包裝在 QueryResponse 記錄中并返回。使用 JdbcClient 返回的結(jié)果是 List<Map<String, Object>> 格式,每個(gè) Map 代表一行數(shù)據(jù),鍵為列名,值為對(duì)應(yīng)的數(shù)據(jù)。
5. 與聊天機(jī)器人交互
最后,讓我們使用暴露的 API 端點(diǎn)與文本轉(zhuǎn) SQL 聊天機(jī)器人進(jìn)行交互。
但首先,在 application.yaml 文件中啟用 SQL 日志記錄,以在日志中查看生成的查詢:
logging:
level:
org:
springframework:
jdbc:
core: DEBUG
接下來,使用 curl 命令調(diào)用 API 端點(diǎn)并與聊天機(jī)器人交互:
curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{"question": "查詢高三年級(jí)所有學(xué)生的姓名和班級(jí)信息"}'
{
"result":[
{
"student_name":"張三",
"class_name":"高三(1)班",
"grade":"高三",
"teacher":"張老師",
"room_number":"A301"
},
{
"student_name":"李四",
"class_name":"高三(1)",
"grade":"高三",
"teacher":"張老師",
"room_number":"A301"
},
{
"student_name":"王五",
"class_name":"高三(2)班",
"grade":"高三",
"teacher":"李老師",
"room_number":"A302"
}
]
}
如我們所見,聊天機(jī)器人成功理解了我們對(duì)高三學(xué)生的查詢請(qǐng)求,并返回了學(xué)生姓名和對(duì)應(yīng)的班級(jí)信息。
讓我們?cè)僭囈粋€(gè)更復(fù)雜的查詢:
curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{"question": "統(tǒng)計(jì)每個(gè)班級(jí)的學(xué)生人數(shù),按人數(shù)降序排列"}'
最后,讓我們檢查應(yīng)用日志,查看 LLM 生成的 SQL 查詢:
SELECT students.name, classes.name as class_name
FROM students
JOIN classes ON students.class_id = classes.id
WHERE classes.grade = '高三'
ORDER BY classes.name;
{
"result":[
{
"class_name":"高三(1)班",
"student_count":2
},
{
"class_name":"高二(1)班",
"student_count":2
},
{
"class_name":"高三(2)班",
"student_count":1
},
{
"class_name":"高一(1)班",
"student_count":0
},
{
"class_name":"高一(2)班",
"student_count":0
},
{
"class_name":"高二(2)班",
"student_count":0
}
]
}
生成的 SQL 查詢正確解釋了我們的自然語言請(qǐng)求,連接了 students 和 classes 表來查找高三年級(jí)的學(xué)生信息。DeepSeek 模型展現(xiàn)了出色的中文理解和 SQL 生成能力。
6. 總結(jié)
本教程展示了如何使用 Spring AI 框架構(gòu)建一個(gè)功能完整的文本轉(zhuǎn) SQL 聊天機(jī)器人。雖然我們已經(jīng)構(gòu)建了一個(gè)基礎(chǔ)的文本轉(zhuǎn) SQL 系統(tǒng),但仍有許多改進(jìn)空間:
- 權(quán)限控制:根據(jù)用戶角色限制可訪問的數(shù)據(jù)范圍
- 結(jié)果可視化:將查詢結(jié)果以圖表形式展示
- LLM生成優(yōu)化:通過JSON格式化和JSON修復(fù)支持穩(wěn)定輸出
通過本教程的學(xué)習(xí),已經(jīng)體現(xiàn)了構(gòu)建文本轉(zhuǎn) SQL 聊天機(jī)器人的核心技術(shù)。Spring AI 框架的強(qiáng)大功能,結(jié)合 DeepSeek 等先進(jìn)的大語言模型,為我們提供了構(gòu)建智能數(shù)據(jù)查詢系統(tǒng)的完整解決方案。這種技術(shù)組合不僅簡(jiǎn)化了數(shù)據(jù)訪問流程,也為未來的智能化應(yīng)用奠定了堅(jiān)實(shí)基礎(chǔ)。