03-Schema双层架构
Part 3 · Schema 双层架构
Section titled “Part 3 · Schema 双层架构”编译器需要 SQL 物理列名;LLM 决策不需要。两者分离到两份文件,互不污染——这是问数技能能跑稳的关键架构决策之一。
3.1 为什么要双层
Section titled “3.1 为什么要双层”单层 schema 的问题
Section titled “单层 schema 的问题”把所有 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)。
双层 schema 的解法
Section titled “双层 schema 的解法”┌─────────────────────────────────────┐│ 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→ 提交两边变更。
3.2 编译源 schema.yaml 字段约定
Section titled “3.2 编译源 schema.yaml 字段约定”完整字段约定见 scripts-template/schema.yaml.template。这里讲核心要素。
Cube 基本结构
Section titled “Cube 基本结构”<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}.<对方字段>"自动行为声明
Section titled “自动行为声明”<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 containsinferredFilters / defaultFilters / friendlyAlias 的实现详见 Part 4 · DSL 编译层。
Routed Measure(多路由度量)
Section titled “Routed Measure(多路由度量)”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 # ...调用时:
# 二级指令路径cost-query agg-project-indicator --params '{"measureGroups":[{"indicatorType":"建面单方", ...}]}'
# DSL 直查路径cost-query aggregate --dsl '{"measures":[{"member":"ProjectIndicator.indicator","routedKey":"建面单方", ...}]}'Routed measure 在你的领域可能用得到的场景:
- 售楼:同一个”价格”路由到「认购价 / 签约价 / 备案价 / 成交价」;
- 工程:同一个”金额”路由到「合同金额 / 已结算 / 已支付 / 暂估」;
- HR:同一个”薪资”路由到「基本工资 / 加班费 / 奖金 / 总包」。
把这种”同名不同口径”的语义在编译器层封装,比让 LLM 每次都判断要安全得多。
3.3 速查版 references/schema/ 结构
Section titled “3.3 速查版 references/schema/ 结构”fetch_schema.py 从 schema.yaml 派生速查版,做以下转换:
- 拆文件:事实 cube 一个文件、维度 cube 合并一个文件;
- 删物理列:去掉
sql:字段; - 删隐藏字段:去掉
visible: false的字段; - 保留业务描述:完整保留 description / constraints / drillThrough;
- 生成索引:
_index.yaml列所有 cube 一句话清单。
_index.yaml:首读索引
Section titled “_index.yaml:首读索引”version: 1.0.0description: cost-query cube 索引;事实 cube 单文件,维度 cube 合并 dimensions.yamlloading_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 按需精确加载,不必扫整个目录。
事实 cube 单文件结构
Section titled “事实 cube 单文件结构”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: 查具体清单条目时穿透维度合并文件 dimensions.yaml
Section titled “维度合并文件 dimensions.yaml”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 在长上下文 / 改个措辞时仍会忘;只靠代码兜底,遇到用户显式说”查父级”时又会过度兜底。
你的领域哪些陷阱要写
Section titled “你的领域哪些陷阱要写”下面是几个典型领域的陷阱速写:
| 领域 | 陷阱示例 |
|---|---|
| 售楼 | • 未网签 / 已网签 / 已备案 / 已退房 状态切换的时间锚点;• 多手房 vs 一手房口径;• 跨期客户归属 |
| 工程 | • 计划进度 / 实际进度 / 验收进度 三态;• 单位工程 / 分部 / 分项 / 工序的层级混算;• 暂估价 / 合同价 / 结算价口径 |
| HR | • 在职 / 离职 / 待入职 状态切换的时间锚点;• 部门重组后历史归属;• 薪资版本(基本 / 总包 / 含奖金)口径 |
| 财务 | • 应收 / 实收 / 已开票口径;• 含税 / 不含税切换;• 多币种汇率快照时点 |
每条陷阱都要:
- 写在 cube description 里;
- 在编译器层加
defaultFilters或inferredFilters兜底; - 在 query-guide.md 的反模式黑名单中列条目。
3.5 事实 cube 字段所有权差异
Section titled “3.5 事实 cube 字段所有权差异”cost-query 踩过的高频坑:不同事实 cube 的本地字段集差异大。
例:
| Cube | 自身有 cityName? | 自身有 projectName? | 备注 |
|---|---|---|---|
| ProjectIndicator | ✓ | ✓ | DSL 直写都合法 |
| ProjectIndicatorDetail | ✓ | ✓ | DSL 直写都合法 |
| BqIndicator | ❌(用 City.areaName) | ✓ | cityName 撞错 |
| BqBzItemIndicator | ❌(用 City.areaName) | ✓ | cityName 撞错 |
| BqUnitPrice | ❌(用 City.areaName) | ❌(自身字段是 projectCostOwnerNames) | 都撞错 |
| TalentMachinePrice | ❌(自身是 provinceCity,不是 cityName) | ✓ | cityName 撞错 |
如果不在 schema 描述里明确标注,LLM 写 BqUnitPrice.cityName 会撞”未知字段”错。
治理方式:
- 首选:通过
friendlyAlias让 LLM 写cityName时编译器自动展开为City.areaName(自动 JOIN); - 次选:在物理化已完成的 cube 里直接把
cityName物化进事实表(cost-query 2026-05 这么做了); - 必备:在 DSL spec 的错误自救章节列出「错写 → 正写」对照表。
你的领域怎么避免这种坑
Section titled “你的领域怎么避免这种坑”设计 schema 时遵循 3 条规则:
- 业务高频字段优先物化进事实表:宁可冗余存储一份
city_name进事实表,也别让 LLM 每次写 JOIN; - 必须 JOIN 才能取的字段:用 friendlyAlias 把它”包装”成事实表的字段(编译期自动 JOIN);
- schema 描述里列出”自身字段 vs JOIN 字段”对照表:让 LLM 看了就明白。
3.6 Schema 演进治理
Section titled “3.6 Schema 演进治理”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),跑一个版本后再删旧名。
3.7 检查清单
Section titled “3.7 检查清单”完成 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 简化)。
3.8 接下来读什么
Section titled “3.8 接下来读什么”- 要看编译器怎么消费 schema → 读 Part 4 DSL 编译层;
- 要看 schema 怎么映射到二级指令 → 读 Part 5 二级指令系统;
- 想看 cost-query 完整 schema 实例 → 读 ontology-model 仓的 scripts/schema.yaml 与 references/schema/。
Part 3 完。读完应能回答:“为什么 schema 必须双层、编译源/速查版各放什么、自动行为怎么声明、数据语义陷阱怎么标注、schema 怎么演进”。