返回正文目录

AI 数据分析技术篇审阅稿

更新日期:2026 年 10 月 17 日。技术篇第 1 至 5 章。第一章此前已通过且正文未改;前五章已整体自审,按用户要求暂定。

本篇讲直接 Text-to-SQL 与语义 DSL 的基本原理、语义模型的组成和查询约束,再讨论方案选择与系统分工。第六章合并项目范围、引擎介绍与环境接入,作为实战篇开篇,不在本合订稿中。语义 SQL 与统一执行计划仍留到高级篇。

章节 单章入口
第 1 章 从自然语言到 SQL 正文
第 2 章 SQL 能执行结果为什么仍可能不对 正文
第 3 章 语义层的作用与代价 正文
第 4 章 自然语言到 DSL 再到 SQL 正文
第 5 章 怎样选择和组织问数方案 正文

第 1 章 从自然语言到 SQL

“帮我查一下,2025 年 6 月销售额最高的三个客户是谁。”

把问题、表结构和业务说明交给 AI,让它生成 SQL,再由查询工具执行,就是本章介绍的 Text-to-SQL 路线。

先明确销售额怎么算

本章使用 SQLite 和自编的虚构销售数据。销售额按有效发票总金额累计,月份按开票日期统计,只计算 ISSUED 状态。

金额以分保存,按人民币元展示时除以 100,因为 100 分等于 1 元;原本以元保存则无需换算。日期使用同一时区,格式为 YYYY-MM-DD HH:MM:SS。

实际项目中,应先从业务负责人、字段说明或已有报表确认这些口径。

把必要的表结构交给 AI

客户表 customers:

字段 含义
customer_id 客户 ID,主键
customer_name 客户名称

发票表 invoices:

字段 含义
invoice_id 发票 ID,主键
customer_id 对应客户表的客户 ID
invoice_date 开票日期,可能包含时分秒
total_amount_cents 发票总金额,单位为分
status ISSUED 表示有效,CANCELLED 表示作废

一位客户可以有多张发票,两表通过 customer_id 关联。将上述结构和下面的要求一并交给 AI,也可以让它先通过工具读取表结构:

请用 SQLite 查询 2025 年 6 月有效发票金额最高的三个客户。按开票日期统计,只计算 ISSUED 状态,金额从分换算为元。返回客户 ID、名称和销售额,金额相同按客户 ID 升序。

看这句话怎样变成 SQL

对应的查询如下:

SELECT
    c.customer_id,
    c.customer_name,
    ROUND(SUM(i.total_amount_cents) / 100.0, 2) AS sales_amount
FROM invoices AS i
JOIN customers AS c
    ON c.customer_id = i.customer_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY sales_amount DESC, c.customer_id ASC
LIMIT 3;

i、c 是表别名。查询通过 JOIN 关联客户,用 WHERE 筛选有效发票,再按客户分组求和、排序取前三名。

时间范围写成“大于等于 6 月 1 日、小于 7 月 1 日”,可以包含六月最后一天带时分秒的记录。

/ 100.0 将分换算为元,例如 300000 分对应 3000 元;ROUND(..., 2) 将结果四舍五入到小数点后两位。

执行 SQL 后才能得到数字

生成 SQL 后,由数据库客户端或应用提供的查询工具执行,得到真实结果:

flowchart LR
    A[问题和表结构及业务说明] --> B[AI 生成 SQL]
    B --> C[查询工具执行 SQL]
    C --> D[数据库返回结果]
    D --> E[AI 整理回答]

本例结果如下:

客户 ID 客户名称 销售额(元)
1 华东食品 5500.00
2 远山商贸 4200.00
3 青禾便利 3000.00

可以逐笔核对:华东食品的两张有效发票分别为 3000 元和 2500 元,合计 5500 元。北辰超市六月有一张 99999 元的作废发票,已被排除。

AI 整理回答时,应同时说明统计月份、使用开票日期和排除作废发票的口径。

先通过提示词解释数据结构

从这个例子可以看出,Text-to-SQL 首先要通过提示词向 AI 解释数据库的表、字段和关系,让它理解数据结构,再结合业务口径生成查询。

这些信息可以人工整理,也可以由程序读取元数据后补充到提示上下文中。表结构之外,还要说明状态编码、日期口径和金额单位,才能把“销售额”这样的业务说法对应到具体字段与计算方式。

让 AI 从源码整理提示词

已有 Java 源码时,可以让 AI 先整理提示词草稿:

枚举和业务代码还能补充状态含义与计算规则。可以这样交代:

请阅读客户、发票相关的实体类、Mapper、SQL 和业务代码,整理表、字段、主键及关联,补充金额单位、状态、日期和计算规则。注明来源文件与位置,不确定的列为待核对项,输出供 Text-to-SQL 使用的提示词草稿。

