Skip to content

03-Schema双层架构

编译器需要 SQL 物理列名;LLM 决策不需要。两者分离到两份文件,互不污染——这是问数技能能跑稳的关键架构决策之一。


把所有 schema 信息塞一个文件,LLM 每次查询都加载全部内容:

# 单层 schema(反例)
ProjectIndicator:
description: 项目指标汇总
dimensions:
projectName:
sql: "{self}.project_name" # ← LLM 不需要看这个
type: string
description: 项目名称
visible: true
is_deleted: # ← 隐藏字段,LLM 不需要看
sql: "{self}.is_deleted"
visible: false
measures:
indicator:
sql_routes: # ← LLM 看不懂物理列路由
1: "df_amount_type1"
2: "df_amount_type2"
3: "df_amount_type3"
4: "df_amount_type4"
joins:
- from: "{self}.project_guid" # ← LLM 不需要看 JOIN ON
to: "Project.project_guid"

LLM 拿到 700-1000 行这种文件,结果是:

  • 上下文挤占:单文件能占 5000-10000 tokens,大部分对决策无用;
  • 决策干扰:物理列名(df_amount_type1)让 LLM 误以为要直接写 SQL;
  • 隐藏字段污染visible: false 的字段 LLM 会以为可用,往里写 filter;
  • JOIN 细节诱导:LLM 看到 JOIN ON 容易绕开二级指令直接拼 DSL,掉进字段所有权的坑(详见 §3.5)。
┌─────────────────────────────────────┐
│ scripts/schema.yaml(编译唯一源) │ 人手维护
│ • cube/dimension/measure/join 完整 │
│ • 含 SQL 物理列名、JOIN ON、visible│
│ • 含 inferredFilters / defaultFilters / friendlyAlias 等自动行为声明
│ 只给编译器看(query.py 启动时 load)│
└─────────────────┬───────────────────┘
▼ fetch_schema.py(自动生成)
┌─────────────────────────────────────┐
│ references/schema/(AI 速查版) │
│ ├─ _index.yaml(首读,一句话清单) │
│ ├─ <FactCube>.yaml × N(事实拆文件)│
│ └─ dimensions.yaml(维度合并文件) │
│ • 删 SQL 物理列、visible:false 字段│
│ • 保留业务描述、风险标注、关联导航 │
│ 只给 LLM 看(按需 Read) │
└─────────────────────────────────────┘

派生关系

  • 唯一源是 scripts/schema.yaml
  • 速查版是自动生成的产物,严禁直接编辑(下次 fetch_schema.py 跑会覆盖);
  • schema 变更流程:改 schema.yaml → 跑 fetch-schema → 提交两边变更。

完整字段约定见 scripts-template/schema.yaml.template。这里讲核心要素。

<CubeName>:
table: <宽表名> # 必填,事实 cube 对应 dwd_/dws_ 表,维度 cube 对应 dim_ 表
role: fact | dimension # 必填,fact 表示事实 cube
grain: <粒度描述> # 必填,描述这张表"一行代表什么"
description: |
<业务语义描述>
⚠️ 数据语义陷阱:<把已知坑写在这里,LLM 速查版会看到>
dimensions:
<fieldName>:
sql: "{self}.<物理列名>" # 必填,编译期 {self} 替换为主 cube 别名
type: string | number | time
description: <业务描述>
visible: true # 默认 true;false 表示不暴露给 LLM
measures:
<fieldName>:
sql: "{self}.<物理列名>"
type: number
agg: sum | avg | min | max | count # 默认聚合方式
description: <业务描述>
joins:
- to: <TargetCube>
type: left | inner # 默认 left
on: "{self}.<本字段> = {target}.<对方字段>"
<CubeName>:
# ...
inferredFilters: # 编译期按规则自动推断 filter
- field: isJianAn
rule: |
任意位置引用 productTypeName → 自动 isJianAn=1
全部未引用 productTypeName → 自动 isJianAn=0
hint: "已推断 isJianAn={value}(filters 引用 productTypeName)"
defaultFilters: # 静态兜底 filter
- field: isEndCost
value: 1
unless: [bzItemName, bzItemCode] # 这些字段被引用时跳过
hint: "默认 isEndCost=1(避免父子科目混算)"
friendlyAlias: # 业务化短名映射
cityName: "City.areaName" # 自动 JOIN City
buildArea: "Project.buildArea"
keyword: "{self}.keywords" # 多列 OR contains

inferredFilters / defaultFilters / friendlyAlias 的实现详见 Part 4 · DSL 编译层

cost-query 中 ProjectIndicator.indicator 是一个路由度量——一个逻辑度量名按参数路由到 4 个不同物理列:

ProjectIndicator:
measures:
indicator:
type: routed
routes:
1: { sql: "{self}.df_amount_type1", semantic: "建面单方" }
2: { sql: "{self}.df_amount_type2", semantic: "单位造价" }
3: { sql: "{self}.df_amount_type3", semantic: "含量" }
4: { sql: "{self}.df_amount_type4", semantic: "综合单价" }
aliases: # routedKey 接受中文别名
建面单方: 1
建面单方造价: 1
单方造价: 1
元/㎡: 1
# ...

