7-元数据知识库总览与构建入口
7 - 电商问数:元数据知识库总览与构建入口
本章课程目标:
- 先看清“元数据知识库”为什么是问数系统真正开始工作的前提。
- 理解一次元数据知识库构建到底会产出什么,以及会写入哪些存储组件。
- 搞清楚同步脚本是如何从命令行启动、如何把配置传进服务层、以及配置最终如何变成程序对象。
学习建议: 这一章是元数据知识库篇的入口。先看它为什么必须先建,再看同步脚本如何接收配置文件,最后看配置如何进入服务层。读完后最好能回答:为什么不能让大模型直接猜表字段,为什么构建脚本要配置驱动,为什么入口脚本只做调度、不堆业务逻辑。
对应代码分支: 07-metadata-base-overview
前面几章,我们已经把「电商问数」运行所需的基础设施准备好了,包括:MySQL、Qdrant、Elasticsearch、Embedding、配置管理、日志管理、客户端封装。
这些内容本质上都在做一件事:**为真正的业务逻辑做准备。**前面第 1 章和第 2 章,已经分别讲清楚了「电商问数」要解决什么问题,以及这套系统整体是怎么运转的。因此从这一章开始,我们不再重复项目背景,而是把视角正式切进第一条核心业务线:构建元数据知识库。
你也可以把这一章先看作“元数据知识库篇章的总入口”。它要解决的,不是某一个具体入库细节,而是先把下面这几个前置问题讲清楚:为什么这一章先讲元数据知识库;一次同步脚本到底会构建什么;这条构建流程从哪里进入;配置文件是如何被读取并传进系统的。
1、为什么先构建元数据知识库
这一点在第 1 章和第 2 章其实已经铺垫过了。问数智能体在真正生成 SQL 之前,必须先有一套可检索、可组织、可复用的知识底座。否则它后面就很难稳定地完成下面这些动作:
- 找到该查哪些表和字段
- 理解字段的业务含义和建模角色
- 识别用户问题里提到的字段取值
- 把业务指标和底层字段对应起来
所以从工程实现上看,元数据知识库并不是一个“附加能力”,而是问数智能体能够稳定工作的前提。
这里的元数据,主要包括四类:表信息、字段信息、字段取值、指标信息。一旦这些内容被结构化保存,并建立好向量索引和全文索引,后续智能体在生成 SQL 前,才能真正做到:先理解上下文,再动手生成。

2、元数据知识库入口说明
这一章的一个重要设计思路是:**先不急着实现所有业务细节,而是先把脚本入口、执行方式和调用链路搭清楚。**因为如果脚本本身都没法稳定执行,后面业务写得再多也跑不起来。
2.1 代码结构目录说明
元数据知识库的构建逻辑并不简单。它至少同时涉及:读配置文件,写元数据库,读数仓,写向量库,写全文索引。
如果把这些逻辑全部堆在一个 build() 函数里,这个函数很快就会变得又长又乱,后面既不好维护,也不好讲解。
所以项目里采用了一种非常经典的分层思路。如果你做过常规后端开发,可以类比成下面这个结构:
controller:接收请求,负责入口service:组织业务流程repository:负责具体存储读写
在当前这个项目里,虽然我们写的不是 HTTP 接口,而是一个脚本,但这个类比仍然很好用:
scripts可以类比为controllerservices负责核心业务逻辑repositories负责和MySQL / Qdrant / Elasticsearch打交道
你可以这样理解:
- 接口程序里,
controller接收的是 HTTP 参数 - 这个脚本里,
scripts接收的是命令行参数
两者本质上都是“程序入口”。
如果把“构建元数据知识库”这一块进一步映射到当前项目的目录中,它的核心代码结构大致如下:
1 | shopkeeper-agent/ |
这样分层之后,后面你再去看代码时,就不会只看到一堆零散文件,而会知道它们分别处在这条链路的哪个位置。
2.2 分清 4 类角色:配置文件、业务实体、ORM 模型、mappers
2.2.1 角色概览
看到这里时,很多同学都会有一连串很自然的疑问:
- 既然已经有配置文件了,为什么还要有业务实体?
- 既然最后是写数据库,为什么不直接用 ORM 模型?
entities、models、mappers这些目录看起来都和“表、字段、指标”有关,它们到底区别在哪?
这些问题问得非常好,因为后面第 8 章真正展开代码时,主线其实就是在这 4 类角色之间流转。可以先把它们按下面这 4 层来看。

