元知识库构建方案

元知识库构建:从YAML配置到MySQL + Qdrant + Elasticsearch多引擎索引

写在前面

NL2SQL系统有一个常被忽略的核心组件:元知识库(Meta Knowledge Base)。它不是数据库本身,而是关于数据库的"说明书"——哪些表、哪些字段、字段什么含义、有哪些别名、取值范围是什么。

LLM再聪明,也不可能凭空知道你公司的数据库里有个字段叫order_amount表示销售额。这些信息必须提前构建好,在查询时检索出来注入Prompt

这篇文章拆解小欧问数项目中元知识库的完整构建流程:YAML配置 → 数据解析 → MySQL持久化 → Qdrant向量索引 → Elasticsearch全文索引。


一、设计目标

元知识库需要回答NL2SQL工作流中的三个核心问题:

问题 对应存储 检索方式
"销售额"对应哪个字段? Qdrant(向量索引) 语义相似度检索
"华东"是哪个字段的值? Elasticsearch(全文索引) 中文分词匹配
这个字段的类型是什么?属于哪张表? MySQL(结构化存储) 主键精确查询

同一份元数据,三种存储形式,服务于不同的检索场景


二、YAML配置驱动

一切从一个YAML配置文件开始。选择YAML而非数据库录入,是因为:

  1. 版本可控:配置文件可以进Git,变更有记录
  2. 批量维护:DBA或数据开发可以一次性维护几十张表
  3. 环境隔离:开发/测试/生产可以用不同的配置

2.1 表配置

tables:
  - name: fact_order
    role: fact
    description: 订单事实表,记录订单数量和金额等核心指标。
    columns:
      - name: order_amount
        role: measure
        description: 订单金额。
        alias: [销售额, 订单金额, 收入]
        sync: false

      - name: region_id
        role: foreign_key
        description: 关联地区维度的外键。
        alias: [地区ID, 区域ID]
        sync: false

每个字段需要标注:

  • roleprimary_key/foreign_key/dimension/measure,决定了字段在SQL中的角色
  • alias:别名列表,"销售额"、"收入"都应该能匹配到order_amount
  • sync:是否同步取值到ES(枚举值字段如"地区"需要sync,ID类字段不需要)

2.2 指标配置

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

指标是独立于字段的业务概念层。relevant_columns建立了指标→字段的映射,让系统在召回指标时能自动关联到相关字段。

2.3 配置Schema

用Python dataclass定义配置结构,配合OmegaConf做校验:

@dataclass
class ColumnConfig:
    name: str
    role: str
    description: str
    alias: list[str]
    sync: bool  # 是否同步取值到ES

@dataclass
class TableConfig:
    name: str
    role: str  # "dim" 维度表 / "fact" 事实表
    description: str
    columns: list[ColumnConfig]

@dataclass
class MetaConfig:
    tables: Optional[list[TableConfig]] = None
    metrics: 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_amount
    points.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_namecategory)才需要:

- name: region_name
  sync: true   # 需要同步取值
- name: order_id
  sync: 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 幂等性保证

  • MySQLsession.begin() 事务,失败自动回滚
  • Qdrantupsert(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?

原因有三:

  1. 语义缺失order_amount DECIMAL(10,2) 不包含"这是销售额"的语义信息
  2. 别名缺失:数据库不会告诉你"收入"就是order_amount
  3. 业务指标不存在于Schema中:GMV、AOV是业务定义,不在DDL里
  4. 取值范围未知:LLM需要知道region_name的取值有"华东/华南/华北"才能正确生成WHERE条件

元知识库本质上是给数据库加了一层业务语义层,弥合"机器Schema"和"人类语言"之间的鸿沟。


十一、总结

这套元知识库构建方案的核心设计:

  1. YAML配置驱动:版本可控、批量维护、环境隔离
  2. 一份数据三种存储:MySQL做结构化查询、Qdrant做语义检索、ES做精确匹配
  3. 多维度Embedding:字段名+描述+别名分别建向量,最大化召回覆盖
  4. sync标记分级:枚举字段全量同步,ID字段跳过,控制索引规模
  5. 指标-字段关联:召回指标时自动带出相关字段,减少信息缺失

对于任何需要"让AI理解你的数据"的场景——NL2SQL、RAG、智能报表——这套"配置→多引擎索引"的范式都值得参考。

posted @ 2026-06-08 07:07  黄忠  阅读(58)  评论(0)    收藏  举报