再由人核对源码是否对应当前数据库,以及关联、单位和业务含义是否准确。这样可以减少逐表逐字段编写说明的工作。

自己改一次查询

把问题改成“2025 年 7 月销售额最高的两个客户”,调整时间上下界和条数限制,再核对结果。

将建表与数据、查询和复跑脚本放在同一目录,安装 Python 3 后进入该目录运行:

python -X utf8 verify.py

脚本使用 Python 自带的 SQLite,在内存中建库,核对六月查询和七月练习,输出结果并保存为 chapter-01-results.json。

本章小结

这次 Text-to-SQL 查询中,AI 承担了以下工作:

环节 AI 在本例中需要完成的工作
理解问题 确定月份、汇总对象和前三名要求
匹配表和字段 从结构说明中选择客户、发票及所需字段
确定关联与粒度 按客户 ID 关联,每张有效发票金额只累计一次
展开计算规则 确定有效状态、时间边界和金额求和方式
按需换算金额单位 以分保存、按元展示时,将金额合计除以 100.0;原本以元保存则无需换算
生成 SQL 按 SQLite 语法组织关联、筛选、分组、计算、排序和条数限制
根据结果回答 根据查询结果说明客户排名、金额和统计口径

查询工具提交 SQL,数据库执行数据读取与计算,AI 再根据结果组织回答。后面对比 DSL 路线时,可以逐项看哪些工作仍由 AI 完成,哪些定义交给语义模型和查询引擎。

下一章加入发票明细,看看 SQL 正常执行时,金额为什么仍可能算错。

第 2 章 SQL 能执行结果为什么仍可能不对

上一章,青禾便利六月的销售额是 3000 元。这次增加一个要求:“把商品明细行数也列出来。”

查询关联发票明细表后,SQL 正常执行,青禾便利却变成了 6600 元,排名超过了远山商贸。

加上明细表以后

统计口径沿用上一章:按开票日期统计六月有效发票的总金额。

新增的 invoice_lines 每行代表一条商品明细,通过 invoice_id 关联发票;line_id 是明细 ID,line_amount_cents 是明细金额,单位为分。本题统计明细行数,与商品销售件数不同。

如果直接关联明细,再按客户累计发票金额、统计行数,会得到下面的查询:

SELECT
    c.customer_id,
    c.customer_name,
    ROUND(SUM(i.total_amount_cents) / 100.0, 2) AS sales_amount,
    COUNT(d.line_id) AS detail_line_count
FROM invoices AS i
JOIN customers AS c
    ON c.customer_id = i.customer_id
LEFT JOIN invoice_lines AS d
    ON d.invoice_id = i.invoice_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY sales_amount DESC, c.customer_id ASC;

为方便核对,这次列出本期所有有有效发票的客户:

客户 销售额(元) 商品明细行数
华东食品 11000.00 4
青禾便利 6600.00 4
远山商贸 4200.00 1
南桥百货 1800.00 2

远山商贸的金额没变,其他客户却被多算了。

一张发票为什么被加了两次

华东食品的 1002 号发票共 3000 元,有两条明细。关联结果如下,金额已换算成元:

发票 ID 明细 ID 发票总金额(元) 明细金额(元)
1002 21 3000.00 1200.00
1002 22 3000.00 1800.00

一张发票关联成两行,两行都带着 3000 元的发票总金额,SUM(i.total_amount_cents) 就把它加成了 6000 元。华东食品的两张发票各有两条明细,因此 5500 元变成了 11000 元。

青禾便利的一张 1800 元发票有三条明细,另一张 1200 元发票有一条,错误结果是 1800 × 3 + 1200 = 6600 元。

这就是粒度问题:一行代表一张发票,还是一条商品明细。数据库按关联后的行求和,查询编写者需要保证发票金额每张只计一次。手写 SQL 同样可能犯这个错误。

先把明细汇总回发票这一层

先按发票统计明细条数,让中间结果每张发票只剩一行,再关联回来。将原来的明细 JOIN 替换为:

LEFT JOIN (
    SELECT invoice_id, COUNT(*) AS detail_line_count
    FROM invoice_lines
    GROUP BY invoice_id
) AS d
    ON d.invoice_id = i.invoice_id

SELECT 中原来的 COUNT(d.line_id) 也要改为累加已汇总的明细条数:

SUM(COALESCE(d.detail_line_count, 0)) AS detail_line_count

日期、状态和金额求和保持原样,完整 SQL 见修正查询。每张发票只关联一行统计结果;没有明细时,LEFT JOIN 保留发票,COALESCE 将条数按零处理。

结果恢复为:

