QueryRouter를 구현하고(이전 글 ) 30문항짜리 벤치마크를 처음 돌렸다. EX(Execution Accuracy) 66.67%, 30문항 중 20개. 네 번의 측정-개선 루프로 80%까지 올린 과정이다.

방법은 단순했다. 30문항을 한 번 돌리고, 틀린 쿼리를 유형별로 나눈 다음 영향이 큰 부분만 손봤다. 그리고 다시 돌렸다. 이 과정을 네 번 반복했다.

시작점: 모든 쿼리가 LLM으로 간다

Phase 0.5에서 Vanna에 DDL과 Glossary YAML(25개 비즈니스 용어)을 학습시킨 상태였다. PoC DB는 전자상거래 샘플로 테이블 21개, 행이 23만 개쯤 된다. 여기에 맞춰 30문항 테스트셋을 만들고 EX를 처음 측정했더니 66.67%가 나왔다. 30개 중 20개다.

막상 돌려보니 한 가지 문제가 바로 드러났다. 쉬운 쿼리든 어려운 쿼리든 전부 LLM이 SQL을 생성했다. “이번 달 총 매출은?” 같은 단순 집계도, “분기별 YoY 성장률을 구해줘” 같은 복잡한 쿼리도 둘 다 Vanna가 처음부터 끝까지 만들었다. LLM이 만든 SQL은 같은 질문에도 결과가 조금씩 달라질 수 있다. 단순 집계처럼 패턴이 뻔한 쿼리까지 LLM을 태울 이유는 없었다.

그래서 QueryRouter를 만들었다.

사이클 1: QueryRouter 도입

QueryRouter는 쿼리를 세 가지로 분류한다.

  • DETERMINISTIC: 패턴이 정해진 쿼리. “총 매출 합계”, “카테고리별 주문 건수"처럼 SQL 템플릿으로 풀 수 있는 것들. LLM을 안 거치고 바로 SQL을 만든다
  • HYBRID: glossary 용어가 포함되어 있으면서 조건이 복잡한 쿼리. 규칙으로 SQL 초안을 만들고 LLM이 검증한다
  • PROBABILISTIC: 위 두 가지에 해당하지 않는 자유 형식 쿼리. Vanna에 그대로 위임한다

MVP라서 분류기를 거창하게 만들지 않았다. 키워드 매칭과 정규식으로 분기한다. “합계”, “총”, “평균” 같은 키워드가 있으면 DETERMINISTIC 후보로 보고, glossary에 등록된 용어가 쿼리에 포함되어 있는지 체크한다.

30문항을 돌렸더니 EX 70%. +3.33%p. 숫자만 보면 미미한데, 안을 들여다보면 의미 있는 변화가 두 개 있었다.

하나는 동의어 인식률이 33%에서 67%로 뛴 것이다. “매출"을 “Revenue"로 물어봤을 때 이전에는 못 알아듣던 걸 Router가 glossary의 동의어 목록을 보고 잡아낸다.

다른 하나는 P95 응답시간(전체 쿼리 중 느린 쪽 5% 구간의 값)이 26초에서 3.3초로 87% 줄어든 것이다. DETERMINISTIC 경로는 LLM을 안 거치니까 SQL 템플릿을 바로 실행한다. 비용도 줄고 속도도 빠르다.

근데 여기서 예상 못한 문제가 나왔다.

“상위 10명” 함정: Fake Determinism

DETERMINISTIC으로 분기된 쿼리가 20개였는데, 그중 7개가 틀렸다. 35%다.

대표적인 케이스가 “쿠폰 사용 상위 10명 고객"이다. Router는 “상위"를 보고 계층 탐색 템플릿으로 보냈다. 하지만 실제 의도는 ORDER BY coupon_count DESC LIMIT 10이었다.

같은 단어지만 의미가 다르다. “상위 카테고리"는 계층이고, “상위 10명"은 랭킹이다.

이 상태가 더 위험하다. LLM은 맥락으로 보정할 여지가 있다. 하지만 DETERMINISTIC으로 분기되면 그대로 실행된다. 틀린 결과가 그대로 나간다.

사이클 2: top-N 패치 + sanity check

세 가지를 같이 수정했다.

우선 top-N 패턴을 정규식으로 잡아서 랭킹 표현이 먼저 매칭되면 HIERARCHY로 보내지 않도록 했다. 이 경우 HYBRID나 PROBABILISTIC으로 넘긴다.

TOP_N_PATTERNS = [
    r"상위\s*\d+",       # "상위 10명"
    r"top\s*\d+",        # "top 5"
    r"가장\s*(많|높|큰)", # "가장 많은"
]

