메달리온 데이터 위에 Text-to-SQL과 RAG 올리기
Gold 테이블이 있으면 바로 질문할 수 있나?
이전 글에서 감사 이벤트를 Bronze → Silver → Gold로 정제했다. Gold 테이블에 “이번 달 비밀번호 초기화가 몇 건이었나?”에 답할 수 있는 데이터가 들어 있지만, 그 데이터에 접근하려면 여전히 SQL을 직접 작성해야 한다.
관리자나 비개발 직군이 이 데이터를 활용하려면, 자연어로 질문하면 적절한 SQL이 생성되는 구조가 필요했다. 동시에, SQL로는 답할 수 없는 질문 — “이 시스템의 아키텍처는 어떻게 되어 있나?”, “배포 절차가 어떻게 되나?” — 도 있었다. 이런 질문에는 문서 기반의 답변이 필요했다.
Dual Engine: 질문을 분류하고 라우팅하기
두 가지 종류의 질문이 있으므로, 먼저 질문을 분류하는 라우터가 필요했다.
라우터는 LLM을 사용해 질문의 의도를 분류한다. 핵심은 단순한 키워드 매칭이 아니라 질문의 의미를 파악하는 것이다.
아래는 실제 구현을 그대로 옮긴 것이 아니라, 라우팅 구조를 설명하기 위한 개념 예시다.
# 개념 예시 — 실제 프롬프트는 도메인에 맞게 조정해야 한다
ROUTER_PROMPT = """Classify the user's question into exactly one category.
- "data": metrics, counts, trends, statistics, numbers
- "docs": documentation, processes, architecture, how-to, team info
Respond with ONLY the word "data" or "docs"."""- “지난 주 로그인 수”는 →
sql(데이터 조회) - “배포 절차가 어떻게 되나?”는 →
wiki(문서 검색) - 분류 실패 시 기본값은
wiki(안전한 fallback)
Text-to-SQL: 스키마와 예시를 임베딩하기
Text-to-SQL의 핵심 도전은 LLM이 어떤 테이블에 어떤 컬럼이 있는지 알아야 한다는 것이다. Gold 테이블이 8개이고 각각 수십 개의 컬럼이 있으면, 모든 스키마를 매번 프롬프트에 넣는 것은 토큰 낭비이자 성능 저하다.
Vector Search로 관련 스키마만 검색
모든 스키마를 넣는 대신, 질문과 관련된 스키마만 Vector Search로 찾아서 넣는 방식을 택했다.
두 가지 Vector Search 인덱스를 만들었다:
| 인덱스 | 임베딩 대상 | 검색 결과 |
|---|---|---|
| 스키마 인덱스 | 테이블 설명 + 컬럼명 + 컬럼 설명 | 관련 테이블의 DDL, 컬럼 정보 |
| 예시 인덱스 | 자연어 질문 (한국어/영어) | 유사 질문에 대한 검증된 SQL 예시 |
스키마 인덱스 구성
각 Gold 테이블의 메타데이터를 Vector Search 인덱스로 만들었다. 아래는 인덱스에 넣는 문서의 구조를 보여주는 개념 예시다. 실제 테이블명이나 컬럼은 도메인에 따라 달라진다.
# 개념 예시 — 스키마 인덱스에 넣을 문서 구조
schema_documents = [
{
"doc_id": "daily_event_summary",
"table_name": "daily_event_summary",
"content": "일별 이벤트 집계 테이블. 이벤트 유형별 건수, 고유 사용자 수를 보여준다.",
"columns_json": '{"event_date": "DATE", "event_type": "STRING", "event_count": "BIGINT", ...}',
"sample_queries": "SELECT event_type, SUM(event_count) FROM ... GROUP BY 1"
},
# ... Gold 테이블마다 하나씩
]예시 인덱스 구성
실제 사용자가 물어볼 법한 질문과 그에 대한 검증된 SQL을 쌍으로 임베딩했다. 이 예시들은 few-shot learning의 역할을 한다 — LLM이 비슷한 질문을 받으면 예시의 SQL 패턴을 참고해서 더 정확한 SQL을 생성한다.
# 개념 예시 — 자연어 질문과 검증된 SQL 쌍
example_documents = [
{
"question": "How many events happened last month?",
"question_ko": "지난 달 이벤트가 몇 건이었나?",
"sql_query": """SELECT event_type, COUNT(*) AS cnt
FROM daily_event_summary
WHERE event_date >= date_trunc('MONTH', current_date() - INTERVAL 1 MONTH)
AND event_date < date_trunc('MONTH', current_date())
GROUP BY 1""",
"explanation": "일별 집계 테이블에서 지난 달 이벤트를 유형별로 합산"
},
# ... 수십 개의 예시
]SQL 생성과 실행
아래는 Text-to-SQL 파이프라인의 전체 흐름을 보여주는 개념 예시다. 실제 구현에서는 에러 처리, 타임아웃, 결과 해석 등의 단계가 추가된다.
# 개념 예시 — Text-to-SQL 파이프라인 흐름
def generate_sql(question: str) -> str:
# 1. 질문과 관련된 테이블 스키마 검색
schemas = schema_index.similarity_search(question, num_results=5)
# 2. 유사 질문에 대한 검증된 SQL 예시 검색
examples = example_index.similarity_search(question, num_results=3)
# 3. 검색 결과를 프롬프트에 조합
prompt = f"""You are a SQL expert.
Available tables:
{format_schemas(schemas)}
Similar examples:
{format_examples(examples)}
Current date: {current_date()}
Generate a SELECT query for: {question}
Only SELECT is allowed. Always add LIMIT."""
# 4. LLM이 SQL 생성
sql = llm.generate(prompt)
# 5. 안전 검증 (SELECT만 허용, DDL/DML 차단)
validate_sql(sql)
return sqlRAG: 문서 기반 답변
SQL로 답할 수 없는 질문은 RAG(Retrieval-Augmented Generation)로 처리한다. Confluence 문서를 청크 단위로 분할하고 Vector Search 인덱스로 임베딩했다.
Knowledge Base 구성
| 항목 | 값 |
|---|---|
| 소스 | Confluence 페이지 (팀 문서, 아키텍처, 절차 등) |
| 청크 수 | 약 8,000개 |
| 임베딩 모델 | databricks-gte-large-en |
| 인덱스 타입 | Delta Sync (소스 테이블 변경 시 자동 동기화) |
아래는 RAG 파이프라인의 흐름을 보여주는 개념 예시다. Vector Search로 관련 문서를 찾고, 찾은 문서를 컨텍스트로 넣어 LLM이 답변을 생성한다.
# 개념 예시 — RAG 검색 및 답변 생성 흐름
def rag_answer(question: str):
# 1. 질문과 유사한 문서 청크 검색
results = vector_index.similarity_search(
query_text=question,
columns=["text", "url", "page_title"],
num_results=5
)
# 2. 검색된 문서를 LLM 컨텍스트로 구성
context = "\n".join([
f"[Document {i}] {r.page_title}\n{r.text}"
for i, r in enumerate(results)
])
# 3. LLM이 컨텍스트 기반으로 답변 생성
answer = llm.generate(
f"Based on the following documents, answer: {question}\n\n{context}"
)
# 답변과 출처 URL을 함께 반환
return answer, [r.url for r in results]Delta Sync 인덱스를 사용하면 Confluence 문서가 업데이트될 때 자동으로 인덱스도 갱신된다. 수동으로 재인덱싱할 필요가 없다.
왜 하나의 엔진으로 통합하지 않았나
두 엔진을 하나로 합치려는 유혹이 있었다. LLM에게 “데이터가 필요하면 SQL을 쓰고, 아니면 문서를 검색해”라고 하면 되지 않을까?
실제로 해봤더니 문제가 있었다:
| 통합 방식 | 문제 |
|---|---|
| 단일 프롬프트 | 질문 유형에 따라 프롬프트 최적화가 달라서 하나로 합치면 둘 다 품질이 떨어짐 |
| Agent 패턴 | 도구 선택 단계가 추가되어 응답 시간이 길어짐 |
| 하이브리드 검색 | 스키마 임베딩과 문서 임베딩이 같은 공간에 있으면 검색 정확도가 떨어짐 |
라우터를 앞에 둔 Dual Engine 패턴은 각 엔진의 프롬프트를 독립적으로 최적화할 수 있고, 새로운 엔진 유형을 추가하기도 쉬운 구조다. 라우팅 비용은 LLM 호출 1회(max_tokens=5, temperature=0.0)에 불과하다.
Vector Search 인덱스 관리
인덱스를 한번 만들어 놓으면 끝이 아니다. Gold 테이블 스키마가 바뀌거나, 새로운 질문 유형이 추가되면 인덱스도 갱신해야 한다.
| 인덱스 | 갱신 시점 | 방법 |
|---|---|---|
| 스키마 | Gold 테이블 DDL 변경 시 | 스키마 문서 재생성 → Delta 테이블 업데이트 → 자동 동기화 |
| 예시 | 새로운 질문 패턴 발견 시 | 예시 쌍 추가 → Delta 테이블 업데이트 → 자동 동기화 |
| Knowledge Base | Confluence 문서 업데이트 시 | 크롤링 → Delta 테이블 업데이트 → 자동 동기화 |
Delta Sync 인덱스를 쓰면 소스 Delta 테이블이 바뀔 때 자동으로 임베딩이 갱신되기 때문에, 파이프라인 자체는 단순하다. 다만 어떤 예시를 추가할지가 Text-to-SQL 정확도에 가장 큰 영향을 미쳤다.
데이터에서 지식으로
감사 이벤트가 Kafka의 JSON 메시지에서 시작해서, 메달리온 아키텍처를 거쳐 Gold 테이블이 되고, 그 Gold 테이블의 스키마가 임베딩되어 자연어로 질문할 수 있는 구조가 되기까지 — 이 흐름 전체가 데이터를 지식으로 바꾸는 과정이었다.
하지만 이 시점에서는 아직 파이프라인과 인덱스만 존재했다. 실제로 사용자가 대화하듯 질문하고, 결과를 차트로 보고, 스트리밍으로 응답을 받을 수 있는 제품은 다음 글에서 만든다.