夜雨聆风学习资料网

ARTICLE · 1069101

数据分析助手:AI帮你写SQL并解释结果

数据分析助手:AI帮你写SQL并解释结果

2025年10月,运营团队又来找我——他们每周要花 10+ 小时写 SQL 取数,数据分析师不够用,业务方天天催报表。“能不能让 AI 帮我们写 SQL?” 产品总监在周会上直接问。

我说先跑个 PoC。两周后上线,准确率 71%,业务方不满意——30% 的 SQL 有语法错误或逻辑偏差,直接跑会给出错误数据。花了 3 个月调优,准确率做到 93%,现在日均处理 200+ 个查询请求,基本替代了运营团队 60% 的取数需求。

NL2SQL 全链路架构

整个系统分 6 个环节:自然语言理解 → Schema 注入 → Few-shot 检索 → SQL 生成 → SQL 校验 → 结果解释。每个环节都有明确的输入输出和失败回退。

用户问题    │    ▼┌─────────────┐    ┌──────────────┐    ┌─────────────┐│ Schema 裁剪  │───▶│ Few-shot 检索 │───▶│ SQL 生成     ││ (注入相关表) │    │ (检索示例)    │    │ (LLM生成)   │└─────────────┘    └──────────────┘    └──────┬──────┘                                              │                                              ▼┌─────────────┐    ┌──────────────┐    ┌─────────────┐│ 结果解释     │◀───│ SQL 执行      │◀───│ SQL 校验     ││ (文字+图表) │    │ (只读权限)    │    │ (语法+权限) │└─────────────┘    └──────────────┘    └─────────────┘

Schema 注入与裁剪:别把整个库丢给模型

最大的坑:直接把所有表结构扔给 LLM。我们上线第一版就是这么干的,结果有两个问题——一是 prompt 太长,qwen-turbo 处理 50 张表的 schema 要 8 秒,P95 延迟超过 12 秒;二是模型被无关表分散注意力,生成错误 JOIN 的概率大幅增加。

正确的做法是按查询意图裁剪 schema。我们用两阶段方案:

阶段一:表级意图匹配。 把用户问题和所有表的 comment 做向量相似度匹配,选出 top-5 相关表。Embedding 模型用 bge-large-zh(1024维),向量存到 Milvus 的 schema_index collection。

