元知识数据库

分清四类角色:配置文件 + 业务实体 + ORM模型 + mappers

配置文件:

配置文件是“外部输入”,告诉程序:这次要处理什么。

image

它主要负责:

  • 要同步哪些表
  • 每张表有哪些字段
  • 字段的角色、描述、别名是什么
  • 哪些字段后续还要同步真实取值
  • 哪些指标要进入知识库

所以配置文件更像一份“任务说明书”或“同步清单”。但它还不是程序内部真正干活时最核心的对象,因为它通常只描述了业务语义和同步范围,并不一定包含完整的运行时信息

业务实体:

业务实体是“程序内部统一流转的数据对象”,告诉系统:我内部准备怎么表示这些表、字段、指标。也可以理解成系统内部的“标准件”。

它的作用是把来自不同地方的信息,统一组织成系统后续都能复用的结构。所以业务实体关注的重点不是“怎么存数据库”,而是:系统内部如何统一表达一个表、一个字段、一个指标

image

ORM模型:

ORM 模型是“数据库映射对象”,告诉 ORM 框架:这些 Python 类怎么对应数据库中的表和列。

业务实体关心“系统内部如何表达”,ORM 模型关心“数据库表结构如何映射”。这两个对象看起来字段可能很像,但职责完全不一样 .

image

mappers: 

mappers 不是新的业务对象,也不是新的数据库模型,它本质上只是一个翻译层。它解决的问题是:service 层更适合处理业务实体,repository 层在落库时又需要 ORM 模型。那中间总要有人负责做转换,这个人就是 mapper。职责不是“定义新数据”,而是:把业务实体转换成 ORM 模型,或者把 ORM 模型转换回业务实体。

image  

如果把这 4 类角色放在一起看,可以先记成下面这样:

  • 配置文件:决定“这次同步什么”
  • 业务实体:决定“系统内部怎么统一表示这些数据”
  • ORM 模型:决定“这些数据怎么映射到数据库表”
  • mappers:负责在业务实体和 ORM 模型之间做转换

角色关系与调用链路:

如果再把“谁调用谁”也一起说清楚,可以看作下面这条业务链:

  1. 脚本入口 build_meta_knowledge.py 启动流程。
  2. MetaKnowledgeService.build(config_path) 读取配置文件。
  3. OmegaConf 把配置文件解析成程序里的配置对象 MetaConfig
  4. service 层根据 MetaConfig 构造业务实体,例如 TableInfoColumnInfo
  5. 在构造业务实体的过程中,service 会调用 DWMySQLRepository 去数仓补齐真实字段类型、示例值。
  6. 当业务实体准备好后,service 再调用 MetaMySQLRepository 去执行落库。
  7. MetaMySQLRepository 内部不会直接要求上层传 ORM 模型,而是先调用 mapper,把业务实体转换成 ORM 模型。
  8. 最后由 ORM 模型通过 session.add_all() 等方式写入 Meta MySQL

这条链路里,每一层的调用关系其实很清楚:

  • script 调 service
  • service 调 repository
  • repository 调 mapper
  • mapper 负责对象转换
  • ORM 模型最终参与数据库读写

也就是说,mapper 并不是一层单独发起业务的角色,它更像是夹在 repository 内部的一个翻译器。

假设配置文件里有这样一张表:

tables: #表信息 
  - name: fact_order    # 真实表名
    role: fact    # 表角色
    description: 订单事实表     # 表的业务含义说明
    columns:    
      - name: order_amount    # # 真实字段名
        role: measure    #字段角色
        description: 订单金额    # 字段的业务含义
        alias: ["销售额", "成交金额"] # 字段同义词
        sync: true | false     # 该字段取值是否需要同步到 ES 建立全文索引
        # sync 是这个配置文件里最容易被忽略、但非常关键的一个字段
        # sync: true:表示这个字段的真实取值需要同步到 Elasticsearch
        # sync: false:表示只保留字段元信息,不同步真实取值
        # 它控制的不是“这个字段要不要进入知识库”,而是:这个字段要不要额外建立字段值全文索引
        # 所有出现在配置里的字段,都会作为字段元数据进入系统;只有 sync: true 的那些字段,才会继续把真实取值同步到 ES
