12 | 组装 SQL 生成上下文

12 | 组装 SQL 生成上下文

项目地址:github.com/frontzhm/n2...

每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。

这是一篇系列文,请按顺序阅读。

本文目标

上一篇已经将召回结果过滤为最小必要集合:

text 复制代码
filter_table  → table_infos
filter_metric → metric_infos

但过滤结果还不是完整的 SQL 生成上下文,因为它可能缺少:

  • 表和字段的完整 MySQL 元数据
  • 已选指标依赖的字段
  • 多表 JOIN 所需的主键、外键和连接条件
  • 两张维度表之间需要经过的桥接事实表
  • 解释"今天、本月、今年"所需的当前日期和时区
  • SQL 方言、数据库名称和实际版本

本文实现流程:

text 复制代码
过滤后的表字段 + 过滤后的指标
                    ↓
       批量读取 meta MySQL 快照
                    ↓
      以 meta 库为准补全字段与指标
                    ↓
          补入指标 relevant_columns
                    ↓
    推断表关系并寻找最短 JOIN 路径
                    ↓
      补入桥接表和 JOIN 两端键
                    ↓
       补充当前日期和数据库信息
                    ↓
               sql_context

先看输入和输出

1. 输入:两个并行过滤节点的结果

add_extra_context 从 State 读取:

python 复制代码
{
    "query": "北京今年销售额是多少?",
    "table_infos": [
        {
            "id": "fact_order",
            "columns": [
                {
                    "id": "fact_order.order_amount",
                    "candidate_sources": ["column_recall"],
                    "recall_score": 0.89,
                    "matched_queries": ["销售额", "订单金额"],
                    "matched_values": [],
                }
            ],
        },
        {
            "id": "dim_region",
            "columns": [
                {
                    "id": "dim_region.province",
                    "candidate_sources": ["value_recall"],
                    "matched_values": [
                        {
                            "id": "dim_region.province.北京市",
                            "value": "北京市",
                            "score": 18.58,
                            "matched_queries": ["北京", "北京市"],
                        }
                    ],
                }
            ],
        },
    ],
    "metric_infos": [
        {
            "id": "GMV",
            "recall_score": 0.91,
            "matched_queries": ["销售额", "GMV"],
        }
    ],
}

这些对象保留了召回证据,但字段元数据可能不完整,不能直接当作最终 Schema。

2. 输出:自包含的 SQL 生成上下文

add_extra_context 最终写入:

python 复制代码
{
    "sql_context": {
        "query": "北京今年销售额是多少?",
        "tables": [
            {
                "id": "fact_order",
                "name": "fact_order",
                "role": "fact",
                "description": "订单事实表",
                "context_sources": ["table_filter"],
                "columns": [
                    {
                        "id": "fact_order.order_amount",
                        "name": "order_amount",
                        "type": "decimal(18,2)",
                        "role": "measure",
                        "description": "订单金额",
                        "context_sources": [
                            "column_recall",
                            "table_filter",
                            "selected_metric",
                        ],
                        "matched_values": [],
                    },
                    {
                        "id": "fact_order.region_id",
                        "name": "region_id",
                        "role": "foreign_key",
                        "context_sources": ["join_relation"],
                    },
                ],
            },
            {
                "id": "dim_region",
                "name": "dim_region",
                "role": "dim",
                "description": "地区维度表",
                "context_sources": ["table_filter"],
                "columns": [
                    {
                        "id": "dim_region.province",
                        "name": "province",
                        "role": "dimension",
                        "context_sources": [
                            "value_recall",
                            "table_filter",
                        ],
                        "matched_values": [
                            {"value": "北京市"}
                        ],
                    },
                    {
                        "id": "dim_region.region_id",
                        "name": "region_id",
                        "role": "primary_key",
                        "context_sources": ["join_relation"],
                    },
                ],
            },
        ],
        "metrics": [
            {
                "id": "GMV",
                "name": "GMV",
                "description": "成交金额总和",
                "alias": ["成交额"],
                "relevant_columns": ["fact_order.order_amount"],
                "recall_score": 0.91,
                "matched_queries": ["销售额", "GMV"],
            }
        ],
        "relationships": [
            {
                "foreign_table_id": "fact_order",
                "foreign_column_id": "fact_order.region_id",
                "primary_table_id": "dim_region",
                "primary_column_id": "dim_region.region_id",
                "condition": "fact_order.region_id = dim_region.region_id",
                "inference": "matching foreign_key and primary_key names",
            }
        ],
        "date_info": {
            "current_datetime": "2026-07-31T10:30:00+08:00",
            "current_date": "2026-07-31",
            "year": 2026,
            "month": 7,
            "day": 31,
            "quarter": "Q3",
            "weekday": "Friday",
            "iso_weekday": 5,
            "timezone": "Asia/Shanghai",
        },
        "database_info": {
            "dialect": "mysql",
            "database": "dw",
            "version": "8.4.0",
        },
    }
}

