元知识库构建:从YAML配置到MySQL + Qdrant + Elasticsearch多引擎索引
写在前面
NL2SQL系统有一个常被忽略的核心组件:元知识库(Meta Knowledge Base)。它不是数据库本身,而是关于数据库的"说明书"——哪些表、哪些字段、字段什么含义、有哪些别名、取值范围是什么。
LLM再聪明,也不可能凭空知道你公司的数据库里有个字段叫order_amount表示销售额。这些信息必须提前构建好,在查询时检索出来注入Prompt。
这篇文章拆解小欧问数项目中元知识库的完整构建流程:YAML配置 → 数据解析 → MySQL持久化 → Qdrant向量索引 → Elasticsearch全文索引。
一、设计目标
元知识库需要回答NL2SQL工作流中的三个核心问题:
| 问题 | 对应存储 | 检索方式 |
|---|---|---|
| "销售额"对应哪个字段? | Qdrant(向量索引) | 语义相似度检索 |
| "华东"是哪个字段的值? | Elasticsearch(全文索引) | 中文分词匹配 |
| 这个字段的类型是什么?属于哪张表? | MySQL(结构化存储) | 主键精确查询 |
同一份元数据,三种存储形式,服务于不同的检索场景。
二、YAML配置驱动
一切从一个YAML配置文件开始。选择YAML而非数据库录入,是因为:
- 版本可控:配置文件可以进Git,变更有记录
- 批量维护:DBA或数据开发可以一次性维护几十张表
- 环境隔离:开发/测试/生产可以用不同的配置
2.1 表配置
tables:- name: fact_orderrole: factdescription: 订单事实表,记录订单数量和金额等核心指标。columns:- name: order_amountrole: measuredescription: 订单金额。alias: [销售额, 订单金额, 收入]sync: false- name: region_idrole: foreign_keydescription: 关联地区维度的外键。alias: [地区ID, 区域ID]sync: false
每个字段需要标注:
- role:
primary_key/foreign_key/dimension/measure,决定了字段在SQL中的角色 - alias:别名列表,"销售额"、"收入"都应该能匹配到
order_amount - sync:是否同步取值到ES(枚举值字段如"地区"需要sync,ID类字段不需要)
2.2 指标配置
metrics:- name: GMVdescription: 全称Gross Merchandise Value,所有订单成交金额总和。relevant_columns:- fact_order.order_amountalias: [成交总额, 订单总额]
指标是独立于字段的业务概念层。relevant_columns建立了指标→字段的映射,让系统在召回指标时能自动关联到相关字段。
2.3 配置Schema
用Python dataclass定义配置结构,配合OmegaConf做校验:
@dataclass
class ColumnConfig:name: strrole: strdescription: stralias: list[str]sync: bool # 是否同步取值到ES@dataclass
class TableConfig:name: strrole: str # "dim" 维度表 / "fact" 事实表description: strcolumns: list[ColumnConfig]@dataclass
class MetaConfig:tables: Optional[list[TableConfig]] = Nonemetrics: Optional[list[MetricConfig]] = None
OmegaConf的merge机制可以做到默认值 + 用户配置覆盖,容错性好:
context = OmegaConf.load(config_path)
schema = OmegaConf.structured(MetaConfig)
meta_config = OmegaConf.to_object(OmegaConf.merge(schema, context))
三、构建流程总览
YAML配置│├──▶ Step 1: 表信息 → MySQL(持久化)│ ││ ├──▶ Step 2: 字段 → Qdrant(向量索引)│ ││ └──▶ Step 3: 字段取值 → Elasticsearch(全文索引)│└──▶ Step 4: 指标 → MySQL(持久化)│└──▶ Step 5: 指标 → Qdrant(向量索引)
每一步都是幂等的,可以单独重跑。
四、Step 1:表信息持久化到MySQL
4.1 从数据仓库补充类型信息
YAML里只有字段的语义描述,但字段的数据类型(int、varchar、decimal)需要直接从数据仓库查询:
# _save_tables_to_meta_db
column_types = await dw_mysql_repository.get_column_types(table.name)
# {"order_id": "bigint", "order_amount": "decimal(10,2)", ...}
同时查询每个字段的部分取值作为examples:
column_values = await dw_mysql_repository.get_column_values(table.name, column.name, 10)
# ["华东", "华南", "华北", ...]
这些examples会被存入Qdrant的payload,在召回时一起返回给LLM作为参考。
4.2 组装实体并持久化
column_info = ColumnInfo(id=f"{table.name}.{column.name}", # 复合ID:表名.字段名name=column.name,type=column_types[column.name],role=column.role,examples=column_values,description=column.description,alias=column.alias,table_id=table.name,
)
ID设计:fact_order.order_amount 这样的复合ID,既保证全局唯一,又能从ID直接解析出表名。
五、Step 2:字段向量索引到Qdrant
5.1 多维度Embedding策略
一个字段需要在多个"名字"上被检索到。所以每个字段生成多条向量记录:
for column_info in column_infos:# 字段名:order_amountpoints.append({"embedding_text": column_info.name, "payload": column_info})# 描述:订单金额points.append({"embedding_text": column_info.description, "payload": column_info})# 每个别名:销售额、订单金额、收入for alias in column_info.alias:points.append({"embedding_text": alias, "payload": column_info})
这样一个字段可能生成5-8条向量记录。当用户问"收入多少","收入"的Embedding会和alias="收入"的向量高度相似,从而召回order_amount字段。
5.2 批量Embedding + 批量写入
# 分批Embedding(避免单次请求过大)
for i in range(0, len(embedding_texts), 10):batch = embedding_texts[i:i+10]batch_embeddings = await embedding_client.aembed_documents(batch)# 分批写入Qdrant(batch_size=20)
await column_qdrant_repository.upsert(ids, embeddings, payloads, batch_size=20)
5.3 Qdrant Collection配置
VectorParams(size=1024, distance=Distance.COSINE)
- 1024维:BGE-Large-ZH的输出维度
- 余弦相似度:适合语义相似场景,归一化后分数在0-1之间,便于设定阈值
六、Step 3:字段取值索引到Elasticsearch
6.1 为什么要单独存取值?
字段名和取值需要不同的检索策略:
- "销售额" → 语义匹配 → 向量检索
- "华东" → 精确匹配 → 全文检索
如果把取值也存进Qdrant,"华东"和"华南"的向量会很接近,导致误召回。全文检索的精确匹配特性更适合取值场景。
6.2 sync标记控制同步范围
不是所有字段的取值都需要索引。ID类字段(order_id)有百万级取值,索引无意义。只有枚举类字段(region_name、category)才需要:
- name: region_namesync: true # 需要同步取值
- name: order_idsync: false # 不需要同步
6.3 取值索引构建
for column_info in column_infos:if sync:values = await dw_mysql_repository.get_column_values(table_name, column_name, 100000)value_infos = [ValueInfo(id=f"{column_info.id}.{value}", value=value, column_id=column_info.id)for value in values]
await value_es_repository.index(value_infos)
6.4 ES索引mapping
{"properties": {"value": {"type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_max_word"},"column_id": {"type": "keyword"}}
}
ik_max_word分词器会把"华东地区"细分为"华东/地区/华东地区",提高短查询的召回率。
七、Step 4 & 5:指标处理
指标的处理和字段类似:MySQL持久化 + Qdrant向量索引。但有一个额外的关联关系:指标-字段关联表(column_metric)。
for metric in meta_config.metrics:metric_info = MetricInfo(name=metric.name, ...)for column_id in metric.relevant_columns:column_metric = ColumnMetric(column_id=column_id, metric_id=metric.name)
这个关联表在merge节点中被使用:当GMV指标被召回时,系统通过关联表自动把fact_order.order_amount字段也加入召回集合。
八、数据流向全景图
meta_config.yaml│▼
┌─────────────────────────────────────────────────────────┐
│ MetaKnowledgeService.build() │
│ │
│ 1. _save_tables_to_meta_db() │
│ ├── dw_mysql.get_column_types() → 补充字段类型 │
│ ├── dw_mysql.get_column_values() → 补充字段取值 │
│ └── meta_mysql.save() → MySQL持久化 │
│ │
│ 2. _save_column_info_to_qdrant() │
│ ├── 每个字段 → name/description/alias 多条embedding │
│ └── qdrant.upsert() → Qdrant向量索引 │
│ │
│ 3. _save_value_info_to_es() │
│ ├── 按sync标记过滤 → 只同步枚举字段 │
│ └── es.index() → Elasticsearch全文索引 │
│ │
│ 4. _save_metrics_to_meta_db() → MySQL持久化 │
│ 5. _save_metric_info_to_qdrant() → Qdrant向量索引 │
└─────────────────────────────────────────────────────────┘
九、工程化细节
9.1 幂等性保证
- MySQL:
session.begin()事务,失败自动回滚 - Qdrant:
upsert(update or insert),重跑不会重复 - ES:以
id作为_id,重复索引会覆盖
9.2 批量处理
所有写操作都做了批量分批:
- Embedding:每10条一批(避免单次请求超时)
- Qdrant写入:每20条一批
- ES写入:每20条一批
9.3 连接生命周期
# 构建脚本入口
async def build(config_path):meta_mysql_client_manager.init()qdrant_client_manager.init()es_client_manager.init()...await meta_mysql_client_manager.close()await qdrant_client_manager.close()await es_client_manager.close()
所有客户端在构建开始前初始化,结束后显式关闭。避免连接泄漏。
9.4 命令行接口
python -m app.scripts.build_meta_knowledge -c conf/meta_config.yaml
通过-c参数指定配置文件,支持不同环境使用不同的元数据配置。
十、为什么不直接用数据库Schema?
有人可能会问:直接SHOW CREATE TABLE拿Schema不就行了,为什么要额外维护YAML?
原因有三:
- 语义缺失:
order_amount DECIMAL(10,2)不包含"这是销售额"的语义信息 - 别名缺失:数据库不会告诉你"收入"就是
order_amount - 业务指标不存在于Schema中:GMV、AOV是业务定义,不在DDL里
- 取值范围未知:LLM需要知道
region_name的取值有"华东/华南/华北"才能正确生成WHERE条件
元知识库本质上是给数据库加了一层业务语义层,弥合"机器Schema"和"人类语言"之间的鸿沟。
十一、总结
这套元知识库构建方案的核心设计:
- YAML配置驱动:版本可控、批量维护、环境隔离
- 一份数据三种存储:MySQL做结构化查询、Qdrant做语义检索、ES做精确匹配
- 多维度Embedding:字段名+描述+别名分别建向量,最大化召回覆盖
- sync标记分级:枚举字段全量同步,ID字段跳过,控制索引规模
- 指标-字段关联:召回指标时自动带出相关字段,减少信息缺失
对于任何需要"让AI理解你的数据"的场景——NL2SQL、RAG、智能报表——这套"配置→多引擎索引"的范式都值得参考。
