Spring AI RAG - 11 Text-to-SQL 实现跨向量聚合

文章目录

引言

向量检索擅长"语义相似度匹配",但有些问题它天生答不好。比如:

  • "知识库里出现了多少次' artisan'这个词?"
  • "成绩低于 60 分的学生有哪些?"
  • "近 30 天上传的文档总数是多少?"

这些是统计类、计数类、范围类问题,向量库的 Top-K 机制根本无能为力------你召回 5 条最相似的文档,怎么算总数?

本篇将介绍项目 v2.0 的进阶能力:通过 SuperSQL + Spring AI Tool 实现 Text-to-SQL,让 AI 自动识别聚合查询并走 SQL 路径。

设计说明

跨向量聚合的本质

向量检索的局限:

Java 复制代码
向量库 = 一堆语义向量 + 元数据
检索操作 = 找最相似的 K 个向量

它本质上是"信息查找",不是"信息汇总"。要做汇总,必须在结构化数据上做。

业界主流的三种解决方案:

方案 适用场景 代价
知识库隔离 多租户、多项目 用户体验差,无法跨库
Text-to-SQL 结构化数据 + 数值聚合 需要维护两套库
Map-Reduce 全文摘要、跨片段总结 计算成本高,依赖 LLM 多轮

本项目针对"统计类查询"采用 Text-to-SQL 方案。

整体架构

Java 复制代码
用户提问
    ↓
ChatClient(带 RagTool)
    ↓
LLM 判断意图
    ├─ 普通问答 → QuestionAnswerAdvisor → 向量检索
    └─ 聚合查询 → 调用 RagTool → SuperSQL → 数据库 → 返回结果
                                                         ↓
                                            AI 整合成自然语言回答

关键创新点:不再让 LLM 自行判断是否聚合 (这需要额外一次 LLM 调用),而是利用 Spring AI 的 @Tool 机制------LLM 在生成回复时会自动决定是否调用 Tool。

为什么用 SuperSQL?

Text-to-SQL 是个独立的工程问题:

  • 需要让 LLM 了解数据库 schema
  • 需要将自然语言可靠转换为合法 SQL
  • 需要执行 SQL 并把结果格式化

SuperSQL 是一个开源框架,封装了上述能力:自动训练 schema、生成 SQL、执行查询。集成到 Spring AI 项目只需引入 starter。

原理方案

Spring AI 的 @Tool 机制

@Tool 是 Spring AI 对 Function Calling 的封装。在方法上加注解后,方法会被注册为 LLM 可调用的"工具":

java 复制代码
@Tool(description = "涉及统计数据、求和、计数、平均值等聚合操作")
public String getAggregationQuery(@ToolParam(description = "用户的提问") String question) {
    // ...
}

LLM 在生成回复时,会根据 description 判断是否需要调用这个工具。如果调用,参数会被自动从对话上下文中提取(也通过 LLM 推理)。

整个流程:

Java 复制代码
1. 用户:"知识库里有多少个文档?"
2. LLM 看到 prompt + 注册的工具列表
3. LLM 决定调用 getAggregationQuery(question="知识库里有多少个文档?")
4. Spring AI 拦截 function call,执行对应 Java 方法
5. 方法返回结果(如 "{"count": 12}")
6. LLM 收到结果,组织成自然语言:"您的知识库目前有 12 个文档..."

SuperSQL 工作流

java签 复制代码
自然语言问题
    ↓
SpringSqlEngine.generateSql(question)
    ├─ 加载已训练的 schema 信息(DDL、表关系)
    ├─ 构造 prompt 调用 LLM
    └─ 解析返回的 SQL 字符串
    ↓
SpringSqlEngine.executeSql(sql)
    ├─ 通过 JDBC 执行
    └─ 返回 List<Map<String, Object>> 或 POJO
    ↓
JSON 序列化结果,返回给上层

配置驱动训练

yaml 复制代码
super-sql:
  init-train: false   # 第一次设为 true(训练 schema),之后可改为 false
  scope: ALONE        # ALL 整库训练, ALONE 单库训练
  schemas:
    - schema: artisan_rag  # 指定数据库

