构建语义层与指标引擎:让 BI 数据真正可信

构建语义层与指标引擎:让 BI 数据真正可信

一、为什么我们需要语义层?

做数据平台最怕什么?不是数据存不下来,而是大家算出来的数对不上。

同一个"活跃用户数",产品看的是登录,运营看的是交互,财务看的是付费。三份报表摆在一起,数字能差出 30%。管理层拿着这些数据做决策,心里肯定犯嘀咕。

更麻烦的是计算逻辑散落在各处:SQL 脚本、Python Notebook、Excel 公式、BI 工具里的计算字段......版本乱成一团。一旦业务规则变了(比如"活跃"的定义改了),得把相关报表翻个底朝天手动改,漏一个就是一个坑。

我们做这个 AI 增强的 BI 平台,核心就是想通过**语义层(Semantic Layer)**把指标定义收归统一。顺便利用大模型做自然语言查询和异常归因,让 BI 工具别只做个"报表生成器",能真正帮人分析数据。

二、语义层架构:把业务逻辑和数据物理层解耦

2.1 分层设计

语义层的作用很简单:在原始数据和业务应用之间,垫一层统一的指标定义。

graph TB subgraph 物理层 T1[订单表 orders] T2[用户表 users] T3[商品表 products] T4[行为日志 events] end subgraph 语义层 D1[维度: 区域/品类/时间] M1[度量: GMV/订单量/客单价] I1[指标: 活跃用户率/复购率/GMV环比] R1[关系: 用户-订单-商品关联] end subgraph 应用层 B1[BI看板] B2[自然语言查询] B3[异常告警] B4[报告生成] end T1 --> D1 T2 --> D1 T3 --> D1 T4 --> M1 T1 --> M1 D1 --> I1 M1 --> I1 R1 --> I1 I1 --> B1 I1 --> B2 I1 --> B3 I1 --> B4

这层主要管四样东西:

  • 维度(Dimension):看数据的角度,比如时间、区域。决定了数据怎么切分。
  • 度量(Measure):能聚合的数字,比如 GMV、订单量。定义了是用 SUM 还是 COUNT。
  • 指标(Metric):具体的业务公式,比如"客单价=GMV/订单量"。这是业务方直接关心的。
  • 关系(Relation):表怎么连,JOIN 条件是什么,基数怎么算。

2.2 指标也要讲版本

指标定义不是刻在石头上的。业务变了,公式就得改,但历史数据还得按旧口径算。

所以语义层里,每个指标都得维护一个版本链。查数据的时候,要么指定查哪个版本,要么默认用最新的。这样既保证了历史可比性,又支持了业务演进。

三、代码实现:语义层与智能计算引擎

python 复制代码
"""AI增强BI平台:语义层与智能指标计算引擎"""

import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional


class AggregationType(Enum):
    """聚合类型"""
    SUM = "sum"
    COUNT = "count"
    COUNT_DISTINCT = "count_distinct"
    AVG = "avg"
    MAX = "max"
    MIN = "min"
    MEDIAN = "median"


class MetricType(Enum):
    """指标类型"""
    ATOMIC = "atomic"       # 原子指标,直接聚合
    DERIVED = "derived"     # 衍生指标,基于其他指标计算
    WINDOW = "window"       # 窗口指标,涉及时间窗口计算


@dataclass
class Dimension:
    """维度定义"""
    name: str
    display_name: str
    sql_expression: str          # 对应的SQL表达式
    data_type: str               # string / int / date
    hierarchy: Optional[list[str]] = None  # 层级维度,如 [省, 市, 区]


@dataclass
class Measure:
    """度量定义"""
    name: str
    display_name: str
    sql_expression: str
    aggregation: AggregationType
    table_name: str
    description: str = ""


@dataclass
class MetricVersion:
    """指标版本"""
    version: int
    formula: str                 # 计算公式
    description: str             # 口径说明
    created_at: str = ""
    is_active: bool = True