metrics: # 指标信息
  - name: <metric_name> # 指标名称,如 GMV、AOV
    description: <metric_description> # 指标的业务含义与计算口径说明
    relevant_columns: [<table_name.column_name>] # 指标相关字段
    # 这里的 relevant_columns 很重要,因为它把“指标”和“底层字段”连接起来了。
    # 后面往元数据库写数据时,column_metric 这张关系表就是根据这个字段来生成的
    alias: [<alias1>, <alias2>] # 指标同义词

那么后面大致会发生下面这些事:

  1. 先从配置里读到: fact_orderorder_amountmeasure订单金额销售额
  2. 再从 DW 查询到: order_amount 的真实类型,比如 float
  3. 然后在 service 层组装出业务实体:
  4. ColumnInfo(
        id="fact_order.order_amount",
        name="order_amount",
        type="float",
        role="measure",
        examples=[...],
        description="订单金额",
        alias=["销售额", "成交金额"],
        table_id="fact_order",
    )
  5. 到了 repository 层,再通过 ColumnInfoMapper 把它转换成 ColumnInfoMySQL
  6. 最后由 ORM 把 ColumnInfoMySQL 写进 column_info 表

这样一看就很清楚了:

  • 配置文件提供了起点
  • 业务实体承接了系统内部表达
  • mapper 完成了对象翻译
  • ORM 模型完成了数据库映射

 

同步脚本说明:  

整体上,它要完成 5 步:

  1. 读取配置文件
  2. 将指定的表信息和字段信息写入 Meta MySQL
  3. 为字段信息建立向量索引,写入 Qdrant
  4. 为需要同步的字段取值建立全文索引,写入 Elasticsearch
  5. 将指标信息和指标字段关系写入 Meta MySQL,并为指标建立向量索引

如果把这条链路对应到不同存储中,可以得到下面这张总表:

存储组件 主要存什么 在本项目中的作用
MySQL 表信息、字段信息、指标信息、字段指标关系 保存结构化元数据
Qdrant 字段向量、指标向量 支撑语义召回
Elasticsearch 字段真实取值 支撑关键词和值域检索

build_meta_knowledge.py这份脚本本身并不承载复杂业务细节,它更像一个总调度器,主要负责:

  • 初始化客户端
  • 创建仓储对象
  • 创建服务对象
  • 调用服务层的 build(config_path)
  • 在结束时关闭连接

更贴切地说,是把整条元数据知识库构建链路调度起来,而不是把所有构建细节都直接写在脚本里。 真正的元数据知识库构建主流程,在meta_knowledge_service.py里。

这是一种很典型、也很值得初学者养成的工程习惯:入口只负责调度,复杂业务逻辑沉淀到服务层。

meta_knowledge_service.py:

async def build(self, config_path: Path):
    # 1. 读取并解析配置
    context = OmegaConf.load(config_path)
    schema = OmegaConf.structured(MetaConfig)
    meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

    # 2. 处理表和字段
    if meta_config.tables:
        column_infos = await self._save_tables_to_meta_db(meta_config)
        await self._save_column_info_to_qdrant(column_infos)
        await self._save_value_info_to_es(meta_config, column_infos)

    # 3. 处理指标
    if meta_config.metrics:
        metric_infos = await self._save_metrics_to_meta_db(meta_config)
        await self._save_metric_info_to_qdrant(metric_infos)

做成配置驱动

这是本章最重要的设计思想之一。它不是单纯指“项目里同时有脚本和配置文件”,而是指:脚本本身不写死业务范围,而是由配置文件来决定这次要同步什么内容。

很多同学一开始会想:“既然我要同步数仓元数据,那直接把所有表都扫一遍不就行了吗?”