为了兼容后续节点,State 同时写入:

text 复制代码
table_infos  → 补全后的 tables
metric_infos → 补全后的 metrics
date_info    → 当前日期与时区
db_info      → 数据库方言、名称和版本
sql_context  → 包含上述内容和表关系的自包含对象

第一部分:项目实现

1. 元数据以哪里为准

Qdrant 和 Elasticsearch 中的元数据是为检索准备的副本,可能只保存检索所需字段。

因此职责划分为:

text 复制代码
Qdrant / Elasticsearch
→ 负责找到候选 ID 和召回证据

meta MySQL
→ 负责提供最终的表、字段和指标元数据

dw MySQL
→ 负责提供真实数据库名称和版本

上下文构建时,候选对象只提供 ID 和召回证据,字段类型、角色、描述和指标关联以 meta MySQL 为准。

2. 增加批量读取仓储

新增:

text 复制代码
app/repositories/metadata_context_repository.py

MetadataContextRepository 使用一个 meta Session 读取:

python 复制代码
async with self.meta_database.session() as session:
    tables = await TableInfoRepository(session).list_all()
    columns = await ColumnInfoRepository(session).list_all()
    metrics = await MetricInfoRepository(session).get_by_ids(metric_ids)

对于当前的小型 Schema,每次构建上下文固定执行三次 meta 查询,而不是每张表、每个字段分别查询。

这一点很重要:

text 复制代码
错误:1 次查表 + N 次查字段 + M 次查指标

当前:1 次查全部表 + 1 次查全部字段 + 1 次批量查指标

数据库信息从真实数仓读取:

sql 复制代码
SELECT DATABASE() AS database_name, VERSION() AS version

不在 Prompt 中写死 MySQL 版本。

3. 实现 SQLContextService

新增:

text 复制代码
app/application/sql_context_service.py
3.1 收集过滤后的 ID 和召回证据
python 复制代码
selected = self._collect_selected_candidates(
    table_infos,
    metric_infos,
)

该步骤收集:

text 复制代码
table_ids
column_ids
metric_ids
column_evidence
metric_evidence

召回证据不用来替代 meta MySQL,但会继续保留:

text 复制代码
recall_score
matched_queries
candidate_sources
matched_values
3.2 并发读取 meta 快照和数据库信息
python 复制代码
snapshot, database_info = await asyncio.gather(
    repository.load_snapshot(selected["metric_ids"]),
    repository.get_database_info(),
)

meta 库和 dw 库使用不同连接池,两路读取可以并发执行。

3.3 拒绝已经失效的 ID

过滤后的 ID 必须在 meta MySQL 中真实存在:

python 复制代码
self._require_ids("表", selected["table_ids"], table_lookup)
self._require_ids("字段", selected["column_ids"], column_lookup)
self._require_ids("指标", selected["metric_ids"], metric_lookup)

如果召回索引与 meta 库不一致,节点会立即失败:

text 复制代码
meta 库缺少字段:['fact_order.not_exists']

不允许 LLM 继续使用已删除或不存在的 Schema。

4. 补入指标依赖字段