이 규칙을 추가하니 Fake Det Rate가 35%에서 20%까지 내려갔다.

다음은 sanity check다. DETERMINISTIC 경로로 SQL을 실행한 뒤 결과가 비정상이면 LLM fallback으로 한 번만 재시도한다. “비정상"의 기준은 두 개, 결과가 0건이거나 NULL 비율이 70%를 넘는 경우다.

if det_result.row_count == 0 or det_result.null_ratio > 0.7:
    return await self._execute_probabilistic(query)  # 1회만 재시도

무한 재귀에 빠지면 안 되니까 fallback은 딱 한 번만 한다. PROBABILISTIC 결과가 나오면 그게 맞든 틀리든 그대로 반환한다.

마지막으로 few-shot exemplar 보강. 틀린 쿼리들의 오류 유형을 분류해보니 wrong_mapping(테이블/컬럼 매핑 오류)이 6건, wrong_formula(계산식 오류)가 2건이었다. 각 유형에 맞는 SQL 예시를 Vanna 학습 데이터에 추가했다. “이번 달 매출은?“이라는 질문에 DATE_TRUNC 패턴을 쓰는 SQL 예시, “순매출"을 구할 때 할인과 반품을 빼는 공식 예시 같은 것들이다.

결과는 EX 76.67%. +6.67%p. HYBRID 분기가 1건에서 6건으로 늘었다. Router가 “확신 없으면 LLM에 넘긴다"는 방향으로 움직인 건데, 설계 의도대로다.

사이클 3: is_active 함정, 그리고 정답이 틀릴 수 있다는 것

wrong_mapping 중에 한 패턴이 계속 잡혔다. 고객 테이블을 조회할 때 is_active=true 필터를 안 넣는 거다. 비활성 고객(탈퇴, 휴면)이 포함되면 집계가 달라진다. few-shot으로 예시를 여러 번 줘도 안 먹혔다.

여기서는 접근을 바꿨다. 이건 지식 문제가 아니라 정책이다. “활성 고객만 집계한다"는 건 비즈니스 규칙이고, LLM이 추론할 영역이 아니다. 그래서 sqlparse로 SQL을 파싱해서, is_active 컬럼이 있는 테이블이 쿼리에 포함되면 WHERE 조건을 강제로 붙이도록 했다.

단순 문자열 치환이 아니라 SQL AST(Abstract Syntax Tree, SQL 문법 구조를 파싱한 트리) 레벨에서 처리했다. subquery 안에는 건드리지 않고, GROUP BY 뒤에 조건이 붙는 실수도 안 생긴다. DET/HYBRID/PROB 모든 경로에서 SQL 생성 직후, 실행 직전에 적용된다.

is_active rule을 넣고 벤치마크를 돌렸다. EX가 56.67%로 떨어졌다.

문제는 다른 데 있었다. gold SQL(정답 SQL)에 is_active=true 조건이 없었다. 시스템은 비즈니스 정책에 맞게 필터를 넣었는데, 정답지가 그 정책 없이 작성되어 있으니 strict 비교에서 불일치로 판정된 거다.

시스템 문제가 아니라 기준 문제였다. BIRD 벤치마크에서도 비슷한 사례가 있는데, strict scoring이 인간 전문가 판단과 62%만 일치하고 나머지 38%는 맞는 SQL을 틀렸다고 판정하는 false negative였다.

사이클 3.1: gold SQL 수정 → 80%

작업은 크게 어렵지 않았다. gold SQL 8건에 is_active=true 조건을 추가했다. 시스템이 생성하는 SQL과 정답 SQL의 정책이 일치하도록 맞춘 거다.

재측정 결과 EX 80.00%. 목표로 잡았던 80%에 도달했다.

Phase 0.5부터 여기까지의 흐름을 표로 정리하면 이렇다.

버전EX핵심 변화틀린 건수
Phase 0.566.67%Vanna + Glossary RAG10/30
v170.00%QueryRouter 추가9/30
v276.67%top-N 수정 + sanity check + few-shot7/30
v3.180.00%is_active hard rule + gold 정합화6/30

+13.33%p 누적 개선. easy 난이도는 100%, Fake Det Rate는 35%에서 13.3%까지 내려갔다. hard 난이도는 37.5%로 변화 없는데, formula/JOIN 복잡도 문제라 DataHub synonym 확장이나 exemplar 보강이 필요한 영역이다.

이 과정에서 배운 것들

이번 케이스에서 분명해진 게 있다. is_active 필터는 LLM이 해결할 문제가 아니었다. few-shot을 여러 번 넣어도 해결되지 않았다. 정책은 규칙으로 강제하는 게 훨씬 빠르고 확실했다.


