首页/数据能力/语义层

语义层

数据能力的第一组。语义层回答的是: 一句话问题里哪些部分可以被权威地翻译成 SQL,哪些不能。 能翻译的部分不交给模型,不能翻译的部分明确说出来——这一页讲这套判定是怎么做的。

语义层的职责边界

语义模型存在 semantics.yml 里,采用 Apache OSSIE 核心规范的 semantic_model 结构(datasets + metrics,表达式按方言声明、表名限定)。 这是唯一被读取的格式:更早的扁平 terms: 写法已经不再读取—— 解析出来是零条,只留一条迁移警告。手里有旧格式的库,重跑一次 /kb init --overwrite 再生成即可。

先把一件事说清楚,因为这里最容易误会:语义层不是「更好的提示词」。 提示词影响的是模型输出的概率分布,而语义层做的是编译——把结构化的计划 确定性地翻译成 SQL,同一份计划永远得到同一条 SQL。

语义层做语义层不做
指标口径 把指标展开成它声明的表达式 猜一个没声明过的指标该怎么算
表关联 按声明的 relationship 求 join 路径 在两条都说得通的路径里选一条
行集正确性 识别会放大/复制行的 join,拒绝它 生成一条「大概对」的 SQL 让人自己发现
权限 把声明的行过滤谓词注入每个引用该表的查询 替代数据库侧的只读角色

语义模型的结构

语义模型由四类构件组成。它们的关系是:数据集提供表,关系提供边, 字段提供可用的列,指标提供算法。

models.py:354 SemanticModel models.py:82 SemanticMetric models.py:119 SemanticField models.py:190 SemanticRelationship

关系上有一个字段值得单独说:fan_out。它在模型里是显式声明的, 而不是运行期推断的——因为「这条边会不会放大行数」是关于业务的判断, 数据库的元数据里没有这个信息。

models.py:222 fan_out models.py:221 cardinality

语义模型工作台(financial):顶栏数据源切换与「重新检测漂移 / 新建资产」;摘要行 86 指标 · 8 数据集 · 55 字段 · 0 待审批 · 漂移检测 刚刚;KPI 行;问题卡里「校验问题」与「漂移」两节都为空,汇总一行「无校验问题 · 未发现漂移」;底部 tab 行与搜索框 1 2 3 4
图 2 · 语义模型工作台(financial):可答边界在这里维护——摘要、问题卡与四类构件的入口同屏。
  1. 1顶栏:数据源切换 + 动作——语义模型按源分开(financial · default 与 demo 两枚胶囊),切换即换整份模型;「重新检测漂移」手动跑一次库与模型的对照,「新建资产」是四类构件的统一入口。
  2. 2摘要行——86 指标 · 8 数据集 · 55 字段 · 0 待审批,后面缀着「最近提交 2 分钟前 · 漂移检测 刚刚」:这是一句 freshness 声明,漂移读数过期时它自己会说。
  3. 3问题卡:校验问题 + 漂移——两节各自列问题;都为空时卡片底部汇总一行「无校验问题 · 未发现漂移」,而不是渲染两张空表。漂移 = 物理库与模型声明的对照结果。
  4. 4tab 行 + 搜索——指标 / 数据集 / 字段 / 待审批是四类条目的入口(本图 86 条指标选中中);改动一律先落 pending 草稿,经审批流应用到 semantics.yml。

主题域:把「可答范围」声明成域

模型还可以声明主题域(topics):一个业务域 = 名字 + 数据集清单,外加给人和给检索用的佐料(同义词、指标、示例问句、描述)。 它把「这个数据源里哪些数据集属于哪块业务」从口头约定变成声明,消费面有三处: 用户端的选择器(提问时选域收敛范围)、 定时任务的域引用(写任务时校验)、 以及域级授权(谁可见哪些域)。

models.py:303 TopicDomain models.py:379 topics

运行期只有三件事,全部零 LLM、纯函数(topics.py):

