来源:https://mehrabr.com/writing/2026/06/20/text-to-sql-is-a-solved-demo/
Text-to-SQL 是一个已经解决的演示问题
作者: Mehrab Rahman
发布日期: 2026年6月20日
阅读时间: 6分钟
演示总是能成功。你询问按地区划分的收入,它写出连接查询,数字返回,每个人都点头。然后你把它指向一个真实的数据库,问一些数据里没有的东西,它仍然用同样的语气、同样的自信回答了。我第一次针对真实模式运行它时,它给了我一个客户的电子邮件地址。数据库里根本没有电子邮件列。
数字是错的,但它听起来和正确的数字一模一样。这就是整个问题所在。
I.
第一个修复方案很便宜,是你在花真金白银之前就会做的事情:运行查询,让数据库来报错。如果它解析失败,或者引用了一个不存在的列,DuckDB 会准确地告诉你问题出在哪里,然后你直接把错误信息传回给模型。
Binder Error: Referenced column "l_ship_mode" not found in FROM clause!
Candidate bindings: "l_shipmode", "l_extendedprice"
大多数错误在这里就自然消亡了,无需花费第二次模型调用的费用。阶梯的其余部分成本更高,而你只会在廉价检查通过但你仍然不信任答案时才会向上攀登------对结果进行合理性检查,然后进行一次调用,将 SQL 翻译回英语,以核对它是否与用户实际提出的问题相符,然后,在情况已经不稳定时,进行几次独立的运行并相互比较结果。攀登多远基本上是一个预算问题。
II.
以上这些都触及不到那个不存在的列。
当然,你可以捕获错误------查询无法运行------但一个只会重试的模型会一直虚构列,直到其中一个碰巧能解析通过,然后你就会得到一个自信满满、可以正常执行但却是错误的答案。下游的另一个检查也无法救你。代理本身必须审视这个问题,并决定这里没有东西可以回答。
yaml
- id: u_customer_email
question: 客户 1 的电子邮件地址是什么?
kind: unanswerable
没有可以绑定的列。正确的输出就是不输出。拒绝必须是代理有意为之的行为。
III.
然后还有相反的问题------有多个正确答案的问题。
询问前五名客户,但你遗漏了最关键的部分:按什么排名?终身消费额、账户余额、订单数量------每一个都是完全合理的解读,每一个都会返回不同的五个人。代理必须选择一个方向,并为它写出正确的 SQL。
yaml
- id: a_top_customers
question: 谁是前五名客户?
kind: ambiguous
acceptable_sql:
- "... ORDER BY sum(o_totalprice) DESC ..."
- "... ORDER BY c_acctbal DESC ..."
- "... ORDER BY count(*) DESC ..."
因为代理选择了消费额而不是余额就判定它错误,那是在评判问题的模糊性,而不是代理本身。所以,任何站得住脚的解读都应算对。衡量的应该是它是否找到了一个合理的解释,而不是它是否读懂了我的心思。
IV.
一旦你开始给所有这些打分,"放弃"实际上变成了两件完全不同的事情,却披着同一张面孔:一个正确拒绝了不可能问题的代理,和一个本应回答却搞砸了的代理。从外部看它们一模一样------同样两手空空。
所以你把它们分开评分。
python
if q.kind == "unanswerable":
return res.gave_up
对于一个无法回答的问题,放弃是正确的答案,而运行出一个查询则是失败。度量标准一分为二。拒绝召回率 :在所有没有答案的问题中,它拒绝了多少。拒绝精确率:在所有它拒绝的事情中,有多少是正确的判断------精确率低意味着它在本可以完成的工作上退缩了。一次放弃只有当你能证明它知道自己在看什么类型的问题时,才算是值得的。
对于可回答的问题,正确意味着返回的行匹配,而不是 SQL 匹配。两个查询可能看起来完全不同,但返回相同的答案,所以你把结果作为多重集合进行比较,并对浮点数留有一些容忍度。如果你对 SQL 字符串进行评分,你会因为代理写查询的方式和你不同而惩罚它。
V.
当代理不确定时,显而易见的做法是再问它一次,看看答案是否一致。抽取样本是容易的部分。问题在于答案太容易一致了。
想象一下目击者。三个独立描述同一辆车的人告诉了你一些信息。三个先聚在一起对过笔记的人则什么也没告诉你,无论他们的描述多么完美一致------他们只是互相说服,编造了一个故事。语言模型默认是第二种。让一个模型看到自己之前的草稿,或者让第二个模型看到第一个写了什么,它们就会趋向一致,因为达成一致是它们擅长的事。答案并没有变得更好;它们只是协调了。所以你应该在无共享上下文的情况下盲目抽取样本,然后根据它们是否返回相同的行来进行聚类。只有当达成一致的那些东西从未有机会协调时,这种一致才有效。
VI.
测试 Text-to-SQL 的标准方法是使用 TPC-H------一个固定的模式和每个人都会用来基准测试的二十二个规范查询。
这二十二个查询中的每一个都已经出现在每个值得使用的模型的训练数据中,而且是公开的,多次出现。对着它们打分,你测试的是模型能否重现查询 14,而不是它是否足够理解模式以写出它。一个基准测试在它出名之前衡量的是能力,在那之后,它衡量的就是记忆。
所以我写了自己的测试------针对相同的模式设计了六十四个问题,没有一个是用规范问题。修复从来都不在于评分器。关键在于准备一些模型不可能已经见过答案的问题。
VII.
仓库报告显示 98.4% 的准确率。
"所以它有效。" 这 98.4% 是测试工具在针对一个返回已知正确 SQL 的模拟对象进行自我评分------它告诉你尺子是直的,而不是说任何一个模型表现良好。唯一的一次失败是我故意在一个可回答问题上设置的一次放弃,目的是让拒绝精确率降到 8/9,以证明这个度量标准确实能区分好的拒绝和坏的拒绝。
我发布了这个不那么光彩的数字,并附带了所有说明,因为这个测试工具是我构建的东西,而这个分数只是测试工具在检查自己的工作。真正的数字来自于将它指向一个真实的模型。它们会更低。而它们最低的那些类别,才是全部的意义所在。
VIII.
编写一个能吐出 SQL 的代理用了一个下午。那之后的一切------阶梯、拒绝、区分好坏拒绝的评分、选择那些模型无法记住答案的问题------才是真正的工作,而所有这些都不是代理本身。它是那把尺子。
这些东西的默认失败模式是一个流畅的、错误的答案。值得构建的是那个能空手而归的、有目的性的、并且有理由那么做的系统------而这一点只有当你能证明它知道其中的区别时才有效。