7 - 电商问数:元数据知识库总览与构建入口


本章课程目标:

  • 先看清“元数据知识库”为什么是问数系统真正开始工作的前提。
  • 理解一次元数据知识库构建到底会产出什么,以及会写入哪些存储组件。
  • 搞清楚同步脚本是如何从命令行启动、如何把配置传进服务层、以及配置最终如何变成程序对象。

学习建议: 这一章是元数据知识库篇的入口。先看它为什么必须先建,再看同步脚本如何接收配置文件,最后看配置如何进入服务层。读完后最好能回答:为什么不能让大模型直接猜表字段,为什么构建脚本要配置驱动,为什么入口脚本只做调度、不堆业务逻辑。

对应代码分支: 07-metadata-base-overview


前面几章,我们已经把「电商问数」运行所需的基础设施准备好了,包括:MySQLQdrantElasticsearchEmbedding、配置管理、日志管理、客户端封装。

这些内容本质上都在做一件事:**为真正的业务逻辑做准备。**前面第 1 章和第 2 章,已经分别讲清楚了「电商问数」要解决什么问题,以及这套系统整体是怎么运转的。因此从这一章开始,我们不再重复项目背景,而是把视角正式切进第一条核心业务线:构建元数据知识库

你也可以把这一章先看作“元数据知识库篇章的总入口”。它要解决的,不是某一个具体入库细节,而是先把下面这几个前置问题讲清楚:为什么这一章先讲元数据知识库;一次同步脚本到底会构建什么;这条构建流程从哪里进入;配置文件是如何被读取并传进系统的。


1、为什么先构建元数据知识库

这一点在第 1 章和第 2 章其实已经铺垫过了。问数智能体在真正生成 SQL 之前,必须先有一套可检索、可组织、可复用的知识底座。否则它后面就很难稳定地完成下面这些动作:

  • 找到该查哪些表和字段
  • 理解字段的业务含义和建模角色
  • 识别用户问题里提到的字段取值
  • 把业务指标和底层字段对应起来

所以从工程实现上看,元数据知识库并不是一个“附加能力”,而是问数智能体能够稳定工作的前提。

这里的元数据,主要包括四类:表信息、字段信息、字段取值、指标信息。一旦这些内容被结构化保存,并建立好向量索引和全文索引,后续智能体在生成 SQL 前,才能真正做到:先理解上下文,再动手生成。

电商问数系统整体架构:数据仓库、配置与同步脚本、元数据知识库与问数智能体


2、元数据知识库入口说明

这一章的一个重要设计思路是:**先不急着实现所有业务细节,而是先把脚本入口、执行方式和调用链路搭清楚。**因为如果脚本本身都没法稳定执行,后面业务写得再多也跑不起来。

2.1 代码结构目录说明

元数据知识库的构建逻辑并不简单。它至少同时涉及:读配置文件,写元数据库,读数仓,写向量库,写全文索引。

如果把这些逻辑全部堆在一个 build() 函数里,这个函数很快就会变得又长又乱,后面既不好维护,也不好讲解。

所以项目里采用了一种非常经典的分层思路。如果你做过常规后端开发,可以类比成下面这个结构:

  • controller:接收请求,负责入口
  • service:组织业务流程
  • repository:负责具体存储读写

在当前这个项目里,虽然我们写的不是 HTTP 接口,而是一个脚本,但这个类比仍然很好用:

  • scripts 可以类比为 controller
  • services 负责核心业务逻辑
  • repositories 负责和 MySQL / Qdrant / Elasticsearch 打交道

你可以这样理解:

  • 接口程序里,controller 接收的是 HTTP 参数
  • 这个脚本里,scripts 接收的是命令行参数

两者本质上都是“程序入口”。

如果把“构建元数据知识库”这一块进一步映射到当前项目的目录中,它的核心代码结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
shopkeeper-agent/
├── conf/ # 项目级配置目录
│ └── meta_config.yaml # 配置文件,用于声明待同步的表、字段和指标
└── app/ # 后端应用主包
├── conf/ # 程序内配置结构与配置加载相关代码
│ └── meta_config.py # 定义 meta_config.yaml 在程序里的结构
├── scripts/ # 脚本入口层,负责接收参数并启动流程
│ └── build_meta_knowledge.py # 同步脚本入口,负责解析参数并调度构建流程
├── services/ # 业务编排层,负责组织完整构建流程
│ └── meta_knowledge_service.py # 元数据知识库构建的核心业务逻辑
├── models/ # ORM 模型层,定义元数据库表对应的 SQLAlchemy 模型
│ ├── base.py # ORM 基类
│ ├── table_info.py # table_info 表对应的 ORM 模型
│ ├── column_info.py # column_info 表对应的 ORM 模型
│ ├── metric_info.py # metric_info 表对应的 ORM 模型
│ └── column_metric.py # column_metric 表对应的 ORM 模型
├── entities/ # 业务实体层,统一表示表、字段、指标和值
│ ├── table_info.py # 表信息业务实体
│ ├── column_info.py # 字段信息业务实体
│ ├── metric_info.py # 指标信息业务实体
│ ├── column_metric.py # 字段与指标关系业务实体
│ └── value_info.py # 字段取值业务实体
└── repositories/ # 存储访问层,负责和底层存储打交道
├── mysql/ # MySQL 相关仓储
│ ├── meta/ # 元数据库读写相关仓储
│ │ ├── meta_mysql_repository.py # 负责实现 meta 数据库的读写
│ │ └── mappers/ # 业务实体与 ORM 模型之间的转换层
│ │ ├── table_info_mapper.py # table_info ORM 与业务实体转换
│ │ ├── column_info_mapper.py # column_info ORM 与业务实体转换
│ │ ├── metric_info_mapper.py # metric_info ORM 与业务实体转换
│ │ └── column_metric_mapper.py # column_metric ORM 与业务实体转换
│ └── dw/ # 数仓查询相关仓储
│ └── dw_mysql_repository.py # 负责实现 dw 数据库的查询
├── qdrant/ # Qdrant 向量库相关仓储
│ ├── column_qdrant_repository.py # 负责 column 向量集合的读写
│ └── metric_qdrant_repository.py # 负责 metric 向量集合的读写
└── es/ # Elasticsearch 全文索引相关仓储
└── value_es_repository.py # 负责字段取值全文索引的写入