步骤口径
解析选择 域名单平台比较(大小写 / 空白不敏感)→ 声明里的那一个。解析不到是显式的失败信号,不静默退回全量——用户选的是范围,范围没了就该说,而不是把问题答在更大的数据上
算作用域 声明数据集 ∩ 模型当前数据集。交集而不是直接采信声明:域写在 YAML 里、模型会随 /kb init 演进,悬空名字只会让作用域比作者以为的更窄;交集为空也是显式状态——empty_scope,域过期,不是「不限制」
收敛
(入口,不是出口)
锚定候选的三条通道(词法、字段名、KB/live term 表)一律收敛。收敛放在入口:出口过滤会让域外表先占满候选与上限、再被删掉——域内表可能已经被上限挤出去了。另外引擎唯一一处「自己加表」的指标扩展(沿 metric.datasets 拉表)之后还要再收敛一次:只靠入口过滤挡不住它

解析结果四态,每种都对应一句说得出的话:

状态含义
none未选择——行为与不启用主题域字节级一致
ok命中;作用域 = 声明 ∩ 现有数据集(非空)
not_found名字不在模型里——「域不存在」与「你没被授权」是同一种拒绝(见下)
empty_scope名字在、但声明的数据集已全部不在模型里——域过期
拒绝不能变成域名清单

名字解析不到、或者域存在但被域级授权挡掉,返回的是同一个 topic_not_found 拒绝。分开写的话,任何一个用户都能拿报错文案当预言机, 逐个试出「哪些域存在」——拒绝文案不能泄露它不该知道的存在性。

拒绝文案里附的可选域清单(available_topics)也按主体过滤: 你看不见的域,既不在选择器里,也不在报错里。域过期的拒绝另成一档 (topic_empty_scope),因为它和「名字写错」处置不同: 改名字没用,得修模型。

域收敛的是锚定,不是关系图

作用域只回答「这个问题关于哪些数据集」,不裁剪 join 路径—— 编译器按声明的 join 图补齐中间表是既有语义,主题域不是关系图的裁剪器。 选域是为了收敛入口,不是给跨表分析上锁。

命中后,语义上下文里会多一行确定性的声明:Topic domain: <名> (scope: …)。 渲染给生成侧的是作用域事实(本问收敛在哪些数据集),不是营销文案—— 生成据此不再往域外找表。

topics.py:25 TopicResolution topics.py:58 resolve_topic topics.py:81 filter_scoped topics.py:98 render_topic_line schema_linking.py:175 _semantic_match_datasets schema_linking.py:460 allows_topic policy.py:73 visible_topics

编译:三态产出

编译入口有两个。新契约返回三态(成功 / CompileMiss / PartialCompile), 旧契约为了字节级向后兼容只返回两态(任何 MISS 都返回 None):

compiler.py:2917 compile_detailed compiler.py:2888 compile_from_plan compiler.py:1973 SemanticCompiler