客户 销售额(元) 商品明细行数
华东食品 5500.00 4
远山商贸 4200.00 1
青禾便利 3000.00 4
南桥百货 900.00 2

本样例直接累计 line_amount_cents 也能对上,因为明细合计与发票总金额一致。这里保留发票口径,同时增加明细条数;实际项目中需先确认两种金额是否一致。

按金额去重还会遇到另一种错误

还有一种看似省事的改法:

SUM(DISTINCT i.total_amount_cents)

当前样例恰好能算对,但 DISTINCT 去重的是金额值。

给华东食品增加一张 ID 为 1099、金额为 3000 元的有效发票后,三张发票应合计 3000 + 2500 + 3000 = 8500 元。金额去重后却只剩 5500 元。

要判断是否同一张发票,应看发票 ID;两张发票金额相同,也需要分别累计。

粒度正确以后还要核对哪些口径

避开重复累计,还不等于选对了业务口径:用户问订单金额,却查了发票金额;同样叫“销售额”,也可能有含税、未税之分。

日期也会漏数:若结束条件写成 invoice_date <= '2025-06-30',月底 23:59:59 的发票就会被漏掉,青禾便利只剩 1800 元。

空结果也不能直接解释为“没有销售”,还需检查筛选、数据覆盖范围和刷新时点。

熟悉 SQL 不等于熟悉你的数据库

教学库的 total_amount_cents 把单位写在了字段名里,真实业务库未必这么清楚。假设 order_amount 存的是分,service_fee 存的是元,同样保存 1000,分别表示 10 元和 1000 元。查询两个字段时,不能统一除以 100。

即使提示词逐项说明,LLM 仍可能漏掉分转元,或把已经是元的字段再除一次。说明能帮助选对字段,却不能代替实际执行换算的计算规则。

旧字段也是陷阱:两个金额字段名字相似,其中一个却早已停止更新。选错后 SQL 照样能执行,数值还可能看着挺像那么回事。字段名、注释和样例值都要结合业务核实。

直接基于物理表生成 SQL,AI 通常需要相关结构和字段说明;工具若开放 Schema 浏览或展示 SQL,使用者也会接触这些物理细节。

说点不那么规范的:有时候写代码写烦了,我也会给字段起个 xxx_*** 之类的名字,或者加点不方便在客户面前念出来的前缀。这些名字留在库里还好,出现在问数界面上,可能就得先解释一下当时的精神状态了。大家还是多遵守命名规范,出来混都是要还的,指不定哪天就社死了。

准备查询数据时,应只提供当前主题所需的表和字段,排除过时字段;把常用换算、计算和过滤写成可复用的 SQL 表达式或视图,再用简短说明补充业务歧义。表结构或口径变化后,这些定义也要维护。Databricks 的实践建议同样优先采用结构化定义和查询示例,避免把所有规则堆进文字说明。

复杂 SQL 的复核和权限处理成本

前面只加了一次关联,就需要展开记录才能看清错误。再增加多层子查询、CTE、UNION 和计算字段,复核就要逐层追查字段来源、过滤位置和聚合粒度。

权限条件也有位置之分:把右表的权限条件直接放进 WHERE,可能让 LEFT JOIN 丢掉本应保留的无匹配记录;先计算全公司的前三名再过滤,也不等于本部门的前三名。

Text-to-SQL 可以通过数据库行级策略、受限视图或查询服务落实权限。数据库策略需要身份映射与配置;查询服务自行解析、改写 SQL,则要处理表、别名、子查询和分支。复杂 SQL 增加的是这类改写与验证成本,不意味着数据库原生权限也必须逐条改写查询。数据库行级策略示例、查询引擎权限示例。

权限必须来自可信身份和授权配置,不能根据用户在对话里自报的部门判断,也不能只要求 AI 记得加条件。

跨表探索可能需要较宽的只读范围,但仍须按工作需要授权。只读限制写入,不等于限制能读取哪些人的数据。

结果仍需要独立复核

本例可以逐笔核对发票金额,按发票 ID 检查是否重复累计;实际项目也要对照原始记录或已确认的报表,核对口径、粒度和结果。AI 可以辅助审查,但让它再解释一遍自己生成的 SQL,还不能算完成了复核。

运行本章样例

按样例说明中的第 2 章,将所需的六个文件放在同一目录,运行:

python -X utf8 verify-chapter-02.py

脚本在独立内存库中核对基础样例和边界情况,结果写入 chapter-02-results.json,不修改第一章原始数据。

本章小结

讲到这里,Text-to-SQL 的实现与限制应该更清楚了:LLM 熟悉 SQL,不代表它熟悉你的数据库。字段含义、业务口径和权限范围,需要有人确认、由系统落实,并用真实数据核对。

