前两篇讲了两件事。
第一,企业查询不适合把最终 SQL 作为自然语言生成的直接结果。更容易控制的方式是:
text
Text -> DSL -> SQL
第二,生成 DSL 时,上下文不能只有原始数据库 schema,还需要整理过的语义查询说明。
这一篇用一个具体问题,把这个过程串起来。
用户先提出一个业务问题
假设业务人员问:
text
统计本月各客户的已完成订单金额,按金额从高到低排前 20 个。
这个问题看起来很普通。
如果是开发人员手写 SQL,大概也不难。
但如果要让系统根据这句话生成查询,就不能只看文本本身。
系统至少要知道:
- 当前有没有订单查询模型。
- "客户"用哪个字段表达。
- "本月"按创建时间还是完成时间。
- "已完成"对应哪个状态值。
- "订单金额"是不是可以做
sum。 - 汇总查询是否允许按客户分组。
- 排序能不能引用聚合别名。
这些信息不能靠临场判断,而要来自语义查询说明。
先准备一份查询说明
上下文可以是类似这样的内容:
text
OrderQueryModel:订单查询模型
适合回答:
- 订单列表查询
- 按客户、状态、时间统计订单金额
字段:
- customer$caption:客户名称,可返回、可分组、可排序。
- orderStatus:订单状态,可筛选。已完成使用 COMPLETED。
- completedAt:订单完成时间,可筛选、可排序。本月统计默认使用该字段。
- orderAmount:订单金额,可返回、可聚合,常用 sum(orderAmount) as totalAmount。
规则:
- 明细查询使用 columns。
- 汇总查询中,非聚合字段必须放入 groupBy。
- 聚合表达式需要设置别名。
- orderBy 可以引用聚合别名。
- 默认不要返回超过 50 行。
这段说明不解释底层表怎么 JOIN,也不把所有数据库字段都暴露出去。
它只说明当前查询入口能做什么,以及 DSL 要按什么规则写。
先得到 DSL,不直接得到 SQL
基于这份说明,用户问题可以先转换成 DSL:
json
{
"columns": [
"customer$caption",
"sum(orderAmount) as totalAmount"
],
"slice": [
{
"field": "orderStatus",
"op": "=",
"value": "COMPLETED"
},
{
"field": "completedAt",
"op": "[]",
"value": ["2026-06-01", "2026-06-30"]
}
],
"groupBy": [
"customer$caption"
],
"orderBy": [
"-totalAmount"
],
"limit": 20
}
customer$caption 出现在 columns,也出现在 groupBy。
这是因为它是汇总结果里的非聚合字段。
sum(orderAmount) as totalAmount 表达的是聚合指标。
orderBy 使用 -totalAmount,表示按聚合结果倒序。
时间范围已经从"本月"展开成了明确的起止日期。
这里得到的是结构化查询意图,不是最终 SQL。
查询引擎接手检查
DSL 生成以后,查询引擎要先检查。
例如:
customer$caption是否存在。customer$caption是否允许返回和分组。orderStatus是否允许筛选。COMPLETED是否是可识别的状态值。completedAt是否支持区间查询。orderAmount是否允许聚合。totalAmount是否和已有字段重名。groupBy是否覆盖了所有非聚合字段。orderBy引用的别名是否来自当前查询。
这些检查听起来琐碎,但它们很重要。
因为这一步能把很多错误拦在 SQL 生成之前。
如果字段写成了 customerName,系统可以提示字段不存在。
如果忘了 groupBy,系统可以指出汇总查询结构不完整。
如果把 sum(orderAmount) 的别名写成已有字段名,系统也能提前拒绝。
错误提示越明确,后续人工排查和自动修正都更容易。
SQL 由系统生成
DSL 通过检查以后,查询引擎再根据 TM/QM、字段映射和数据库适配规则生成 SQL。
最终 SQL 可能类似这样:
sql
select
c.name as customer_caption,
sum(o.order_amount) as total_amount
from biz_order o
join biz_customer c on c.id = o.customer_id
where o.status = ?
and o.completed_at >= ?
and o.completed_at <= ?
group by c.name
order by total_amount desc
limit 20
参数是:
text
COMPLETED
2026-06-01
2026-06-30
这段 SQL 是系统生成的。
这部分不需要放到上游生成结果里。
biz_order 和 biz_customer 怎么关联,当前数据库怎么分页,日期和字段别名怎么处理,都放在查询引擎里处理。
结果返回也要保留语义
数据库执行以后,结果可能是:
json
[
{
"customer$caption": "华东渠道客户",
"totalAmount": 186320.50
},
{
"customer$caption": "华南直营客户",
"totalAmount": 142008.00
}
]
这时返回给用户的,不能只是数据库行。
系统最好还能保留一些查询语义:
- 使用的是
OrderQueryModel。 - 时间字段是
completedAt。 - 订单状态筛选为
COMPLETED。 - 指标是
sum(orderAmount) as totalAmount。 - 排序是按
totalAmount倒序。 - 最多返回 20 行。
这样,用户看到结果时,才知道这次统计的口径是什么。
如果结果被质疑,开发人员也能回看:用户问题是什么,中间 DSL 是什么,系统最终执行了什么 SQL。
为什么要这样拆
从自然语言到 SQL,中间多了一层 DSL,看起来是多了一步,但这一步把分工拆清楚了。
用户问题保留原始意图。
语义查询说明描述当前能查什么。
DSL 承载结构化查询意图。
查询引擎负责校验、改写和生成 SQL。
数据库只负责执行最终查询。
这样,上游不需要理解整个数据库,后端也不用把任意 SQL 当成业务查询入口。
当问题变复杂时,系统还可以继续扩展:增加更多查询模型、增加指标规则、增加错误提示、增加审计记录、增加人工确认流程。
但基本流程不变:
text
自然语言问题
-> 语义查询说明
-> 受控 DSL
-> 查询引擎校验
-> SQL
-> 结果与口径说明
到这里,"从动态 SQL 到语义查询"这条线基本就走完了一段。
它不是从一开始就奔着自然语言查询去的。
最早只是为了解决 Java 项目里动态 SQL 越写越乱的问题。
后来有了模板、helper、权限注入、DSL、TM/QM、聚合、时间窗口、层级、pivot、错误提示和查询引擎。
再往后,自然语言只是多了一个新的入口。
关键仍然是这套查询能力本身是否清楚、受控、可检查、可复核。
下一阶段如果继续写,就不只讲查询引擎本身了。
更适合进入一个新的方向:
企业 AI 分析项目到底应该怎么落地。