调用时:

Terminal window
# 二级指令路径
cost-query agg-project-indicator --params '{"measureGroups":[{"indicatorType":"建面单方", ...}]}'
# DSL 直查路径
cost-query aggregate --dsl '{"measures":[{"member":"ProjectIndicator.indicator","routedKey":"建面单方", ...}]}'

Routed measure 在你的领域可能用得到的场景:

  • 售楼:同一个”价格”路由到「认购价 / 签约价 / 备案价 / 成交价」;
  • 工程:同一个”金额”路由到「合同金额 / 已结算 / 已支付 / 暂估」;
  • HR:同一个”薪资”路由到「基本工资 / 加班费 / 奖金 / 总包」。

把这种”同名不同口径”的语义在编译器层封装,比让 LLM 每次都判断要安全得多。


fetch_schema.py 从 schema.yaml 派生速查版,做以下转换:

  1. 拆文件:事实 cube 一个文件、维度 cube 合并一个文件;
  2. 删物理列:去掉 sql: 字段;
  3. 删隐藏字段:去掉 visible: false 的字段;
  4. 保留业务描述:完整保留 description / constraints / drillThrough;
  5. 生成索引_index.yaml 列所有 cube 一句话清单。
version: 1.0.0
description: cost-query cube 索引;事实 cube 单文件,维度 cube 合并 dimensions.yaml
loading_strategy:
- AI 查询前必读本索引
- 事实 cube:加载 references/schema/<CubeName>.yaml
- 维度 cube:全部在 dimensions.yaml 单文件
- 按 cubes[].file 字段直接定位文件
cubes:
- name: ProjectIndicator
description: '项目指标汇总:按项目×科目×(业态)预计算建面单方/单位造价/含量/综合单价。
⚠️ 数据语义陷阱:表内同一项目【末级科目+父级科目】共存...'
grain: 项目 × 科目(含父级与末级两类行)
role: fact
file: ProjectIndicator.yaml
drillTargets: [ProjectIndicatorDetail, BqIndicator, BqUnitPrice]
- name: BqUnitPrice
description: 清单单价明细:清单明细粒度的工程量+综合单价+含税单价
role: fact
file: BqUnitPrice.yaml
# ... 19 个 cube

关键设计

  • description 字段含**「数据语义陷阱」明文警告**,首读即提醒;
  • drillTargets 列出该事实 cube 可穿透的下钻目标,引导多步查询;
  • file 字段让 LLM 按需精确加载,不必扫整个目录。
ProjectIndicator:
description: |
项目指标汇总:按项目×科目×(业态)预计算建面单方/单位造价/含量/综合单价。
⚠️ 数据语义陷阱:...
grain: 项目 × 科目(含父级与末级两类行)
constraints:
- rule: 聚合粒度由引擎自动推断,调用方按用户语义在任意位置使用 productTypeName 即可
applies_to: [agg-project-indicator, rank-project-indicator]
reason: '库内 is_jian_an=0 行无业态切分... 引擎在 _builder.py:_infer_isJianAn 自动推断'
severity: info
dimensions:
projectName: { type: string, description: 项目名称 }
cityName: { type: string, description: 城市名称 }
productTypeName: { type: string, description: 业态名称 }
bzItemName: { type: string, description: 科目名称 }
isJianAn: { type: number, description: 是否建安:0=项目级 / 1=业态级 }
isEndCost: { type: number, description: 是否末级科目 }
measures:
indicator:
type: routed
description: 路由度量;按 routedKey/indicatorType 路由到具体物理列
routes:
建面单方 / 建面单方造价: type=1
单位造价: type=2
含量: type=3
综合单价: type=4
joins:
City: 按 city_guid join;用于查城市/省份维度
Project: 按 project_guid join;用于查 buildArea/saleArea 等项目属性
drillThrough:
- target: ProjectIndicatorDetail
via: project_guid + bz_item_code
hint: 查具体清单条目时穿透

12 个维度 cube 合并到一个文件,因为:

  • 维度字段比事实少(通常每个维度 5-15 个字段);
  • LLM 做维度标准化时经常需要跨多个维度对照;
  • 拆 12 个小文件反而碎片化。

3.4 速查版的”数据语义陷阱”明文化

Section titled “3.4 速查版的”数据语义陷阱”明文化”

这是 cost-query 沉淀的关键经验:把已知坑写在 cube description 里,LLM 首读时就提醒

实例(ProjectIndicator):

description: |
项目指标汇总:按项目×科目×(业态)预计算建面单方/单位造价/含量/综合单价。
⚠️ 数据语义陷阱:表内同一项目的【末级科目(钢筋/混凝土/防水...)+
父级科目(土建/建安/项目成本)】共存,每行是独立科目层级的聚合。
不限定 bzItemName 直接 SUM 会重复累加(曾出现项目 5 万㎡ 被 SUM 26 次到
130 万㎡,单位造价 44664→8159 偏 80%)。
查"整体造价"应显式传 bzItemName=建筑安装工程费 / 项目成本(父级精确名);
查"末级品类"传具体科目名(钢筋/混凝土/防水/...)。
默认 agg-project-indicator 已加 isEndCost=1 兜底(仅当不传 bzItemName 时生效)。