下一章介绍怎样把可复用的业务定义组织为语义模型,让其中已经确认的规则由引擎重复执行。

第 3 章 语义层的作用与代价

前两章为了查出六月客户销售额,已经确认了不少事情:金额来自发票,按开票日期筛选,只统计有效发票,分转元,每张发票只累计一次。

换成七月、按月份统计,这些规则大多仍然适用。我们希望建立的语义层,能让 AI 理解这些业务含义,正确调用查询,并依据实际结果如实回答用户。为此,系统需要提供可复用的定义、执行规则,以及 AI 能理解的查询接口和返回信息。

语义层是什么

面向分析的语义层,通常会将底层数据整理成以事实为中心、由维度和指标组织起来的逻辑分析模型,下文称“语义模型”。它沿用星型模型的核心思想,让 AI 面向业务概念查询,再由系统处理底层关联与计算。

先看一张典型的星型模型图:

典型星型模型:中心是事实表,周围是维度表

图片来源:Microsoft Learn:理解星型模型。

中间的 Fact table(事实表)记录业务发生了什么,例如一笔销售;周围的 Dimension table(维度表)提供观察角度,例如客户、商品、日期和地区。

这里的“向星型结构对齐”,发生在逻辑分析层。底层可以仍是现有业务表,日期也可以直接来自发票字段,无须另建日期表。语义模型负责整理来源与关联,并定义有效状态、分转元和汇总规则,为 AI 提供含义明确的销售分析入口。

整个语义层还可以包含销售、回款等多个分析主题,形成多颗相互关联的“星”;跨主题查询仍需协调各自粒度。

语义模型的基本构成

沿用客户和发票,几个常见概念可以这样理解:

要素 回答什么问题 本例
业务对象 分析围绕什么业务事物 发票、客户
粒度 一条事实代表什么 一张发票,而非一条商品明细
主键 怎样唯一识别对象 invoice_id 识别发票,customer_id 识别客户
维度 从什么角度筛选、分组 客户、开票日期
属性 怎样描述业务对象 客户名称;同名客户仍靠 ID 区分
指标 要计算什么、怎样汇总 有效开票销售额
关联 对象怎样连接、是一对几 多张发票对应一位客户

属性也可以公开为筛选或分组字段,具体用途由语义模型定义。指标则需要明确计算与聚合规则,不能只列一个字段名。

“六月哪些客户销售额最高”,就是按日期筛选、按客户汇总销售额。

先把销售额定义清楚

沿用小销售库,为本例的“有效开票销售额”写一张定义卡:

项目 本例定义
金额来源 invoices.total_amount_cents,存储单位为分
统计粒度 一张发票,以 invoice_id 识别;每张只累计一次
有效条件 status = 'ISSUED'
日期口径 使用 invoice_date,按统一时区解释
汇总与单位 累加有效发票金额,再除以 100.0,得到元
客户关系 通过 customer_id 关联客户,一位客户可以有多张发票
可见范围 按系统提供的当前用户身份和权限限制数据

“查六月”“按客户分组”“取前三名”属于本次查询条件。查七月时,指标定义不变,日期范围变了。

含税、未税、退款等业务差异也要明确,必要时分别命名指标,不能指望所有人说的“销售额”天然是同一个数。

从定义卡到可执行的语义模型

上面的卡片供人确认口径;要参与执行,还要转换为引擎认识的语义模型。下面是结构化定义示意,不是某个产品可直接加载的配置:

name: SalesInvoiceQueryModel
caption: 有效开票销售分析
object: invoice
source: invoices
primaryKey: invoice_id
fixedFilter: "status = 'ISSUED'"
dimensions:
  invoiceDate:
    column: invoice_date
    type: datetime
  customer:
    source: customers
    key: customer_id
    caption: customer_name
    join: "invoices.customer_id = customers.customer_id"
    relationship: many_to_one
metrics:
  salesAmount:
    expression: "invoices.total_amount_cents / 100.0"
    aggregation: sum
    type: decimal
    unit: 元

这里包含来源、主键、类型、过滤条件、关联关系和聚合表达式。加载时,系统将它们解析为语义模型对象,检查引用和表达式;查询时按这些定义组织计算。

写“单位为元”只是说明;expression 定义了单张发票的分转元,aggregation: sum 定义了汇总方式。系统依据这些规则生成计算,AI 不必每次重新决定这个字段是否除以 100。

建模人员:准备并验证语义模型

建模人员确认口径、编写语义模型,再用已知结果验证。这些工作可以由 AI 辅助;语法检查通过,仍不代表业务口径正确。

sequenceDiagram
    participant B as 建模人员
    participant S as 语义层
    B->>S: 提交已审核的语义模型
    S->>S: 校验并加载
    B->>S: 用已知问题试查
    S-->>B: 返回结果,供核对与修正