这样分层之后,后面你再去看代码时,就不会只看到一堆零散文件,而会知道它们分别处在这条链路的哪个位置。

2.2 分清 4 类角色:配置文件、业务实体、ORM 模型、mappers

2.2.1 角色概览

看到这里时,很多同学都会有一连串很自然的疑问:

  • 既然已经有配置文件了,为什么还要有业务实体?
  • 既然最后是写数据库,为什么不直接用 ORM 模型?
  • entitiesmodelsmappers 这些目录看起来都和“表、字段、指标”有关,它们到底区别在哪?

这些问题问得非常好,因为后面第 8 章真正展开代码时,主线其实就是在这 4 类角色之间流转。可以先把它们按下面这 4 层来看。

4 类角色代码目录结构

第一类:配置文件

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

在当前项目里,对应的是:

  • conf/meta_config.yaml

它主要负责描述:

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

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

例如:字段类型 type 不一定写在配置里,字段示例值 examples 也不一定写在配置里。这些信息往往还需要程序运行时再去 DW 库里查询补齐。

第二类:业务实体

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

在当前项目里,对应的是:

  • app/entities/table_info.py
  • app/entities/column_info.py
  • app/entities/metric_info.py
  • app/entities/column_metric.py

你可以把业务实体理解成系统内部的“标准件”。

它的作用是把来自不同地方的信息,统一组织成系统后续都能复用的结构。例如:一部分信息来自配置文件;一部分信息来自数仓查询;后面还可能继续被拿去写 MySQL、写 Qdrant、写 Elasticsearch。

所以业务实体关注的重点不是“怎么存数据库”,而是:系统内部如何统一表达一个表、一个字段、一个指标。

如果你做过前端开发,可以把它看作一个运行时真实存在的 DTO / domain object。它有点像 TS 里的类型定义,但它不只是静态类型提示,而是程序运行时真的会创建出来的 Python 对象。

例如后面代码里会真的创建:

1
2
3
4
5
6
table_info = TableInfo(
id="fact_order",
name="fact_order",
role="fact",
description="订单事实表",
)

这个 table_info 不是只给编辑器看的类型说明,而是程序运行时真实存在的对象。

第三类:ORM 模型

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

在当前项目里,对应的是:

  • app/models/table_info_mysql.py
  • app/models/column_info_mysql.py
  • app/models/metric_info_mysql.py
  • app/models/column_metric_mysql.py

它们关注的重点是:这张数据库表叫什么,这几个字段如何映射到 Python 属性,ORM 应该如何执行插入、查询、更新。所以 ORM 模型更偏向“面向数据库”,而不是“面向业务语义”。

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

第四类:mappers

mappers 不是新的业务对象,也不是新的数据库模型,它本质上只是一个翻译层

在当前项目里,对应的是:

  • app/repositories/mysql/meta/mappers/table_info_mapper.py
  • app/repositories/mysql/meta/mappers/column_info_mapper.py
  • app/repositories/mysql/meta/mappers/metric_info_mapper.py
  • app/repositories/mysql/meta/mappers/column_metric_mapper.py

它解决的问题是:service 层更适合处理业务实体,repository 层在落库时又需要 ORM 模型。那中间总要有人负责做转换,这个人就是 mapper

它本质上就是:

  • TableInfo <-> TableInfoMySQL 的翻译器
  • ColumnInfo <-> ColumnInfoMySQL 的翻译器

它的职责不是“定义新数据”,而是:把业务实体转换成 ORM 模型,或者把 ORM 模型转换回业务实体。

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

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

2.2.2 角色关系与调用链路

后面第 8 章你会看到的真实链路,其实可以抽象成下面这样:

1
2
3
4
5
6
meta_config.yaml
-> MetaConfig
-> TableInfo / ColumnInfo / MetricInfo
-> Mapper 转换
-> TableInfoMySQL / ColumnInfoMySQL / MetricInfoMySQL
-> Meta MySQL

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

  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

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

  • scriptservice
  • servicerepository
  • repositorymapper
  • mapper 负责对象转换
  • ORM 模型最终参与数据库读写

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

