Text-to-SQL 평가 파이프라인 구축: AI 쿼리 신뢰하기 전에 테스트하는 방법
요약
LLM 기반 Text-to-SQL 시스템은 자연어 질문을 SQL로 변환하지만, 생성된 쿼리가 항상 정확한 비즈니스 결과를 보장하지 못합니다. 본 가이드는 Python과 SQLite를 활용하여 AI가 만든 SQL을 신뢰하기 전에 체계적으로 테스트할 수 있는 실용적인 평가 프레임워크 구축 방법을 안내합니다.
핵심 포인트
- AI 생성 SQL은 반드시 테스트해야 하는 소프트웨어로 취급해야 합니다.
- 평가 프레임워크는 기대 결과와 실제 출력을 비교하여 정확도를 측정합니다.
- Spider, BIRD 등 기존 벤치마크를 넘어선 엔터프라이즈 워크플로우 평가가 중요합니다.
- 회귀 테스트 구축을 통해 모델이나 프롬프트 변경에 대비할 수 있습니다.
대규모 언어 모델(LLM)은 자연어 질문을 SQL 쿼리로 변환할 수 있어, SQL을 모르는 사람들에게도 비즈니스 데이터에 대한 접근성을 높여줍니다. 하지만 겉보기에 올바른 쿼리를 생성하는 것과 정확한 답변을 반환하는 쿼리를 생성하는 것은 같지 않습니다.
쿼리는 성공적으로 실행되더라도 오해를 불러일으키는 결과를 산출할 수 있습니다. 잘못된 조인(join)을 사용하거나, 부정확한 날짜 필터(date filter)를 적용하거나, 가중 평균(weighted average) 대신 단순 평균을 계산하거나, 관련 레코드를 조용히 제외할 수 있습니다.
기업 환경에서 이러한 실수는 매출 예측, 고객 세분화, 운영 보고서 및 경영진 결정에 영향을 미칠 수 있습니다.
해결책은 AI가 생성한 SQL을 신뢰하기 전에 반드시 테스트해야 하는 소프트웨어로 취급하는 것입니다.
이 튜토리얼에서는 Python과 SQLite를 사용하여 실용적인 Text-to-SQL 평가 프레임워크를 안내합니다. 우리는 테스트 데이터베이스를 정의하고, 기대 결과가 포함된 자연어 질문을 생성하며, 언어 모델(language model)로 SQL을 생성하고, 쿼리를 검증하며, 출력을 비교하고, 실행 정확도를 측정하며, 향후 모델이나 프롬프트 변경에 대비한 회귀 테스트(regression tests)를 구축할 것입니다.
Text-to-SQL에 전용 평가 프레임워크가 필요한 이유
Text-to-SQL 시스템은 자연어 요청을 실행 가능한 데이터베이스 쿼리로 변환합니다. 개념 자체는 간단하지만, 근본적인 작업에는 여러 가지 개별적인 과제가 포함됩니다:
- 사용자가 실제로 측정하고자 하는 것이 무엇인지 이해하는 것.
- 비즈니스 용어를 올바른 데이터베이스 테이블 및 컬럼에 매핑하는 것.
- 적절한 조인, 필터, 집계 함수를 선택하는 것.
- 대상 데이터베이스 방언(dialect)을 위한 구문적으로 유효한 SQL을 생성하는 것.
- 의도된 비즈니스 로직과 일치하는 결과를 반환하는 것.
- 사용자의 접근 권한 및 데이터 거버넌스 규칙 내에서 작동하는 것.
연구 벤치마크는 이러한 과제들이 왜 중요한지 보여줍니다.
Spider 벤치마크(https://yale-lily.github.io/spider)는 모델이 단순히 쿼리 패턴을 암기하는 것이 아니라, 다양한 데이터베이스와 스키마 전반에 걸쳐 일반화해야 하는 교차 도메인 텍스트-투-SQL 평가 환경을 도입했습니다.
2023년에 발표된 BIRD 벤치마크(https://bird-bench.github.io/)는 대규모 데이터베이스, 실제 값, 외부 지식 및 계산 효율성을 통합하여 이 과제를 확장했습니다.
더 최근에는 2024년에 발표되고 ICLR 2025에서 발표된 연구 논문 Spider 2.0: Evaluating Language Models on Real-World Enterprise Text-to-SQL Workflows이 복잡한 스키마, 여러 SQL 방언 및 실제 데이터 환경을 포함하는 632개의 엔터프라이즈 수준 워크플로우 문제를 도입했습니다. 저자들은 이전 벤치마크와 엔터프라이즈 워크플로우 간에 상당한 성능 격차가 있음을 보고했습니다.
이것은 중요한 시사점을 가집니다: 단순화된 SQL 벤치마크에서의 강력한 성능이 프로덕션 데이터베이스에 대한 신뢰성을 보장하지는 않습니다.
평가 파이프라인은 팀이 자체 스키마, 비즈니스 질문 및 예상 결과를 사용하여 그 격차를 측정하도록 돕습니다.
1. 테스트 데이터셋 및 데이터베이스 스키마 정의
의미 있는 비즈니스 시나리오를 나타내는 작은 데이터베이스부터 시작합니다.
이 예시에서는 고객과 주문을 포함하는 판매(sales) 데이터베이스를 사용합니다. 목표는 다음과 같은 질문들을 평가하는 것입니다:
- 총 수익은 얼마입니까?
- 어떤 고객이 가장 많은 수익을 창출했습니까?
- 특정 기간 동안 몇 건의 주문이 이루어졌습니까?
- 평균 주문 가치는 얼마입니까?
setup_db.py라는 파일을 생성합니다:
`import sqlite3
DB_PATH = "sales.db"
def setup_database():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.executescript("""
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
...
if name == "main":
setup_database()
print("Database created successfully.")`
스크립트를 실행합니다:
python setup_db.py
결과 데이터베이스는 의도적으로 작고, 결정론적이며, 검사하기 쉽습니다.
실제 운영 환경의 평가 스위트(evaluation suite)를 위해서는 null 값, 중복 레코드, 빈 결과 세트, 날짜 경계, 다중 통화, 그리고 주문 기록이 없는 고객 등 엣지 케이스(edge cases)를 포함하도록 이 데이터셋을 확장해야 합니다.
또한, 모든 평가 실행이 알려진 기준선(baseline)을 사용하도록 데이터셋은 버전 관리되어야 합니다.
2. 기대 결과가 포함된 자연어 질문 생성하기
다음 단계는 사용자 의도와 검증된 답변을 연결하는 테스트 스위트를 만드는 것입니다.
각 테스트 케이스에는 다음이 포함되어야 합니다:
- 자연어 질문(natural-language question).
- 기대 결과(expected result).
- 선택적으로, 참조 SQL 쿼리(reference SQL query).
- 테스트되는 비즈니스 규칙을 설명하는 메타데이터.
예시를 위해 test_cases.py 파일을 생성합니다:
TEST_CASES = [
{
"id": "revenue_001",
"question": "완료된 주문의 총 매출액은 얼마입니까?",
"expected": [(3450.0,)],
"reference_sql": """
SELECT SUM(amount)
FROM orders
WHERE status = 'completed'
""",
},
{
"id": "customer_001",
"question": "가장 높은 매출을 기록한 고객은 누구입니까?",
"expected": [("Gamma Ltd", 1200.0)],
"reference_sql": """
SELECT c.name, SUM(o.amount) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'completed'
GROUP BY c.id, c.name
ORDER BY revenue DESC
LIMIT 1
""",
},
{
"id": "orders_001",
"question": "2026년 2월에 완료된 주문은 몇 건입니까?",
"expected": [(2,)],
"reference_sql": """
SELECT COUNT(*)
FROM orders
WHERE status = 'completed'
AND order_date >= '2026-02-01'
AND order_date < '2026-03-01'
""",
},
]
기대 결과가 언어 모델과 독립적으로 정의된다는 점에 주목하십시오.
이 구분은 근본적입니다. 만약 동일한 모델이 SQL 쿼리와 그 기대 답변을 모두 생성한다면, 평가는 오류를 감지하기보다는 모델 자체의 실수를 재현할 수 있습니다.
SQL 구문뿐만 아니라 비즈니스 의미론(business semantics)을 테스트하세요
사용자가 '완료된 주문 매출'을 요청했다고 가정해 봅시다. 모델은 다음과 같은 쿼리를 생성할 수 있습니다:
SELECT SUM(amount) FROM orders;
이 쿼리는 유효한 SQL이지만, 취소된 주문도 포함하고 있습니다. 그 결과는 정확한 $3,450가 아니라 $3,650입니다.
구문 검증기(syntax validator)로는 이 오류를 잡아낼 수 없습니다. 실행 기반 비교(execution-based comparison)만이 가능합니다.
더 복잡한 데이터셋의 경우, 예상 출력값은 도메인 전문가나 독립적으로 검토된 참조 쿼리(reference queries)에 의해 검증되어야 합니다. 단일 참조 쿼리를 절대적인 것으로 취급하는 것을 피하세요. 참조 로직 자체에 결함이 포함될 수 있습니다.
3. LLM을 사용하여 SQL 쿼리 생성하기
이제 언어 모델(LLM)을 도입합니다. 모델은 사용자 질문과 관련 데이터베이스 스키마를 받아야 하며, 전체 프로덕션 환경에 무제한으로 접근해서는 안 됩니다.
간단한 생성 인터페이스는 다음과 같을 수 있습니다:
def generate_sql(question, schema, llm_client):
prompt = f"""
You are a SQL query generator.
Generate one read-only SQLite SELECT query
that answers the user's question.
...
The llm_client.generate() 메서드는 사용자가 선택한 제공업체의 SDK나 내부 모델 엔드포인트에 대한 자리 표시자입니다. 그 구현은 어떤 모델을 선택하느냐에 따라 달라질 것입니다.
이 평가를 위해, 모델은 예상 출력값을 받아서는 안 됩니다. 그렇지 않으면 테스트가 질문을 독립적으로 해석하는지 여부보다는 알려진 답변을 재현할 수 있는지 측정하게 됩니다.
고정된 프롬프트(fixed prompt), 모델 버전, 온도(temperature), 그리고 스키마 스냅샷 역시 결과를 재현하기 쉽게 만듭니다.
어렵거나 모호한 질문 포함시키기
좋은 테스트 스위트(test suite)는 단순한 집계 이상의 것을 포함해야 합니다.
다음과 같은 질문을 추가하는 것을 고려해 보세요:
- 여러 테이블에 걸친 조인(Joins).
- 그룹화 및 정렬(Grouping and sorting).
- 날짜 범위 및 시간대(Date ranges and time zones).
- Null 처리(Null handling).
- 고유 개수(Distinct counts).
- 백분율 및 비율(Percentages and ratios).
- 기간 간 비교(Comparisons between time periods).
- 모호한 비즈니스 용어(Ambiguous business terminology).
- 사용자가 접근 권한이 없는 데이터 요청.
모호한 질문의 경우, 근거 없는 가정에 기반하여 쿼리를 생성하기보다는 명확히 설명을 요청하는 것이 올바른 동작일 수 있습니다.
4. SQL 구문 유효성 검사 및 실행 권한 제한
모델이 생성한 SQL을 그저 그럴듯해 보인다는 이유만으로 프로덕션 데이터베이스에 절대 실행해서는 안 됩니다.
더 안전한 평가 파이프라인은 쿼리 생성과 쿼리 실행을 분리하고 여러 가지 통제를 적용합니다.
쿼리 구문 분석 및 검사하기
SQLGlot 같은 라이브러리는 SQL을 구문 분석하여(parse) 해당 구문의 유형을 식별하는 데 도움을 줄 수 있습니다.
import sqlglot
def validate_sql(sql):
try:
statements = sqlglot.parse(sql, read="sqlite")
except sqlglot.errors.ParseError:
return False
if len(statements) != 1 or statements[0] is None:
return False
...
이것은 기본적인 예시일 뿐, 완전한 보안 경계는 아닙니다. 프로덕션 검증기는 허용된 구문 유형, 읽기 전용 CTE(Common Table Expression), 방언별 구문, 그리고 금지되어야 하는 모든 함수나 연산을 명시적으로 처리해야 합니다.
예를 들어, WITH 문은 데이터 수정이 가능한 CTE를 지원하는 방언에서도 여전히 데이터를 수정할 수 있습니다. 검증은 단순한 문자열 접두사에 의존하기보다는 실제 데이터베이스 방언을 고려해야 합니다.
읽기 전용 데이터베이스 접근 사용하기
SQLite의 경우, 데이터베이스를 읽기 전용 모드로 엽니다:
import sqlite3
def open_readonly_database(path):
uri = f"file:{path}?mode=ro"
return sqlite3.connect(uri, uri=True)
또한 다음 사항들을 수행해야 합니다:
- 서버 데이터베이스의 경우 읽기 전용 권한을 가진 전용 계정을 사용합니다.
- 접근 가능한 스키마, 테이블 및 열을 제한합니다.
- 쿼리 시간 초과(timeout), 행 제한(row limits), 리소스 제한을 적용합니다.
- 사용자 제공 값을 전달할 때는 매개변수화된 SQL(parameterized SQL)을 사용합니다.
- 생성된 SQL, 검증 결과, 실행 시간 및 실패 내용을 기록(log)합니다.
- 자격 증명(credentials)과 민감한 데이터는 프롬프트와 로그에서 제외합니다.
- 가능할 때 합성 데이터 또는 격리된 스냅샷을 대상으로 평가를 수행합니다.
읽기 전용 연결(read-only connection)은 해당 연결을 통한 일반적인 쓰기 작업(write operations)을 방지하지만, 모든 비용이 많이 드는 쿼리(expensive query)를 자동으로 막거나 접근 가능한 모든 정보가 공개해도 안전하다는 것을 보장하지는 않습니다.
보안 검증(Security validation)과 의미론적 평가(semantic evaluation)는 별개의 요구 사항입니다. 둘 다 통과해야 합니다.
5. 반환된 결과와 예상 출력 비교하기
쿼리가 유효성 검사를 통과하면, 테스트 데이터베이스에 대해 실행하고 그 출력을 예상 결과와 비교합니다.
evaluate.py 생성:
import sqlite3
from setup_db import DB_PATH
from test_cases import TEST_CASES
def evaluate_query(sql, expected):
conn = sqlite3.connect(
f"file:{DB_PATH}?mode=ro",
uri=True,
)
try:
cursor = conn.execute(sql)
actual = cursor.fetchall()
...
완전히 자동화된 실행을 위해서는, 각 질문을 LLM 어댑터에 전달하여 생성된 SQL을 검증한 다음 evaluate_query()를 호출합니다.
위 코드는 샘플 예상 출력이 정의된 순서를 가지고 있기 때문에 정확한 행 비교(exact row comparison)를 사용합니다. 이는 모든 평가에 적합하지 않습니다.
질문에 따라 비교 전략을 선택하세요:
순서가 있는 결과(Ordered results): 정렬이 중요한 경우, 기대되는 순서대로 행을 비교합니다.
순서가 없는 결과(Unordered results): SQL이 보장하지 않는 경우에 행 순서를 정규화합니다.
숫자 결과(Numeric results): 부동 소수점 산술(floating-point arithmetic)이 관련된 경우 명시적인 허용 오차(tolerance)를 적용합니다.
집계(Aggregations): 측정 지표, 필터, 그룹화 및 계산 논리를 확인합니다.
비즈니스 특정 결과(Business-specific results): 카테고리 전반에 걸친 합계가 일치하는 등 관련 불변성(invariants)을 확인합니다.
예를 들어, 정확한 총 수익을 반환하지만 잘못된 고객 정보를 포함하는 쿼리는 여전히 틀릴 수 있습니다. 단일 스칼라 결과만으로는 비즈니스 의미론(business semantics)을 검증하기에 항상 충분하지 않습니다.
더 높은 신뢰도의 테스트를 위해서는 예상 결과 비교와 독립적인 비즈니스 규칙 단언(business-rule assertions)을 결합하세요.
6. 실행 정확도 및 실패율 측정
Text-to-SQL 평가 프레임워크는 성공적으로 실행된 쿼리 수보다 더 많은 것을 보고해야 합니다.
실행 정확도(Execution Accuracy)를 다음과 같이 정의합니다:
실행 정확도 (Execution Accuracy)
$$ ext{실행 정확도} = rac{ ext{정확한 쿼리 결과}}{ ext{총 테스트 케이스}} imes 100$$
구문 실패(syntax failures), 실행 오류(execution errors), 부정확한 결과(incorrect results), 보안 거부(security rejections), 및 시간 초과(timeouts)를 개별적으로 추적해야 합니다.
기본 보고 함수:
def summarize_results(results):
total = len(results)
if total == 0:
return {
"total": 0,
...
결과 목록에는 거부되거나 실행되지 않은 쿼리를 포함하여 전체 파이프라인의 결과가 담겨야 합니다. 그렇지 않으면 실패를 제외하는 것이 정확도를 인위적으로 부풀릴 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기