别再让大模型瞎写 SQL:维度建模 + 指标体系,落地真正的指标智能问数
一句话总结:让大模型"不写 SQL",只负责把自然语言翻译成指标、维度、业务限定、时间范围四要素,剩下的交给语义层确定性编译成 SQL。口径不跑偏,数据可信,2 核 4G 就能跑。
一、Text2SQL 的天花板,不在模型,在语义
过去一年,"自然语言问数"几乎成了每个数据平台的标配。做法也很直接:把建表语句(DDL)塞给大模型,让它生成一段 SQL,执行后返回结果。
Demo 很惊艳,一上生产就翻车:
- 口径对不上:你问"销售额",模型
SUM(amount);财务的销售额要扣退款、只算已支付。同一个词,两个数。 - 幻觉表名字段名:表一多、字段一杂,
fact_order和fact_sales_order傻傻分不清,生成的 SQL 直接报表不存在。 - 中文业务词映射难:"北京"到底是
dim_store.city还是dim_region.province?"最近一周"从哪天算起? - 权限失控:模型生成的 SQL 绕过了数据权限,用户看到了不该看的数据。
- 不可解释:出了问题,没人知道这段 SQL 是怎么来的。
问题的根子在于:我们让一个概率模型,去承担本该由确定性系统承担的口径与权限职责。
正确的分工应该是:
自然语言 ──► LLM:只做「语义解析」 ──► 四要素
(用户提问) (理解意图) (指标/维度/限定/时间)
│
▼
语义层:确定性编译为 SQL
(口径、权限、方言全在这里)
│
▼
数据结果
而"语义层",在数据仓库里有一个成熟的名字——维度建模 + 指标体系。
二、概念篇:维度建模与指标管理
2.1 维度建模:给数据一个"业务坐标"
维度建模(Dimensional Modeling)是 Ralph Kimball 提出的一套面向分析的数据组织方法,核心就两个概念:
| 概念 | 含义 | 例子 |
|---|---|---|
| 事实表(Fact) | 业务过程中发生的可度量事件 | 一笔销售订单、一次访问 |
| 维度表(Dimension) | 描述事件的业务观察角度 | 谁(客户)、什么(商品)、哪里(门店)、何时(日期) |
事实表保存度量值与指向各维度的外键,维度表保存描述性属性。以电商为例:
┌─────────────┐
│ dim_product│
│ 商品维度 │
└──────┬──────┘
│ product_sk
┌─────────────┐ ┌──────┴──────────┐ ┌─────────────┐
│ dim_store │────▶│ fact_sales_order│◀────│ dim_customer│
│ 门店维度 │store│ 事实表 │cust │ 客户维度 │
└─────────────┘ _sk│ amount │ _sk └─────────────┘
│ quantity │
│ cost_amount │
└──────┬──────────┘
│ order_date_sk
┌──────┴──────┐
│ dim_date │
│ 日期维度 │
└─────────────┘
这种"事实表居中、维度表环绕"的结构叫星型模型;如果维度再拆出子维度(如商品→品牌→品类),就是雪花模型。
维度建模的价值,是给冷冰冰的表和字段,建立起一张业务语义地图:"amount 是金额,它挂在订单上,可以从门店、商品、时间等任意角度看"。这正是大模型最缺、也最需要的东西。
在工程落地时,维度通常还会细分三类,用来覆盖常见的分析诉求:
| 维度类型 | 说明 | 典型场景 |
|---|---|---|
| 普通维度 | 平铺的成员列表 | 客户、门店、商品 |
| 层级维度 | 多级从属,如 一级品类 → 二级 → 三级 | 品类、组织架构、行政区划 |
| 时间维度 | 年 / 季 / 月 / 周 / 日多粒度 | 趋势、同环比、周期对比 |
2.2 指标管理:把"口径"沉淀成资产
如果说维度建模定义了"从哪些角度看数据",那指标管理定义的就是"看什么、怎么算"。
指标通常分两类:
- 原子指标:建立在某个事实表字段上的聚合,是口径的最小单元。
例如
销售额 = SUM(amount)、订单数 = COUNT(DISTINCT order_id)。 - 衍生指标:由原子指标(或其他衍生指标)通过公式组合而来。
例如
毛利率 = 毛利 / 销售额、客单价 = 销售额 / 订单数。
一个完整的指标定义,除了名字和公式,还要包含:
- 业务限定(Filter):只统计已支付订单、只看直营门店等;
- 单位与数据类型:元、人、%、DECIMAL;
- 可加性:能否跨维度求和(如"订单数"不可加);
- 所属目录:让成百上千个指标有组织、可检索。
指标体系 = 维度建模 + 指标管理。前者提供"分析坐标",后者提供"统一口径"。二者合在一起,就构成了智能问数真正依赖的语义层。
三、基于 DataY 构建维度模型与指标体系
DataY 是一个"极致精简"的轻量数据平台:一个 JAR、内嵌 DuckDB,2 核 4G 就能跑通「数据集成 → 维度建模 → 任务调度 → 高性能数仓 → 智能问数」全链路。下面用平台预置的电商示例,走一遍建模到问数的闭环。
登录后,示例资产已开箱即用:
3.1 三种维度类型,字段自动生成
在「数据模型 → 维度建模」里新建维度模型时,选对类型能省掉大量手写工作:
- 普通维度:自动带出
member_id / member_name成员字段; - 层级维度:按层级数自动生成
level1_id / level1_name … levelN_id / levelN_name; - 时间维度:按勾选的粒度自动生成
year_id / quarter_id / month_id / day_id、主键date_key以及层级路径hierarchy。
时间维度是最高频的。勾选「年 / 季 / 月 / 日」后,平台会按由粗到细自动排序,并生成对应的 id/name 字段:
保存后点「物化」,一次性把维度表建到内嵌 DuckDB,并勾选"生成预置数据",平台会按日期范围逐天生成日期数据(如 2024-01-15 → year_id=2024、quarter_id=2024Q1、month_id=202401、day_id=20240115):
3.2 事实表关联维度:语义的源头
事实表的建模同样简单:以"注册模式"从物理表导入字段,然后给每个外键字段在「关联维度」列指向对应维度模型,再指定一个「时间周期字段」。
以 fact_sales_order_item 为例,order_date_sk → dim_date、product_sk → dim_product、store_sk → dim_store:
这一步是整个方案的关键:关联关系一旦建立,指标就自动获得了业务语义——它能从哪些维度看、用哪个字段做时间过滤,全部由事实表的外键与时间字段推导而来,不需要在指标里重复声明。后续新增维度,指标也自动可用。
3.3 原子指标:一个聚合,一个口径
在「指标管理」中新建原子指标:绑定事实表、写好聚合公式、配置业务限定即可。
例如:
| 指标 | 公式 | 说明 |
|---|---|---|
销售额 sales_amount | SUM(amount) | 单位:元 |
销售量 sales_quantity | SUM(quantity) | 件 |
毛利 gross_profit | SUM(profit) | 元 |
订单数 order_count | COUNT(DISTINCT order_id) | 不可加 |
保存前可以「预览 SQL」核对口径,避免"建完才发现算错"。
3.4 衍生指标:用 ${编码} 组合口径
衍生指标不绑定事实表,而是用 ${指标编码} 引用其他指标,纯算术组合:
毛利率 gross_profit_rate = ${gross_profit} / ${sales_amount}
客单价 avg_order_value = ${sales_amount} / ${order_count}
平台会对衍生公式做严格校验:只能引用已存在的指标、只能包含算术运算、并做环路检测(防止 A 引用 B、B 又引用 A)。被引用的指标不允许删除,保证口径链完整。
到这里,一套"事实表 + 三维度 + 指标体系"的语义层就搭好了。它不依赖大模型,本身就能通过「公共指标查询面板」手动选指标、选维度、设时间,直接出数——这也成了校验 AI 口径的基准工具:
四、语义检索增强:默认 DuckDB 最简向量库,可扩展其他向量库
语义层建好了,但大模型还是听不懂"销售额""北京""最近一周"这些口语。DataY 的做法是:把指标、维度、维度成员向量化,问数前先做一轮语义检索(RAG),再把候选喂给模型。
而向量存储与检索,默认走一条最简路径:直接用内嵌 DuckDB 承载,不引入额外组件;同时把检索层设计成可替换的,业务长大后能平滑接入专业向量库。
4.1 为什么要 RAG
指标动辄成百上千,如果全量塞进 Prompt,token 爆炸且模型容易分心。RAG 的作用是先召回、再精排:
- 用户说"销售额",但指标库里叫"销售金额"——靠向量相似度召回;
- 用户说"北京",要能定位到
dim_store.city——靠维度成员的向量召回; - 用户说"各品类",要能对上层级维度
dim_category——靠维度字段的向量召回。
4.2 独立 DuckDB 文件 + 三张向量表
向量库是独立的 DuckDB 文件(默认 ./data/metric-rag.duckdb),与业务数仓物理隔离,互不影响。核心三张表:
| 表 | 存什么 |
|---|---|
rag_metric | 指标:编码、名称、口径描述、向量 |
rag_dimension | 维度字段:维度编码、字段名、是否层级、向量 |
rag_member | 维度成员值:如"北京""华东""手机",用于口语值对齐 |
其中维度成员索引是最容易被忽略、却最见功力的一层。平台会自动采样可作为过滤值的字符串字段(排除主键、*_id、*_code、日期等),执行 SELECT DISTINCT 字段 ... LIMIT N 拿到真实取值并向量化。于是:
"北京" 的向量,能命中
dim_store.city下的成员 "北京" —— 模型就知道该给哪个维度字段加city = '北京'。
4.3 默认最简模式:DuckDB 核心函数,免装 vss
很多方案一提到 DuckDB 向量库,第一反应是 INSTALL vss、建 HNSW 索引。DataY 的默认选择是更简的一档:不装任何扩展,直接用 DuckDB 核心函数做余弦检索。
判断依据很朴素:指标、维度、维度成员的量级通常只有几百到几万条,暴力扫描足够快,没必要引入扩展依赖和额外的索引维护成本。
向量以 JSON 字符串存入 VARCHAR 字段,检索时直接调用 DuckDB 核心函数 list_cosine_similarity:
SELECT code, name,
list_cosine_similarity(embedding::FLOAT[], ?::FLOAT[]) AS score
FROM rag_metric
WHERE tenant_id = ?
ORDER BY score DESC
LIMIT ?;
4.4 需要时再升级:检索层可扩展其他向量库
DuckDB 是默认最简模式,不是唯一模式。当指标规模增长到数十万级、成员值海量,或团队本身已有向量基础设施时,可以平滑接入 Milvus、pgvector、Elasticsearch、Chroma 等专业向量库:
- 接口抽象:向量化已抽象为独立的
EmbeddingClient接口,检索层也按可替换的方式设计,上层工具链(retrieve_metric_context等)与问数逻辑不感知底层存储; - 按需切换:更换向量库只需替换实现与配置,指标/维度建模、指标管理、问数助手全部无需改动;
- 渐进演进:小规模用 DuckDB 最简模式起步,规模上来再升级,不必一开始就为向量库付运维成本。
一句话:默认用最简方案把链路跑通,规模需要时再升级,而不是一开始就上重型组件。
4.5 两档 Embedding,默认零依赖
Embedding 提供两种实现,通过 datay.ai.rag.provider 切换:
| provider | 实现 | 特点 |
|---|---|---|
local(默认) | 字符 1–3 gram 哈希(FNV-1a)+ L2 归一化 | 完全离线、零 Key、零外部依赖,对中文同形近义友好 |
openai | 兼容 OpenAI 的 /embeddings 接口 | 默认 text-embedding-3-small,质量更高 |
这点对私有化部署特别重要:没网、没有 Embedding Key,也能开箱即用地跑智能问数。
另外有个小心思:指标做 embedding 时,名称会被重复拼接三次,用于加权。这样"销售额"不会被"平均销售价格"稀释掉,召回更准。
五、指标智能问数:让 LLM 只做翻译,SQL 交给系统
前面所有铺垫,都是为了这一刻。DataY 的「智能问数」设计有一条铁律:
大模型不写 SQL,只输出结构化的四要素;SQL 由服务端确定性编译。
5.1 四要素解析
用户问:"最近一周北京的销售额",模型要拆成:
| 要素 | 解析结果 |
|---|---|
| 指标 | sales_amount |
| 维度 | 无(或按需) |
| 业务限定 | dim_store.city = '北京' |
| 时间范围 | start = 今天 − 6 天、end = 今天 |
再复杂一点:"上个月各品类的销售额和毛利率":
- 指标:
sales_amount、gross_profit_rate(衍生指标自动展开公式) - 维度:
dim_category(层级维度,默认取最细层级) - 时间:
timeRange = 上月首日 ~ 上月末日
而"今年每个月的销售趋势",模型不会逐月查 12 次,而是把时间维度 dim_date 的 year, month 作为分组维度,一次查询出趋势。
5.2 五个工具,串起完整链路
问数助手(metric-query)只挂载 5 个只读工具:
| 工具 | 作用 |
|---|---|
retrieve_metric_context | 用用户原话做向量检索,召回候选指标 / 维度字段 / 维度成员(带相似度) |
list_metrics | 拉取完整指标清单(可带关键字),做模糊匹配确认 |
describe_metrics | 返回所选指标可用的真实维度、层级、时间字段 |
sample_dimension_values | 采样某维度字段的真实取值,确认"北京"该落哪个字段 |
query_metric_data | 传入四要素,执行查询并返回数据 |
一次问数的执行流:
用户提问
│
▼
① retrieve_metric_context ← DuckDB 向量库语义召回
│ "销售额"→sales_amount, "北京"→dim_store.city
▼
② list_metrics / describe_metrics ← 用真实元数据核对
│ 拿到维度编码、层级序号、时间粒度字段
▼
③ query_metric_data ← 传入结构化的四要素
│
▼
服务端 MetricQueryService:确定性编译 SQL → 执行
│
▼
Markdown 表格回答(不含 SQL)
5.3 服务端确定性编译 SQL
query_metric_data 拿到的不是 SQL,而是一份结构化参数(指标编码数组、维度数组、业务限定、时间范围)。服务端据此编译:
- 单事实表:以事实表为主表,按维度外键
LEFT JOIN维度表,WHERE拼接业务限定与时间条件,GROUP BY分组字段; - 多事实表:各事实表各生成子查询,再按共同维度做
FULL OUTER JOIN,用COALESCE合并维度展示值——所以"销售额和订单数一起看"这种跨事实指标也能一次取数; - 层级/时间维度:按层级序号取
levelN_id分组、levelN_name展示,天然支持"按一级品类""按月"这类粒度; - 衍生指标:沿
${code}引用递归展开成嵌套的聚合表达式,环路由前面的校验兜底。
因为 SQL 是编译出来的,口径 100% 来自指标定义,不存在模型编造表名、字段名或口径的可能。
5.4 权限:不是过滤,是前置
数据权限在这里不是"查完再过滤",而是编译进 SQL 的强制前置条件。角色配置的维度成员范围(如某区域经理只能看华东门店),会作为 WHERE 的第一段拼在最前面,用户提交的业务限定只能 AND 在后面,无法绕过。缺失维度时还可配置 SKIP(跳过并告警)或 DENY(直接拒绝)策略。
5.5 顺带一提:把语义层开放给任意 AI 客户端(MCP)
除了平台内置的问数助手,DataY 还把这 5 个工具封装成了 MCP Server(/mcp/metric)。这意味着:
Claude Desktop、Cursor 等任意支持 MCP 的客户端,用自己的大模型,就能直接查询你的指标体系——服务端不需要任何 LLM Key。
同一套语义层与权限控制,既能"内置问数",也能"对外开放"。
六、小结:这套架构为什么值得借鉴
回到开头的痛点,DataY 的答案其实就三句话:
- 确定性归系统,生成式归模型。让大模型做它擅长的语义理解,把口径、SQL、权限交给确定性的语义层,从根上消灭幻觉。
- 语义层是护城河。维度建模提供"分析坐标",指标管理沉淀"统一口径",再叠加一层向量检索做口语对齐——三者合起来,才是可用的智能问数。
- 能用简单方案解决的,绝不堆组件。向量库用 DuckDB 核心函数 + 本地哈希 Embedding,不装
vss、不依赖外部 Embedding 服务;整个平台一个 JAR、2 核 4G 起步。
智能问数的下半场,拼的不是模型多大,而是背后的语义层有多扎实。
体验与获取