LLM 看到的是导出的查询元数据

语义模型加载后,系统按当前用户可见范围导出查询元数据,组织成 Markdown、JSON 或工具返回的描述,交给 LLM 使用。这里交付的是查询说明,而非完整的语义模型源文件;定义变更后,导出元数据也要同步更新。

导出的内容重点是:语义模型适合回答什么问题,字段怎样引用,单位和业务含义是什么,哪些字段能筛选、分组或聚合。物理表名、JOIN 表达式和内部权限配置,不必全部交给 LLM。

本例将客户关联的键和名称分别公开为 customerId、customerName;查询字段名可以与底层列名不同。

下面是该语义模型导出的 Markdown 简化示例。正式元数据还应包含字段的数据类型(如日期、数值、文本)等信息;示例字段名仅作说明,具体接口以实际公开名称为准:

# 有效开票销售分析
语义模型标识:SalesInvoiceQueryModel
语义模型名称:有效开票销售分析
适用:按开票日期分析有效发票销售额。

可查询字段:
- customerId:客户 ID,可分组;用它区分同名客户。
- customerName:客户名称,展示时与客户 ID 一起使用。
- invoiceDate:开票日期,支持日期范围筛选。
- salesAmount:单张发票金额,单位元,可求和。

LLM 据此把“六月销售额最高的客户”对应到日期、客户分组和金额求和,并用 SalesInvoiceQueryModel 指定查询目标。语义模型元数据怎样用于查询生成。

一次查询怎样使用语义模型

本次查询只需表达:按客户统计六月的 salesAmount,金额降序,客户 ID 升序,取前三名。这里先用文字表示意图,下一章再写具体请求。

建模不必随每次提问重复。用户提问后,LLM 结合查询元数据与协议组织请求;问题有歧义时先澄清。下图展示问题已明确的查询过程:

sequenceDiagram
    participant U as 用户
    participant A as LLM
    participant S as 语义层
    participant D as 数据库
    U->>A: 六月销售额最高的前三名客户?
    A->>S: 获取当前用户可见的查询元数据
    S-->>A: 返回语义模型标识及字段说明
    A->>S: 提交语义查询
    S->>S: 校验请求、落实权限、展开规则
    S->>D: 提交编译后的物理 SQL
    D->>D: 执行查询
    D-->>S: 返回结果或错误
    S-->>A: 返回数据、口径、范围与执行状态
    A-->>U: 依据结果回答,说明限制

其中,语义层的查询处理包括:

环节 本例怎样处理
字段绑定 将请求中的 customerId、salesAmount 绑定到语义字段及其物理来源
计算展开 按求和规则展开为 SUM(invoices.total_amount_cents / 100.0)
过滤与关联 加入语义模型的 ISSUED 条件,按声明路径关联客户,并应用六月范围
聚合规划 按客户 ID、名称汇总,确保本次取数不展开为多条明细
校验与编译 拒绝不可用字段或不支持的组合,落实权限,生成目标数据库的查询

这些规则无须 LLM 在每次请求中重写:它没有填写有效状态条件,固定过滤仍应生效。报表或应用提交同样的语义请求,也会使用这些规则。

权限同样需要执行链路:可信身份提供用户范围,引擎或数据库施加约束。仅在描述里写“只能查本部门”,不会自动形成授权策略。

有些集成只用语义模型描述辅助 AI 生成物理 SQL。它能改善理解,却没有获得上述查询入口对业务规则的执行约束,两种用法需要分清。

指标也不只是字段别名

销售额可以求和,其他指标却未必。假设再定义“平均每张有效发票金额”:

平均每张有效发票金额 = 有效开票销售额 ÷ 有效发票张数

按客户查询时,分子和分母都在各客户范围内计算;看六月总计时,要用全期总金额除以总张数。不能把各客户的平均值直接相加,也不能不看张数就取平均。

因此,语义模型不仅要说明字段叫什么,还要表达指标依赖、聚合方式和适用维度;执行器要在当前分组、筛选范围和计算阶段展开这些定义。指标不是预先算好的一个数,也不是给某列换个中文名。指标在查询上下文中求值的解释。

字段公开了,不等于能任意组合

发票金额可以按客户汇总;扩展到商品时,一张发票却可能包含多种商品,不能把整张发票金额算给每一种商品。此时要明确按商品分摊的规则,或改用商品明细金额。

语义模型需要声明关联的基数(一对一、多对一或一对多)和指标适用的维度。引擎在支持范围内规划关联与汇总,无法可靠处理的组合应明确拒绝。只声明“发票关联明细”,不会自动解决重复累计。维度与指标粒度的约束示例。

查对了,还要答得如实