@dataclass
class Metric:
    """指标定义"""
    name: str
    display_name: str
    metric_type: MetricType
    versions: list[MetricVersion] = field(default_factory=list)
    dimensions: list[str] = field(default_factory=list)  # 可用维度
    owner: str = ""              # 指标负责人
    tags: list[str] = field(default_factory=list)

    @property
    def active_version(self) -> Optional[MetricVersion]:
        """获取当前生效的版本"""
        active = [v for v in self.versions if v.is_active]
        return active[-1] if active else None


class SemanticLayer:
    """语义层引擎"""

    def __init__(self):
        self.dimensions: dict[str, Dimension] = {}
        self.measures: dict[str, Measure] = {}
        self.metrics: dict[str, Metric] = {}

    def register_dimension(self, dim: Dimension) -> None:
        """注册维度"""
        self.dimensions[dim.name] = dim

    def register_measure(self, measure: Measure) -> None:
        """注册度量"""
        self.measures[measure.name] = measure

    def register_metric(self, metric: Metric) -> None:
        """注册指标"""
        if not metric.versions:
            raise ValueError(f"指标 {metric.name} 必须至少有一个版本")
        self.metrics[metric.name] = metric

    def update_metric_formula(
        self, metric_name: str, new_formula: str, description: str,
    ) -> int:
        """更新指标公式,创建新版本"""
        metric = self.metrics.get(metric_name)
        if not metric:
            raise KeyError(f"指标 {metric_name} 不存在")

        # 将旧版本标记为非活跃
        for v in metric.versions:
            v.is_active = False

        # 创建新版本
        new_version = len(metric.versions) + 1
        metric.versions.append(MetricVersion(
            version=new_version,
            formula=new_formula,
            description=description,
            is_active=True,
        ))
        return new_version

    def compile_metric_to_sql(
        self, metric_name: str,
        dimensions: Optional[list[str]] = None,
        filters: Optional[dict[str, Any]] = None,
        version: Optional[int] = None,
    ) -> str:
        """将指标编译为SQL查询"""
        metric = self.metrics.get(metric_name)
        if not metric:
            raise KeyError(f"指标 {metric_name} 不存在")

        # 确定使用的版本
        if version:
            ver = next(
                (v for v in metric.versions if v.version == version), None
            )
            if not ver:
                raise ValueError(f"版本 {version} 不存在")
        else:
            ver = metric.active_version

        # 解析公式中的度量引用,替换为SQL表达式
        formula = ver.formula
        for measure_name, measure in self.measures.items():
            pattern = r'\b' + measure_name + r'\b'
            agg_sql = f"{measure.aggregation.value}({measure.sql_expression})"
            formula = re.sub(pattern, agg_sql, formula)

        # 构建SELECT子句
        select_parts = []
        dim_exprs = []
        if dimensions:
            for dim_name in dimensions:
                dim = self.dimensions.get(dim_name)
                if dim:
                    select_parts.append(
                        f"{dim.sql_expression} AS {dim_name}"
                    )
                    dim_exprs.append(dim.sql_expression)

        select_parts.append(f"{formula} AS {metric_name}")

        # 构建FROM和JOIN子句(简化版,实际需要关系推导)
        involved_tables = set()
        if dimensions:
            for dim_name in dimensions:
                dim = self.dimensions.get(dim_name)
                if dim and '.' in dim.sql_expression:
                    involved_tables.add(dim.sql_expression.split('.')[0])
        for measure_name in re.findall(r'\b\w+\b', ver.formula):
            m = self.measures.get(measure_name)
            if m:
                involved_tables.add(m.table_name)

        from_clause = ", ".join(involved_tables)

        # 构建WHERE子句
        where_parts = []
        if filters:
            for key, value in filters.items():
                dim = self.dimensions.get(key)
                if dim:
                    if isinstance(value, list):
                        vals = "','".join(str(v) for v in value)
                        where_parts.append(
                            f"{dim.sql_expression} IN ('{vals}')"
                        )
                    else:
                        where_parts.append(
                            f"{dim.sql_expression} = '{value}'"
                        )

        # 组装SQL
        sql = f"SELECT {', '.join(select_parts)} FROM {from_clause}"
        if dim_exprs:
            sql += f" GROUP BY {', '.join(dim_exprs)}"
        if where_parts:
            sql += f" WHERE {' AND '.join(where_parts)}"

        return sql

    def get_metric_lineage(self, metric_name: str) -> dict:
        """获取指标的血缘关系"""
        metric = self.metrics.get(metric_name)
        if not metric:
            return {}

        ver = metric.active_version
        # 从公式中提取依赖的度量和指标
        deps = {
            "metric": metric_name,
            "formula": ver.formula,
            "depends_on_measures": [],
            "depends_on_tables": [],
        }
        for measure_name in re.findall(r'\b\w+\b', ver.formula):
            m = self.measures.get(measure_name)
            if m:
                deps["depends_on_measures"].append(measure_name)
                deps["depends_on_tables"].append(m.table_name)

        return deps