2.2.3 最小示例

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

1
2
3
4
5
6
7
8
9
tables:
- name: fact_order
role: fact
description: 订单事实表
columns:
- name: order_amount
role: measure
description: 订单金额
alias: ["销售额", "成交金额"]

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

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

这样一看就很清楚了:

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

如果你先把这一段概念吃透,后面再去看第 8 章,就会发现那一章其实只是在把这条链路展开成具体代码。

2.3 同步脚本说明

先把这条链路的大图景看清楚。这个脚本不是只做“入库”这一件事,而是要串起一整套知识构建流程。

整体上,它要完成 5 步:

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

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

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

所以,“元数据知识库”这个词,在「电商问数」里并不是指某一个数据库,而是指一整套为问数服务的知识组织方案。

先从同步脚本入口本身看起,项目中的同步脚本路径是:shopkeeper-agent/app/scripts/build_meta_knowledge.py

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

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

也就是说,“同步脚本的作用”更贴切地说,是把整条元数据知识库构建链路调度起来,而不是把所有构建细节都直接写在脚本里。
真正的元数据知识库构建主流程,位于:shopkeeper-agent/app/services/meta_knowledge_service.py

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

如果你想再往代码上靠近一步,可以看一下 MetaKnowledgeService.build(...) 的编排骨架。把细节先省略掉,它的主流程大致就是这样:

项目对应文件路径:shopkeeper-agent/app/services/meta_knowledge_service.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)

这段代码非常值得先建立整体印象,因为后面第 8 章到第 10 章,本质是在逐段展开这几行流程。


3、为什么要做成“配置驱动”

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

脚本负责“怎么做”、配置负责“做哪些内容”。放到「电商问数」里,具体就是由配置文件来决定:

  • 要同步哪些表
  • 每张表里哪些字段要纳入元数据知识库
  • 哪些字段需要把真实取值同步到 Elasticsearch
  • 哪些指标要纳入知识库
  • 指标和哪些字段相关

所以这里说的“配置驱动”,本质上就是:不是代码决定同步什么,而是配置决定同步什么。

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

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

  1. 不是所有表都值得同步

真实数仓里往往会有很多表:中间表、临时表、废弃表、只服务某个离线任务的技术表。这些表即使真实存在,也未必适合暴露给问数系统。问数智能体真正关心的,通常只是其中一小部分“有业务意义、适合查询、适合解释”的表。

  1. 数仓内容将来一定会有增量变化

今天数仓里也许有 100 张表,明天可能又新增了 10 张。如果构建逻辑完全写死,每次新增表都得改代码,那维护成本会越来越高。

更合理的方式是:代码负责“怎么同步”、配置负责“同步哪些内容”。这样一来,新表上线时,只需要改配置,不需要动核心逻辑。

  1. 字段是否同步真实取值也应该是可配的

并不是每个字段都需要把真实取值同步到 Elasticsearch

例如:provinceregion_namemember_level 这类字段,用户在自然语言里很可能直接说出它们的值,所以适合同步字段取值,建立全文索引。

但像 order_idcustomer_idproduct_id 这类技术标识字段,一般没有必要把取值全部写入 Elasticsearch

因此,是否同步字段值,也应该交给配置来控制。这就是为什么项目里会专门设计一个 YAML 配置文件,作为同步脚本的输入。


4、Python 脚本执行方式与模块导入

这一节虽然放在元数据知识库这一章里,但它本质上讲的是一个更通用的 Python 问题:包内模块应该怎么执行,为什么直接运行文件时经常会报 No module named app

这个问题不只会出现在同步脚本里,也会出现在其他放在 app/ 包下面的调试文件里。核心要点其实只有一句:

在包结构项目里,不要把 app/.../*.py 当成零散文件去直接运行,而要把它当成模块来执行。

4.1 典型现象:为什么会报错

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

1
python3 app/scripts/build_meta_knowledge.py

很可能会看到类似报错:

1
2
3
4
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'

也就是说,同步脚本本身如果被当成“普通文件”直接运行,而不是当成模块运行,也会遇到同类问题。只要文件里用了这种绝对导入:

1
from app.core.log import logger

就可能遇到同类问题。

4.2 根本原因:解释器找模块,要看 sys.path

理解这个问题,抓住 sys.path 就够了。它本质上就是:Python 启动后,用来查找模块的一组路径清单。

当你直接执行:

1
python3 app/scripts/build_meta_knowledge.py

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

下图可以帮助你直观看清这个路径关系:直接执行脚本文件时,解释器更容易从 app/scripts/ 开始找模块,而不是从项目根目录开始找。

直接执行 build_meta_knowledge.py 时,解释器更容易从 app/scripts/ 开始查找模块,而 app 包实际位于项目根目录下

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

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

4.3 推荐做法:用模块方式执行

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

例如:

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

这里要记住两点:

  • -m 表示按模块方式运行
  • 当前工作目录要位于 app 包的上一层,也就是后端项目根目录

这样解释器就会更自然地把当前目录作为模块搜索起点,从而正确找到 app 包。

4.4 python -m、uv run 与 PYTHONPATH

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

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

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

PYTHONPATH 当然也能解决问题,比如:

1
2
export PYTHONPATH=$(pwd)
python3 app/clients/qdrant_client_manager.py

但它更适合临时调试,不适合作为团队长期的统一执行约定。
因此在当前项目里,最推荐的还是:

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

4.5 为什么 PyCharm 能运行

这是因为 IDE 往往会额外帮你补一些运行配置,比如工作目录、内容根目录、环境变量和解释器选择,这些设置都会影响 Python 启动后的模块查找路径。

所以:

  • PyCharm 能跑,不代表命令写法就是最稳妥的
  • 终端报错,也不代表代码本身错了

从团队协作和部署角度看,更可靠的做法还是把终端中的执行命令固定下来。


5、脚本参数解析:如何使用 argparse

现在脚本的执行方式已经明确了,接下来就要解决另一个很实际的问题:脚本启动之后,怎么知道这一次应该读取哪一份配置文件?

答案就是:通过命令行参数把配置文件路径传进来。在当前项目里,这个参数就是:

1
2
3
-c conf/meta_config.yaml
# 或者
--conf conf/meta_config.yaml

5.1 命令行参数是什么

命令行参数,就是你在执行程序时额外传给它的信息。

例如这条命令:

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

其中真正的“参数”就是:

  • -c
  • conf/meta_config.yaml

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

5.2 argparse 简介

官方文档:https://docs.python.org/zh-cn/3.13/library/argparse.html

Python 标准库里的 argparse,就是专门用来解析命令行参数的。

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

这也是为什么在真实项目里,一般不建议直接用 sys.argv[1]sys.argv[2] 去硬取值。因为只要参数稍微复杂一点,就会遇到这些问题:

这里顺手解释一下这两个写法的含义。
sys.argv 是 Python 用来保存命令行参数的列表。例如执行:

1
python demo.py hello world

那么:

1
2
import sys
print(sys.argv)

通常会得到:

1
['demo.py', 'hello', 'world']

这时候:

  • sys.argv[0] 是脚本名 demo.py
  • sys.argv[1] 是第一个参数 hello
  • sys.argv[2] 是第二个参数 world

也就是说,sys.argv[1]sys.argv[2] 本质是“按位置硬取命令行参数”的写法。它在极简单的脚本里能用,但只要参数顺序变化、参数变多,或者需要支持 -c / --conf 这种可选参数时,就会越来越不稳。

  • 参数顺序可能变化
  • 有些参数可能可选
  • 参数名和值需要配对识别
  • 用户输错时,希望程序自动提示正确用法

argparse 正是为这些场景设计的。

5.3 最常用的 4 步写法

对初学者来说,argparse 最常用的用法其实就 4 步:

1
2
3
4
5
6
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 对象。
所以我们通常会通过属性的方式取值,比如:

1
args.conf

5.4 argparse 常用能力

结合官方文档,在入门阶段最值得掌握的,其实就是下面这几个点。

5.4.1 自动生成帮助信息

默认情况下,ArgumentParser 会自动给脚本加上 -h/--help

例如你执行:

1
uv run python -m app.scripts.build_meta_knowledge -h

它就会自动输出脚本用法说明。这也是 argparse 比手写 sys.argv 更适合真实项目的原因之一。

5.4.2 区分位置参数和可选参数

官方文档里特别强调了两类常见参数:

  • 位置参数:直接靠位置识别,例如 filename
  • 可选参数:带 --- 前缀,例如 -c--conf

当前项目里用的是可选参数,因为:

  • 可读性更强
  • 顺序更灵活
  • 更适合后续扩展多个参数

5.4.3 为参数补充说明

如果想让帮助信息更友好,最常用的写法是给参数加 help=

例如:

1
2
3
4
5
parser.add_argument(
"-c",
"--conf",
help="meta_config.yaml 的路径",
)

这样用户执行 -h 时,就能看到这个参数是干什么的。

5.4.4 指定默认值或必填约束

官方文档里,最常用的两个参数配置是:

  • default=:没传时使用默认值
  • required=True:要求用户必须传这个参数

例如更完整一点的写法可以是:

1
2
3
4
5
6
parser.add_argument(
"-c",
"--conf",
required=True,
help="meta_config.yaml 的路径",
)

这表示:如果用户不传 -c/--conf,程序就直接报错并提示正确用法。

5.5 放回当前项目:参数解析在做什么

当前项目脚本中的参数解析代码是:

1
2
3
4
5
6
7
if __name__ == "__main__":
parser = ArgumentParser()
parser.add_argument("-c", "--conf")
args = parser.parse_args()

config_path = Path(args.conf)
asyncio.run(build(config_path))

把它翻译成大白话,其实就是:

  1. 先声明:这个脚本支持一个配置参数 -c/--conf
  2. 再从命令行里把这个参数解析出来
  3. 拿到配置文件路径
  4. 把这个路径传给 build(config_path)

所以当你执行:

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

程序最终拿到的关键输入就是:

1
args.conf == "conf/meta_config.yaml"

然后再进一步转成:

1
Path("conf/meta_config.yaml")

后面服务层就是基于这个路径去加载配置文件的。

5.6 为什么转成 Path

相比直接把路径当普通字符串传来传去,转成 Path 对象至少有两个好处:语义更清楚,别人一看就知道这是路径,不是普通文本;后续做拼接、判断是否存在、读取文件时更方便。

所以这里虽然只是一个小动作,但体现的是比较好的代码习惯。

5.7 本节小结

  1. 命令行参数就是程序启动时从终端传进来的额外信息。
  2. argparse 最核心的流程就是:创建解析器、声明参数、解析参数、读取结果。
  3. -c/--conf 是脚本把“配置驱动”真正落到命令行入口上的关键参数。
  4. 在当前项目里,脚本最终拿到的是配置文件路径,并把它转成 Path 传给 build(config_path)

6、核心文件速览

在这一部分里,最值得先读懂的是下面这几个核心文件和目录:

  • conf/meta_config.yaml:配置文件,决定“同步什么”,也就是这次要同步哪些表、字段和指标
  • app/conf/meta_config.py:配置结构定义文件,决定配置在程序里应该如何表示
  • app/scripts/build_meta_knowledge.py:同步脚本入口文件,决定“从哪里进入”,负责接收参数、初始化依赖并启动构建流程
  • app/services/meta_knowledge_service.py:服务层核心文件,核心业务逻辑,决定“具体怎么构建”,负责读取配置并组织元数据构建逻辑
  • app/models/...:ORM 模型层,负责把元数据库中的表结构映射成可读写的 Python 类
  • app/repositories/...:仓储层,负责和 MySQL、Qdrant、Elasticsearch 等底层存储打交道
  • app/entities/...:业务实体层,负责统一表示表、字段、指标和值等核心对象

6.1 meta_config.yaml:配置文件

它是给人看的配置文件,本质是一份“同步清单”,里面声明:

  • 哪些表要进入知识库
  • 每张表有哪些字段值得同步
  • 哪些字段需要把真实取值同步到 Elasticsearch
  • 哪些指标要进入知识库
  • 指标和哪些字段相关

也就是说,它决定的是:同步范围

6.2 meta_config.py:配置结构定义

它是给程序看的配置结构。也就是说,meta_config.yaml 是 YAML 文本,程序不能直接拿它当业务对象来用;
所以我们需要在 Python 里定义一套结构,让程序知道:

  • tables 应该是什么类型
  • columns 里面每个元素应该有哪些字段
  • metrics 里面每个元素应该有哪些字段

也就是说,它决定的是:配置文件在程序里应该如何表示

在这条构建链路里,它会被 app/services/meta_knowledge_service.py 导入使用:

1
from app.conf.meta_config import MetaConfig

然后配合 OmegaConf,把 meta_config.yaml 转成程序里可直接访问的 MetaConfig 对象。

6.3 build_meta_knowledge.py:同步脚本入口

它是整个构建流程的入口脚本。

这一层主要负责:

  • 接收命令行参数
  • 初始化客户端
  • 创建 repository 对象
  • 创建 service 对象
  • 调用 MetaKnowledgeService.build(config_path)

也就是说,它决定的是:从哪里进入这条构建链路

6.4 meta_knowledge_service.py:核心业务逻辑

真正的构建逻辑不写在脚本入口里,而写在服务层里。

这一层主要负责:

  • 读取配置文件
  • 根据配置决定先处理 tables 还是 metrics
  • 调用 repository 去读数仓、写元数据库、建索引

也就是说,它决定的是:具体怎么构建

6.5 app/models 的作用

前面你已经看到,项目里既有:

  • app/models/...
  • app/entities/...
  • app/repositories/mysql/meta/mappers/...

它们看起来都和“表、字段、指标”有关,但职责并不一样。

app/models 这一层,放的是 ORM 模型。
它的作用是把元数据库里的表,映射成 SQLAlchemy 可以直接读写的 Python 类。

前面在 2.2 里,我们已经把“配置文件、业务实体、ORM 模型、mappers”这 4 类角色的职责讲清楚了。
这里就不再重复展开定义,只补一个和当前目录结构直接相关的落点:

  • models:面向数据库,负责表结构映射
  • entities:面向业务,负责统一表示系统内部的核心对象
  • mappers:负责在 ORM 模型和业务实体之间做转换

所以这里同时存在 app/models/column_info.pyapp/entities/column_info.py,并不是重复设计,而是因为它们本来就在解决两个不同层面的问题。

6.6 repository 层分工

理解完 models 之后,再看 repository 层就会顺很多。

这一层的核心作用,可以记成一句话:service 负责组织流程,repository 负责执行具体读写。

也就是说,服务层不应该直接拿着 session(“程序和数据库之间的一次会话对象”,真正执行查询、写入、事务提交时都会用到它)到处写数据库操作,而是应该把这些操作收口到 repository 中。
这样做的好处是很明确的:服务层可以专注于“先做什么、后做什么”;存储读写逻辑不会散落在各个业务函数里;后面如果 SQL、ORM、索引写入方式发生变化,调整范围会更集中。

在当前项目里,repository 还不是只有一层“按技术栈划分”,而是进一步按数据来源和存储目标做了拆分。

先看 MySQL 这一层:

  • app/repositories/mysql/meta/:面向元数据库,负责写入和查询系统自己的元数据表
  • app/repositories/mysql/dw/:面向数仓库,负责查询数仓中的表结构、字段类型和字段取值

这也是为什么项目里会同时存在两个 MySQL repository:

  • MetaMySQLRepository:负责和元数据库打交道
  • DWMySQLRepository:负责和数仓库打交道

它们虽然都基于 MySQL,但职责并不一样。

例如:

  • MetaMySQLRepository 更像“写自己的业务库”,会负责保存表信息、字段信息、指标信息,以及读取某些已经入库的元数据
  • DWMySQLRepository 更像“读外部数据源”,会负责查询字段类型、字段示例值,或者后续执行 SQL 校验相关操作

除了 MySQL,后面你还会看到:

  • app/repositories/qdrant/:负责字段向量和指标向量的读写
  • app/repositories/es/:负责字段取值全文索引的写入和查询

所以 repository 层的分工方式可以总结成两层:

  • 先按底层存储类型拆分:MySQL、Qdrant、Elasticsearch
  • 再按具体职责拆分:例如 MySQL 下继续分 metadw

这一章先把 repository 的职责边界看清楚就够了。后面的章节里,再分别进入 MetaMySQLRepositoryDWMySQLRepository 以及其他 repository 的具体实现。

到这里你可以抓住一句最重要的话:配置文件决定“同步什么”,入口脚本决定“从哪里进入”,服务层决定“具体怎么做”,mappers 负责对象转换,ORM 模型负责“怎么和数据库表对上”。


7、meta_config.yaml 详解

先从配置文件本身看起。在真正写 Python 代码之前,更重要的是先讲清楚:这个配置文件到底应该长成什么样。

项目对应文件路径:shopkeeper-agent/conf/meta_config.yaml

这个配置文件采用 YAML 格式,用来指定待同步的表信息和指标信息。先看它的结构模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tables: # 表信息
- name: <table_name> # 真实表名
role: dim | fact # 表角色
description: <table_description> # 表的业务含义说明
columns:
- name: <column_name> # 真实字段名
role: primary_key | foreign_key | dimension | measure # 字段角色
description: <column_description> # 字段的业务含义
alias: [<alias1>, <alias2>] # 字段同义词
sync: true | false # 该字段取值是否需要同步到 ES 建立全文索引

metrics: # 指标信息
- name: <metric_name> # 指标名称,如 GMV、AOV
description: <metric_description> # 指标的业务含义与计算口径说明
relevant_columns: [<table_name.column_name>] # 指标相关字段
alias: [<alias1>, <alias2>] # 指标同义词

在当前这套数据仓库模拟环境中,一共有 5 张表和 2 个指标需要同步到元数据知识库。下面这段 YAML 就是其中的具体配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tables:
- name: dim_region
role: dim
description: 地区维度表,用于描述订单发生的地理区域信息。
columns:
- name: province
role: dimension
description: 订单所属的省份名称。
alias: [省份, , 所在省份]
sync: true

metrics:
- name: GMV
description: 全称 Gross Merchandise Value,表示所有订单的成交金额总和。
relevant_columns:
- fact_order.order_amount
alias: [成交总额, 订单总额]

7.1 顶层结构:tables 与 metrics

这一点很重要。

这里先把同步内容分成了两大类:

  • tables:描述要同步的表和字段
  • metrics:描述要同步的指标

这样一来,后面服务层就可以直接写出这样的判断:

1
2
3
4
5
if meta_config.tables:
# 处理表和字段

if meta_config.metrics:
# 处理指标

也就是说,配置文件的顶层结构,本身就在服务层里对应成了两条处理分支。

7.2 tables:表配置结构

tables 是一个列表,列表里的每个元素都表示一张要同步的表。

每张表至少包含这几个信息:

  • name:表名
  • role:这张表是维度表还是事实表
  • description:这张表的业务含义
  • columns:这张表下哪些字段要进入知识库

这里的 role 也很关键:

  • dim:维度表
  • fact:事实表

它不是单纯写给人看的说明,而是后面智能体理解数仓结构时会用到的信息。

7.3 columns:字段配置结构

columns 也是一个列表,里面每个元素都表示一个字段。

每个字段主要有 5 个配置:

  • name:字段名
  • role:字段角色
  • description:字段业务含义
  • alias:字段别名
  • sync:是否同步真实取值到 Elasticsearch

字段级 role 一般有这几种:

  • primary_key
  • foreign_key
  • dimension
  • measure

这里的 alias 很重要,因为用户提问时经常不会直接说真实字段名,而会说业务口语。
例如 order_amount 的别名可以是:销售额订单金额收入

这样后面做召回时,就更容易把自然语言和真实字段对齐起来。

7.4 sync:字段取值同步开关

sync 是这个配置文件里最容易被忽略、但非常关键的一个字段。

  • sync: true:表示这个字段的真实取值需要同步到 Elasticsearch
  • sync: false:表示只保留字段元信息,不同步真实取值

注意,它控制的不是“这个字段要不要进入知识库”,而是:这个字段要不要额外建立字段值全文索引。

也就是说,所有出现在配置里的字段,都会作为字段元数据进入系统;只有 sync: true 的那些字段,才会继续把真实取值同步到 ES。

7.5 metrics:指标配置结构

metrics 也是一个列表,里面每个元素都表示一个指标。

每个指标主要有这几个信息:

  • name:指标名
  • description:指标含义
  • relevant_columns:这个指标和哪些字段相关
  • alias:指标别名

例如:

1
2
3
4
5
6
metrics:
- name: GMV
description: 全称 Gross Merchandise Value,表示所有订单的成交金额总和。
relevant_columns:
- fact_order.order_amount
alias: [成交总额, 订单总额]

这里的 relevant_columns 很重要,因为它把“指标”和“底层字段”连接起来了。后面往元数据库写数据时,column_metric 这张关系表就是根据这个字段来生成的。

7.6 为什么不配置 type 和 examples

这也是这里非常容易让初学者困惑的点。很多同学第一次看到这里,会觉得有点奇怪:

  • 元数据库里的 column_info 明明有 type
  • 字段信息里后面也会有 examples
  • 为什么配置文件里却没有这两个字段

原因其实很简单:能通过程序自动查到的信息,就尽量不要再让用户手工填写。

比如:

  • type 可以直接去数仓里查字段类型
  • examples 可以直接去数仓里查字段示例值

这样做有两个明显好处:配置文件更精简,自动读取通常比人工填写更准确。所以这里的配置文件,只要求你填写那些程序无法自动推断、必须由业务方声明的内容,比如:表和字段的角色、描述信息、别名、是否同步取值。


8、build_meta_knowledge.py 详解

这一部分有一个非常重要的设计原则:**脚本入口不要一上来就堆满业务逻辑。**它更适合做“总调度”,而不是做“总实现”。

项目对应文件路径:shopkeeper-agent/app/scripts/build_meta_knowledge.py

为了先把主线看清楚,下面这段代码保留的是“入口脚本的核心骨架”。当前仓库里的完整实现还会额外初始化 QdrantEmbeddingElasticsearch 等依赖,但这些都只是入口层的依赖准备,而不是具体业务处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import argparse
import asyncio
from pathlib import Path

from app.clients.mysql_client_manager import (
dw_mysql_client_manager,
meta_mysql_client_manager,
)
from app.repositories.mysql.dw.dw_mysql_repository import DWMySQLRepository
from app.repositories.mysql.meta.meta_mysql_repository import MetaMySQLRepository
from app.services.meta_knowledge_service import MetaKnowledgeService


async def build(config_path: Path):
# 整条构建链路会同时访问两套 MySQL
# meta 用来写结构化元数据
# dw 用来读取真实表结构和字段示例值
# 1. 初始化两个 MySQL 客户端:
# 一个连元数据库,一个连数仓模拟库
meta_mysql_client_manager.init()
dw_mysql_client_manager.init()

# 2. 打开两个异步 Session,分别供两个 repository 使用
async with (
meta_mysql_client_manager.session_factory() as meta_session,
dw_mysql_client_manager.session_factory() as dw_session,
):
# 3. 创建 repository 对象
meta_mysql_repository = MetaMySQLRepository(meta_session)
dw_mysql_repository = DWMySQLRepository(dw_session)

# 4. 创建 service 对象,并把 repository 注入进去
meta_knowledge_service = MetaKnowledgeService(
meta_mysql_repository,
dw_mysql_repository,
)

# 5. 真正进入服务层的构建逻辑
await meta_knowledge_service.build(config_path)

# 6. 结束后关闭客户端连接
await meta_mysql_client_manager.close()
await dw_mysql_client_manager.close()


if __name__ == "__main__":
# 7. 解析命令行参数
# 由外部决定本次构建使用哪份配置文件
parser = argparse.ArgumentParser()
parser.add_argument("-c", "--conf")
args = parser.parse_args()

# 8. 将字符串路径转成 Path
# 再启动异步 build
asyncio.run(build(Path(args.conf)))

这段代码最值得先抓住的,不是某一行具体实现,而是它把“入口层该做什么”界定得很清楚:

  • 解析命令行参数
  • 初始化依赖
  • 创建 repository 和 service
  • 调用服务层的 build(config_path)
  • 在流程结束后关闭连接

也就是说,build_meta_knowledge.py 的重点是:把整条元数据知识库构建链路调起来。

至于“表和字段如何入库”“字段向量索引如何建立”“字段值全文索引如何写入”,这些都不应该继续塞在入口脚本里,而会在后面的具体章节分别展开。

8.1 为什么入口脚本不直接写业务逻辑

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

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

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

这就是为什么这里要坚持“分层”的原因。

8.2 为什么 build() 接收 config_path

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

1
await meta_knowledge_service.build(config_path)

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

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

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


9、meta_knowledge_service.py 详解

如果说入口脚本只负责“把流程调起来”,那么服务层就负责“把具体业务串起来”。

项目对应文件路径:shopkeeper-agent/app/services/meta_knowledge_service.py

为了突出“配置加载 -> 处理表链路 -> 处理指标链路”这条主线,下面保留的是一个教学化简后的总编排骨架。这里先只看“它决定先做什么、后做什么”,不进入表字段入库的具体细节。

当前 MetaKnowledgeService.build() 的骨架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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)
meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))
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("当前阶段完成:配置加载与元数据知识库构建骨架准备")

9.1 这段代码先看什么

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

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

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

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

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

至于 _save_tables_to_meta_db(...) 里面“表和字段究竟怎么构造、怎么查数仓、怎么入库”,会放到下一章详细展开。

9.2 为什么服务层持有 repository,而不是 session

这一点也是理解代码分层时非常关键的地方。

如果服务层直接拿着 session 到处写 SQL,那分层很快就会失效。
更合理的依赖关系应该是:

1
2
3
4
scripts
-> service
-> repository
-> session / client

也就是说:服务层负责“组织流程”,repository 层负责“执行具体读写”。

这样后面逻辑越来越复杂时,代码结构才不会迅速失控。

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

从这段代码里也能很直观地看到原因。

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

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

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


10、MetaConfig 与 OmegaConf 如何读入配置

项目对应文件路径:shopkeeper-agent/app/services/meta_knowledge_service.py

前面你已经看到,服务层里的第一步就是:

1
2
3
context = OmegaConf.load(config_path)
schema = OmegaConf.structured(MetaConfig)
meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

如果不把这三行拆开看,初学者很容易觉得有点抽象。其实它的作用非常明确:YAML 文本 -> 结构化配置对象

10.1 MetaConfig 的作用

项目对应文件路径:shopkeeper-agent/app/conf/meta_config.py

这个文件的核心结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from dataclasses import dataclass
from typing import Optional


@dataclass
class ColumnConfig:
name: str
role: str
description: str
alias: list[str]
sync: bool


@dataclass
class TableConfig:
name: str
role: str
description: str
columns: list[ColumnConfig]


@dataclass
class MetricConfig:
name: str
description: str
relevant_columns: list[str]
alias: list[str]


@dataclass
class MetaConfig:
# 允许只同步表,或只同步指标,所以两个字段都做成 Optional
tables: Optional[list[TableConfig]] = None
metrics: Optional[list[MetricConfig]] = None

可以把它概括成一句话:MetaConfig 在定义“这份配置文件在程序里应该长成什么样”。

也正因为有了这些数据类,后面服务层看到的就不再是一堆松散字典,而是一套结构稳定、字段明确的对象。

这里还有一个很容易被忽略、但非常值得单独解释的点:

为什么 tablesmetrics 要写成 Optional[...] = None

因为实际使用时,并不是每次都一定同时同步表和指标。有时候你可能只想同步表,有时候只想补充几个指标。所以把它们声明成可选项,会更符合后续脚本的真实使用方式。

10.2 OmegaConf 三步加载流程

现在再回来看这三行代码,就会清楚很多:

1
2
3
context = OmegaConf.load(config_path)
schema = OmegaConf.structured(MetaConfig)
meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

可以把它拆成 3 步。

10.2.1 OmegaConf.load(config_path):读取 YAML

1
context = OmegaConf.load(config_path)

这一步做的事情最朴素:先根据路径把 YAML 文件内容读出来。此时你拿到的还只是“配置内容”,还没有真正变成 MetaConfig 对象。

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

1
schema = OmegaConf.structured(MetaConfig)

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

10.2.3 merge + to_object:生成配置对象

1
meta_config: MetaConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))

这一行其实是在连续做两件事:

  1. OmegaConf.merge(schema, context):把“结构模板”和“实际配置内容”合并
  2. OmegaConf.to_object(...):把结果转成真正的 Python 对象

所以最终你得到的,不再是松散的 YAML 内容,而是一个真正可在代码里直接使用的:

1
MetaConfig

10.3 整条链路回顾

到这里,我们可以把这几份文件的关系重新串起来了:

1
2
3
4
5
6
7
8
9
-c/--conf
-> Path("conf/meta_config.yaml")
-> build_meta_knowledge.py 接收路径
-> MetaKnowledgeService.build(config_path)
-> OmegaConf.load 读取 YAML
-> MetaConfig 定义结构
-> OmegaConf.merge + to_object
-> 得到 meta_config 对象
-> 根据 tables / metrics 分支继续处理

这一条链路一旦看顺了,后面你再进入“保存表信息和字段信息到数据库”“建立向量索引”“建立全文索引”这些具体章节,就不会觉得代码零散了。


本章小结:

  • meta_config.yaml 负责声明“同步什么”,MetaConfig 负责定义“这些配置在程序里应该长成什么样”
  • build_meta_knowledge.py 是入口脚本,负责解析参数、初始化依赖、把流程调起来
  • meta_knowledge_service.py 是核心业务编排层,负责先读配置,再决定处理表还是处理指标
  • 这里的代码重点不是把所有细节一次写完,而是先把“配置文件 -> 入口脚本 -> 服务层”的骨架搭清楚
  • 推荐在后端项目根目录下,使用 uv run python -m app.scripts.build_meta_knowledge -c conf/meta_config.yaml 执行脚本

如果你已经把这条链路看清楚了,那么下一章我们就可以正式进入第一条具体构建链路:把表信息和字段信息同步到元数据库。