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

---

**本章课程目标：**

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

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

**对应代码分支：** `07-metadata-base-overview`

---

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

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

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

---

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

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

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

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

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

![电商问数系统整体架构：数据仓库、配置与同步脚本、元数据知识库与问数智能体](images/2/2-1-2-1.png)

---

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

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

### 2.1 代码结构目录说明

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

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

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

- `controller`：接收请求，负责入口
- `service`：组织业务流程
- `repository`：负责具体存储读写

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

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

你可以这样理解：

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

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

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

```text
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 模型？
- `entities`、`models`、`mappers` 这些目录看起来都和“表、字段、指标”有关，它们到底区别在哪？

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

![4 类角色代码目录结构](images/7/7-2-1-1.png)

**第一类：配置文件**

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

在当前项目里，对应的是：

- `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 对象。

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

```python
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 章你会看到的真实链路，其实可以抽象成下面这样：

```text
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` 构造业务实体，例如 `TableInfo`、`ColumnInfo`。
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` 内部的一个翻译器。

#### 2.2.3 最小示例

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

```yaml
tables:
  - name: fact_order
    role: fact
    description: 订单事实表
    columns:
      - name: order_amount
        role: measure
        description: 订单金额
        alias: ["销售额", "成交金额"]
```

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

1. 先从配置里读到：
   `fact_order`、`order_amount`、`measure`、`订单金额`、`销售额`
2. 再从 `DW` 查询到：
   `order_amount` 的真实类型，比如 `float`
3. 然后在 `service` 层组装出业务实体：

```python
ColumnInfo(
    id="fact_order.order_amount",
    name="order_amount",
    type="float",
    role="measure",
    examples=[...],
    description="订单金额",
    alias=["销售额", "成交金额"],
    table_id="fact_order",
)
```

4. 到了 `repository` 层，再通过 `ColumnInfoMapper` 把它转换成 `ColumnInfoMySQL`
5. 最后由 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`

```python
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. **不是所有表都值得同步**

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

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

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

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

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

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

例如：`province`、`region_name`、`member_level` 这类字段，用户在自然语言里很可能直接说出它们的值，所以适合同步字段取值，建立全文索引。

但像 `order_id`、`customer_id`、`product_id` 这类技术标识字段，一般没有必要把取值全部写入 `Elasticsearch`。

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

---

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

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

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

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

### 4.1 典型现象：为什么会报错

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

```bash
python3 app/scripts/build_meta_knowledge.py
```

很可能会看到类似报错：

```text
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'
```

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

```python
from app.core.log import logger
```

就可能遇到同类问题。

### 4.2 根本原因：解释器找模块，要看 sys.path

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

当你直接执行：

```bash
python3 app/scripts/build_meta_knowledge.py
```

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

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

![直接执行 build_meta_knowledge.py 时，解释器更容易从 app/scripts/ 开始查找模块，而 app 包实际位于项目根目录下](./images/7/7-4-2-1.png)

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

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

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

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

例如：

```bash
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` 当然也能解决问题，比如：

```bash
export PYTHONPATH=$(pwd)
python3 app/clients/qdrant_client_manager.py
```

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

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

### 4.5 为什么 PyCharm 能运行

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

所以：

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

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

---

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

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

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

```bash
-c conf/meta_config.yaml
# 或者
--conf conf/meta_config.yaml
```

### 5.1 命令行参数是什么

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

例如这条命令：

```bash
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 用来保存命令行参数的列表。例如执行：

```bash
python demo.py hello world
```

那么：

```python
import sys
print(sys.argv)
```

通常会得到：

```python
['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 步：

```python
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` 对象。  
所以我们通常会通过属性的方式取值，比如：

```python
args.conf
```

### 5.4 argparse 常用能力

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

#### 5.4.1 自动生成帮助信息

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

例如你执行：

```bash
uv run python -m app.scripts.build_meta_knowledge -h
```

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

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

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

- 位置参数：直接靠位置识别，例如 `filename`
- 可选参数：带 `-` 或 `--` 前缀，例如 `-c`、`--conf`

当前项目里用的是**可选参数**，因为：

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

#### 5.4.3 为参数补充说明

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

例如：

```python
parser.add_argument(
    "-c",
    "--conf",
    help="meta_config.yaml 的路径",
)
```

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

#### 5.4.4 指定默认值或必填约束

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

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

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

```python
parser.add_argument(
    "-c",
    "--conf",
    required=True,
    help="meta_config.yaml 的路径",
)
```

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

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

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

```python
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)`

所以当你执行：

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

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

```python
args.conf == "conf/meta_config.yaml"
```

然后再进一步转成：

```python
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` 导入使用：

```python
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.py` 和 `app/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 下继续分 `meta` 和 `dw`

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

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

---

## 7、meta_config.yaml 详解

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

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

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

```yaml
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 就是其中的具体配置示例：

```yaml
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`：描述要同步的指标

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

```python
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`：指标别名

例如：

```yaml
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`

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

```python
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

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

```python
await meta_knowledge_service.build(config_path)
```

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

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

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

---

## 9、meta_knowledge_service.py 详解

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

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

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

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

```python
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，那分层很快就会失效。  
更合理的依赖关系应该是：

```text
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`

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

```python
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`

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

```python
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` 在定义“这份配置文件在程序里应该长成什么样”。

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

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

为什么 `tables` 和 `metrics` 要写成 `Optional[...] = None`？

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

### 10.2 OmegaConf 三步加载流程

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

```python
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

```python
context = OmegaConf.load(config_path)
```

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

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

```python
schema = OmegaConf.structured(MetaConfig)
```

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

#### 10.2.3 merge + to_object：生成配置对象

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

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

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

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

```python
MetaConfig
```

### 10.3 整条链路回顾

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

```text
-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` 执行脚本

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