本例查出的六月前三名客户销售额分别是 5500、4200、3000 元,合计 12700 元。AI 可以说“前三名客户合计 12700 元”,却不能说“六月总销售额为 12700 元”:查询只取了前三名。全体客户的六月合计是 13600 元,需要另一项汇总才能得到。

因此,查询工具返回数值时,还应保留指标、单位、筛选范围、是否截断和执行状态,供 AI 核对和说明。没有记录、金额为零和查询失败要分别处理;问题有歧义时先澄清,只有销售数据时,也不能凭空编出业绩变化的原因。语义层能提供依据与约束,最终回答仍需检查。

回看第二章:哪些工作交给了语义层

第二章已经用 SQL 修正了重复累计;视图、公共表达式和数据库策略也能复用规则。下面对比直接面向物理表生成 SQL 与通过语义层查询,看看工作怎样分配:

第二章的问题 直接生成物理 SQL 通过语义层查询
分、元混用,口径不一致 在 SQL 或视图中落实换算与过滤 在语义模型中集中定义,由引擎展开
关联后重复累计 按粒度组织 JOIN 与汇总,核对结果 声明粒度与关联,由引擎在支持范围内规划;无法可靠处理时拒绝
旧字段与内部命名 整理 Schema,或用视图提供业务字段 公开确认过的语义字段,物理映射留在后台
权限处理复杂 使用数据库策略、受限视图,或解析改写 SQL 对语义请求统一校验,结合执行引擎或数据库策略落实权限
查询复核困难 追查 SQL 中的字段来源、过滤与聚合 先核对语义定义与查询意图,再追溯实际 SQL

上一章那些不方便念出来的字段名,也终于有机会留在幕后。

可以看到,语义层承担了大部分确定性工作,LLM 不必直接处理复杂的物理表结构、关联与计算细节,减少了犯错的概率。

它又带来了哪些问题

规则有了统一的执行位置,LLM 还需要掌握查询接口。沿“自然语言 → DSL → SQL”这条路线,主要新增的门槛在 DSL(领域专用语言)的使用上:LLM 即使熟悉 SQL,也不能默认它熟悉某个系统自定义的 DSL。

查询元数据告诉它有哪些语义模型和指标,却不一定告诉它该怎样拼出合法请求。接入时需要提供两类上下文:

上下文 回答什么问题
查询元数据 查哪个语义模型、字段是什么意思、单位与可用分析范围是什么
查询协议 语义模型标识放在哪里,怎样选择字段、写过滤、分组、聚合、排序与分页

即使请求是合法 JSON,字段、操作符或计算阶段写错,仍然不能正确查询。第四章会用具体请求说明。

因此,要通过提示词、工具 Schema 和完整示例,让 LLM 知道这个接口的用法,包括支持什么、出错后怎样修正。复杂分析增加新操作后,协议说明与验证题库也要跟上;更换 LLM 时,也要重新验证适配效果。

放在一起看,直接查询物理表时,LLM 可以使用熟悉的 SQL,却要直面业务口径与表关系的复杂度;改用语义 DSL,业务含义清晰了,又需要教会 LLM 怎样使用查询协议。

语义层也可以提供 SQL 入口,让 LLM 面向虚拟业务表查询;本系列先用 DSL 学完整的问数流程,语义 SQL 留到高级篇“语义 SQL 与虚拟业务表”专题展开。

SQL 的表达能力很强,但部分复杂的分阶段或多维分析,用合适的 DSL 可以表达得更简洁。

我个人认为,随着 LLM 理解和推理能力增强,自定义 DSL 的适配门槛有望逐步降低。当 LLM 能够准确理解协议、拆解分析步骤时,就更能发挥 DSL 在复杂分析中表达简洁、边界清晰的优势。这些差异留到高级篇展开。

没有数仓,也可以先建语义层

ETL 负责抽取、转换和加载,例如统一日期格式、处理重复记录、汇集不同来源的数据;语义层负责业务含义和查询规则。源数据漏了发票,语义模型再完整也算不回来。

中小企业可以先对现有业务表或视图建模,遇到数据质量、跨来源对齐问题,再补充必要的数据准备。多来源处理放到高级篇展开。

一个小练习:客户数量怎么算

为“六月客户数量”补全三项定义:统计什么客户、按什么键去重、使用哪个日期。

在原始样例的全部数据范围内,“客户档案总数”是 5;“六月有有效发票的客户数”是 4。后者需要按开票日期和有效状态筛选,再按 customer_id 去重。即使一位客户有多张发票,也只能算一位。

两个数都可能算对,回答的是不同问题。语义层要把这种区别命名、定义清楚。

本章小结

