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_infos 和 metric_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
新增测试覆盖:
- 补全已选表和字段元数据
- 补入指标
relevant_columns - 保留值召回结果与召回证据
- 自动补入 JOIN 两端键
- 在两张维度表之间加入事实桥接表
- 拒绝 meta 库中不存在的字段
- 拒绝无法连接的多表上下文
第二部分: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 猜测。
本文小结
本文完成了:
- 从 meta MySQL 批量读取表、字段和已选指标
- 以 meta 库为准补全过滤结果
- 自动补入指标依赖字段
- 根据当前元数据约定推断主外键关系
- 使用 BFS 寻找多表最短连接路径
- 必要时自动加入桥接表和 JOIN 键
- 补充当前日期、时区和数据库运行信息
- 生成下一节点可以直接消费的
sql_context
下一篇将把 sql_context 注入 SQL 生成 Prompt,通过 LangChain LCEL 管道生成符合 MySQL 方言的查询语句。