init-train: true 时,SuperSQL 会扫描数据库的所有 DDL,把表结构信息训练到内部知识库中,供后续 generateSql 时参考。

代码解析

依赖引入

xml 复制代码
<dependency>
    <groupId>com.aispace.supersql</groupId>
    <artifactId>super-sql-spring-boot-starter</artifactId>
    <version>1.0.0-M1</version>
</dependency>

starter 会自动装配 SpringSqlEngine Bean。

RagTool ------ 聚合查询工具

java 复制代码
@Service
public class RagTool {

    @Autowired
    private SpringSqlEngine sqlEngine;

    @Autowired
    private ChatModel chatModel;
    
    @Tool(description = "涉及统计数据、求和、计数、平均值等聚合操作")
    public String getAggregationQuery(@ToolParam(description = "用户的提问") String question) {
        // 1. 用 SuperSQL 的 text-to-sql 功能生成 SQL
        String actualSql = sqlEngine
                .setChatModel(chatModel)
                .setOptions(RagOptions.builder()
                        .topN(10)
                        .rerank(false)
                        .limitScore(0.1)
                        .build())
                .generateSql(question);
        
        // 2. 执行 SQL
        Object object = sqlEngine.executeSql(actualSql);

        // 3. 序列化为 JSON 返回
        return JSON.toJSONString(object);
    }
}

关键点:

  1. @Tool(description = ...) ------ description 是 LLM 判断是否调用此工具的依据,必须清晰描述适用场景
  2. @ToolParam(description = ...) ------ 参数的描述也很重要,LLM 会根据它从对话中提取参数值
  3. 链式调用 SuperSQL ------ setChatModel 设置生成 SQL 用的模型,setOptions 配置检索参数(topN、rerank、阈值)
  4. 返回 JSON 字符串 ------ LLM 接收的是字符串,所以执行结果要序列化

SuperSqlIntegrationService ------ 封装服务

java 复制代码
@Slf4j
@Service
public class SuperSqlIntegrationService {

    @Autowired
    private SpringSqlEngine sqlEngine;

    /**
     * 使用 SuperSQL 生成 SQL
     */
    public String generateSql(String question) {
        try {
            String sql = sqlEngine.generateSql(question);
            log.info("生成 SQL: question={}, sql={}", question, sql);
            return sql;
        } catch (Exception e) {
            log.error("生成 SQL 失败: question={}", question, e);
            return null;
        }
    }

    /**
     * 训练 DDL,让 SuperSQL 了解表结构
     */
    public void trainWithDDL(String ddl) {
        try {
            TrainBuilder trainBuilder = TrainBuilder.builder()
                    .content(ddl)
                    .policy(TrainPolicyType.DDL)
                    .build();
            sqlEngine.train(trainBuilder);
            log.info("训练 DDL 成功: {}", ddl);
        } catch (Exception e) {
            log.error("训练 DDL 失败", e);
        }
    }

    /**
     * 判断是否应该创建表
     */
    public boolean shouldCreateTable(String question) {
        try {
            String sql = generateSql(question);
            return sql != null && sql.toUpperCase().contains("CREATE TABLE");
        } catch (Exception e) {
            return false;
        }
    }
}

trainWithDDL 用于动态扩展 schema 知识。比如上传 Excel 后,自动建表并把表结构训练进去。

注册 Tool 到 ChatClient

java 复制代码
public AiRagController(ChatModel chatModel, ChatMemory chatMemory,
                       VectorStore vectorStore, RagTool ragTool) {
    this.chatModel = chatModel;
    this.chatClient = ChatClient.builder(chatModel)
            .defaultSystem(DEFAULT_SYSTEM_PROMPT)
            .defaultSystem(p -> p.param("rag_message", ""))
            .defaultAdvisors(
                    PromptChatMemoryAdvisor.builder(chatMemory).build(),
                    SimpleLoggerAdvisor.builder().build(),
                    new MetadataAwareQuestionAnswerAdvisor()
            )
            .defaultTools(ragTool)  // 注册 Tool
            .build();
    this.vectorStore = vectorStore;
}