语义模型定义业务含义和执行规则,元数据与查询协议帮助 LLM 表达意图,引擎负责校验与编译,数据库执行计算。AI 再依据结果及其范围回答;查询成功,仍需检查答案是否如实。

采用自定义 DSL 时,还要解决 LLM 对查询协议的适配。下一章用同一个销售问题,写出具体请求,逐项对照生成的 SQL。

延伸阅读:指标定义与分组维度、业务对象与语义映射。

第 4 章 自然语言到 DSL 再到 SQL

仍然查询“2025 年六月销售额最高的三位客户”。上一章已经定义了语义模型;现在看 LLM 怎样表达本次查询,以及引擎怎样把它转换为物理 SQL。

这条路线是:自然语言 → 语义 DSL → 物理 SQL → 数据库执行。

先选择语义模型,再读取字段

沿用上一章的 SalesInvoiceQueryModel:以发票为粒度,只统计有效发票,金额统一为元,并公开以下字段。

语义字段 含义及本例用法
customerId 客户 ID,分组时区分同名客户
customerName 客户名称,用于展示
invoiceDate 开票日期,用于筛选
salesAmount 单张有效发票金额,单位元,可以求和

LLM 先从当前用户可见的语义模型中选择,再读取查询元数据,确认字段、类型、单位与可用操作。

本次问题可以拆成:筛选六月、按客户分组、累计销售额、按金额降序取三位。金额并列时按客户 ID 升序,保证结果顺序稳定。

把业务用语对应到字段和值

字段选对了,筛选值也要有依据:

查询元数据提供业务含义与已定义的映射;具体维度值可以按需查询,不必一次全部塞给 LLM。

用 DSL 表达本次查询

元数据告诉 LLM“能查什么”,查询协议告诉它“怎样写请求”。为说明二者怎样配合,这里约定一套简化 DSL,用 JSON 承载查询意图,不作为某个产品的可执行接口:

{
  "model": "SalesInvoiceQueryModel",
  "columns": [
    "customerId",
    "customerName",
    "SUM(salesAmount) AS totalSales"
  ],
  "slice": [
    {
      "field": "invoiceDate",
      "op": "[)",
      "value": ["2025-06-01", "2025-07-01"]
    }
  ],
  "groupBy": ["customerId", "customerName"],
  "orderBy": ["-totalSales", "customerId"],
  "limit": 3
}

即使之前没用过这套协议,看到字段、分组、排序和条数,大概也能读出它想查什么。DSL 不必从零发明一套陌生语法,可以借助 JSON、YAML 等熟悉的格式表达领域含义:上一章用 YAML 定义语义模型,这里用 JSON 描述查询。需要学习的是具体的键名、操作符和执行含义。

在这套约定里,model 指定语义模型;columns 选择返回字段,支持求和及别名;slice 筛选聚合前的记录;groupBy 指定分组;orderBy 中的负号表示降序;limit 限制返回条数。

这里的 SUM 适用于可加总的单张发票金额。平均金额、比例等指标,应遵循语义模型公开的计算方式,不能一律求和。

[) 表示左闭右开区间,包含六月末尾,不包含七月一日。LLM 需要通过协议说明、工具 Schema 和示例获知这些写法。

筛选发生在聚合前还是聚合后

同样是“超过三千元”,筛选对象不同,结果含义也不同:

用户问题 处理含义
只统计单张金额超过三千元的有效发票 先筛选发票,再汇总金额
找累计有效开票销售额超过三千元的客户 先按客户汇总,再筛选客户的汇总金额

在 SQL 中,两者通常分别用 WHERE 和 HAVING 表达。本章的简化 DSL 只示范了 slice 聚合前筛选;聚合后筛选需要查询协议另行支持,不能把汇总条件直接塞进 slice。

语义模型的有效状态和权限约束仍要生效;请求按需表达行筛选、分组、汇总后筛选、排序和条数。LLM 要分清这些阶段,引擎也要校验相应操作是否支持。

引擎怎样落实查询

引擎先检查语义模型和字段是否可用、类型、字段组合与聚合是否合法,再根据可信身份落实权限。随后展开金额公式、加入固定过滤、组织客户关联,编译为目标数据库的 SQL。

下面用 SQLite 写出本例的等价物理 SQL,帮助读者核对规则落在了哪里。它是教学示意,不是某个引擎的真实编译输出。

SELECT
    c.customer_id,
    c.customer_name,
    SUM(i.total_amount_cents / 100.0) AS total_sales
FROM invoices AS i
JOIN customers AS c ON c.customer_id = i.customer_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY total_sales DESC, c.customer_id ASC
LIMIT 3;

其中,客户 JOIN、ISSUED 条件和 / 100.0 来自语义模型;六月范围、客户分组、排序与前三名来自本次请求。