class AIAnomalyAttributor:
    """AI驱动的指标异常归因"""

    def __init__(self, llm_client):
        self.llm_client = llm_client

    def attribute_anomaly(
        self, metric_name: str, anomaly_info: dict,
        semantic_layer: SemanticLayer,
    ) -> dict:
        """对指标异常进行归因分析"""
        lineage = semantic_layer.get_metric_lineage(metric_name)
        metric = semantic_layer.metrics[metric_name]
        ver = metric.active_version

        # 构建归因分析Prompt
        prompt = f"""指标 {metric_name} 出现异常,请进行归因分析。

异常信息:
- 指标名称:{metric.display_name}
- 当前值:{anomaly_info.get('current_value')}
- 预期值:{anomaly_info.get('expected_value')}
- 偏差幅度:{anomaly_info.get('deviation_pct')}%
- 时间段:{anomaly_info.get('time_range')}

指标定义:
- 计算公式:{ver.formula}
- 口径说明:{ver.description}
- 依赖度量:{lineage.get('depends_on_measures', [])}
- 依赖表:{lineage.get('depends_on_tables', [])}

请从以下维度分析可能的原因:
1. 上游度量变化:哪些依赖度量发生了显著变化
2. 维度拆解:哪个维度的细分数据贡献了最大偏差
3. 数据质量:是否存在数据缺失或重复
4. 业务事件:是否有促销、活动等业务因素

返回JSON格式的归因结果。"""

        # 调用LLM进行归因分析
        response = self.llm_client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3,
        )
        return {
            "metric": metric_name,
            "attribution": response.choices[0].message.content,
        }

四、工程上的几个坑

4.1 灵活性与性能的博弈

语义层本质是把业务公式翻译成 SQL。这中间涉及公式解析、表关系推导、JOIN 路径选择。对于复杂指标(比如带窗口函数、子查询的),生成的 SQL 往往不如手写优化得好,查询性能可能会掉 20%-50%。

怎么折中?

高频查的指标,直接预编译成物化视图;低频的保持动态编译。另外得留个后门,允许对特定指标手动指定优化后的 SQL 模板,别让语义层卡住性能。

4.2 版本管理的复杂度

指标之间往往有依赖:指标 C 依赖 B,B 依赖 A。底层指标口径一变,上面全得跟着变。如果每个指标都独立版本化,组合爆炸会让版本矩阵变成噩梦。

实际做法:

把相关指标打包成"指标组",同组同步更新。跨组依赖通过接口约定,别直接在公式里硬引用。

4.3 什么时候别用语义层

别为了技术而技术,以下情况建议慎重:

  • 报表少于 20 个:口径问题还不明显,维护语义层的成本高于收益。
  • 查询高度定制:每个报表的 SQL 都是特化的,通用抽象覆盖不了。
  • 实时流式计算:语义层主要面向批处理 SQL,对流式窗口的支持目前还比较弱。

五、总结

这套方案的核心价值,是把散落在 SQL、Notebook 和 Excel 里的指标定义收拢到一处。版本管理让变更可追溯,血缘分析能帮你快速定位影响范围,AI 归因则把排查异常从"人工逐层下钻"变成了"智能定位"。

落地路线建议:

  1. 先挑核心业务指标(GMV、DAU、转化率)建语义层,验证编译准确性和性能。
  2. 再慢慢扩大覆盖范围,加上版本管理和血缘追踪。
  3. 最后接入 AI 归因,实现从"看数据"到"理解数据"的转变。

语义层不是要替代 SQL,而是让指标的定义、计算和使用有章可循。数据可信了,决策才能踏实。