Francisco Ingham 和 Jon Luo 是社区中推动 SQL 集成变革的两名核心成员。我们非常激动能与他们一同撰写这篇博文,分享他们在实践中积累的各种技巧和窍门。更让我们兴奋的是,我们将与他们一起举办一个为期一小时的网络研讨会,深入探讨这些学习心得并解答其他相关问题。本次网络研讨会将于 3 月 22 日举行,请通过以下链接报名。
LangChain 库提供了多种 SQL 链和 SQL 代理,旨在让与 SQL 数据交互变得尽可能容易。以下是一些相关链接:
引言
传统上,企业的大部分数据都存储在 SQL 数据库中。由于其中存储着大量有价值的数据,那些便于查询和理解这些数据的商业智能 (BI) 工具日益流行。但如果能够用自然语言与 SQL 数据库进行交互呢?借助当今的 LLM,这已成为可能。LLM 理解 SQL 并且能够相当好地编写 SQL。然而,有几个问题使得这项任务并非易事。
问题所在
所以 LLM 能编写 SQL,还需要什么呢?
不幸的是,还有一些东西。
主要的问题是“幻觉”。LLM 可以编写 SQL,但它们常常倾向于编造表、编造字段,或者编写执行后在数据库中实际上无效的 SQL。因此,我们面临的一大挑战是如何让 LLM 遵循事实,从而生成有效的 SQL。
解决此问题的关键思路(稍后将详细介绍)是向 LLM 提供关于数据库实际存在的知识,并指示它编写与该知识一致的 SQL 查询。然而,这会引出第二个问题——上下文窗口长度。LLM 具有一定的上下文窗口限制,这限制了它们可以处理的文本量。这一点之所以重要,是因为 SQL 数据库通常包含大量信息。因此,如果我们想当然地将所有数据都用于让 LLM 遵循事实,很可能会遇到这个问题。
第三个问题比较基础:有时 LLM 就是会出错。它编写的 SQL 可能出于任何原因而错误,或者可能是正确的,但返回的结果出乎意料。这时我们该怎么办?放弃吗?
(高层)解决方案
在思考如何解决这些问题时,可以借鉴我们人类解决这些问题的方式。如果能复制我们解决问题时的步骤,我们也能帮助 LLM 做到这一点。所以,让我们思考一下数据分析师在被要求回答 BI 问题时会怎么做。
当数据分析师查询 SQL 数据库时,他们通常会做一些事情来帮助他们编写正确的查询。例如,他们通常会事先进行一个抽样查询,以了解数据的样子。他们可以查看表的结构,甚至是一些行。这可以被认为是数据分析师了解了数据的样子,以便他们将来编写 SQL 查询时能够基于实际存在的数据进行。数据分析师通常也不会一次性查看所有数据(或数千行)——他们可能会将任何探索性查询限制在前 K 行,或者查看汇总统计数据。这可以为如何绕过上下文窗口限制提供一些提示。最后,如果数据分析师遇到错误,他们不会轻易放弃——他们会从错误中吸取教训,然后编写新的查询。
我们在下面的各个部分中分别讨论这些解决方案。
描述您的数据库
为了给 LLM 提供足够的信息,以便它能够为给定的数据库生成合理的查询,我们需要在提示中有效地描述数据库。这可以包括描述表结构、数据示例,甚至优秀数据库查询的示例。下面的示例来自 Chinook 数据库。
描述模式(Schema)
在旧版本的 LangChain 中,我们只提供表名、列名及其类型。
Table 'Track' has columns: TrackId (INTEGER), Name (NVARCHAR(200)), AlbumId (INTEGER), MediaTypeId (INTEGER), GenreId (INTEGER), Composer (NVARCHAR(220)), Milliseconds (INTEGER), Bytes (INTEGER), UnitPrice (NUMERIC(10, 2))Rajkumar 等人进行了一项研究,评估了 OpenAI Codex 在各种不同提示结构下的文本到 SQL 性能。他们发现,当使用 `CREATE TABLE` 命令提示 Codex 时,性能最佳,这些命令包含了列名、数据类型、列引用和键。对于 `Track` 表,这看起来是这样的:
CREATE TABLE "Track" (
"TrackId" INTEGER NOT NULL,
"Name" NVARCHAR(200) NOT NULL,
"AlbumId" INTEGER,
"MediaTypeId" INTEGER NOT NULL,
"GenreId" INTEGER,
"Composer" NVARCHAR(220),
"Milliseconds" INTEGER NOT NULL,
"Bytes" INTEGER,
"UnitPrice" NUMERIC(10, 2) NOT NULL,
PRIMARY KEY ("TrackId"),
FOREIGN KEY("MediaTypeId") REFERENCES "MediaType" ("MediaTypeId"),
FOREIGN KEY("GenreId") REFERENCES "Genre" ("GenreId"),
FOREIGN KEY("AlbumId") REFERENCES "Album" ("AlbumId")
)描述数据
通过额外提供数据示例,我们可以进一步提高 LLM 生成最优查询的能力。例如,如果我们正在 `Track` 表中搜索作曲家,那么了解 `Composer` 列是包含全名、缩写名称、两者都有,还是其他表示方式,将会非常有用。 Rajkumar 等人发现,在 `CREATE TABLE` 描述后提供 `SELECT` 语句中的示例行会带来一致的性能提升。有趣的是,他们发现提供 3 行数据是最优的,而提供更多数据库内容甚至会降低性能。
我们已经将他们论文中的最佳实践发现作为默认设置。总的来说,我们在提示中的数据库描述如下所示:
db = SQLDatabase.from_uri(
"sqlite:///../../../../notebooks/Chinook.db",
include_tables=['Track'], # including only one table for illustration
sample_rows_in_table_info=3
)
print(db.table_info)哪个输出
CREATE TABLE "Track" (
"TrackId" INTEGER NOT NULL,
"Name" NVARCHAR(200) NOT NULL,
"AlbumId" INTEGER,
"MediaTypeId" INTEGER NOT NULL,
"GenreId" INTEGER,
"Composer" NVARCHAR(220),
"Milliseconds" INTEGER NOT NULL,
"Bytes" INTEGER,
"UnitPrice" NUMERIC(10, 2) NOT NULL,
PRIMARY KEY ("TrackId"),
FOREIGN KEY("MediaTypeId") REFERENCES "MediaType" ("MediaTypeId"),
FOREIGN KEY("GenreId") REFERENCES "Genre" ("GenreId"),
FOREIGN KEY("AlbumId") REFERENCES "Album" ("AlbumId")
)
SELECT * FROM 'Track' LIMIT 3;
TrackId Name AlbumId MediaTypeId GenreId Composer Milliseconds Bytes UnitPrice
1 For Those About To Rock (We Salute You) 1 1 1 Angus Young, Malcolm Young, Brian Johnson 343719 11170334 0.99
2 Balls to the Wall 2 2 1 None 342562 5510424 0.99
3 Fast As a Shark 3 2 1 F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman 230619 3990994 0.99使用自定义表信息
虽然 LangChain 会方便地自动组装模式和样本行描述,但在某些情况下,最好用手工编写的描述覆盖自动信息。例如,如果您知道表中前几行数据没有信息量,最好手动提供示例行,为 LLM 提供更多信息。例如,在 `Track` 表中,有时多个作曲家是用斜杠而不是逗号分隔的。这种情况最早出现在该表的第 111 行,远远超出了我们 3 行的限制。我们可以提供这些自定义信息,以便示例行包含这些新信息。 这里有一个实际应用中如何做到这一点的例子。
也可以使用自定义描述来限制 LLM 可见的表列。下面是一个将这两种用法应用于 `Track` 表的示例:
CREATE TABLE "Track" (
"TrackId" INTEGER NOT NULL,
"Name" NVARCHAR(200) NOT NULL,
"Composer" NVARCHAR(220),
PRIMARY KEY ("TrackId"),
)
SELECT * FROM 'Track' LIMIT 4;
TrackId Name Composer
1 For Those About To Rock (We Salute You) Angus Young, Malcolm Young, Brian Johnson
2 Balls to the Wall None
3 Fast As a Shark F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman
4 Money Berry Gordy, Jr./Janie Bradford如果您有不希望发送到 API 的敏感数据,可以使用此功能提供模拟数据而不是实际数据库。
限制输出大小
当我们在链或代理中用 LLM 进行查询时,查询结果将被用作另一个 LLM 的输入。如果查询结果太大,将会超出我们模型的输入大小限制。因此,明智地限制查询输出的大小通常是一个好习惯。我们可以通过指示 LLM 使用尽可能少的列并限制返回的行数来实现这一点。
正如我们在下面的示例中所看到的,如果我们要求列出每个国家的总销售额而没有指定国家数量,查询将被限制为 10。您可以使用 `top_k` 参数来管理此限制。
agent_executor.run("List the total sales per country. Which country's customers spent the most?")>>
…
Action Input: SELECT c.Country, SUM(i.Total) AS TotalSales FROM Invoice i INNER JOIN Customer c ON i.CustomerId = c.CustomerId GROUP BY c.Country ORDER BY TotalSales DESC LIMIT 10
Observation: [('USA', 523.0600000000003), ('Canada', 303.9599999999999), ('France', 195.09999999999994), ('Brazil', 190.09999999999997), ('Germany', 156.48), ('United Kingdom', 112.85999999999999), ('Czech Republic', 90.24000000000001), ('Portugal', 77.23999999999998), ('India', 75.25999999999999), ('Chile', 46.62)]
…
检查语法
如果 LLM 生成的查询在语法上是错误的,那么在运行我们的链或代理时会收到一个 traceback。如果我们希望将其用于生产目的,这将非常成问题。我们如何帮助 LLM 纠正查询呢?我们可以完全复制我们自己犯错时的做法。我们将原始查询和 traceback 日志发送给 LLM,并要求它通过准确理解问题所在来纠正查询。这个概念的灵感来源于这篇博文,您可以在其中找到更详细的解释。
在下面的文档示例中,您可以看到模型试图查询一个不存在的列,当它发现查询错误时,会立即使用 `query_checker_sql_db` 工具进行纠正。
Observation: Error: (sqlite3.OperationalError) no such column: Track.ArtistId
[SQL: SELECT Artist.Name, SUM(InvoiceLine.Quantity) AS TotalQuantity FROM Artist INNER JOIN Track ON Artist.ArtistId = Track.ArtistId INNER JOIN InvoiceLine ON Track.TrackId = InvoiceLine.TrackId GROUP BY Artist.Name ORDER BY TotalQuantity DESC LIMIT 3]
(关于此错误的背景信息:https://sqlalche.me/e/14/e3q8)
想法:在执行查询之前,我应该仔细检查一下。
行动:query_checker_sql_db
行动输入:SELECT Artist.Name, SUM(InvoiceLine.Quantity) AS TotalQuantity FROM Artist INNER JOIN Track ON Artist.ArtistId = Track.ArtistId INNER JOIN InvoiceLine ON Track.TrackId = InvoiceLine.TrackId GROUP BY Artist.Name ORDER BY TotalQuantity DESC LIMIT 3
Observation
SELECT Artist.Name, SUM(InvoiceLine.Quantity) AS TotalQuantity
FROM Artist
INNER JOIN Track ON Artist.ArtistId = Track.ArtistId
INNER JOIN InvoiceLine ON Track.TrackId = InvoiceLine.TrackId
GROUP BY Artist.Name
ORDER BY TotalQuantity DESC
LIMIT 3;
想法:我现在知道最终答案了。
行动:query_sql_db
行动输入:SELECT Artist.Name, SUM(InvoiceLine.Quantity) AS TotalQuantity FROM Artist INNER JOIN Album ON Artist.ArtistId = Album.ArtistId INNER JOIN Track ON Album.AlbumId = Track.AlbumId INNER JOIN InvoiceLine ON Track.TrackId = InvoiceLine.TrackId GROUP BY Artist.Name ORDER BY TotalQuantity DESC LIMIT 3
未来工作
正如您所知,这个领域发展迅速,我们正共同探索实现最佳 LLM-SQL 交互的最佳方法。以下是未来的待办事项:
少样本示例(Few-shot examples)
Rajkumar 等人还发现,在基准测试中,通过少样本学习(在提示中附加问题-查询示例,参见图 2),Codex 的 SQL 生成准确率得到了提高。
使用子查询
一些用户发现,让代理将问题分解成多个子查询,并附带关于每个子查询作用的注释,有助于代理得出正确答案。通过子查询进行思考会迫使代理按逻辑步骤思考,从而降低在查询中犯结构性错误的概率。这类似于在非 SQL 问题中向提示添加 CoT(Chain-of-Thought)类型的短语,如“分步思考这个问题”。
如果您想帮助实现其中任何一项,或者有其他您觉得有用的最佳实践,请在 Discord 的 #sql 频道讨论区分享您的想法,或者直接尝试提交 PR!