版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。
一、用户一句话,库里五十七列
1.1 三个词,落在三个地方
运营同学在群里问了一句:「上月华东的毛利是多少。」
这句话里能当检索线索的词就三个:上月、华东、毛利。我把它对到一个 9 张表、57 列的销售分析库上(本机自建,脚本与日志见附表 A),得到的对应关系是这样:
| 用户说的词 | 库里怎么存 | 属于哪类信息 | 文本检索能不能直接对上 |
|---|---|---|---|
| 毛利 | fact_sales.gross_profit_amt,注释「毛利金额,单位分」 | 列名 + 注释 | 能,靠「毛利」二字 |
| 上月 | fact_sales.stat_month,取值为2026-09这样的字符串 | 列名 + 取值格式 | 只能命中「月」,还得先换算成具体月份 |
| 华东 | dim_region.region_name这一列里的一个取值 | 行数据 | 不能。表名、列名、注释里都没有「华东」 |
三行看完,问题的形状就出来了:三个词里只有一个能靠字面对上,另外两个一个藏在行取值里,一个藏在格式约定里。而「把 schema 拼成一段文本再检索」这种做法,只看得到字面。
1.2 出错的不是 SQL,是选表选列
Text2SQL(把自然语言问题翻译成 SQL 的那类系统)的默认叙事是「生成」:把表结构给模型,让它写 SQL。但真跑过一阵就会知道,语法错反而是最容易发现的一种,因为数据库会立刻拒绝执行。
难查的是另一种错:SQL 语法全对、能跑出结果,但表选错了、列选错了,数字是另一个东西。你盯着屏幕上的结果集,它长得完全正常。
所以第一步其实不是生成,是选——从几十张表、几百列里挑出这个问题真正要用的那几张表、那几列。选对了,生成几乎是顺带的;选错了,后面每一步都在给错误增加精度。
二、文本检索在这里为什么失效
把 schema(表结构:有哪些表、每张表有哪些列、列与列之间什么关系)拼成一段文字做向量检索,是第一反应。它在文档上有效,因为文档的语义本来就写在文字里。schema 不是。
2.1 失效点一:列名在自然语言里没有共现
共现,指两个词在同一段话里一起出现过。用户说「毛利」,列叫gross_profit_amt;说「上月」,列叫stat_month;说「华东」,压根没有这一列。这几组词在日常语料里从不一起出现,所以它们的向量也不会有任何关系。
| 用户说法 | 库内写法 | 需要靠什么才能对上 |
|---|---|---|
| 毛利 | gross_profit_amt(注释:毛利金额,单位分) | 语义 + 别名表 |
| 上月 | stat_month = '2026-09' | 时间换算规则 + 格式约定 |
| 华东 | dim_region.region_name的取值'华东' | 取值索引(把行数据也进检索) |
| 品类 | dim_category.category_name,在另一张表里 | 外键路径 |
这张表说明的是:需要的信息分散在「列名、行取值、外键路径」三处,而文本检索只看得见第一处的字面。
2.2 失效点二:必须一起出现的表,靠外键连
外键(foreign key)是一条声明:这张表某列的取值,必须来自另一张表的某一列。SQLite 官方文档的原话是,外键约束用来强制表之间的「存在」关系(enforce “exists” relationships between tables),并把这边的表叫子表、被引用的叫父表。
问题「各品类的退款金额排名」需要四张表。这四张里两张是维表(存「是什么」:商品、品类),两张是事实表(存「发生了什么」:订单、退款):
| 表 | 用户提到了吗 | 为什么必须进来 |
|---|---|---|
fact_refund | 提到「退款」 | 退款金额在这里 |
fact_sales | 没提 | 品类挂在商品上,商品挂在订单上 |
dim_product | 没提 | 桥接:product_code→category_code |
dim_category | 提到「品类」 | 品类名称在这里 |
按文本相似度排序,dim_product会排在最后,因为它的表名、列名和问题没有一个共同词。这不是巧合:桥接表的存在理由就是两边都没有共同词。如果它和两边都同名,建模的人根本不会把它单独拆出来。
2.3 失效点三:判别性信息在约束里,不在名字里
真正决定「选哪一列」的信息,大部分不是文字,是约束。SQLite 的 CREATE TABLE 文档把可加的约束列得很清楚:UNIQUE、NOT NULL、CHECK、FOREIGN KEY。PostgreSQL 文档还补了一句关键的:外键必须引用一个主键、唯一约束或唯一索引覆盖的列。
这句话的含义比它看起来重:「这两张表能连」这件事,是靠约束声明出来的,不是靠名字像。约束是结构,不是文本。
还有个更容易被忽略的细节:SQLite 文档写明 CHECK 约束(一条限定列取值必须满足某个条件的规则)只在写入时校验、读的时候不校验。这意味着你从数据里抽样看到的取值,未必是全部合法取值——枚举(某列的值被限定为有限的几个固定值,比如订单状态只能是三种之一)得从约束定义或字典表里拿,不能靠SELECT DISTINCT猜。
把这三节合起来,schema 里最容易在「拼成文本」这一步丢掉的东西有五类:主键、外键、枚举取值、单位、时间粒度。它们全是结构信息。
三、schema 是张图:两跳邻域怎么算
3.1 把表读成点和边
如果 schema 是图——表是点、外键是边——那「选表」就变成一个在图上游走的问题,而不是在文本里比相似度。
fact_sales(24 列,本库的枢纽) ├─ stat_month → dim_date (1 跳) ├─ product_code → dim_product → dim_category (1 跳 / 2 跳) ├─ region_code → dim_region → dim_region.parent_code(自引用,2 跳) ├─ channel_code → dim_channel (1 跳) ├─ emp_code → dim_employee → dim_region (1 跳 / 2 跳) └─ order_id ← fact_refund → dim_refund_reason(1 跳 / 2 跳)从fact_sales出发,一跳能碰到 6 张表,两跳能碰到 8 张——全库 9 张表里除自己以外全部可达(本机实测)。这提醒一件事:两跳不能无脑用,从事实表出发两跳几乎等于全库,必须配合裁剪。
关系怎么读出来?不靠人肉看 DDL,靠元数据。SQLite 里PRAGMA foreign_key_list(表名)返回这张表声明了哪些外键。这里有个坑值得记住:SQLite 的外键约束默认是关闭的,文档原文说 disabled by default,且必须每个连接单独开启PRAGMA foreign_keys = ON。
我在本机验证了这个状态:默认连接下PRAGMA foreign_keys的值是0,而我在父表只有'2026-09'的情况下,插了一条stat_month = '202609'的记录,照样插进去了。但PRAGMA foreign_key_list依然读得出那条外键声明。
**约束声明是元数据,跟它是否被强制无关。**这一点对选表很关键:你读的是声明,不是校验结果。所以图能建起来,哪怕这个库平时并不强制外键。
3.2 两跳邻域:一条能跑的 SQL
下面这条查询从fact_refund出发,一跳取它直接连的父表,两跳再顺着父表往外走一层。它不写死任何表名,全部从元数据里读:
WITHfkAS(SELECTm.nameASchild,fk."table"ASparentFROMsqlite_masterASm,pragma_foreign_key_list(m.name)ASfkWHEREm.type='table')SELECTDISTINCTparent,1AShopFROMfkWHEREchild='fact_refund'UNIONSELECTDISTINCTf2.parent,2FROMfkASf1JOINfkASf2ONf1.parent=f2.childWHEREf1.child='fact_refund'ORDERBYhop,parent本机输出是 7 行:一跳拿到fact_sales和dim_refund_reason,两跳拿到dim_channel、dim_date、dim_employee、dim_product、dim_region。也就是说,从一张退款表出发,两跳就摸到了整库大半。
这里要说明一句:pragma_foreign_key_list是 SQLite 特有的写法。PostgreSQL 里对应的是information_schema.table_constraints与key_column_usage,思路一样——先读出边,再在边上走。
3.3 实测:图解决「漏」,不解决「排序」
我拿 6 个中文问题,人工标注了每个问题最少需要哪几张表(1.1 那句「上月华东毛利」是其中之一),比了三种策略,k取该问题标注的表数(也就是排序后取前 k 个):
| 策略 | 候选集里覆盖到标注表 | top-k 命中 |
|---|---|---|
| A 纯文本:表名 + 列名 + 注释 | 13/15 | 12/15 |
| B 在 A 的种子上做外键两跳闭包 | 15/15 | 12/15 |
| C 同 B 的候选集,但打分时把维表取值算进去 | 15/15 | 13/15 |
三个读法:
第一,B 把「标注表是否进入候选集」从 13/15 提到了 15/15。这才是两跳邻域真正的贡献——保证不漏。1.1 那个问题里,纯文本的候选集只有fact_sales一张,dim_region根本不在里面,后面无论怎么排都排不出来。
第二,B 的 top-k 命中还是 12/15,一点没涨。候选集变全了,排名没变好。「不漏」和「排对」是两个问题,用同一个文本得分兼做两件事,必然有一头是瘸的。
第三,C 把维表取值也纳入打分,涨到 13/15。涨得有限,原因很实在:k取的是「标注表数」这个事后才知道的数字。真实系统里没人告诉你答案需要几张表,这恰恰是下面那条判据要解决的问题。
四、裁剪清单:该带哪些列
4.1 一张能照着做的表
选完表还要选列。下面这张表是我按「漏了会出什么错」倒推出来的,可以直接当 checklist 用:
| 信息类别 | 该带什么 | 为什么非它不可 | 漏了会怎样 |
|---|---|---|---|
| 主键 | 每张表的主键列名 | 唯一标识一行,join 与去重都靠它 | 一对多 join 之后行数翻倍,汇总偏大 |
| 外键 | 列名 + 指向哪张表的哪一列 | 唯一的连接线索 | 桥接表选不中,SQL 缺一张表 |
| 枚举取值 | CHECK 或字典表里的合法取值,中文含义一并带上 | 把「已支付」映射成PAID | 过滤条件写不出,或写成一个不存在的值 |
| 时间粒度 | 字段格式 + 是日/月/季 + 是否已汇总 | 「上月」要换成具体值 | 要么报类型错,要么静默命中 0 行 |
| 单位 | 金额是分还是元,比率是小数还是百分数 | 决定要不要除 100 | 结果差 100 倍,且看不出来 |
| 注释 | 一句话说明这列是什么 | 唯一能弥合命名差异的文本 | 模型只能靠猜列名 |
关于k的判据,我给一句能直接用的:先问「这条 SQL 至少要几张表」,再问「哪张表最像」。前者是数路径:目标表 → 外键 → 维表,路径上有几个点就是几张表。后者只能给你一个起点。把两跳邻域当成候选集的构造方式,k由路径长度决定,这比固定取 top-3 靠谱得多。
4.2 裁剪的粒度:57 列里带 3.8 列
本机这个库里fact_sales一张表 24 列,全库 57 列。6 个问题人工标注最少需要的列数分别是 4、5、4、5、2、3,平均 3.8 列。
把全库塞进提示词,等于把 57 列摊在一个只有 3.8 列需求的问题上;按问题裁剪,量级差着一个数量级。列越多,无关列对注意力的摊薄越明显——这不是玄学,是每多一列就多一份「看起来也能用」的干扰。
裁剪流程大致是三步,值得写成代码而不是靠提示词自觉:
⚠️代码待验证
defpick(question,graph,k):seeds=retrieve(question,graph,top_n=3)# 表名/列名/注释/取值一起检索cand=two_hop_closure(seeds,graph)# 外键闭包,只负责"不漏"cand=rank(cand,question,use_values=True)# 排序用取值与约束信息tables=cand[:k]# k 由"至少几张表"估出来return[prune_columns(t,question)fortintables]五、生成前的四个自检
前四章讲的是「怎么选」,这一章讲「选完怎么验」。这四个自检不需要模型参与,用 SQL 就能查。
| 自检项 | 具体查什么 | 不通过怎么办 |
|---|---|---|
| 列名对不对 | 每个用到的列,是否真在这张表里 | 回上一步补表,不要改列名硬凑 |
| 枚举映射上没上 | 问题里的中文说法,能否对上库里的码值 | 把映射表显式写进上下文 |
| 单位与粒度 | 金额单位、时间格式是否与字段一致 | 写进上下文,或改用视图统一 |
| 聚合口径 | 分母是订单数还是行数 | 找业务确认,别自己定 |
5.1 枚举:中文和码值之间没有字面桥梁
SELECTDISTINCTstatusFROMfact_salesORDERBY1;本机返回三个值:PAID、REFUND、UNPAID。而用户问的是「已支付和已退款订单分别有多少」。我算了一下,「已支付/已退款」这几个字与三个码值的字符二元组交集是空集——字面上一点线索都没有。
也就是说,如果上下文里只有列名和一句「订单状态,取值 PAID/UNPAID/REFUND」,模型得靠英文常识去猜PAID对应「已支付」。它大概率猜对,但这是运气,不是机制。把PAID=已支付、REFUND=已退款、UNPAID=未支付明写进去,这一步才从猜变成查。
5.2 粒度:同一个九月被切成两组
我在同一张表里放了两条记录,一条stat_month = '2026-09',一条stat_month = '202609',毛利都是 2345。按月份汇总之后:
stat_month | SUM(gross_profit_amt) |
|---|---|
'2026-09' | 2345 |
'202609' | 2345 |
同一个九月,两行。SQL 没错,语法没错,GROUP BY也没错——错在字段里有两种格式,而这个信息不在列名里,只在数据里。
金额同理:SUM(gross_profit_amt)出来是 4690。按分读是 46.9 元,按元读是 4690 元,差 100 倍。这两类错误有个共同特征:SQL 语法全对、结果全错,而且不会报任何错。所以它们必须在生成前就被写进上下文,而不是生成后再靠人眼发现。
5.3 聚合口径:客单价的分母
聚合口径,指汇总时分子和分母各算什么。这个定义不在表结构里,只在业务规则里。
「哪个渠道的客单价最高」这句话里,客单价 = 净额合计 ÷ 订单数。但fact_sales一行是一个订单行,一单多商品就是多行。用行数做分母,客单价会被系统性拉低,而且各渠道的商品组合不同,被拉低的幅度也不同——排名可能整个反过来。
口径不属于 schema,它属于业务定义。这类东西没法从表结构里推出来,只能问人。这也是第六章那条「什么时候不要用」的由来。
这份资料里的 schema 与建表练习:本文的迷你库和 6 个问题的标注我是自己搭的,配套资料包里有一份从零建库到写查询的练习清单,可以和本文的裁剪表对着做。放在资料包里,扫码即可获取:
六、什么时候不要用 Text2SQL
选表选列做得再好,也只解决「能选中」的问题,解决不了「这个问题本身没有唯一答案」。以下情形我建议直接停手,转人工确认:
| 情形 | 判断依据 | 该怎么做 |
|---|---|---|
| join 超过三张 | 数路径上的表,含两跳桥接 | 让人确认,或先建宽视图 |
| 需要窗口函数 | 排名、同比、累计、同一主体取最后一条 | 预置模板,别指望一次生成 |
| 口径有歧义 | 毛利含不含运费税、退款算不算冲减 | 先定口径,再写 SQL |
| 涉及行级权限 | 不同角色能看的数据范围不同 | 在视图或查询层拦,不交给模型 |
| 是写操作 | INSERT / UPDATE / DELETE | 生成端只读,写操作走审批 |
6.1 判据要提前定,不要事后补
这张表里的每一条,都是在问题进来之前就该定好的闸门。等 SQL 生成完、结果算出来了再判断「这个口径对不对」,成本高得多:你得先看出数字不对,而 5.2 那两个坑恰好就是看不出来的那种。
一个实用做法是给问题加一个前置分类:命中上表任一条,就走「先确认、再生成」的通道;没命中,才进自动生成。分类器不需要模型,规则就够。
6.2 停手不等于回到手工
「不要用」不等于「让人从头写 SQL」。更实际的做法是把选择权收窄:把歧义变成两三个选项让人点一下,把常见口径写成视图或指标定义,让生成环节只面对已经定义好的口径。生成的自由度降下来,可控性才上去。
七、把「选表选列」当成独立的检索问题
7.1 召回和排序要分开看
回到 3.3 那张实测表。三个策略的数字摆在一起,结论其实很清楚:
- 召回(该在候选集里的表在不在)靠图——两跳闭包把 13/15 提到 15/15;
- 排序(在候选集里能不能排进前 k)靠取值与约束——把维表取值纳入打分工,才从 12/15 走到 13/15。
这两件事塞进一个向量检索里做,一定有一头是瘸的。工程上更省事的做法是拆开:图负责「不漏」,取值与约束负责「排对」,最后按「至少几张表」定 k。
7.2 一个能落地的顺序
- 先建关系图。SQLite 用
pragma_foreign_key_list,PostgreSQL 用information_schema,MySQL 用KEY_COLUMN_USAGE。这一步不依赖任何模型。 - 把枚举和单位进索引。取值不是噪声,它是把用户的话映射到过滤条件的唯一桥梁。
- 用「至少几张表」定 k,再按问题裁剪列。别把全库塞进提示词。
- 生成后回读校验。列名、枚举、单位、粒度四项过一遍,再交付。
7.3 一句话收尾
文档检索的难点在「找到讲这件事的那一段」;表结构检索的难点在「找到必须一起出现的那几张表」。前者是文本问题,后者是图问题。把 schema 当文本处理,丢掉的恰好是它最值钱的那部分——约束、关系、取值。这也是为什么我建议把「选表选列」当成一个独立的检索问题来做,而不是生成步骤的附属动作。
这篇里的迷你库和图遍历脚本:9 张表、57 列、10 条外键的建表语句,以及两跳邻域那几条查询,我都放进了资料包,可以自己在本地跑一遍改改看。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| 事实 | 出处 | 本文位置 |
|---|---|---|
| 外键约束用于强制表之间的「存在」关系;子表/父表、子键/父键的定义 | SQLite 官方文档《SQLite Foreign Key Support》第 1 节,sqlite.org/foreignkeys.html | 2.2 |
外键约束默认关闭(disabled by default),需每个连接单独PRAGMA foreign_keys = ON | 同上,第 2 节「Enabling Foreign Key Support」 | 3.1 |
| CREATE TABLE 支持 UNIQUE、NOT NULL、CHECK、FOREIGN KEY 约束 | SQLite 官方文档《CREATE TABLE》,sqlite.org/lang_createtable.html | 2.3 |
| CHECK 约束只在写入时校验,查询时不校验 | 同上,第 3.7 节「CHECK constraints」 | 2.3 |
| 外键必须引用主键、唯一约束或唯一索引覆盖的列 | PostgreSQL 官方文档 18《5.5. Constraints》5.5.5 Foreign Keys,postgresql.org/docs/current/ddl-constraints.html | 2.3 |
| 主键定义了外键引用该表时的默认目标列 | 同上,5.5.4 Primary Keys | 2.3 |
9 张表 / 57 列 / 10 条外键边;fact_sales24 列,一跳 6 张表、两跳 8 张表 | 本机实测(脚本work/2026-10-22-10_表结构实测.py,日志同名.log) | 3.1 |
默认连接下PRAGMA foreign_keys值为 0,'202609'仍可插入父表只有'2026-09'的字段 | 本机实测(同上脚本[fk]段) | 3.1 |
| 两跳邻域查出 7 张表(一跳 2 张、两跳 5 张) | 本机实测(同上脚本[demo]段) | 3.2 |
| 三策略对照:候选集覆盖 13/15 → 15/15;top-k 命中 12/15、12/15、13/15 | 本机实测(同上脚本[s1]、[s2]段,6 个问题人工标注,k 取标注表数) | 3.3 |
| 全库 57 列,6 个问题标注列数 4/5/4/5/2/3,平均 3.8 | 本机实测(同上脚本[s3]段) | 4.2 |
status取值为 PAID/REFUND/UNPAID,与「已支付/已退款」的字符二元组交集为空集 | 本机实测(同上脚本[enum]段) | 5.1 |
同一月份'2026-09'与'202609'在GROUP BY后被分成两组,各 2345;SUM为 4690(分) | 本机实测(同上脚本[demo]段) | 5.2 |
| 运行环境:Python 3.13.14、sqlite3 3.53.1 | 本机实测(同上脚本运行环境) | 全文 |
| SQL 标准中存在对约束的规范定义,本文未引用具体年份版本 | 未核到可靠一手文本,待验证 | 2.3 |
附表 B:术语速查表
| 术语 | 一句话解释 | 在本文哪里用到 |
|---|---|---|
| schema | 一个库的表结构:有哪些表、每张表有哪些列、列之间什么关系 | 全文 |
| 主键 | 能唯一标识一行的那一列(或几列组合) | 4.1 |
| 外键 | 一条声明,说这列的值必须来自另一张表的某列 | 2.2 |
| 桥接表 | 只用来把两张表连起来的中间表,自身几乎没有业务字段 | 2.2 |
| 维表 / 事实表 | 维表存「是什么」(区域、商品、渠道),事实表存「发生了什么」(订单、退款) | 2.2 |
| 枚举取值 | 某列被限定为有限的几个值,如PAID/REFUND | 2.3 |
| 两跳邻域 | 从一张表出发,沿外键走两步能碰到的所有表 | 3.1 |
| 召回 | 该在候选集里的东西,有没有进候选集 | 3.3 |
| top-k 命中 | 候选集排序后取前 k 个,里面有几个是对的 | 3.3 |
| 聚合口径 | 汇总时分子分母各算什么,如客单价的分母是订单数还是行数 | 5.3 |
| 时间粒度 | 时间字段精确到日、月还是季,是否已经汇总过 | 4.1 |
写在最后:这篇用到的资料
写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:
- 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
- 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
- AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
- 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。