双重保险

  • 速查版 description 提醒 LLM(行为层);
  • 编译源 schema 的 defaultFilters: isEndCost=1 兜底(代码层);

两条防线缺一不可——只靠提醒,LLM 在长上下文 / 改个措辞时仍会忘;只靠代码兜底,遇到用户显式说”查父级”时又会过度兜底。

下面是几个典型领域的陷阱速写:

领域陷阱示例
售楼• 未网签 / 已网签 / 已备案 / 已退房 状态切换的时间锚点;• 多手房 vs 一手房口径;• 跨期客户归属
工程• 计划进度 / 实际进度 / 验收进度 三态;• 单位工程 / 分部 / 分项 / 工序的层级混算;• 暂估价 / 合同价 / 结算价口径
HR• 在职 / 离职 / 待入职 状态切换的时间锚点;• 部门重组后历史归属;• 薪资版本(基本 / 总包 / 含奖金)口径
财务• 应收 / 实收 / 已开票口径;• 含税 / 不含税切换;• 多币种汇率快照时点

每条陷阱都要:

  1. 写在 cube description 里;
  2. 在编译器层加 defaultFiltersinferredFilters 兜底;
  3. 在 query-guide.md 的反模式黑名单中列条目。

cost-query 踩过的高频坑:不同事实 cube 的本地字段集差异大

例:

Cube自身有 cityName自身有 projectName备注
ProjectIndicatorDSL 直写都合法
ProjectIndicatorDetailDSL 直写都合法
BqIndicator❌(用 City.areaName)cityName 撞错
BqBzItemIndicator❌(用 City.areaName)cityName 撞错
BqUnitPrice❌(用 City.areaName)❌(自身字段是 projectCostOwnerNames)都撞错
TalentMachinePrice❌(自身是 provinceCity,不是 cityName)cityName 撞错

如果不在 schema 描述里明确标注,LLM 写 BqUnitPrice.cityName 会撞”未知字段”错。

治理方式

  1. 首选:通过 friendlyAlias 让 LLM 写 cityName 时编译器自动展开为 City.areaName(自动 JOIN);
  2. 次选:在物理化已完成的 cube 里直接把 cityName 物化进事实表(cost-query 2026-05 这么做了);
  3. 必备:在 DSL spec 的错误自救章节列出「错写 → 正写」对照表。

设计 schema 时遵循 3 条规则:

  1. 业务高频字段优先物化进事实表:宁可冗余存储一份 city_name 进事实表,也别让 LLM 每次写 JOIN;
  2. 必须 JOIN 才能取的字段:用 friendlyAlias 把它”包装”成事实表的字段(编译期自动 JOIN);
  3. schema 描述里列出”自身字段 vs JOIN 字段”对照表:让 LLM 看了就明白。

schema 不是写完就不变的,业务演进会带来:

  • 新增 cube(新业务实体);
  • 新增字段(业务上线了新维度);
  • 改字段名(业务定义对齐后改命名);
  • 删字段(废弃字段);
  • 改自动行为(业务规则变化)。

演进流程

业务变化
1. 改 scripts/schema.yaml(唯一源)
2. 跑 fetch-schema 重新生成 references/schema/
3. 跑测评集(验证已有能力没破坏)
4. 改相关二级指令模板 commands/*.yaml(如果字段被指令引用)
5. 改 query-guide.md / dsl-spec.md 中相关文档
6. 提交(schema.yaml + references/schema/ 一起提交)

铁律

  • 严禁直接编辑 references/schema/ 下的文件(生成式产物);
  • 任何 schema 改动必须跑测评集回归;
  • 字段重命名走废弃流程:先加新名(字段 alias),跑一个版本后再删旧名。

完成 schema 设计后逐项检查:

  • 已经区分 schema.yaml(编译源)与 references/schema/(速查版);
  • schema.yaml 字段命名一致(snake_case / camelCase 二选一);
  • 所有 cube 有 description / grain / role 三个必填字段;
  • 所有 visible=false 的字段已在 schema.yaml 中标注;
  • 已知数据语义陷阱已写进 description 明文;
  • 对应的陷阱已通过 inferredFilters / defaultFilters 在编译器层兜底;
  • 业务高频字段已物化进事实表(避免每次 JOIN);
  • 必须 JOIN 的字段已通过 friendlyAlias 包装成事实表字段;
  • fetch_schema.py 跑通;references/schema/ 已生成;
  • LLM 首读 _index.yaml 能在 200 tokens 内拿到 cube 概览;
  • 每个事实 cube 单文件 < 500 行(超过考虑拆 description 简化)。

Part 3 完。读完应能回答:“为什么 schema 必须双层、编译源/速查版各放什么、自动行为怎么声明、数据语义陷阱怎么标注、schema 怎么演进”。