理论上可以,但工程上通常不会这么做,原因至少有三个。

  1. 不是所有表都值得同步
    1. 真实数仓里往往会有很多表:中间表、临时表、废弃表、只服务某个离线任务的技术表。问数智能体真正关心的,通常只是其中一小部分“有业务意义、适合查询、适合解释”的表
  2. 数仓内容将来一定会有增量变化
    1. 今天数仓里也许有 100 张表,明天可能又新增了 10 张。如果构建逻辑完全写死,每次新增表都得改代码,那维护成本会越来越高
  3. 字段是否同步真实取值也应该是可配的
    1. 并不是每个字段都需要把真实取值同步到 Elasticsearch

Pyhon执行方式与模块导入

如果你在项目根目录下直接执行:

python3 app/scripts/build_meta_knowledge.py

很可能会看到类似报错:

Traceback (most recent call last):
  File ".../app/scripts/build_meta_knowledge.py", line 8, in <module>
    from app.core.log import logger
ModuleNotFoundError: No module named 'app'

当你直接执行时,解释器更容易把脚本所在目录 app/scripts/ 放进模块搜索路径。但我们真正想让它识别的,是项目根目录 shopkeeper-agent/,因为 app 包是放在这里下面的。

所以问题就变成了:解释器从 app/scripts/ 开始找模块;但这个目录下面并没有一个上层包叫 app;于是 from app... 这样的绝对导入就失败了。

也就是说,很多时候并不是代码错了,而是启动方式不对

推荐方法:

更推荐的做法是:在项目根目录下,用 python -m 执行包内模块。

uv run python -m app.scripts.build_meta_knowledge -c conf/meta_config.yaml

这里要记住两点:

  • -m 表示按模块方式运行
  • 当前工作目录要位于 app 包的上一层,也就是后端项目根目录
  • 这样解释器就会更自然地把当前目录作为模块搜索起点,从而正确找到 app 包。

这三个东西很容易混在一起,其实它们各管一件事:

  • python -m ...:解决“模块怎么找”
  • uv run ...:解决“用哪个 Python、用哪套依赖来跑”
  • PYTHONPATH:手动往模块搜索路径里加目录

所以真正解决 No module named 'app' 这个问题的关键,是 -m 和当前工作目录,而不是 uv run 本身

命令行参数:

真正的“参数”就是:

  • -c
  • conf/meta_config.yaml

它们组合起来表达的意思是:把配置文件路径传给脚本。在这个项目里,这一步很关键,因为同步脚本不是写死“要同步哪些表和指标”,而是由配置文件决定同步范围。也就是说,脚本本身负责“怎么构建”,配置文件负责“构建哪些内容”

argparse:

Python 标准库里的 argparse,就是专门用来解析命令行参数的。按照 Python 官方文档的说法,它是一个用于命令行选项、参数和子命令的解析器。把它理解成一句更简单的话:程序先声明自己支持哪些参数,argparse 再负责把命令行里的输入解析出来。

这也是为什么在真实项目里,一般不建议直接用 sys.argv[1]sys.argv[2] 去硬取值.

基本用法:

from argparse import ArgumentParser

parser = ArgumentParser()
parser.add_argument("-c", "--conf")
args = parser.parse_args()
print(args.conf)

这 4 步分别在做什么?

  1. ArgumentParser():创建一个参数解析器
  2. add_argument("-c", "--conf"):声明脚本支持 -c 和 --conf 这两种写法
  3. parse_args():真正开始解析命令行输入
  4. args.conf:从解析结果中取出参数值

两个很重要的初学者概念。

第一,-c 和 --conf 是同一个参数的两种写法:

  • -c 是短写法,输入更快
  • --conf 是长写法,可读性更强

第二,parse_args() 返回的不是字典,而是一个 Namespace 对象。
所以我们通常会通过属性的方式取值,比如:

args.conf

