本体宽表映射设计
本体↔宽表映射设计
Section titled “本体↔宽表映射设计”核心观点:本体设计 ≠ 宽表设计——这是两件不同的设计工作。 一张超大宽表(如交易宽表)往往把客户、合同、项目好几个本体类拍平塞进一行;本体则把同一份现实拆成几个有身份、有关系的类。一个在合并、一个在分解,所以中间需要一层显式映射把两者对齐:类落到哪张 cube、维度/度量落到哪些列、约束怎么编译成 WHERE、被宽表拍平的关系怎么用 设计指南/本体设计/ABox 记回。设计指南/本体设计/TBox / ABox 是语义层(业务真相),宽表是物理查询底座——这篇讲两者怎么对齐而不混为一谈。
📎 来源:实践分享第三章 + cost-query V1→V2 演进。上级:设计指南/本体设计;姊妹:设计指南/本体设计/TBox · 设计指南/本体设计/ABox。落地细节见 场景指南/问数/02-数据底座、场景指南/问数/03-Schema双层架构。
一、核心观点:本体设计 ≠ 宽表设计
Section titled “一、核心观点:本体设计 ≠ 宽表设计”这篇所有的方法都立在一句话上:设计本体,和设计宽表,是两件不同的事。 一张超大宽表里往往藏着好几个本体类——下面这个例子最能说明问题。
一张交易宽表,藏着客户 / 合同 / 项目好几个本体
Section titled “一张交易宽表,藏着客户 / 合同 / 项目好几个本体”设想一张为「成交分析」建的交易宽表 dws_deal_detail,一行 = 一笔成交:
| 列(节选) | 其实来自哪个业务概念 |
|---|---|
customer_name / customer_level / customer_phone | 客户 |
contract_no / contract_amount / signed_time / settle_status | 合同 |
project_name / project_city / product_type | 项目 |
salesperson_name / channel | 销售 / 渠道 |
deal_amount / deal_time | 成交事实本身 |
为了查得快,它把客户、合同、项目、销售四五个概念拍平塞进同一行。这是一张好宽表——JOIN 预算掉了、一行自解释、聚合飞快。
但在本体视角下,同一份现实根本不是「一张表」,而是几个有身份、有关系的类:
Customer ──签约──> Contract ──属于──> Project 客户 合同 项目 │经办 Salesperson- 客户是一个类:有唯一身份(一个客户多笔成交)、有自己的属性(等级/手机)、有自己的生命周期;
- 合同是另一个类:金额 / 结算状态 / 签约时间是合同的,不是客户的;
- 项目又是一个类:城市 / 业态是项目的;
- 它们之间有关系:合同→属于→客户、合同→属于→项目。
关键:宽表把「客户 / 合同 / 项目」压成一行里的几组列;本体把同一份现实拆成几个有身份、有关系的类。一个在合并,一个在分解——这就是为什么”设计宽表”和”设计本体”不是同一件事。
两者在回答完全不同的问题
Section titled “两者在回答完全不同的问题”| 宽表设计 | 本体设计 | |
|---|---|---|
| 设计单位 | 一张表 / 一行 | 一个概念 / 一条关系 |
| 核心决策 | 一行什么粒度、塞哪些列、为性能冗余物化哪些 | 有哪些类、各自身份与属性、彼此什么关系、什么口径约束 |
| 同一客户多笔成交 | 客户信息在多行里冗余重复(无所谓,查得快就行) | 客户是一个实例,多笔成交都指向它(身份唯一) |
| 合同↔项目的关系 | 拍平消失(变成同一行里相邻的列) | 显式建模(合同→属于→项目),归因要靠它 |
| 服务对象 / 变化驱动 | 让引擎查得快 / 随性能调优变(快) | 让 AI 看懂业务 / 随业务概念演进(慢) |
| 物理无关性 | 就是执行底座本身,换后端整个重建 | 与执行底座无关,换后端一行不动 |
一张宽表 = N 个本体类拍平在一起,映射是 N:1。 所以你不能用”设计宽表”的方式去设计本体——前者按查询便利合并,后者按业务概念分解。把两件事当成一件,结局只有两种:
- 照着宽表一比一建本体 → 得到一个伪本体:只是宽表换了层皮,客户/合同/项目的身份和关系全丢了,AI 照样不会归因;
- 照着本体一比一建宽表 → 得到一张烂宽表:每个类一张表、查个成交要 JOIN 五张,性能崩、LLM 也写不对。
正因为两者是不同的设计,中间才需要一层显式映射:把 N 个类按 table/grain/filter 锚到当下的宽表上,语义跟着概念走、不跟着物理列走。
cost-query 的实证:V1 = 本体 + API(OBDA,多步 API 编排),V2 = 本体 + 宽表(DSL→SQL 直查星型宽表)。换掉的是整个执行底座,本体作为概念层一行没动——能做到这点,正因为本体设计从一开始就没和宽表设计长在一起。
二、一个类怎么钉到宽表:表 · 粒度 · 口径
Section titled “二、一个类怎么钉到宽表:表 · 粒度 · 口径”cost-query 走的是轻量本体路线:本体(cost.ttl)只保留高层注解,字段级映射全部下沉到 schema.yaml。本体里每个类只挂三个锚点:
| 锚点 | 回答 | 例 |
|---|---|---|
dwh:table | 这个类的事实/实例存在哪张宽表 | Class.ProjectIndicator → dws_cost_project_indicator |
dwh:grain | 这张表一行代表什么(粒度) | 项目 × 科目 × 业态 |
dwh:filter | 取这个类时默认带哪些口径 | is_end_cost=1(只取末级) |
┌──────────────────────────────┐│ 本体 cost.ttl(概念层) │ 类 / 关系 / 约束│ 每类挂 dwh:table/grain/filter │└──────────────┬───────────────┘ │ 三个锚点把概念钉到物理表 ▼┌──────────────────────────────┐│ schema.yaml(编译唯一源) │ dimension / measure / join + SQL 物理列└──────────────┬───────────────┘ │ {self} 替换为表别名,编译成 SQL ▼┌──────────────────────────────┐│ 宽表 dws_/dwd_/dim_ │ 物理列└──────────────────────────────┘为什么不把字段级映射也写进本体?因为字段会随宽表重构频繁动,塞进本体会让”概念层”被物理细节污染、失去稳定性。本体只锚”这个类落在哪张表、什么粒度、带什么默认口径”;具体哪列叫什么,是 schema.yaml 的事。 这条边界就是轻量本体能稳的关键,推荐给大多数团队。
三、TBox 的每个零件,落到宽表的哪里
Section titled “三、TBox 的每个零件,落到宽表的哪里”设计指南/本体设计/TBox 有五种成分。它们到宽表的映射是一一对应的:
| TBox 成分 | 映射到宽表 | 落点 | 说明 |
|---|---|---|---|
| 类 Class | 一张 cube → 一张宽表 | table + role: fact|dimension | 事实类→dwd_/dws_,维度类→dim_ |
| 维度属性 dimension | 一个 dimension 列 | sql: "{self}.<列名>" | 可筛选/分组;高频且稳定的可物化进事实表 |
| 度量属性 measure | 一个 measure 列 | sql + agg + 可选 sql_routes | 可聚合;口径分叉用 sql_routes 路由到不同物理列 |
| 关系 relation | JOIN(维度关系)/ ABox(语义关系) | joins: 或 ABox 显式记回 | 两类关系走两条路,见第五节 |
| 约束 axiom | WHERE 注入 | defaultFilters / inferredFilters | 业务铁律编译期自动补,用户无感,见第六节 |
一个完整例子:成本指标类怎么落到宽表
Section titled “一个完整例子:成本指标类怎么落到宽表”TBox(概念):
tbox: classes: - id: Class.ProjectIndicator label: 项目成本指标 dwh:table: dws_cost_project_indicator # ← 锚点①:落哪张表 dwh:grain: 项目 × 科目 × 业态 # ← 锚点②:粒度 dwh:filter: is_end_cost=1 # ← 锚点③:默认口径 dimensionProperties: - {name: costSubject, label: 成本科目} - {name: productType, label: 业态} measureProperties: - {name: areaUnitCost, label: 建面单方, agg: sum} axioms: - 默认只取末级科目(is_end_cost=1),避免父子重复累加schema.yaml(字段级映射,编译唯一源):
ProjectIndicator: table: dws_cost_project_indicator role: fact grain: 项目 × 科目(含父级+末级)× 业态 dimensions: costSubject: {sql: "{self}.bz_item_name", type: string, description: 成本科目} productType: {sql: "{self}.product_type_name", type: string, description: 业态} isEndCost: {sql: "{self}.is_end_cost", type: number, visible: false} # 护栏用,不暴露给 LLM measures: areaUnitCost: {sql: "{self}.area_unit_cost", type: number, agg: sum, description: 建面单方} joins: - to: Project on: "{self}.project_guid = {target}.project_guid" defaultFilters: - isEndCost = 1 # ← axiom 编译成 WHERE编译出的 SQL(引擎自动,LLM/用户都看不到):
SELECT bz_item_name, SUM(area_unit_cost)FROM dws_cost_project_indicatorWHERE is_end_cost = 1 -- ← axiom 自动注入,用户没写GROUP BY bz_item_name;注意三层职责:本体说”有项目成本指标这个概念、末级口径”;schema.yaml说”它在
dws_cost_project_indicator、costSubject是bz_item_name列”;SQL 是编译产物。LLM 只看 schema 的速查版(删了sql物理列、删了visible:false),决策时根本碰不到列名——这正是 场景指南/问数/03-Schema双层架构 双层分离要解决的。
四、粒度对齐:映射的命门
Section titled “四、粒度对齐:映射的命门”映射里最容易出错、后果最严重的,不是列名对错,而是粒度对不上。一个类的 grain 必须和它落地的宽表”一行代表什么”严格一致,否则聚合就会偷偷错。
🐛 货值三倍累加 bug:一桩粒度映射失配
Section titled “🐛 货值三倍累加 bug:一桩粒度映射失配”同一笔货值在宽表里按 Level=1/2/3 三层各存了一份等额金额(父子共存的事实表)。如果本体没把”末级口径”作为锚点钉进映射:
| SQL | 结果 | |
|---|---|---|
| AI 裸查(语法对) | SUM(...) WHERE IsBenchmark=1 | 8490 万 ❌(错 3 倍) |
| 本体补口径 | ... AND Level=3 | 2830 万 ✅ |
根因:类的 grain 是”末级科目一行”,宽表却同时存了父级行。映射时必须用 dwh:filter: Level=3(或 is_end_cost=1)把粒度锚死,编译期自动补进 WHERE。否则语法永远对、口径永远错——这是最隐蔽的一类错。
父子共存表的两种处理
Section titled “父子共存表的两种处理”业务实体常有树形结构(科目层级、组织层级、楼栋-单元)。宽表落地两条路:
- 路径展开:维度表里加
full_path_name/hierarchy_code/level,层级变成可筛选的列; - 父子共存:事实表里父级行 + 末级行同表(如 cost-query 的
is_end_cost=0/1)。
父子共存表必须在映射层声明默认 filter,否则不限定层级直接 SUM 就父子重复累加。这条铁律来自 场景指南/问数/02-数据底座 的「坑 5」,跨领域同构(工程的”分部/分项/工序”、组织的”集团/区域/末级公司”都是)。
五、关系映射:维度关系走 JOIN,语义关系靠 ABox 记回
Section titled “五、关系映射:维度关系走 JOIN,语义关系靠 ABox 记回”设计指南/本体设计/TBox 的 relation 落地时要分两类,这是本篇最容易被忽略的一点:
| 关系类型 | 例 | 映射到 | 谁负责 |
|---|---|---|---|
| 维度关系(查询拼装用) | 合同→属于→项目、项目→在→城市 | 宽表 joins:(外键) | schema.yaml |
| 语义关系(归因下钻用) | 钢筋↔混凝土(共振)、木模↔铝模(替代) | 设计指南/本体设计/ABox 显式关系图 | 本体 ABox |
为什么语义关系不能靠 JOIN:宽表星型化的本质是把关系拍平成外键——它能表达”这条记录属于哪个项目”,但表达不了”钢筋用量高,是不是因为层高高、要不要换铝模”。这类共振/替代/父子的归因关系,在拍平的那一刻就丢了。
宽表拍平关系 → ABox 再把关系显式记回来。 归因(L3)、决策建议(L4)沿这些关系下钻,所以一旦能力要到 L3/L4,映射设计就不能只画 JOIN,必须额外建 ABox 关系图。能力只到 L1/L2(事实查询、多维对比)时,维度 JOIN 就够,可以先不上 ABox(见 设计指南/本体设计 第四节的能力分级)。
TBox.relation ├── 维度关系(属于/在/挂)──→ 宽表 JOIN ──→ 拼查询 └── 语义关系(共振/替代/父子)─→ ABox 关系图 ─→ 归因下钻 ↑ 宽表拍平时丢失,必须在 ABox 重建六、口径护栏写在本体,不塞进宽表
Section titled “六、口径护栏写在本体,不塞进宽表”本体的 axiom(口径铁律)有两种落法,只有一种是对的:
- ❌ 物化进宽表:在 ETL 阶段就把”只算末级”焊死进数据 → 换个查询场景(比如真要看父级汇总)就没法回头,且换后端即丢。
- ✅ 声明进映射层:写成
defaultFilters/inferredFilters,编译器自动注入 WHERE,用户无感、后端可换。
ProjectIndicator: defaultFilters: - isEndCost = 1 # 默认只取末级 inferredFilters: - field: isJianAn rule: 任意位置引用 productTypeName → 自动 isJianAn=1 # 按 DSL 上下文推断护栏不是枷锁,是默认安全值。 业务铁律写在映射层、跟着语义走,模型再厉害也绕不过;塞进宽表则换个后端就漏。两者声明方式与编译细节见 设计指南/本体设计/TBox 与 设计指南/CLI设计/CLI设计规范。
七、底座可换:API 还是宽表,映射层吸收差异
Section titled “七、底座可换:API 还是宽表,映射层吸收差异”本体是物理无关的语义层,下面接什么底座由场景决定,映射层负责吸收差异:
| 场景 | 底座 | 映射形态 |
|---|---|---|
| 简单查询(取一条确定数据、端点固定) | API | 类 → API endpoint + 参数绑定(OBDA) |
| 复杂查询(多维组合、下钻归因) | 宽表 | 类 → cube + dimension/measure/join(DSL→SQL) |
cost-query V1=本体+API → V2=本体+宽表,换的是映射层的形态(endpoint 绑定 → cube 绑定),概念层岿然不动。判断标准:查询是否需要多维自由组合 / 下钻——是,就上宽表;只是固定端点取数,API 更轻。
八、映射设计检查清单
Section titled “八、映射设计检查清单”落地一套本体↔宽表映射前,逐条过:
- 本体是按业务概念分解出来的(N 个类),不是照着一张宽表一比一翻出来的伪本体?(一张交易宽表通常对应客户/合同/项目等好几个类)
- 每个类都挂了
table/grain/filter三锚点?(缺 grain 最危险) - 类的 grain 与宽表”一行代表什么”严格一致?父子共存表是否声明了默认层级 filter?
- 维度/度量列名只在 schema.yaml,没渗进本体?(本体保持物理无关)
- 维度关系画了 JOIN,语义关系建了 ABox?能力到 L3/L4 而只有 JOIN 是漏配。
- axiom 走
defaultFilters注入,没物化进宽表? - LLM 速查版删掉了
sql物理列与visible:false字段?(见 场景指南/问数/03-Schema双层架构) - 换后端时只动映射层,本体不动?动到本体了说明映射边界画错了。
- 上游概念:[设计指南/本体设计/TBox]· [设计指南/本体设计/ABox]· 总览 设计指南/本体设计
- 宽表怎么建(星型布局、分层、避坑 6 条)→ 场景指南/问数/02-数据底座
- schema.yaml 双层架构与字段约定 → 场景指南/问数/03-Schema双层架构
- 约束怎么编译成 WHERE / 三指令 → 设计指南/CLI设计/CLI设计规范