LLMs and SQL

LLMs 和 SQL

8 分钟阅读

Francisco InghamJon 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 数据库。

描述模式

在 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 生成的查询在语法上是错误的,我们会发现在运行我们的链或代理时会得到回溯。如果我们想将其用于生产目的,这将是很有问题的。我们如何帮助 LLM 更正查询?我们可以完全复制如果我们自己犯了错误会做的事情。我们将原始查询与回溯日志一起发送给 LLM,并要求它通过准确理解哪里出错了来纠正它。这个概念的灵感来自 这篇博文,您可以在其中找到更详细的解释。

在以下文档示例中,您可以看到模型尝试查询一个不存在的列,当它发现查询错误时,它会立即使用 query_checker_sql_db 工具对其进行更正

观察:错误:(sqlite3.OperationalError) 没有名为 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
观察
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 交互的最佳方法。以下是未来的待办事项

少量示例

Rajkumar 等人 还发现,在少量学习的基准测试中,Codex 的 SQL 生成准确性有所提高,其中问题-查询示例被附加到提示中(参见图 2)。

使用子查询

一些用户发现,告诉代理将问题分解为多个子查询,包括对每个子查询作用的注释,有助于代理获得正确的答案。以子查询的方式思考迫使代理以逻辑步骤思考,从而降低查询中出现结构性错误的可能性。这类似于为非 sql 问题在提示中添加 CoT 类型短语,例如“逐步思考这个问题”。

如果您想帮助实施这些方法中的任何一种,或者有其他您认为有用的最佳实践,请在 Discord 的 #sql 频道中的讨论中分享您的想法,或直接尝试提交 PR!