常用能力:

  1. 自动生成帮助信息
    1. 默认情况下,ArgumentParser 会自动给脚本加上 -h/--help
    2. uv run python -m app.scripts.build_meta_knowledge -h
    3. 它就会自动输出脚本用法说明
  2. 区分位置参数和可选参数
    1. 位置参数:直接靠位置识别,例如 filename
    2. 可选参数:带 - 或 -- 前缀,例如 -c--conf
    3. 当前项目里用的是可选参数,因为:
      1. 可读性更强
      2. 顺序更灵活
      3. 更适合后续扩展多个参数
  3. 为参数补充说明
    1. 如果想让帮助信息更友好,最常用的写法是给参数加 help=
    2. parser.add_argument(
          "-c",
          "--conf",
          help="meta_config.yaml 的路径",
      )
      # 这样用户执行 -h 时,就能看到这个参数是干什么的。
  4. 指定默认值或必填约束
    1. default=:没传时使用默认值
    2. required=True:要求用户必须传这个参数
    3. 完整写法
      1.   
        parser.add_argument(
            "-c",
            "--conf",
            required=True,
            help="meta_config.yaml 的路径",
        )
      2. 这表示:如果用户不传 -c/--conf,程序就直接报错并提示正确用法。

核心文件速览

  • conf/meta_config.yaml:配置文件,决定“同步什么”,也就是这次要同步哪些表、字段和指标
    • 哪些表要进入知识库
    • 每张表有哪些字段值得同步
    • 哪些字段需要把真实取值同步到 Elasticsearch
    • 哪些指标要进入知识库
    • 指标和哪些字段相关
    • 它决定的是:同步范围
  • app/conf/meta_config.py:配置结构定义文件,决定配置在程序里应该如何表示
    • 给程序看的配置结构
    • 程序不能直接拿它当业务对象来用
    • 需要在 Python 里定义一套结构,让程序知道 tables应该是什么类型;columns 里面每个元素应该有哪些字段;
    • 它决定的是:配置文件在程序里应该如何表示
  • app/scripts/build_meta_knowledge.py:同步脚本入口文件,决定“从哪里进入”,负责接收参数、初始化依赖并启动构建流程
    • 整个构建流程的入口脚本
    • 主要负责
      • 接收命令行参数
      • 初始化客户端
      • 创建 repository 对象
      • 创建 service 对象
      • 调用 MetaKnowledgeService.build(config_path)
  • app/services/meta_knowledge_service.py:服务层核心文件,核心业务逻辑,决定“具体怎么构建”,负责读取配置并组织元数据构建逻辑
    • 读取配置文件
    • 根据配置决定先处理 tables 还是 metrics
    • 调用 repository 去读数仓、写元数据库、建索引
  • app/models/...:ORM 模型层,负责把元数据库中的表结构映射成可读写的 Python 类
  • app/repositories/...:仓储层,负责和 MySQL、Qdrant、Elasticsearch 等底层存储打交道
    • service 负责组织流程,repository 负责执行具体读写
    • 服务层不应该直接拿着 session到处写数据库操作,而是应该把这些操作收口到 repository 中
    • 好处是很明确的:服务层可以专注于“先做什么、后做什么”;存储读写逻辑不会散落在各个业务函数里;后面如果 SQL、ORM、索引写入方式发生变化,调整范围会更集中
  • app/entities/...:业务实体层,负责统一表示表、字段、指标和值等核心对象

模块分层:

如果你把“读配置文件、查数仓、写元数据库、建索引”这些逻辑全写在脚本里,入口文件会很快变得又长又乱。

而现在这种写法的好处是:

  • scripts 文件夹:只负责入口和调度
  • service 文件夹:只负责业务编排
  • repository 文件夹:只负责存储读写

为什么build()接收path

这一点也很关键。入口脚本最终拿到的并不是配置文件内容本身,而是配置文件路径。

这样服务层就可以自己决定:

  • 怎么读取配置文件
  • 怎么把配置文件转换成对象
  • 怎么根据配置决定处理哪些分支

也就是说,脚本只负责把“入口参数”传进去,而不是在脚本层把所有细节都展开

service.py骨架代码

from pathlib import Path

from omegaconf import OmegaConf

from app.conf.meta_config import MetaConfig
from app.core.log import logger
from app.repositories.mysql.dw.dw_mysql_repository import DWMySQLRepository
from app.repositories.mysql.meta.meta_mysql_repository import MetaMySQLRepository