flowchart LR
    P["查询计划(query_sketch 产出)"]:::entry --> C["SemanticCompiler.compile_detailed"]:::gen
    M[("semantics.yml")]:::entry --> C
    C -- "全部解析" --> OK["成功:权威 SQL
生成通道基本不参与"]:::entry C -- "只缺词表 / 取值 / 口径" --> PC["PartialCompile
骨架 SQL + 交接契约 + 未解析清单"]:::entry C -- "结构性问题" --> CM["CompileMiss
携带 reason"]:::gate OK --> RUN["执行"]:::exec PC --> GEN["生成通道按契约补齐"]:::gen GEN --> CHK{"执行前:骨架保真校验
join 边 / WHERE / 分组宽度"}:::gate CHK -- "保住了" --> RUN CHK -- "丢了" --> RT["打回重生成"]:::entry CM --> RF["refuse:拒绝 + 起草扩展草案"]:::gate classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px; classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px; classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px; classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px; linkStyle 9 stroke:#f59e0b,stroke-width:1.5px
图 1 · 编译三态与去向。运行时的路由看的是产物类型(CompileMiss / PartialCompile),不是去追 is_hard_miss 的调用点。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

PartialCompile 是这套设计里最特别的一个产物。它同时携带三样东西: 已经编译好的骨架 SQL、一份交给生成通道的交接契约、以及未解析组件的清单。

compiler.py:1374 PartialCompile contract.py:160 PlanContract contract.py:220 render_contract

契约的用途不是「描述」,是「约束」

契约会被渲染成一段文字注入生成提示,但它的作用不是告诉模型「我们做了什么」,而是划定不许改动的部分。模型补完的 SQL 事后要按同一份契约校验。

硬与软的完整判据

分类的权威是这三个集合。每条 reason 落在哪个集合里,是从「放行它会不会产出一个错的数」推导出来的:

compiler.py:1396 MISS_REASONS compiler.py:1437 HARD_MISS_REASONS compiler.py:1456 SOFT_MISS_REASONS compiler.py:1483 is_hard_miss

硬 MISS 逐条

reason触发条件放行会怎样
no_plan_or_matched 没有查询计划、没有匹配到表,或者压根没有语义模型——编译无从开始 没有可编译的起点:放行等于让后面每一步都建立在猜测上
fan_out join 路径上存在声明的行倍增边 被聚合的明细行被复制,金额类指标成倍放大
unknown_cardinality 边的基数未声明,无从判断是否倍增 不确定就等于有风险——宁可拒绝
ambiguous_join_path 两表间存在多条连通路径,或显式 joins 不构成连通树 选错路径 = 用另一个口径算,结果看着正常但是错的
unreachable_table 投影表守卫:产物引用的表不在连接树内 SQL 引用了一张没被 join 进来的表
table_not_allowed 引用了数据集白名单之外的表 越界访问
derived_cycle / derived_depth / derived_unresolved 派生指标成环、展开超过深度上限、或定义悬空 展开它没有终点,或展开成一个空表达式
metric_anchor_unmatched 指标的锚定表不在本次匹配到的表里 生成的 SQL 会引用未覆盖的表
limit_without_order 要求 LIMIT 但没有排序依据 取到哪些行是不确定的
nothing_compilable 兜底:一个成分都编译不出来 —
guardrail_rejected 编译产物未通过语义侧护栏校验 产物本身越界

判定 fan-out 的地方分两处:显式 joins 走 guard_edge_cardinality, 自动寻路走 BFS,路径上遇到倍增边即拒绝。派生指标的展开深度上限是 5 层。

compiler.py:211 _classify_edges compiler.py:245 guard_edge_cardinality compiler.py:2664 _MAX_INLINE_DEPTH compiler.py:149 JoinResolver

软 MISS 逐条

软 MISS 的共同点是:结构是清楚的,只是某个词/值/口径没登记。 这类问题可以带着缺口继续走,因为缺口的位置是已知的。

reason缺什么
no_metric_match问题里的聚合意图没有对应的已声明指标
bad_time_grain / time_field_not_declared / time_field_not_temporal时间分桶的粒度未声明、字段不是时间类型
unresolved_answer_column要输出的列没有在语义模型里登记
unresolved_filter_field / invalid_op / missing_filter_value过滤字段未声明、算子不适用、比较值缺失
enum_value_unresolved用到了某个枚举值,但它不在该字段的已声明取值里
having_metric_unknown / having_without_aggregationHAVING 里点了一个没有声明的指标,或者压根没有可据以过滤的聚合
time_grain_without_aggregation要按时间分桶,但没有可以分桶的聚合
analysis_*(六条,见下)窗口 / 分析函数这一段没解析出来

软 MISS 一共 18 条,表里按「缺什么」归了类;完整的闭集在 SOFT_MISS_REASONS 里,新增 MISS 分支时必须归入软硬两边之一。

窗口与分析函数

语义计划里可以带一段 analysis,编译期展开成窗口函数。支持六种: share(占整体比例)、running_total(累计)、 mom / yoy(环比 / 同比增量)、pct_change(变化率)、 rank(排名)。它们都是单度量的——窗口计算围绕一个度量展开, 所以计划里必须恰好有一个聚合度量投影。

reason缺什么
analysis_unsupported_type类型不在上面六种里,或写成了一个数组 / 空值
analysis_metric_unknown点名的度量没声明,或聚合度量不止一个
analysis_partition_unresolved分组维度(PARTITION BY 的依据)没解析出来
analysis_order_unresolved排序依据没解析出来——累计 / 环比 / 同比没有它就无从下手
analysis_time_required这类分析必须沿时间轴走,但计划里没有时间维度
analysis_invalid这段 analysis 的形状本身不对(不是对象、内层 SQL 解析不出来)

注意这些全是软 MISS:窗口那一段交回生成通道按计划文本补齐, 内层的分组与过滤仍然由编译器权威产出。缺一段不该让整条查询停摆。

收集软 MISS 的入口是 _record_soft——它只记录,不中断编译, 所以一次编译能把所有缺口都收集齐,而不是修一个报一个。

compiler.py:2898 _record_soft compiler.py:3598 PartialCompile

一个准确度上的细节

is_hard_miss 是 reason → 软硬分类的权威判定函数,但运行时的路由并不是靠调用它决定的,而是靠编译产物的类型:拿到 CompileMiss 就走拒绝路径,拿到 PartialCompile 就走骨架路径。

这个区分在写扩展或调试时很重要——想知道「为什么这次拒绝了」,看的是产出的类型与它携带的 reason,而不是去追 is_hard_miss 的调用点。

骨架保真校验

软 MISS 放行之后,生成通道会拿到骨架和契约去补全 SQL。执行之前有一道校验, 它比对的是三样东西:join 边、WHERE 条件、分组宽度。 投影列不参与比对——补全本来就可能调整输出列,那不是破坏契约。

compiler.py:4346 skeleton_preserved compiler.py:4522 compiled_sql_matches execute_sql.py:192 skeleton_preserved

校验有两种失败方式:SQL 解析不了,或者解析出来发现丢了骨架里的东西。 两种都会把这一轮打回重生成,而不是带着一个已破坏权威性的 SQL 去执行。

execute_sql.py:197 _compile_drift_failure

行级安全:在编译期注入

数据集可以声明一个 row_filter——一个恒真的布尔 SQL 谓词。 编译时,每个引用该数据集的查询都会被注入这个谓词到顶层 WHERE。

models.py:252 row_filter rls.py:99 inject_row_filters rls.py:75 render_row_filter

fail-closed,不是 fail-open

注入失败时(谓词渲染不出来、SQL 结构不认),系统放弃这条路径而不是跳过过滤。快径命中模板后也要单独注入一次——它不经过编译器,所以必须自己补上这一步,注入失败就退回编译路径。

rls.py:30 RLSInjectionError fast_match.py:502 rls_applied

但这份机制有一条明确的边界,不该被误读成安全保证: 声明层的行过滤不替代数据库侧的只读角色。它的作用是让「同一个语义模型下的所有查询」 口径一致,而不是防止有人绕过 Trove 直接连库。

声明式 API 的严格契约

如果是通过声明式 API 调语义层(而不是走对话工作流),契约会变得更严格: 遇到 PartialCompile 直接返回 422,而不是降级补全。

query.py:185 compile_detailed query.py:190 PartialCompile

理由很直接:程序调用方拿到一个「补全过的」SQL,它无法判断哪些部分是权威的、哪些是模型填的。 而对话场景里用户可以看见解释、可以追问。所以同一个语义层,两种调用方,两套契约。

继续读

想了解去哪
概念层面的可答边界与逃生梯产品概念
编译边界的完整推导(硬/软 MISS、骨架保真、草稿免确认的验证门)深潜 · 语义编译边界
指标、字段、枚举从哪来(KB 五件套)知识库
数据集怎么变成对象、关系怎么带业务名、维度链怎么声明成层级本体
成批改这份模型时怎么先看 diff、回放与影响面再合入语义层治理 · 变更评审
同一份模型还有别的消费者吗判定规则、行动与审批
用户端怎么用主题域收敛提问提问与读懂答案 · 主题域
域级授权怎么配置用户·权限·安全
编译器在图上哪一步被调用查询工作流
拒绝之后管理端怎么处置运维管理
本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页