@Component@RequiredArgsConstructorpublic class SchemaRouter {    private final VectorStore vectorStore;    private final TableSchemaService schemaService;    private static final int TOP_K = 5;    public SchemaContext route(String userQuery) {        // 1. 向量检索相关表        List<TableSchema> candidates = vectorStore.search(            SearchRequest.builder()                .query(userQuery)                .topK(TOP_K)                .filter("type = 'table'")                .build()        ).getResults();        // 2. 注入完整列信息        List<ColumnSchema> columns = new ArrayList<>();        for (TableSchema table : candidates) {            columns.addAll(schemaService.getColumns(table.getTableName()));        }        return new SchemaContext(candidates, columns);    }}public record SchemaContext(    List<TableSchema> tables,    List<ColumnSchema> columns) {}

阶段二:列级同义词扩展。 用户说"订单金额",数据库里叫 order_amount;用户说"支付金额",数据库里叫 pay_amount。这种同义词映射我们维护了一张同义词表,在查询时自动展开:

# schema-synonyms.ymlorder_amount:  - 订单金额  - 下单金额  - 交易额  - GMV  - 成交金额pay_amount:  - 支付金额  - 实付金额  - 到手金额user_id:  - 用户ID  - 用户id  - 买家ID  - 下单人

SQL 生成前,把用户 query 里的同义词替换为数据库列名,减少模型的理解负担。这一步让准确率提升了约 8 个百分点。

Few-shot 示例检索:找最像的历史查询

用户的问题千变万化,但 SQL 的写法是有模式的。我们把历史查询(用户问题 + 对应的正确 SQL)存到一个向量库,生成新 SQL 前,先检索 top-3 最相似的历史查询作为 few-shot 示例。

@Component@RequiredArgsConstructorpublic class FewShotRetriever {    private final VectorStore exampleStore;    private final EmbeddingModel embeddingModel;    public List<FewShotExample> retrieve(String userQuery, int k) {        ListQuery query = ListQuery.builder()            .query(userQuery)            .topK(k)            .includeMetadata(true)            .build();        List<ExampleRecord> results = exampleStore.similaritySearch(query);        return results.stream()            .map(r -> new FewShotExample(r.getQuestion(), r.getSql()))            .toList();    }}public record FewShotExample(String question, String sql) {}

Few-shot 的检索质量直接影响 SQL 生成质量。我们做过对照实验:不传 few-shot 时,qwen-turbo 在复杂查询(多表 JOIN + GROUP BY + HAVING)上的准确率为 58%;传 3 个 few-shot 示例后,提升到 79%;传 5 个后,提升到 83%,但 prompt 长度增加导致延迟上升约 200ms。最终选择 k=3,性价比最优。

SQL 生成:模型选型与 Prompt 设计

我们测试了三个模型:qwen-turbo、qwen-plus、glm-4-plus。在简单查询(单表 WHERE)上三者差距不大(准确率 95%+),但在复杂查询(多表 JOIN、子查询、窗口函数)上差距明显:

模型
简单查询
复杂查询
综合准确率
单价(¥/1K tokens)
qwen-turbo
96.2%
71.3%
85.4%
0.003 / 0.006
qwen-plus
97.8%
84.6%
91.7%
0.008 / 0.02
glm-4-plus
97.1%
82.1%
90.2%
0.006 / 0.015

最终方案:简单查询用 qwen-turbo(成本低),复杂查询用 qwen-plus(准确率高)。复杂度判断用规则——检测到 JOIN、GROUP BY、子查询、窗口函数任一关键字即为复杂查询。

Prompt 设计:

@Componentpublic class SqlPromptBuilder {    public Prompt buildPrompt(String userQuery, SchemaContext schema,                                List<FewShotExample> examples) {        String schemaSection = buildSchemaSection(schema);        String fewShotSection = examples.isEmpty()             ? ""             : "\n参考示例:\n" + examples.stream()                .map(e -> String.format("问题:%s\nSQL:%s", e.question(), e.sql()))                .collect(Collectors.joining("\n\n"));        return new Prompt(            """            你是一个SQL生成助手。根据用户问题和数据库schema,生成正确的SQL查询。            约束:            1. 只生成SELECT语句,禁止INSERT/UPDATE/DELETE            2. 表名和字段名使用反引号包裹            3. 日期比较使用DATE()函数确保只比较日期部分            4. 数值计算保留2位小数            5. 如果问题无法用给定schema回答,返回"无法回答"            """,            "用户问题:" + userQuery +             "\n\n数据库Schema:\n" + schemaSection +            fewShotSection        );    }}

SQL 校验:语法 + 权限 + 风险三重检查

生成的 SQL 不能直接执行。我们做了三层校验:

第一层:SQL 语法校验。 用 JSqlParser 解析 SQL,检查语法是否正确。解析失败直接拒绝,返回错误信息给模型重新生成(最多重试 2 次)。

@Componentpublic class SqlSyntaxValidator {    public ValidationResult validate(String sql) {        try {            Statement statement = new JSqlParserUtil().parse(sql);            if (!(statement instanceof Select)) {                return ValidationResult.reject("只允许SELECT语句");            }            return ValidationResult.accept();        } catch (JSQLParserException e) {            return ValidationResult.reject("SQL语法错误: " + e.getMessage());        }    }}

第二层:权限校验。 检查 SQL 引用的表是否在用户的查询权限白名单内。权限配置存在 MySQL 的 query_permission 表中,按用户组管理。

@Component@RequiredArgsConstructorpublic class SqlPermissionValidator {    private final TablePermissionService permissionService;    public ValidationResult checkPermission(String sql, String userId) {        Set<String> allowedTables = permissionService.getAllowedTables(userId);        Set<String> referencedTables = extractTableNames(sql);        Set<String> unauthorized = new HashSet<>(referencedTables);        unauthorized.removeAll(allowedTables);        if (!unauthorized.isEmpty()) {            return ValidationResult.reject(                "无权限访问表: " + String.join(", ", unauthorized)            );        }        return ValidationResult.accept();    }}

第三层:风险校验。 检测以下风险模式:

  无 WHERE 条件的全表扫描(返回行数可能超过 10 万) 包含 ORDER BY 且无 LIMIT 的查询 包含子查询且外层无 LIMIT 的查询 包含 LIKE '%xxx%' 的前缀通配符查询(无法走索引)
@Componentpublic class SqlRiskValidator {    public ValidationResult checkRisk(String sql, String userId) {        Select select = parseSelect(sql);        // 检查是否有WHERE条件        if (select.getWhere() == null) {            // 全表扫描,检查表大小            long estimatedRows = estimateTableRows(sql);            if (estimatedRows > 100_000) {                return ValidationResult.reject("全表扫描风险:预估返回行数超过10万,请添加WHERE条件");            }        }        // 检查ORDER BY无LIMIT        if (select.getOrderByElements() != null && select.getLimit() == null) {            return ValidationResult.reject("ORDER BY 缺少 LIMIT 限制,请添加 LIMIT 子句");        }        // 检查前缀通配符LIKE        if (sql.matches(".*LIKE\\s+['\"]%.*")) {            log.warn("用户 {} 使用了前缀通配符LIKE,可能影响性能", userId);        }        return ValidationResult.accept();    }}

SQL 执行:只读权限 + 超时 + 行数限制

校验通过的 SQL 在只读从库上执行。连接池用 HikariCP,配置:

spring:  datasource:    read:      jdbc-url: jdbc:mysql://read-replica:3306/analytics?useSSL=false&serverTimezone=Asia/Shanghai      username${DB_READONLY_USER}      password${DB_READONLY_PASSWORD}      driver-class-name: com.mysql.cj.jdbc.Driver      hikari:        maximum-pool-size: 10        minimum-idle: 2        connection-timeout: 3000        idle-timeout: 600000        max-lifetime: 1800000

执行时加两道闸刀:

@Component@RequiredArgsConstructorpublic class SqlExecutor {    private final DataSource readOnlyDataSource;    private static final int MAX_ROWS = 10_000;    private static final int QUERY_TIMEOUT_MS = 10_000;    public QueryResult execute(String sql, String userId) {        try (Connection conn = readOnlyDataSource.getConnection();             PreparedStatement stmt = conn.prepareStatement(sql)) {            conn.setNetworkTimeoutExecutors.v001((v,t)->{}); // 设置超时            stmt.setQueryTimeout(QUERY_TIMEOUT_MS / 1000);            try (ResultSet rs = stmt.executeQuery()) {                List<String> columns = extractColumns(rs);                List<List<Object>> rows = new ArrayList<>();                int rowCount = 0;                while (rs.next() && rowCount < MAX_ROWS) {                    rows.add(extractRow(rs, columns.size()));                    rowCount++;                }                return new QueryResult(columns, rows, rowCount, false);            }        } catch (SQLException e) {            return QueryResult.error(e.getMessage());        }    }}
超时:10 秒。超时后返回 partial result(已获取的行)+ 超时标记,不让用户干等。行数限制:10,000 行。超过限制返回截断标记,提示用户添加筛选条件。

结果解释:文字 + 图表建议

SQL 执行结果不只是返回数据,还要给用户一个"人话"解释。我们用 LLM 分析结果集,生成:

关键发现:数据的趋势、异常值、显著变化统计摘要:平均值、中位数、最大值、最小值图表建议:根据数据类型推荐合适的可视化形式
@Component@RequiredArgsConstructorpublic classResultInterpreter{    private final ChatClient chatClient;    public InterpretationResult interpret(String userQuery, QueryResult result) {        if (result.hasError()) {            return InterpretationResult.error(result.getErrorMessage());        }        String dataSummary = buildDataSummary(result);        String prompt = """            用户问题:%s            查询结果:%s            请给出:            1. 关键发现(3-5条,用数据说话)            2. 统计摘要(平均值、中位数、最大值、最小值)            3. 推荐的图表类型(折线图/柱状图/饼图/表格)            格式:JSON            {"findings":["..."],"summary":{...},"chartType":"...","chartData":{...}}            """.formatted(userQuery, dataSummary);        ChatResponse response = chatClient.prompt()            .user(prompt)            .call()            .chatResponse();        return parseInterpretation(response.getResult().getOutput().getText());    }}

图表建议的前端实现:我们把 chartType 和 chartData 返回给前端,由前端 ECharts 渲染。支持 4 种图表:

折线图:时间序列数据(如"近30天订单趋势")柱状图:分类对比(如"各品类销售额")饼图:占比分析(如"渠道来源占比")表格:详细数据展示(如"用户列表")

准确率提升:从71%到93%的4个月

上线第一版准确率 71%,主要问题有三个:

问题一:表名/列名不匹配。 用户说"用户ID",数据库里是 user_id;用户说"订单金额",数据库里是 order_amount。我们加了同义词表(前面提到的 schema-synonyms.yml),准确率提升 8 个百分点。但这个方案有维护成本——每加一张新表,都要手动维护同义词。后来改为用 LLM 自动扩展同义词:每月跑一次批处理,从历史查询中提取用户术语和数据库列名的映射关系,自动更新同义词表。

问题二:复杂查询逻辑错误。 多表 JOIN 和子查询是重灾区。我们加了 few-shot 示例检索(前面提到的),并且按查询复杂度路由到不同模型(简单查询 qwen-turbo,复杂查询 qwen-plus),准确率提升 10 个百分点。

问题三:结果解释不准确。 初期 LLM 经常"编造"数据里的趋势(比如数据明明平稳,却说"呈上升趋势")。我们加了数据校验——先算出实际统计值,再让 LLM 解释,LLM 的解释必须和数据一致,否则回退到纯数据展示。这一步让解释准确率从 65% 提升到 91%。

三个月后的数据:

指标
第一版
第三版
SQL 生成准确率
71%
93%
平均响应时间
8.2s
2.4s
用户满意度
3.2/5
4.3/5
月度查询量
3,000
18,000

成本控制

按 qwen-turbo(简单查询)和 qwen-plus(复杂查询)的混合使用,月度成本:

  qwen-turbo:14,000 次 × 1500 input + 300 output tokens × ¥0.003/¥0.006 = ¥76 qwen-plus:4,000 次 × 2500 input + 500 output tokens × ¥0.008/¥0.02 = ¥96 总计:约 ¥172/月

如果全部用 qwen-plus,成本约 ¥430/月,提升 2.5 倍。路由策略的成本效益明显更优。

上线注意事项

1. 缓存层。 SQL 查询有天然的可缓存性——同样的问题、同样的时间范围,结果一样。我们在执行层加了 Redis 缓存,key 是 sql_hash + time_range,TTL 1 小时。缓存命中率约 35%,平均节省 30% 的 LLM 调用和数据库查询。

2. 查询历史。 每个查询的原文、生成的 SQL、执行结果、用户反馈都存入 query_history 表。历史数据有两个用途:一是作为 few-shot 示例的来源(新查询可以检索相似的历史查询),二是作为准确率评估的标注数据(用户点"有用/没用"后,标注数据用于模型微调)。

3. 模型版本管理。 qwen-turbo 在 2026 年 2 月升级了版本,SQL 生成准确率提升了约 3 个百分点,但 prompt 需要做微调(旧 prompt 在新模型上偶尔出现格式错误)。建议每次模型升级后,用评测集回归测试,记录准确率变化。

4. 权限隔离。 只读权限要严格隔离——不同用户组能访问的表和字段不同。我们用行级权限控制(通过 WHERE tenant_id = ? 自动注入),确保用户只能看到自己 tenant 的数据。

NL2SQL 的核心难点不是"生成 SQL",而是"生成正确的 SQL"。Schema 注入的质量、few-shot 示例的相关性、SQL 校验的严谨性,三个环节缺一不可。我们线上跑通的方案是"qwen-turbo 处理简单查询 + qwen-plus 处理复杂查询 + 同义词表 + few-shot 检索 + 三重校验"的组合,准确率 93%,基本能满足业务需求。

你们团队的数据分析痛点是什么?SQL 生成这块踩过什么坑?

相关学习资料