.defaultTools(ragTool) 把 RagTool 中所有标注 @Tool 的方法都注册为可用工具。

路由判断的两种思路

思路一(项目采用):让 LLM 通过 Tool 自动决定

java 复制代码
// 不需要显式判断,LLM 会自己根据 description 调用工具
return chatClient.prompt()
        .user(message)
        .stream()
        .content();

思路二(注释中保留):显式让 LLM 判断

java 复制代码
boolean isSql = chatClient.prompt()
        .system("用户的查询是否涉及统计数据、求和、计数、平均值等聚合操作?")
        .user(message)
        .call()
        .entity(Boolean.class);

if (isSql) {
    return processAggregationQuery(message);
} else {
    return processNormalRagQuery(sources, message);
}

思路一的优势:少一次 LLM 调用,响应快;劣势:依赖 LLM 的 Function Calling 准确性。

思路二的优势:路由可控;劣势:每次都要先做一次判断,成本翻倍。

项目代码中保留了思路二的注释,作为备选方案。

验证结果

普通问答(走向量检索)

请求:

复制代码
POST /api/v1/ai/rag?message=ChatClient是什么?

响应: 走 RAG 路径,从向量库检索相关文档生成回答。

聚合查询(走 Text-to-SQL)

请求:

复制代码
POST /api/v1/ai/rag?message=日志表里一共有多少条记录?

预期流程:

  1. LLM 识别到"多少条"是聚合操作
  2. 调用 RagTool.getAggregationQuery("日志表里一共有多少条记录?")
  3. SuperSQL 生成 SQL:SELECT COUNT(*) FROM log_info
  4. 执行得到 [{"count":156}]
  5. LLM 整合:日志表中一共有 156 条记录。

复杂查询测试

请求:

复制代码
POST /api/v1/ai/rag?message=统计每个业务类型的热词数量

生成的 SQL:

sql 复制代码
SELECT business_type, COUNT(*) as cnt FROM word_frequency GROUP BY business_type

最终回答:

复制代码
按业务类型统计的热词数量如下:
- log: 234 个
- chat: 56 个

调试日志

复制代码
2026-05-14 INFO  RagTool - 接收到聚合查询: 日志表里一共有多少条记录?
2026-05-14 INFO  SpringSqlEngine - 生成的SQL: SELECT COUNT(*) FROM log_info
2026-05-14 INFO  SpringSqlEngine - 执行结果: [{COUNT(*)=156}]

优化方向

Tool 描述的精细化

@Tool 的描述会直接影响 LLM 是否调用。建议:

  • 列举常见关键词:"多少"、"总数"、"统计"、"平均"、"最大"、"前N"
  • 给出反例:"如果是问'是什么'、'怎么用',不要使用此工具"
java 复制代码
@Tool(description = """
        当用户询问需要数据库聚合查询的问题时使用,例如:
        - "知识库里有多少个文档"
        - "统计每个分类的数量"
        - "查询最近一周的日志总数"
        
        不适用:解释概念、查找具体内容、问答类问题。
        """)
public String getAggregationQuery(...) { ... }

SQL 安全护栏

LLM 生成的 SQL 可能包含 DROP、DELETE 等危险操作。需要白名单校验:

java 复制代码
public String getAggregationQuery(String question) {
    String sql = sqlEngine.generateSql(question);
    
    // 只允许 SELECT
    if (!sql.trim().toUpperCase().startsWith("SELECT")) {
        return "{\"error\": \"暂不支持非查询操作\"}";
    }
    
    // 黑名单关键词
    String upperSql = sql.toUpperCase();
    String[] forbidden = {"DROP", "DELETE", "UPDATE", "INSERT", "TRUNCATE", "ALTER"};
    for (String keyword : forbidden) {
        if (upperSql.contains(keyword)) {
            return "{\"error\": \"包含不允许的操作\"}";
        }
    }
    
    return JSON.toJSONString(sqlEngine.executeSql(sql));
}

