ARTICLE · 1069101
数据分析助手: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、子查询、窗口函数)上差距明显:
最终方案:简单查询用 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/DELETE2. 表名和字段名使用反引号包裹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无LIMITif (select.getOrderByElements() != null && select.getLimit() == null) {return ValidationResult.reject("ORDER BY 缺少 LIMIT 限制,请添加 LIMIT 子句");}// 检查前缀通配符LIKEif (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/Shanghaiusername: ${DB_READONLY_USER}password: ${DB_READONLY_PASSWORD}driver-class-name: com.mysql.cj.jdbc.Driverhikari:maximum-pool-size: 10minimum-idle: 2connection-timeout: 3000idle-timeout: 600000max-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());}}}
结果解释:文字 + 图表建议
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 种图表:
准确率提升:从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%。
三个月后的数据:
成本控制
按 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 生成这块踩过什么坑?