在小销售库中执行这段 SQL,结果仍为:

客户 销售额(元)
华东食品 5500.00
远山商贸 4200.00
青禾便利 3000.00

本例使用全部样例数据,实际结果还取决于当前用户的数据范围。LLM 回答时,应保留“2025 年六月”“有效开票销售额”“前三名”等信息。

字段写错时怎样修正

如果 LLM 把 customerName 写成 clientName,当前语义模型没有这个字段,请求应在校验阶段被拒绝。

LLM 根据诊断重新读取查询元数据,确认是拼写错误、字段未公开,还是选错了语义模型,再修正请求。不能仅凭名字相近就替换,也不能为绕过权限退回物理表查询。

诊断可以帮助修正无效字段,却未必识别业务选择错误。比如用户问回款,LLM 却选择了有效开票销售额,字段和语法都可能合法,仍需核对查询意图。

本章小结

LLM 理解问题、选择语义模型并表达本次查询;引擎依据语义定义校验与编译,数据库完成计算,LLM 再按实际结果回答。业务规则由系统落实,查询意图和回答仍需要核对。

下一章把直接 Text-to-SQL 与语义层查询放在一起,讨论不同场景怎样选择,以及怎样组织完整的问数系统。

本章样例与核对方法提供请求示意、等价 SQL 与核对范围。

第 5 章 怎样选择和组织问数方案

前四章介绍了两条路线:直接生成物理 SQL,或通过语义 DSL 查询。本章先根据使用者和问题选择起点,再看怎样组合工具、语义能力和多步分析。

什么场景适合直接 SQL,什么场景适合语义层

如果服务的是日常与 SQL 打交道的技术人员或数据分析师,他们熟悉业务库,能够审查生成的 SQL 和结果,直接 Text-to-SQL 是合适的起点,也便于探索新问题。

如果目标用户不懂 SQL,甚至不了解数据库概念,更适合先通过语义层整理业务口径与可查询范围,让他们围绕业务概念问数。即使使用者会 SQL,团队反复使用统一指标、需要多人稳定问数时,也值得采用语义层,减少重复定义与维护。

这有点像用户要查数据时,你是给他一个 SQL 客户端,让他自己组织查询,还是提供口径已经确认的报表,让他直接看业务结果。语义层还可以在已定义的范围内支持灵活提问和追问,而不局限于固定报表。

两种方式可以并存:技术人员先探索新问题,再把确认过的常用定义沉淀到语义层。

业务规则可以放在哪里

决定采用语义层后,先看已有工具和数据环境能提供哪些能力:

组合方式 业务定义与执行在哪里 LLM 怎样参与
复用已有分析系统 使用已经确认的维度、指标、关联和查询接口 读取元数据,选择字段与条件,调用已有查询能力
接入独立语义查询服务 由服务加载语义模型,统一校验与执行 调用语义查询工具,与报表等入口复用定义
利用数据平台内的语义能力 在数据平台中维护指标及其访问规则 围绕平台公开的业务查询面组织请求

这些方式可以组合,也可以分步建设。已有合适的语义模型与接口,就先核对能否复用;若要提供语义查询,而手头只有原始业务表,就需要补上业务定义和查询能力。不能因为产品名字里有“AI”或“语义”,就省掉实际核对。

工具接通,还要看它执行什么

CLI、API 或工具协议解决的是怎样调用能力。一个工具可能只返回表结构,也可能执行物理 SQL,或接受上一章的语义查询。这些接口都能被 LLM 调用,承担的工作却不同。

接入时至少看清三件事:

多步分析由谁组织

“六月客户前三名”可能一次查询就够;“销售变化主要来自哪些客户”,往往需要先比较两个月的汇总,再按客户拆解变化,核对客户差额是否与总差额一致。

负责多步任务的 AI 助手,通常称为 Agent,在这里安排步骤、调用工具并检查中间结果。它可以使用同一个语义服务,也可以组合已有分析工具。语义层则负责每次查询所用的业务定义与执行规则,两者可以配合。

用一个问题检查整条链路

仍用“2025 年六月销售额最高的三位客户”检查:LLM 提交了什么查询,系统在哪里落实有效状态和分转元,结果适用于哪个用户范围,最后的回答是否保留了这些含义。

再追问“为什么第一名更高”,看系统会不会检查可用数据、补充查询或说明证据不足。界面能回答第一句话,不等于已经具备持续分析的能力。

本章小结

选择路线时,要同时看使用者审查 SQL 与结果的能力、指标复用和交付要求。采用语义层后,可以复用已有分析系统,也可以接入独立服务;Agent 可以组织多步分析,查询仍经过相应执行入口。

技术篇到这里结束。应用篇从确定首批问题和验收范围开始,动手完成一个小型问数项目。