已选指标以 meta 库中的 relevant_columns 为准:

python 复制代码
for metric_id in selected["metric_ids"]:
    metric = metric_lookup[metric_id]
    for column_id in metric.relevant_columns:
        append_unique(column_ids, column_id)

如果过滤表字段时没有选中 fact_order.order_amount,但指标过滤选中了 GMV,该字段仍然会被补入。

它的来源记录为:

python 复制代码
"context_sources": ["selected_metric"]

5. 推断表关系

当前 meta 模型没有独立的 table_relation 表,但字段已经标记:

text 复制代码
primary_key
foreign_key

因此当前项目使用约定:

text 复制代码
外键字段名 = 另一张表的主键字段名
→ 建立表关系

例如:

text 复制代码
fact_order.region_id      role=foreign_key
dim_region.region_id      role=primary_key

推断出:

sql 复制代码
fact_order.region_id = dim_region.region_id

输出关系同时标记推断方式:

python 复制代码
{
    "condition": "fact_order.region_id = dim_region.region_id",
    "inference": "matching foreign_key and primary_key names",
}

这是当前教学项目的约定,不是通用数据库关系推断规则。后续遇到复合键、字段异名或多条候选关系时,应建立显式的表关系元数据。

6. 为什么需要桥接表

假设用户问:

text 复制代码
北京的女性客户有哪些?

过滤后可能只有:

text 复制代码
dim_region.province
dim_customer.gender

两张维度表不能直接 JOIN,但 Schema 图中存在:

text 复制代码
dim_region
    ↑ region_id
fact_order
    ↓ customer_id
dim_customer

因此需要自动补入 fact_order 作为桥接表。

桥接表不会带入所有字段,只带入:

text 复制代码
fact_order.region_id
fact_order.customer_id

表来源标记为:

python 复制代码
"context_sources": ["join_bridge"]

7. 在 Schema 图中寻找连接路径

项目将每张表视为图的节点,将主外键关系视为边:

text 复制代码
表 = Graph Node
主外键关系 = Graph Edge

_shortest_path() 使用广度优先搜索(BFS)查找表之间的最短路径。

对多张必需表,_connect_tables() 逐步把尚未连接的表接入已连接子图。

如果两张表无法根据当前元数据连接,直接报错:

text 复制代码
无法根据现有主外键元数据连接表:orphan

这比把无关表交给 LLM 后生成笛卡尔积更安全。

8. 补全 JOIN 键但不扩大业务字段

最短路径确定后,将每条关系的两端字段加入上下文:

python 复制代码
for relation in selected_relations:
    add(relation.foreign_column_id)
    add(relation.primary_column_id)

补入的字段标记:

python 复制代码
"context_sources": ["join_relation"]

这样 LLM 能看到真实 JOIN 键,但不会因为添加一张表就收到整张表的全部字段。

9. 添加日期与时区

配置:

yaml 复制代码
sql_context:
  timezone: Asia/Shanghai

不建议只传递:

text 复制代码
2026-07-31

而是同时传递:

python 复制代码
{
    "current_datetime": "2026-07-31T10:30:00+08:00",
    "current_date": "2026-07-31",
    "year": 2026,
    "month": 7,
    "day": 31,
    "quarter": "Q3",
    "weekday": "Friday",
    "iso_weekday": 5,
    "timezone": "Asia/Shanghai",
}

这样后续 Prompt 可以解释:

text 复制代码
今天
本周
本月
今年
上个季度

时区必须显式配置,不要默认依赖服务器所在时区。

10. 实现 add_extra_context 节点

python 复制代码
async def add_extra_context(state, runtime):
    service = runtime.context["sql_context"].service
    sql_context = await service.build(
        query=state.get("query", ""),
        table_infos=state.get("table_infos", []),
        metric_infos=state.get("metric_infos", []),
    )
    return {
        "table_infos": sql_context["tables"],
        "metric_infos": sql_context["metrics"],
        "date_info": sql_context["date_info"],
        "db_info": sql_context["database_info"],
        "sql_context": sql_context,
    }

