元知识数据库
分清四类角色:配置文件 + 业务实体 + ORM模型 + mappers
配置文件:
配置文件是“外部输入”,告诉程序:这次要处理什么。

它主要负责:
- 要同步哪些表
- 每张表有哪些字段
- 字段的角色、描述、别名是什么
- 哪些字段后续还要同步真实取值
- 哪些指标要进入知识库
所以配置文件更像一份“任务说明书”或“同步清单”。但它还不是程序内部真正干活时最核心的对象,因为它通常只描述了业务语义和同步范围,并不一定包含完整的运行时信息
业务实体:
业务实体是“程序内部统一流转的数据对象”,告诉系统:我内部准备怎么表示这些表、字段、指标。也可以理解成系统内部的“标准件”。
它的作用是把来自不同地方的信息,统一组织成系统后续都能复用的结构。所以业务实体关注的重点不是“怎么存数据库”,而是:系统内部如何统一表达一个表、一个字段、一个指标

ORM模型:
ORM 模型是“数据库映射对象”,告诉 ORM 框架:这些 Python 类怎么对应数据库中的表和列。
业务实体关心“系统内部如何表达”,ORM 模型关心“数据库表结构如何映射”。这两个对象看起来字段可能很像,但职责完全不一样 .

mappers:
mappers 不是新的业务对象,也不是新的数据库模型,它本质上只是一个翻译层。它解决的问题是:service 层更适合处理业务实体,repository 层在落库时又需要 ORM 模型。那中间总要有人负责做转换,这个人就是 mapper。职责不是“定义新数据”,而是:把业务实体转换成 ORM 模型,或者把 ORM 模型转换回业务实体。
如果把这 4 类角色放在一起看,可以先记成下面这样:
- 配置文件:决定“这次同步什么”
- 业务实体:决定“系统内部怎么统一表示这些数据”
- ORM 模型:决定“这些数据怎么映射到数据库表”
mappers:负责在业务实体和 ORM 模型之间做转换
角色关系与调用链路:
如果再把“谁调用谁”也一起说清楚,可以看作下面这条业务链:
- 脚本入口
build_meta_knowledge.py启动流程。 MetaKnowledgeService.build(config_path)读取配置文件。OmegaConf把配置文件解析成程序里的配置对象MetaConfig。service层根据MetaConfig构造业务实体,例如TableInfo、ColumnInfo。- 在构造业务实体的过程中,
service会调用DWMySQLRepository去数仓补齐真实字段类型、示例值。 - 当业务实体准备好后,
service再调用MetaMySQLRepository去执行落库。 MetaMySQLRepository内部不会直接要求上层传 ORM 模型,而是先调用mapper,把业务实体转换成 ORM 模型。- 最后由 ORM 模型通过
session.add_all()等方式写入Meta MySQL。
这条链路里,每一层的调用关系其实很清楚:
script调serviceservice调repositoryrepository调mappermapper负责对象转换- 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>] # 指标同义词
那么后面大致会发生下面这些事:
- 先从配置里读到:
fact_order、order_amount、measure、订单金额、销售额 - 再从
DW查询到:order_amount的真实类型,比如float - 然后在
service层组装出业务实体: -
ColumnInfo( id="fact_order.order_amount", name="order_amount", type="float", role="measure", examples=[...], description="订单金额", alias=["销售额", "成交金额"], table_id="fact_order", ) - 到了
repository层,再通过ColumnInfoMapper把它转换成ColumnInfoMySQL - 最后由 ORM 把
ColumnInfoMySQL写进column_info表
这样一看就很清楚了:
- 配置文件提供了起点
- 业务实体承接了系统内部表达
mapper完成了对象翻译- ORM 模型完成了数据库映射
同步脚本说明:
整体上,它要完成 5 步:
- 读取配置文件
- 将指定的表信息和字段信息写入
Meta MySQL - 为字段信息建立向量索引,写入
Qdrant - 为需要同步的字段取值建立全文索引,写入
Elasticsearch - 将指标信息和指标字段关系写入
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)
做成配置驱动
这是本章最重要的设计思想之一。它不是单纯指“项目里同时有脚本和配置文件”,而是指:脚本本身不写死业务范围,而是由配置文件来决定这次要同步什么内容。
很多同学一开始会想:“既然我要同步数仓元数据,那直接把所有表都扫一遍不就行了吗?”
理论上可以,但工程上通常不会这么做,原因至少有三个。
- 不是所有表都值得同步
- 真实数仓里往往会有很多表:中间表、临时表、废弃表、只服务某个离线任务的技术表。问数智能体真正关心的,通常只是其中一小部分“有业务意义、适合查询、适合解释”的表
- 数仓内容将来一定会有增量变化
- 今天数仓里也许有
100张表,明天可能又新增了10张。如果构建逻辑完全写死,每次新增表都得改代码,那维护成本会越来越高
- 今天数仓里也许有
- 字段是否同步真实取值也应该是可配的
- 并不是每个字段都需要把真实取值同步到
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 本身
命令行参数:
真正的“参数”就是:
-cconf/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 步分别在做什么?
ArgumentParser():创建一个参数解析器add_argument("-c", "--conf"):声明脚本支持-c和--conf这两种写法parse_args():真正开始解析命令行输入args.conf:从解析结果中取出参数值
两个很重要的初学者概念。
第一,-c 和 --conf 是同一个参数的两种写法:
-c是短写法,输入更快--conf是长写法,可读性更强
第二,parse_args() 返回的不是字典,而是一个 Namespace 对象。
所以我们通常会通过属性的方式取值,比如:
args.conf
常用能力:
- 自动生成帮助信息
- 默认情况下,
ArgumentParser会自动给脚本加上-h/--help - uv run python -m app.scripts.build_meta_knowledge -h
- 它就会自动输出脚本用法说明
- 默认情况下,
- 区分位置参数和可选参数
- 位置参数:直接靠位置识别,例如
filename - 可选参数:带
-或--前缀,例如-c、--conf - 当前项目里用的是可选参数,因为:
- 可读性更强
- 顺序更灵活
- 更适合后续扩展多个参数
- 位置参数:直接靠位置识别,例如
- 为参数补充说明
- 如果想让帮助信息更友好,最常用的写法是给参数加
help= -
parser.add_argument( "-c", "--conf", help="meta_config.yaml 的路径", ) # 这样用户执行 -h 时,就能看到这个参数是干什么的。
- 如果想让帮助信息更友好,最常用的写法是给参数加
- 指定默认值或必填约束
default=:没传时使用默认值required=True:要求用户必须传这个参数- 完整写法
-
parser.add_argument( "-c", "--conf", required=True, help="meta_config.yaml 的路径", ) - 这表示:如果用户不传
-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):
合并规则:
-
以
schema的结构为基础(只保留schema中定义的字段) -
用
context中的值覆盖schema中对应字段的值 -
context中多余的字段(如extra_field)会被忽略或丢弃
Omegconf.to_object(...):
将 DictConfig 对象转换回普通的 Python 对象。所以最终你得到的,不再是松散的 YAML 内容,而是一个真正可在代码里直接使用的MetaConfig
这段代码看什么
第一,它先读配置文件,再决定做什么。也就是说,配置优先于业务动作。
第二,它先判断 tables,再判断 metrics。这正好对应了前面配置文件的顶层结构。
第三,它已经把后续元数据构建拆成了几条非常清晰的子链路:
- 表信息与字段信息写入元数据库
- 字段信息向量索引构建
- 字段取值全文索引构建
- 指标信息写入与指标向量索引构建
也就是说,这一层的重点不是“把每个细节都直接展开”,而是先把整条元数据知识库的业务编排顺序说明白。
build()为什么要写成异步方法:
在 build() 里要频繁做这些事:
- 查数仓字段类型
- 查数仓字段示例值
- 后续还要写元数据库、写 Qdrant、写 ES
这些几乎都是 I/O 操作,所以整个构建流程天然就更适合写成异步函数

浙公网安备 33010602011771号