LLMs와 SQL
요약
본 글은 LLM을 활용하여 자연어 질문에 따라 SQL 데이터베이스와 상호작용하는 방법을 다룹니다. LLM이 SQL을 생성할 수 있지만, 환각(hallucination) 문제나 컨텍스트 창 길이 제한 등의 기술적 난제들이 존재합니다. 이러한 문제를 해결하기 위해 인간의 데이터 분석가 행동 패턴을 모방한 접근법이 필요함을 제시합니다.
핵심 포인트
- LLMs는 자연어 기반 SQL 쿼리 생성이 가능하지만, 환각 문제가 주요 도전 과제입니다.
- 데이터베이스 스키마와 실제 지식을 제공하여 LLM의 답변을 현실에 근거하도록 해야 합니다.
- 컨텍스트 창 길이 제한 때문에 모든 데이터를 한 번에 전달하기 어렵습니다.
- 인간 데이터 분석가의 탐색적 쿼리 방식을 모방하는 것이 해결책의 핵심입니다.
Francisco Ingham과 Jon Luo는 SQL 통합 분야의 변화를 이끌고 있는 커뮤니티 멤버 두 명입니다. 그들이 이 작업을 수행하면서 배운 모든 팁과 트릭을 소개하는 블로그 게시물을 작성하게 되어 매우 기쁩니다. 또한, 이러한 학습 내용을 논의하고 기타 관련 질문에 답변하기 위해 그들과 함께 한 시간 동안 웨비나를 진행할 것이라는 점을 발표하게 되어 더욱 기대됩니다. 이 웨비나는 3월 22일에 열리니 아래 링크에서 등록하세요:
The LangChain 라이브러리는 여러 SQL 체인과 심지어 데이터베이스에 저장된 데이터와의 상호 작용을 가능한 한 쉽게 만드는 것을 목표로 하는 SQL 에이전트를 갖추고 있습니다. 관련 링크는 다음과 같습니다:
서론
대부분의 기업 데이터는 전통적으로 SQL 데이터베이스에 저장됩니다. 그곳에 저장된 가치 있는 데이터의 양으로 인해, 존재하는 데이터를 쿼리하고 이해하기 쉽게 만드는 비즈니스 인텔리전스(BI) 도구들이 인기를 얻고 있습니다. 하지만 만약 자연어로만 SQL 데이터베이스와 상호 작용할 수 있다면 어떨까요? 오늘날 LLMs를 사용하면 그것이 가능합니다. LLMs는 SQL을 이해하며 이를 꽤 잘 작성할 수 있습니다. 하지만 이것을 사소하지 않은 작업으로 만드는 몇 가지 문제가 존재합니다.
문제점들
그렇다면 LLM이 SQL을 작성할 수 있다면 무엇이 더 필요할까요?
안타깝게도, 몇 가지가 더 필요합니다.
존재하는 주요 문제는 환각(hallucination)입니다. LLMs는 SQL을 작성할 수 있지만, 종종 테이블이나 필드를 지어내거나 일반적으로 데이터베이스에 실행했을 때 실제로 유효하지 않은 SQL을 작성하는 경향이 있습니다. 따라서 우리가 직면하는 큰 도전 과제 중 하나는 LLM을 현실에 근거하여(ground) 유효한 SQL을 생성하도록 하는 방법입니다.
이 문제를 해결하기 위한 주요 아이디어는 LLM에게 데이터베이스에 실제로 존재하는 지식(knowledge)을 제공하고, 그에 일관된 SQL 쿼리를 작성하도록 지시하는 것입니다. 하지만 여기에는 두 번째 문제가 발생합니다. 바로 컨텍스트 창 길이(context window length)입니다. LLM은 처리할 수 있는 텍스트 양에 제한을 두는 컨텍스트 창을 가지고 있습니다. 이는 SQL 데이터베이스가 종종 많은 정보를 포함하고 있다는 점에서 중요합니다. 따라서 현실에 근거하여 LLM에게 모든 데이터를 무작위로 전달한다면, 우리는 이 문제에 직면할 가능성이 높습니다.
세 번째 문제는 좀 더 기본적인 문제입니다. 때로는 LLM이 단순히 실수를 저지르기 때문입니다. 작성된 SQL이 어떤 이유로든 부정확할 수 있거나, 정확하더라도 예상치 못한 결과를 반환할 수도 있습니다. 이럴 때는 어떻게 해야 할까요? 포기해야 할까요?
(개괄적인) 해결책들
이러한 문제들을 다루는 방법을 생각할 때, 우리 인간이 이러한 문제들을 어떻게 처리하는지 생각해보면 유익합니다. 만약 우리가 그러한 문제들을 해결하기 위해 취하는 단계를 재현할 수 있다면, LLM도 그렇게 할 수 있도록 도울 수 있습니다. 자, 데이터 분석가에게 비즈니스 인텔리전스(BI) 질문에 답하라는 요청을 받았을 때 무엇을 할지 생각해 봅시다.
데이터 분석가들이 SQL 데이터베이스를 쿼리할 때, 올바른 쿼리를 작성하는 데 도움이 되는 몇 가지 일반적인 행동이 있습니다. 예를 들어, 그들은 보통 어떤 식으로 데이터가 생겼는지 이해하기 위해 사전에 샘플 쿼리를 만듭니다. 테이블의 스키마(schema)나 심지어 특정 행들을 살펴볼 수 있습니다. 이는 데이터 분석가가 데이터가 어떻게 생겼는지 학습하여 나중에 SQL 쿼리를 작성할 때 실제로 존재하는 것에 근거하도록 생각될 수 있습니다. 또한, 데이터 분석가들은 보통 모든 데이터(또는 수천 개의 행)를 한 번에 보지는 않습니다. 그들은 탐색적 쿼리(exploratory queries)의 범위를 상위 K개 행으로 제한하거나, 요약 통계(summary stats)를 보는 경향이 있습니다. 이는 컨텍스트 창 길이의 한계를 우회하는 방법에 대한 몇 가지 단서를 제공할 수 있습니다. 마지막으로, 데이터 분석가가 오류에 직면하면 단순히 포기하지 않습니다. 그들은 오류로부터 배우고 새로운 쿼리를 작성합니다.
이러한 각 솔루션에 대해서는 아래 별도의 섹션에서 논의하겠습니다.
데이터베이스 설명하기
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의 Text-to-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개의 행을 제공하는 것이 최적이었으며, 더 많은 데이터베이스 내용을 제공할수록 오히려 성능이 저하될 수 있다는 것도 발견했습니다.
저희는 그들의 논문에서 나온 모범 사례(best practice) 결과를 기본 설정으로 채택했습니다. 따라서 프롬프트 내의 데이터베이스 설명은 다음과 같은 모양을 갖습니다:
db = SQLDatabase.from_uri(
"sqlite:///../../../../notebooks/Chinook.db",
include_tables=['Track'], # 예시를 위해 테이블 하나만 포함
sample_rows_in_table_info=3
)
print(db.table_info)
Which outputs:
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에 전송하고 싶지 않은 경우, 실제 데이터베이스 대신 가짜 데이터를 제공하기 위해 이 기능을 사용할 수 있습니다.
출력 크기 제한하기
체인(chain)이나 에이전트(agent) 내에서 LLM을 사용하여 쿼리를 실행할 때, 우리의 쿼리 결과는 또 다른 LLM의 입력으로 사용됩니다. 만약 쿼리 결과가 너무 크면 모델의 입력 크기를 초과하게 됩니다. 따라서 우리의 쿼리 출력을 합리적으로 제한하는 것이 보통 좋은 방법입니다. 우리는 LLM에게 가능한 한 적은 열을 사용하고 반환되는 행 수를 제한하도록 지침을 내려서 이를 수행할 수 있습니다.
다음 예시에서 볼 수 있듯이, 국가별 총 판매액 목록을 요청하면서 국가 수를 지정하지 않으면 쿼리가 10개로 제한됩니다. 이 제한은 top_k 매개변수를 사용하여 관리할 수 있습니다.
`agent_executor.run(
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
생각(Thought): 실행하기 전에 쿼리를 다시 확인해야 합니다.
행동(Action): query_checker_sql_db
행동 입력(Action Input):
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;
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;
생각(Thought): 이제 최종 답변을 알게 되었습니다.
행동(Action): query_sql_db
행동 입력(Action Input):
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
향후 작업(Future work)
아시다시피 이 분야는 빠르게 변화하고 있으며, 우리는 최적의 LLM-SQL 상호 작용을 달성하는 가장 좋은 방법을 공동으로 찾아내고 있습니다. 앞으로의 백로그는 다음과 같습니다:
퓨샷 예제(Few-shot examples)
AI 자동 생성 콘텐츠
본 콘텐츠는 LangChain Blog의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기