数据库账号最小权限

即使 SQL 校验失效,数据库层面也要兜底:

sql 复制代码
-- 创建只读账号
CREATE USER 'rag_readonly'@'%' IDENTIFIED BY 'xxx';
GRANT SELECT ON artisan_rag.* TO 'rag_readonly'@'%';

应用使用这个账号连接,从根本上杜绝写操作。

结果限制

防止用户问"查询所有用户"导致返回百万行:

java 复制代码
@Tool(description = "...")
public String query(String question) {
    String sql = sqlEngine.generateSql(question);
    
    // 强制加 LIMIT
    if (!sql.toUpperCase().contains("LIMIT")) {
        sql += " LIMIT 100";
    }
    
    return JSON.toJSONString(sqlEngine.executeSql(sql));
}

训练数据管理

SuperSQL 的训练效果取决于喂给它的 DDL 和样例。可以:

  • 项目启动时通过 trainWithDDL 训练全部表结构
  • 提供"训练样例"接口,运维人员可以补充常见问题对应的 SQL
  • 定期用真实查询数据回灌训练集
java 复制代码
TrainBuilder.builder()
    .content("查询每个用户的最后登录时间")
    .sql("SELECT user_name, MAX(create_time) FROM tb_user GROUP BY user_name")
    .policy(TrainPolicyType.QA)
    .build();

与 RAG 的协作

聚合查询的结果可以再走一次 RAG。比如:

Java 复制代码
用户问:"最近上传的 PDF 都讲了什么?"
1. 调用 SQL:SELECT file_name FROM ali_oss_file WHERE file_name LIKE '%.pdf' ORDER BY create_time DESC LIMIT 5
2. 拿到 5 个文件名
3. 用这些文件名作为 sources 走 RAG
4. AI 总结每个文件的主要内容

形成"SQL 找范围 → 向量检索取语义"的两段式查询。

监控与可观测

  • SQL 执行成功率
  • 生成 SQL 的平均耗时
  • 哪些问题路由到了 SQL 路径
  • SQL 执行失败的 case 收集,用于训练改进

小结

本篇展示了 RAG 系统中"聚合查询"的优雅解法:

  • 利用 Spring AI 的 @Tool 机制,让 LLM 自动判断是否需要 SQL 查询
  • 集成 SuperSQL 框架,把自然语言可靠地转成 SQL
  • "向量检索 + Text-to-SQL"双路径,覆盖语义查找和数值聚合两类场景
  • 安全要点:白名单校验、最小权限账号、结果限制

下一篇将聚焦"文档更新与全链路删除"------当知识库需要迭代时,如何避免数据碎片化。

相关推荐
ZGi.ai24 天前
自然语言查数据库:Text-to-SQL工程实现与企业落地细节
数据库·sql·nl2sql·text-to-sql
Java后端的Ai之路1 个月前
Text-to-SQL与智能问数完全指南:基本概念、核心原理、Python实战教学及企业项目落地
数据库·python·sql·text-to-sql·智能问数
柠石榴1 年前
【论文阅读笔记】《A survey on deep learning approaches for text-to-SQL》
论文阅读·笔记·深度学习·nlp·text-to-sql
boydfd1 年前
万字长文详解Text-to-SQL
llm·text-to-sql
庞德公2 年前
小白也会SQL:大模型改变交互方式(上)
人工智能·sql·神经网络·自然语言处理·text-to-sql
叶庭云2 年前
基于检索增强的 GPT-3.5 的文本到 SQL 框架,具有样本感知提示和动态修订链。
人工智能·提示工程·text-to-sql·文本生成 sql·gpt-3.5
Q同学的nlp笔记2 年前
论文笔记:SQLPrompt: In-Context Text-to-SQL with Minimal Labeled Data
论文阅读·人工智能·深度学习·自然语言处理·nlp·text-to-sql
Q同学的nlp笔记2 年前
Huggingface T5模型代码笔记
人工智能·深度学习·自然语言处理·nlp·text-to-sql