该节点覆盖 table_infosmetric_infos,把过滤阶段的候选副本替换为 meta MySQL 补全后的完整对象。

11. 注入数据库与上下文服务

FastAPI lifespan 中新增两个进程级数据库对象:

python 复制代码
meta_database = MySQLDatabase(...)
dw_database = MySQLDatabase(...)

然后创建:

python 复制代码
sql_context_dependencies = SQLContextDependencies(
    service=SQLContextService(
        repository=MetadataContextRepository(
            meta_database=meta_database,
            dw_database=dw_database,
        ),
        timezone_name=app_config.sql_context.timezone,
    )
)

注入 RuntimeContext:

python 复制代码
context = {
    "recall": recall_dependencies,
    "filtering": filter_dependencies,
    "sql_context": sql_context_dependencies,
}

应用停止时释放两个连接池:

python 复制代码
await asyncio.gather(
    ...,
    meta_database.close(),
    dw_database.close(),
)

12. SSE 观测事件

上下文构建完成后推送:

json 复制代码
{
  "type": "context",
  "table_count": 2,
  "metric_count": 1,
  "relationship_count": 1,
  "data": {}
}

可以通过该事件检查:

  • 最终给 LLM 了哪些表和字段
  • 哪些字段是因为指标被补入
  • 哪些表是 JOIN 桥接表
  • 最终采用了哪些连接条件
  • 当前业务时区和数据库版本

13. 运行测试

shell 复制代码
uv run python -m unittest discover -s tests -p 'test_*.py' -v

当前:

text 复制代码
Ran 22 tests

OK

新增测试覆盖:

  1. 补全已选表和字段元数据
  2. 补入指标 relevant_columns
  3. 保留值召回结果与召回证据
  4. 自动补入 JOIN 两端键
  5. 在两张维度表之间加入事实桥接表
  6. 拒绝 meta 库中不存在的字段
  7. 拒绝无法连接的多表上下文

第二部分:SQL 上下文科普

本部分只讲通用概念,不依赖当前项目的文件结构。

1. 什么是 Context Engineering

Context Engineering 可以理解为:为模型准备足以完成当前任务、但又不过量的上下文。

对 Text-to-SQL 来说,关键上下文通常包括:

text 复制代码
用户问题
Schema
表关系
指标口径
真实维度值
当前日期
数据库方言

上下文不是越多越好。无关表和字段会:

  • 增加 Token 消耗
  • 增加选错字段的概率
  • 让模型生成多余 JOIN
  • 提高延迟

因此正确目标是"最小但足够"。

2. 什么是 Schema Linking

Schema Linking 是将用户语言中的概念与数据库 Schema 元素建立关联。

text 复制代码
"销售额"
→ fact_order.order_amount

"北京"
→ dim_region.province = '北京市'

"GMV"
→ metric GMV
→ fact_order.order_amount

召回、过滤和上下文补全共同完成 Schema Linking。

3. 主键和外键

主键 Primary Key 用于唯一标识表中一行数据:

text 复制代码
dim_region.region_id

外键 Foreign Key 用于引用另一张表的主键:

text 复制代码
fact_order.region_id

典型 JOIN:

sql 复制代码
JOIN dim_region
  ON fact_order.region_id = dim_region.region_id

在数据仓库中,物理数据库不一定真正创建外键约束,但逻辑关系仍然应在元数据中维护。

4. 什么是桥接表

当两张表没有直接关系时,可能需要经过第三张表连接:

text 复制代码
dim_customer
      ↑
  fact_order
      ↓
dim_region

fact_order 就是这个查询中的桥接表。

桥接表应只携带连接所需字段,不应该因为成为桥接表就把所有字段加入 Prompt。

5. 什么是广度优先搜索

广度优先搜索 Breadth-First Search,简称 BFS。

它从起点开始,先访问所有距离为 1 的节点,再访问距离为 2 的节点。

对无权图来说,BFS 第一次到达目标时得到的就是边数最少的路径。

