Skip to Content
Software Architecture메달리온 데이터 위에 Text-to-SQL과 RAG 올리기
🏗️ Software Architecture2026년 2월 20일

메달리온 데이터 위에 Text-to-SQL과 RAG 올리기

#identity-platform#audit-event#text-to-sql#rag#vector-search#llm
Identity Platform · Series 3 · Audit Event Architecture11 / 15Software Architecture
Gold 테이블 스키마를 임베딩하고, LLM에게 SQL을 쓰게 하고, 문서는 RAG로 답하게 만든 Dual Engine 구조.

Gold 테이블이 있으면 바로 질문할 수 있나?

이전 글에서 감사 이벤트를 Bronze → Silver → Gold로 정제했다. Gold 테이블에 “이번 달 비밀번호 초기화가 몇 건이었나?”에 답할 수 있는 데이터가 들어 있지만, 그 데이터에 접근하려면 여전히 SQL을 직접 작성해야 한다.

관리자나 비개발 직군이 이 데이터를 활용하려면, 자연어로 질문하면 적절한 SQL이 생성되는 구조가 필요했다. 동시에, SQL로는 답할 수 없는 질문 — “이 시스템의 아키텍처는 어떻게 되어 있나?”, “배포 절차가 어떻게 되나?” — 도 있었다. 이런 질문에는 문서 기반의 답변이 필요했다.

Dual Engine: 질문을 분류하고 라우팅하기

두 가지 종류의 질문이 있으므로, 먼저 질문을 분류하는 라우터가 필요했다.

라우터는 LLM을 사용해 질문의 의도를 분류한다. 핵심은 단순한 키워드 매칭이 아니라 질문의 의미를 파악하는 것이다.

아래는 실제 구현을 그대로 옮긴 것이 아니라, 라우팅 구조를 설명하기 위한 개념 예시다.

python
# 개념 예시 — 실제 프롬프트는 도메인에 맞게 조정해야 한다 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 인덱스로 만들었다. 아래는 인덱스에 넣는 문서의 구조를 보여주는 개념 예시다. 실제 테이블명이나 컬럼은 도메인에 따라 달라진다.

python
# 개념 예시 — 스키마 인덱스에 넣을 문서 구조 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을 생성한다.

python
# 개념 예시 — 자연어 질문과 검증된 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 파이프라인의 전체 흐름을 보여주는 개념 예시다. 실제 구현에서는 에러 처리, 타임아웃, 결과 해석 등의 단계가 추가된다.

python
# 개념 예시 — 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 sql
생성된 SQL은 실행 전에 반드시 안전 검증을 거친다. INSERT, UPDATE, DELETE, DROP 등의 키워드가 포함된 SQL은 차단하고, 결과 행 수도 제한한다.

RAG: 문서 기반 답변

SQL로 답할 수 없는 질문은 RAG(Retrieval-Augmented Generation)로 처리한다. Confluence 문서를 청크 단위로 분할하고 Vector Search 인덱스로 임베딩했다.

Knowledge Base 구성

항목
소스Confluence 페이지 (팀 문서, 아키텍처, 절차 등)
청크 수약 8,000개
임베딩 모델databricks-gte-large-en
인덱스 타입Delta Sync (소스 테이블 변경 시 자동 동기화)

아래는 RAG 파이프라인의 흐름을 보여주는 개념 예시다. Vector Search로 관련 문서를 찾고, 찾은 문서를 컨텍스트로 넣어 LLM이 답변을 생성한다.

python
# 개념 예시 — 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 BaseConfluence 문서 업데이트 시크롤링 → Delta 테이블 업데이트 → 자동 동기화

Delta Sync 인덱스를 쓰면 소스 Delta 테이블이 바뀔 때 자동으로 임베딩이 갱신되기 때문에, 파이프라인 자체는 단순하다. 다만 어떤 예시를 추가할지가 Text-to-SQL 정확도에 가장 큰 영향을 미쳤다.

데이터에서 지식으로

감사 이벤트가 Kafka의 JSON 메시지에서 시작해서, 메달리온 아키텍처를 거쳐 Gold 테이블이 되고, 그 Gold 테이블의 스키마가 임베딩되어 자연어로 질문할 수 있는 구조가 되기까지 — 이 흐름 전체가 데이터를 지식으로 바꾸는 과정이었다.

하지만 이 시점에서는 아직 파이프라인과 인덱스만 존재했다. 실제로 사용자가 대화하듯 질문하고, 결과를 차트로 보고, 스트리밍으로 응답을 받을 수 있는 제품은 다음 글에서 만든다.

Last updated on