class MetaKnowledgeService:
    def __init__(
        self,
        meta_mysql_repository: MetaMySQLRepository,
        dw_mysql_repository: DWMySQLRepository,
    ):
        # meta repository 负责结构化元数据的落库
        self.meta_mysql_repository: MetaMySQLRepository = meta_mysql_repository
        # dw repository 负责到教学数仓中读取真实表结构和示例值
        self.dw_mysql_repository: DWMySQLRepository = dw_mysql_repository

    async def build(self, config_path: Path):
        # 1. 读取配置文件并转换成结构化配置对象
        #    后续流程统一围绕 MetaConfig 展开
        context = OmegaConf.load(config_path)
        schema = OmegaConf.structured(MetaConfig)
        # class MetaConfig:
        #    tables: Optional[list[TableConfig]] = None
        #    metrics: Optional[list[MetricConfig]] = None
        meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))
        # 将两个配置(一个结构化的 schema 蓝图,一个包含实际值的字典/对象)合并,然后将合并后的 OmegaConf 对象还原成一个普通的 Python 数据类实例
        logger.info("加载配置文件")

        # 2. 根据配置文件判断后续要进入哪条构建链路
        if meta_config.tables:
            logger.info("检测到 tables 配置,表链路入口已准备就绪")
            logger.info("表信息与字段信息构建流程后续继续补充")
            logger.info("字段向量索引与字段值全文索引逻辑后续继续补充")

        # 3. 根据配置文件同步指定的指标信息
        if meta_config.metrics:
            logger.info("检测到 metrics 配置,指标链路入口已准备就绪")
            logger.info("指标入库与指标向量索引逻辑后续继续补充")

        logger.info("当前阶段完成:配置加载与元数据知识库构建骨架准备")

OmegaConf.load(config_path): 读取YAML

 加载个config_path的文件,生成一个 DictConfig 对象。例:context.tables  打印['users', 'orders']

先根据路径把 YAML 文件内容读出来。此时你拿到的还只是“配置内容”,还没有真正变成 MetaConfig 对象。

OmegaConf.structured(MetaConfig):准备结构模板

这一步可以看作:“先告诉程序,合法的配置应该符合 MetaConfig 这套结构。”也就是说,这一步是在准备一份“标准答案的结构模板”。

使用MetaConfig类中定义的默认值,创建出一个 DictConfig 对象,但它是一个基于 MetaConfig 类定义创建的"带类型的配置模板",使用类中定义的默认值

在普通的 Python 字典或 YAML 文件中,配置值只是数据,没有类型约束。

而通过 OmegaConf.structured() 创建的对象,是严格按照你定义的数据类(dataclass)这个"蓝图"(Schema)来构建和校验的

Omegconf.merge(schema, context):

合并规则:

  1. 以 schema 的结构为基础(只保留 schema 中定义的字段)

  2. 用 context 中的值覆盖 schema 中对应字段的值

  3. context 中多余的字段(如 extra_field)会被忽略或丢弃

Omegconf.to_object(...):

将 DictConfig 对象转换回普通的 Python 对象。所以最终你得到的,不再是松散的 YAML 内容,而是一个真正可在代码里直接使用的MetaConfig

 这段代码看什么

第一,它先读配置文件,再决定做什么。也就是说,配置优先于业务动作

第二,它先判断 tables,再判断 metrics。这正好对应了前面配置文件的顶层结构。

第三,它已经把后续元数据构建拆成了几条非常清晰的子链路:

  • 表信息与字段信息写入元数据库
  • 字段信息向量索引构建
  • 字段取值全文索引构建
  • 指标信息写入与指标向量索引构建

也就是说,这一层的重点不是“把每个细节都直接展开”,而是先把整条元数据知识库的业务编排顺序说明白。

build()为什么要写成异步方法:

在 build() 里要频繁做这些事:

  • 查数仓字段类型
  • 查数仓字段示例值
  • 后续还要写元数据库、写 Qdrant、写 ES

这些几乎都是 I/O 操作,所以整个构建流程天然就更适合写成异步函数

 

posted @ 2026-05-22 09:36  幻影之舞  阅读(2)  评论(0)    收藏  举报