在 Schema 图中:

text 复制代码
节点 → 表
边   → 表关系
最短路径 → 需要的 JOIN 数量尽量少

6. BFS 是否一定能找到正确 JOIN

不一定。

BFS 只能找到"边数最少"的路径,不能自动理解业务语义。

如果同两张表之间存在多条路径,还需要:

  • 显式表关系元数据
  • 关系类型和业务描述
  • 路径优先级
  • 事实表粒度
  • 是否会造成重复计数

当前实现适用于关系简单、命名一致的星型 Schema。

7. 什么是数据库方言

SQL 有通用标准,但不同数据库存在语法差异:

text 复制代码
MySQL
PostgreSQL
SQL Server
Oracle
ClickHouse

差异可能包括:

  • 日期函数
  • 字符串函数
  • 分页语法
  • 标识符引号
  • JSON 函数
  • 窗口函数支持程度

因此不能只告诉模型"生成 SQL",还要提供真实 dialect 和 version。

8. 为什么要显式传递时区

"今天"是相对时间,它依赖时区。

同一个 UTC 时刻,在上海可能已经是第二天,在洛杉矶仍然是前一天。

显式传递:

text 复制代码
current_datetime
current_date
timezone

可以避免模型使用训练时间、服务器默认时区或其他不确定时间。

9. 什么是 N+1 查询问题

N+1 查询指:

text 复制代码
先执行 1 次查询得到 N 个对象
然后为每个对象再执行 1 次查询
最终执行 1 + N 次

当表和字段增多时,N+1 会显著增加延迟和数据库压力。

常见解决方式:

  • WHERE id IN (...) 批量查询
  • JOIN 或预加载
  • 一次读取元数据快照
  • 对低频变更元数据增加缓存

当前项目使用固定次数的批量查询,后续 Schema 规模变大时可以增加版本化元数据缓存。

10. 为什么无法连接时要失败

如果两张表没有已知关系,却仍然把它们交给 SQL 生成模型,模型可能:

  • 编造 JOIN 条件
  • 使用同名但不相关的字段
  • 生成 CROSS JOIN
  • 产生数据量爆炸的笛卡尔积

因此上下文阶段应该及时失败,并把问题定位为元数据不完整,而不是让 LLM 猜测。

本文小结

本文完成了:

  1. 从 meta MySQL 批量读取表、字段和已选指标
  2. 以 meta 库为准补全过滤结果
  3. 自动补入指标依赖字段
  4. 根据当前元数据约定推断主外键关系
  5. 使用 BFS 寻找多表最短连接路径
  6. 必要时自动加入桥接表和 JOIN 键
  7. 补充当前日期、时区和数据库运行信息
  8. 生成下一节点可以直接消费的 sql_context

下一篇将把 sql_context 注入 SQL 生成 Prompt,通过 LangChain LCEL 管道生成符合 MySQL 方言的查询语句。

相关推荐
颜酱15 小时前
15 | 安全执行 SQL 并返回查询结果
人工智能
神经蛙199615 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
新芒15 小时前
海尔洗衣机智慧洗护:AI赋能洗烘护全面进化
人工智能
掘金酱15 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
颜酱15 小时前
14 | 验证并修正 LLM 生成的 SQL
人工智能·python
AI创界者15 小时前
AIGC进阶】Sulphur-2 视频生成大模型离线实战:文生视频/图生视频本地一键部署整合包解压即用与调优指南
人工智能·aigc·音视频
颜酱16 小时前
13 | 使用 LangChain 生成 SQL
人工智能·python·langchain
LDZKKJ16 小时前
OpenAI暂停GPT-6训练:AI行业从“竞速“到“刹车“的分水岭
人工智能·gpt·语言模型·chatgpt·transformer
四方云16 小时前
录音转文字完整技术原理(ASR自动语音识别)技术文档
人工智能·机器人·语音识别·外呼系统·销售成长·拓客
奥莱维16 小时前
酒店客房智能控制如何提升睡眠与入住体验
大数据·人工智能