Skip to content

本体宽表映射设计

核心观点:本体设计 ≠ 宽表设计——这是两件不同的设计工作。 一张超大宽表(如交易宽表)往往把客户、合同、项目好几个本体类拍平塞进一行;本体则把同一份现实拆成几个有身份、有关系的类。一个在合并、一个在分解,所以中间需要一层显式映射把两者对齐:类落到哪张 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
  • 客户是一个类:有唯一身份(一个客户多笔成交)、有自己的属性(等级/手机)、有自己的生命周期;
  • 合同是另一个类:金额 / 结算状态 / 签约时间是合同的,不是客户的;
  • 项目又是一个类:城市 / 业态是项目的;
  • 它们之间有关系:合同→属于→客户、合同→属于→项目。

关键:宽表把「客户 / 合同 / 项目」压成一行里的几组列;本体把同一份现实拆成几个有身份、有关系的类。一个在合并,一个在分解——这就是为什么”设计宽表”和”设计本体”不是同一件事。

宽表设计本体设计
设计单位一张表 / 一行一个概念 / 一条关系
核心决策一行什么粒度、塞哪些列、为性能冗余物化哪些有哪些类、各自身份与属性、彼此什么关系、什么口径约束
同一客户多笔成交客户信息在多行里冗余重复(无所谓,查得快就行)客户是一个实例,多笔成交都指向它(身份唯一)
合同↔项目的关系拍平消失(变成同一行里相邻的列)显式建模(合同→属于→项目),归因要靠它
服务对象 / 变化驱动让引擎查得快 / 随性能调优变(快)让 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.ProjectIndicatordws_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 路由到不同物理列
关系 relationJOIN(维度关系)/ ABox(语义关系)joins: 或 ABox 显式记回两类关系走两条路,见第五节
约束 axiomWHERE 注入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_indicator
WHERE is_end_cost = 1 -- ← axiom 自动注入,用户没写
GROUP BY bz_item_name;

注意三层职责:本体说”有项目成本指标这个概念、末级口径”;schema.yaml说”它在 dws_cost_project_indicatorcostSubjectbz_item_name 列”;SQL 是编译产物。LLM 只看 schema 的速查版(删了 sql 物理列、删了 visible:false),决策时根本碰不到列名——这正是 场景指南/问数/03-Schema双层架构 双层分离要解决的。


映射里最容易出错、后果最严重的,不是列名对错,而是粒度对不上。一个类的 grain 必须和它落地的宽表”一行代表什么”严格一致,否则聚合就会偷偷错。

🐛 货值三倍累加 bug:一桩粒度映射失配

Section titled “🐛 货值三倍累加 bug:一桩粒度映射失配”

同一笔货值在宽表里按 Level=1/2/3 三层各存了一份等额金额(父子共存的事实表)。如果本体没把”末级口径”作为锚点钉进映射:

SQL结果
AI 裸查(语法对)SUM(...) WHERE IsBenchmark=18490 万 ❌(错 3 倍)
本体补口径... AND Level=32830 万 ✅

根因:类的 grain 是”末级科目一行”,宽表却同时存了父级行。映射时必须用 dwh:filter: Level=3(或 is_end_cost=1)把粒度锚死,编译期自动补进 WHERE。否则语法永远对、口径永远错——这是最隐蔽的一类错。

业务实体常有树形结构(科目层级、组织层级、楼栋-单元)。宽表落地两条路:

  • 路径展开:维度表里加 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 更轻。


落地一套本体↔宽表映射前,逐条过:

  • 本体是按业务概念分解出来的(N 个类),不是照着一张宽表一比一翻出来的伪本体?(一张交易宽表通常对应客户/合同/项目等好几个类)
  • 每个类都挂了 table / grain / filter 三锚点?(缺 grain 最危险)
  • 类的 grain 与宽表”一行代表什么”严格一致?父子共存表是否声明了默认层级 filter?
  • 维度/度量列名只在 schema.yaml,没渗进本体?(本体保持物理无关)
  • 维度关系画了 JOIN,语义关系建了 ABox?能力到 L3/L4 而只有 JOIN 是漏配。
  • axiom 走 defaultFilters 注入,没物化进宽表?
  • LLM 速查版删掉了 sql 物理列与 visible:false 字段?(见 场景指南/问数/03-Schema双层架构)
  • 换后端时只动映射层,本体不动?动到本体了说明映射边界画错了。