第一类:配置文件
配置文件是“外部输入”,告诉程序:这次要处理什么。
在当前项目里,对应的是:
conf/meta_config.yaml
它主要负责描述:
- 要同步哪些表
- 每张表有哪些字段
- 字段的角色、描述、别名是什么
- 哪些字段后续还要同步真实取值
- 哪些指标要进入知识库
所以配置文件更像一份“任务说明书”或“同步清单”。但它还不是程序内部真正干活时最核心的对象,因为它通常只描述了业务语义和同步范围,并不一定包含完整的运行时信息。
例如:字段类型 type 不一定写在配置里,字段示例值 examples 也不一定写在配置里。这些信息往往还需要程序运行时再去 DW 库里查询补齐。
第二类:业务实体
业务实体是“程序内部统一流转的数据对象”,告诉系统:我内部准备怎么表示这些表、字段、指标。
在当前项目里,对应的是:
app/entities/table_info.pyapp/entities/column_info.pyapp/entities/metric_info.pyapp/entities/column_metric.py
你可以把业务实体理解成系统内部的“标准件”。
它的作用是把来自不同地方的信息,统一组织成系统后续都能复用的结构。例如:一部分信息来自配置文件;一部分信息来自数仓查询;后面还可能继续被拿去写 MySQL、写 Qdrant、写 Elasticsearch。
所以业务实体关注的重点不是“怎么存数据库”,而是:系统内部如何统一表达一个表、一个字段、一个指标。
如果你做过前端开发,可以把它看作一个运行时真实存在的 DTO / domain object。它有点像 TS 里的类型定义,但它不只是静态类型提示,而是程序运行时真的会创建出来的 Python 对象。
例如后面代码里会真的创建:
1 | table_info = TableInfo( |
这个 table_info 不是只给编辑器看的类型说明,而是程序运行时真实存在的对象。
第三类:ORM 模型
ORM 模型是“数据库映射对象”,告诉 ORM 框架:这些 Python 类怎么对应数据库中的表和列。
在当前项目里,对应的是:
app/models/table_info_mysql.pyapp/models/column_info_mysql.pyapp/models/metric_info_mysql.pyapp/models/column_metric_mysql.py
它们关注的重点是:这张数据库表叫什么,这几个字段如何映射到 Python 属性,ORM 应该如何执行插入、查询、更新。所以 ORM 模型更偏向“面向数据库”,而不是“面向业务语义”。
也就是说:业务实体关心“系统内部如何表达”,ORM 模型关心“数据库表结构如何映射”。这两个对象看起来字段可能很像,但职责完全不一样。
第四类:mappers
mappers 不是新的业务对象,也不是新的数据库模型,它本质上只是一个翻译层。
在当前项目里,对应的是:
app/repositories/mysql/meta/mappers/table_info_mapper.pyapp/repositories/mysql/meta/mappers/column_info_mapper.pyapp/repositories/mysql/meta/mappers/metric_info_mapper.pyapp/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 | meta_config.yaml |
如果再把“谁调用谁”也一起说清楚,可以看作下面这条业务链:
- 脚本入口
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 内部的一个翻译器。
2.2.3 最小示例
假设配置文件里有这样一张表:
1 | tables: |
那么后面大致会发生下面这些事:
- 先从配置里读到:
fact_order、order_amount、measure、订单金额、销售额 - 再从
DW查询到:order_amount的真实类型,比如float - 然后在
service层组装出业务实体:
1 | ColumnInfo( |
- 到了
repository层,再通过ColumnInfoMapper把它转换成ColumnInfoMySQL - 最后由 ORM 把
ColumnInfoMySQL写进column_info表
这样一看就很清楚了:
- 配置文件提供了起点
- 业务实体承接了系统内部表达
mapper完成了对象翻译- ORM 模型完成了数据库映射
如果你先把这一段概念吃透,后面再去看第 8 章,就会发现那一章其实只是在把这条链路展开成具体代码。
2.3 同步脚本说明
先把这条链路的大图景看清楚。这个脚本不是只做“入库”这一件事,而是要串起一整套知识构建流程。
整体上,它要完成 5 步:
- 读取配置文件
- 将指定的表信息和字段信息写入
Meta MySQL - 为字段信息建立向量索引,写入
Qdrant - 为需要同步的字段取值建立全文索引,写入
Elasticsearch - 将指标信息和指标字段关系写入
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 | async def build(self, config_path: Path): |
这段代码非常值得先建立整体印象,因为后面第 8 章到第 10 章,本质是在逐段展开这几行流程。
3、为什么要做成“配置驱动”
这是本章最重要的设计思想之一。这里先把“配置驱动”这个词说清楚。它不是单纯指“项目里同时有脚本和配置文件”,而是指:脚本本身不写死业务范围,而是由配置文件来决定这次要同步什么内容。
脚本负责“怎么做”、配置负责“做哪些内容”。放到「电商问数」里,具体就是由配置文件来决定:
- 要同步哪些表
- 每张表里哪些字段要纳入元数据知识库
- 哪些字段需要把真实取值同步到
Elasticsearch - 哪些指标要纳入知识库
- 指标和哪些字段相关
所以这里说的“配置驱动”,本质上就是:不是代码决定同步什么,而是配置决定同步什么。
很多同学一开始会想:“既然我要同步数仓元数据,那直接把所有表都扫一遍不就行了吗?”
理论上可以,但工程上通常不会这么做,原因至少有三个。
- 不是所有表都值得同步
真实数仓里往往会有很多表:中间表、临时表、废弃表、只服务某个离线任务的技术表。这些表即使真实存在,也未必适合暴露给问数系统。问数智能体真正关心的,通常只是其中一小部分“有业务意义、适合查询、适合解释”的表。
- 数仓内容将来一定会有增量变化
今天数仓里也许有 100 张表,明天可能又新增了 10 张。如果构建逻辑完全写死,每次新增表都得改代码,那维护成本会越来越高。
更合理的方式是:代码负责“怎么同步”、配置负责“同步哪些内容”。这样一来,新表上线时,只需要改配置,不需要动核心逻辑。
- 字段是否同步真实取值也应该是可配的
并不是每个字段都需要把真实取值同步到 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 典型现象:为什么会报错
如果你在项目根目录下直接执行:
1 | python3 app/scripts/build_meta_knowledge.py |
很可能会看到类似报错:
1 | Traceback (most recent call last): |
也就是说,同步脚本本身如果被当成“普通文件”直接运行,而不是当成模块运行,也会遇到同类问题。只要文件里用了这种绝对导入:
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/ 开始找模块,而不是从项目根目录开始找。

所以问题就变成了:解释器从 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 | export PYTHONPATH=$(pwd) |
但它更适合临时调试,不适合作为团队长期的统一执行约定。
因此在当前项目里,最推荐的还是:
1 | uv run python -m app.scripts.build_meta_knowledge -c conf/meta_config.yaml |
4.5 为什么 PyCharm 能运行
这是因为 IDE 往往会额外帮你补一些运行配置,比如工作目录、内容根目录、环境变量和解释器选择,这些设置都会影响 Python 启动后的模块查找路径。
所以:
PyCharm能跑,不代表命令写法就是最稳妥的- 终端报错,也不代表代码本身错了
从团队协作和部署角度看,更可靠的做法还是把终端中的执行命令固定下来。
5、脚本参数解析:如何使用 argparse
现在脚本的执行方式已经明确了,接下来就要解决另一个很实际的问题:脚本启动之后,怎么知道这一次应该读取哪一份配置文件?
答案就是:通过命令行参数把配置文件路径传进来。在当前项目里,这个参数就是:
1 | -c conf/meta_config.yaml |
5.1 命令行参数是什么
命令行参数,就是你在执行程序时额外传给它的信息。
例如这条命令:
1 | uv run python -m app.scripts.build_meta_knowledge -c conf/meta_config.yaml |
其中真正的“参数”就是:
-cconf/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 | import sys |
通常会得到:
1 | ['demo.py', 'hello', 'world'] |
这时候:
sys.argv[0]是脚本名demo.pysys.argv[1]是第一个参数hellosys.argv[2]是第二个参数world
也就是说,sys.argv[1]、sys.argv[2] 本质是“按位置硬取命令行参数”的写法。它在极简单的脚本里能用,但只要参数顺序变化、参数变多,或者需要支持 -c / --conf 这种可选参数时,就会越来越不稳。
- 参数顺序可能变化
- 有些参数可能可选
- 参数名和值需要配对识别
- 用户输错时,希望程序自动提示正确用法
而 argparse 正是为这些场景设计的。
5.3 最常用的 4 步写法
对初学者来说,argparse 最常用的用法其实就 4 步:
1 | from argparse import ArgumentParser |
这 4 步分别在做什么?
ArgumentParser():创建一个参数解析器add_argument("-c", "--conf"):声明脚本支持-c和--conf这两种写法parse_args():真正开始解析命令行输入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 | parser.add_argument( |
这样用户执行 -h 时,就能看到这个参数是干什么的。
5.4.4 指定默认值或必填约束
官方文档里,最常用的两个参数配置是:
default=:没传时使用默认值required=True:要求用户必须传这个参数
例如更完整一点的写法可以是:
1 | parser.add_argument( |
这表示:如果用户不传 -c/--conf,程序就直接报错并提示正确用法。
5.5 放回当前项目:参数解析在做什么
当前项目脚本中的参数解析代码是:
1 | if __name__ == "__main__": |
把它翻译成大白话,其实就是:
- 先声明:这个脚本支持一个配置参数
-c/--conf - 再从命令行里把这个参数解析出来
- 拿到配置文件路径
- 把这个路径传给
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 本节小结
- 命令行参数就是程序启动时从终端传进来的额外信息。
argparse最核心的流程就是:创建解析器、声明参数、解析参数、读取结果。-c/--conf是脚本把“配置驱动”真正落到命令行入口上的关键参数。- 在当前项目里,脚本最终拿到的是配置文件路径,并把它转成
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.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 格式,用来指定待同步的表信息和指标信息。先看它的结构模板:
1 | tables: # 表信息 |
在当前这套数据仓库模拟环境中,一共有 5 张表和 2 个指标需要同步到元数据知识库。下面这段 YAML 就是其中的具体配置示例:
1 | tables: |
7.1 顶层结构:tables 与 metrics
这一点很重要。
这里先把同步内容分成了两大类:
tables:描述要同步的表和字段metrics:描述要同步的指标
这样一来,后面服务层就可以直接写出这样的判断:
1 | if meta_config.tables: |
也就是说,配置文件的顶层结构,本身就在服务层里对应成了两条处理分支。
7.2 tables:表配置结构
tables 是一个列表,列表里的每个元素都表示一张要同步的表。
每张表至少包含这几个信息:
name:表名role:这张表是维度表还是事实表description:这张表的业务含义columns:这张表下哪些字段要进入知识库
这里的 role 也很关键:
dim:维度表fact:事实表
它不是单纯写给人看的说明,而是后面智能体理解数仓结构时会用到的信息。
7.3 columns:字段配置结构
columns 也是一个列表,里面每个元素都表示一个字段。
每个字段主要有 5 个配置:
name:字段名role:字段角色description:字段业务含义alias:字段别名sync:是否同步真实取值到Elasticsearch
字段级 role 一般有这几种:
primary_keyforeign_keydimensionmeasure
这里的 alias 很重要,因为用户提问时经常不会直接说真实字段名,而会说业务口语。
例如 order_amount 的别名可以是:销售额、订单金额、收入。
这样后面做召回时,就更容易把自然语言和真实字段对齐起来。
7.4 sync:字段取值同步开关
sync 是这个配置文件里最容易被忽略、但非常关键的一个字段。
sync: true:表示这个字段的真实取值需要同步到Elasticsearchsync: false:表示只保留字段元信息,不同步真实取值
注意,它控制的不是“这个字段要不要进入知识库”,而是:这个字段要不要额外建立字段值全文索引。
也就是说,所有出现在配置里的字段,都会作为字段元数据进入系统;只有 sync: true 的那些字段,才会继续把真实取值同步到 ES。
7.5 metrics:指标配置结构
metrics 也是一个列表,里面每个元素都表示一个指标。
每个指标主要有这几个信息:
name:指标名description:指标含义relevant_columns:这个指标和哪些字段相关alias:指标别名
例如:
1 | metrics: |
这里的 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 等依赖,但这些都只是入口层的依赖准备,而不是具体业务处理。
1 | import argparse |
这段代码最值得先抓住的,不是某一行具体实现,而是它把“入口层该做什么”界定得很清楚:
- 解析命令行参数
- 初始化依赖
- 创建 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 | from pathlib import Path |
9.1 这段代码先看什么
第一,它先读配置文件,再决定做什么。也就是说,配置优先于业务动作。
第二,它先判断 tables,再判断 metrics。这正好对应了前面配置文件的顶层结构。
第三,它已经把后续元数据构建拆成了几条非常清晰的子链路:
- 表信息与字段信息写入元数据库
- 字段信息向量索引构建
- 字段取值全文索引构建
- 指标信息写入与指标向量索引构建
也就是说,这一层的重点不是“把每个细节都直接展开”,而是先把整条元数据知识库的业务编排顺序说明白。
至于 _save_tables_to_meta_db(...) 里面“表和字段究竟怎么构造、怎么查数仓、怎么入库”,会放到下一章详细展开。
9.2 为什么服务层持有 repository,而不是 session
这一点也是理解代码分层时非常关键的地方。
如果服务层直接拿着 session 到处写 SQL,那分层很快就会失效。
更合理的依赖关系应该是:
1 | scripts |
也就是说:服务层负责“组织流程”,repository 层负责“执行具体读写”。
这样后面逻辑越来越复杂时,代码结构才不会迅速失控。
9.3 为什么 build() 要写成异步方法
从这段代码里也能很直观地看到原因。
在 build() 里要频繁做这些事:
- 查数仓字段类型
- 查数仓字段示例值
- 后续还要写元数据库、写 Qdrant、写 ES
这些几乎都是 I/O 操作,所以整个构建流程天然就更适合写成异步函数。
10、MetaConfig 与 OmegaConf 如何读入配置
项目对应文件路径:shopkeeper-agent/app/services/meta_knowledge_service.py
前面你已经看到,服务层里的第一步就是:
1 | context = OmegaConf.load(config_path) |
如果不把这三行拆开看,初学者很容易觉得有点抽象。其实它的作用非常明确:YAML 文本 -> 结构化配置对象
10.1 MetaConfig 的作用
项目对应文件路径:shopkeeper-agent/app/conf/meta_config.py
这个文件的核心结构如下:
1 | from dataclasses import dataclass |
可以把它概括成一句话:MetaConfig 在定义“这份配置文件在程序里应该长成什么样”。
也正因为有了这些数据类,后面服务层看到的就不再是一堆松散字典,而是一套结构稳定、字段明确的对象。
这里还有一个很容易被忽略、但非常值得单独解释的点:
为什么 tables 和 metrics 要写成 Optional[...] = None?
因为实际使用时,并不是每次都一定同时同步表和指标。有时候你可能只想同步表,有时候只想补充几个指标。所以把它们声明成可选项,会更符合后续脚本的真实使用方式。
10.2 OmegaConf 三步加载流程
现在再回来看这三行代码,就会清楚很多:
1 | context = OmegaConf.load(config_path) |
可以把它拆成 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)) |
这一行其实是在连续做两件事:
OmegaConf.merge(schema, context):把“结构模板”和“实际配置内容”合并OmegaConf.to_object(...):把结果转成真正的 Python 对象
所以最终你得到的,不再是松散的 YAML 内容,而是一个真正可在代码里直接使用的:
1 | MetaConfig |
10.3 整条链路回顾
到这里,我们可以把这几份文件的关系重新串起来了:
1 | -c/--conf |
这一条链路一旦看顺了,后面你再进入“保存表信息和字段信息到数据库”“建立向量索引”“建立全文索引”这些具体章节,就不会觉得代码零散了。
本章小结:
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执行脚本
如果你已经把这条链路看清楚了,那么下一章我们就可以正式进入第一条具体构建链路:把表信息和字段信息同步到元数据库。