평가 기준도 설계 대상이다. gold SQL이 정책을 반영하지 않으면, 정상 동작이 오히려 오답이 된다. 이번에는 8건이었지만, 테스트셋이 수백 개가 되면 나중에 잡기가 훨씬 어려워진다. 초기에 gold SQL이 정책을 반영하는지부터 맞춰야 한다.


변수를 하나씩 고정해서 본 게 컸다. Router를 넣을 때, is_active를 넣을 때, 매번 같은 30문항으로 돌려서 delta를 봤다. 이렇게 하지 않았으면 is_active 문제에서 “EX가 떨어졌으니 rule이 잘못됐다"고 판단했을 수 있다. 실제로는 rule은 제대로 동작했고 gold SQL이 문제였다. baseline을 고정하고 한 번에 하나만 바꾸는 게 지루해 보여도, 나중에 원인을 분리할 수 있는 유일한 방법이다.


Fake Determinism은 규칙 분류기의 구조적 한계다. top-N 패턴 같은 예외 규칙으로 대응했지만, 이런 예외가 늘어나면 유지보수가 어려워진다. Phase 2에서 LLM 분류기를 검토해야 할 수 있다. 당장은 보류다.

오류 유형 분류가 없었으면 다음 액션이 안 보였을 것이다. “틀림"으로 뭉뚱그리지 않고 wrong_mapping, wrong_formula, wrong_aggregation으로 나눴기 때문에 “매핑 exemplar를 보강하자”, “계산식을 glossary에 명시하자” 식으로 구체적으로 움직일 수 있었다.

80%는 시작이다

80%는 MVP 최소 기준이고, Phase 2 목표는 90%다. 그 사이에 아직 안 쓴 카드가 여러 장 남아 있다.

지금 EX 80%는 로컬 YAML 25개 용어 + 규칙 기반 Router + hard rule로 달성한 수치다. 잔여 실패 6건은 전부 hard 난이도로, formula/JOIN 복잡도 문제다. 지금 구조에서는 추가 튜닝으로 올릴 수 있는 폭이 크지 않다.

DataHub 연동 (v4) 이 바로 다음이다. synonym을 확장하고 linked_columns로 테이블/컬럼 매핑을 강화하면, 현재 wrong_mapping 4건 중 일부가 해결된다. 동의어 인식률은 현재 67%에서 90-95%까지 올라갈 것으로 본다. EX 기준으로는 +3-5%p 영역이다.

Few-shot 확대 는 hard 쿼리에 직접 영향을 준다. 지금 학습 데이터는 30문항 테스트셋 + 추가 예시 쿼리 수준이다. 윈도우 함수, CTE, 다중 JOIN에 맞는 예시 쿼리를 충분히 추가하면 hard EX 37.5%가 올라갈 여지가 크다.

Tool Memory 자동 학습 은 Phase 1.5에서 활성화 예정이다. 사용자 질문 → SQL 실행 성공 → Q-SQL 쌍 자동 학습으로, 쓰면 쓸수록 정확도가 올라가는 구조다. 수동 few-shot 추가와 달리 스케일이 된다.

LLM 분류기 전환 은 Phase 2 과제다. 현재 Router의 키워드 매칭 방식은 Fake Det Rate를 13.3%까지 내렸지만 구조적 한계가 있다. LLM 분류기로 전환하면 자연어 중의성 처리가 근본적으로 개선된다.

Apache AGE 그래프 쿼리 는 Phase 1.5-2 범위다. 현재 그래프 DB가 없어서 Multi-hop 추론이 안 된다. “A 공장 이슈가 B 제품 공급망에 미친 영향은?” 같은 관계 탐색 쿼리는 지금 구조로는 처리 불가다. AGE가 붙으면 RCQ(Relationship CQ) 영역이 열리고, Router의 DETERMINISTIC 경로에 Cypher 템플릿이 추가된다.

PRD가 그리는 경로를 정리하면 이렇다.

접근법EX현재 상태
GPT-4o 단독~51%참고용
RAG 추가 (Vanna)~70-75%완료
온톨로지 + RAG≥ 80% (MVP)완료 (YAML)
+ DataHub + Few-shot + Tool Memory≥ 90% (Phase 2)예정
+ Graphiti + Agent Memory≥ 95% (Phase 3)예정

현실적으로 DataHub 연동 + hard 쿼리 예시 보강으로 83-87% 구간은 도달 가능하다. 90%는 Tool Memory + LLM 분류기 전환까지 필요하고, 95%는 Graphiti까지 가야 한다.