사용자가 자연어(한국어)로 질문하면 LLM이 SQL SELECT 쿼리를 자동 생성하여 구조화된 데이터베이스를 직접 조회하고, 결과를 자연어로 요약하는 하이브리드 RAG+SQL 시스템입니다.
기존 RAG(비정형 문서 검색)와 Text-to-SQL(정형 데이터 조회)을 결합한 하이브리드 방식으로, 질문 유형에 따라 최적의 경로를 자동 선택합니다.
| 구분 | RAG (기존) | Text-to-SQL (신규) |
|---|---|---|
| 대상 | 비정형 문서 (PDF, DOCX 등) | 정형 데이터 (DB 테이블) |
| 검색 방식 | 벡터 유사도 + 키워드 | SQL 쿼리 생성 → 실행 |
| 응답 | 문서 기반 설명 | 데이터 조회 결과 + 요약 |
| 예시 | "청년내일채움공제 가입 조건은?" |
"이번 달 신규 가입자 수는?" |
개념/절차/설명 질문
→ 기존 문서 검색 파이프라인
키워드: 설명, 방법, 이유, 절차, 가이드
데이터 조회/집계 질문
→ Text-to-SQL 파이프라인
키워드: 몇 건, 합계, 평균, 목록, 조회
데이터 + 설명 모두 필요
→ 양쪽 결과 통합 (SQL 우선)
키워드: 분석, 추이, 비교 + 조회
Query Router가 질문 의도를 분류하고, 각 파이프라인이 독립적으로 동작한 후 Context Merger에서 결과를 통합합니다.
질문 관련 테이블만 선별 · DDL + 샘플 데이터 + 동의어 로드
LLM 호출 (질문 + 스키마 + Few-shot) → SQL SELECT 생성
sqlparse AST · 단일 SELECT · 테이블 허용목록 · PII 보호
EXPLAIN 비용 상한 검사 · 풀스캔 감지 (운영: 필수 / 개발: 선택)
READ ONLY · timeout 5초 · 최대 1000행 · 동시성 세마포어
마크다운 테이블 + 자연어 요약 + PII 마스킹 + 불확실성 고지
3개의 신규 파일로 구성되며, 기존 코드 패턴(pgvector_store.py, rag_orchestrator.py)을 따릅니다.
classify_intent(question: str) -> Literal["rag", "sql", "hybrid"]
| 분류 | 키워드 예시 |
|---|---|
| SQL | 몇 건, 총합, 합계, 평균, 개수, 목록, 리스트, 조회, 정렬, 최대, 최소, 데이터 |
| RAG | 설명, 방법, 이유, 차이점, 절차, 가이드, 주의사항 |
| Hybrid | 분석, 추이, "비교하고 조회", "설명하면서 조회" |
질문 관련 테이블만 선별하여 DDL + 샘플 데이터 반환 (스키마 최소 노출 원칙). 한국어 동의어 매핑, 스키마 동기화 검사, 샘플 데이터 비식별화 지원.
get_schema_context()check_schema_sync()refresh_cache()
Ollama LLM을 호출하여 자연어 → SQL 변환.
에러 보정 시 retry_context에 이전 에러 정보를 포함하여 재생성.
generate_sql(question, schema_context, few_shot, retry_context)
sqlparse AST 분석 기반 검증. 단일 SELECT만 허용, 차단 키워드, 테이블 허용목록(주 통제), 서브쿼리 깊이 제한(2단계), 함수 허용목록, PII 컬럼 보호.
validate(sql) -> ValidationResult
EXPLAIN (FORMAT JSON)으로 실행 전 비용 상한/풀스캔 위험 검사.
운영: 필수 적용, 개발: 선택적.
check_cost(sql) -> CostCheckResult
읽기전용 커넥션 (sql_reader 역할), READ ONLY,
timeout 5초, 최대 1000행. 동시성 제어 세마포어 적용.
execute(sql, offset, limit) -> SQLExecutionResult
SQL 결과를 마크다운 테이블로 변환, 자연어 요약 생성. PII 마스킹 후처리 + 불확실성 고지 자동 첨부.
format_standalone_response()format_for_llm_context()
SQLStoreConfig + get_readonly_connection() + ensure_sql_schema() 구조.
기존 PostgresConfig 패턴과 동일한 환경변수 기반 설정.
시스템 메타데이터 테이블 3개와 샘플 비즈니스 테이블 4개로 구성됩니다.
LLM 프롬프트에 주입할 테이블/컬럼의 한국어 설명, PII 여부, 마스킹 유형, 동의어 정보를 관리합니다.
is_pii, pii_mask_type, synonyms, sample_values
SQL 생성 정확도 향상을 위한 Few-shot 예제 (한국어 질문 → 정답 SQL 쌍). 난이도별 분류 및 사용 횟수 추적.
question, sql_query, difficulty
모든 SQL 생성/실행 이력의 감사 로그. 요청자, 생성된 SQL, 검증 결과, 비용, 실행 시간, 에러 코드 기록.
request_id, user_id, cost_estimate, error_code
| 테이블명 | 설명 | 주요 컬럼 | PII 컬럼 |
|---|---|---|---|
subscribers |
가입자 정보 | subscriber_id, company_name, subscription_date, program_type, status, monthly_contribution | name |
payment_history |
납부 이력 | payment_id, subscriber_id, payment_date, payment_month, amount, payment_status | - |
consultation_log |
상담 이력 | consultation_id, subscriber_id, consultation_date, channel, category, resolved | counselor_name |
program_products |
프로그램 상품 | product_id, program_name, monthly_amount_min/max, duration_months, government_match_ratio | - |
테이블 접근 차단의 주 통제 수단은 애플리케이션 레벨 SQLValidator의 허용목록이며,
DB 역할 권한(sql_reader)은 보조 방어로 작용합니다.
PostgreSQL 버전별로 시스템 카탈로그 REVOKE 동작이 다를 수 있으므로, DB 권한에만 의존하지 않습니다.
-- 읽기전용 역할 생성
CREATE ROLE sql_reader WITH LOGIN PASSWORD 'reader_secret';
GRANT CONNECT ON DATABASE rag TO sql_reader;
GRANT USAGE ON SCHEMA public TO sql_reader;
-- 비즈니스 테이블에만 SELECT 권한 부여
GRANT SELECT ON subscribers, payment_history, consultation_log, program_products TO sql_reader;
-- RAG 내부 테이블 접근 차단 (보조 방어)
REVOKE ALL ON document_chunks, ingest_history, reocr_queue FROM sql_reader;
API 접근부터 감사 로그까지 7단계 방어 체계로 안전한 SQL 실행 환경을 보장합니다.
| 엔드포인트 | 메서드 | 최소 권한 | 설명 |
|---|---|---|---|
/sql/schema |
GET | sql:read |
스키마 조회 (읽기 전용) |
/sql/schema/{table} |
GET | sql:read |
테이블 상세 조회 |
/sql/query |
POST | sql:execute |
SQL 생성 + 실행 |
/sql/validate |
POST | sql:read |
SQL 검증만 (실행 없음) |
/rag/query (queryMode=sql) |
POST | sql:execute |
RAG 통합 SQL 조회 |
| 조회 유형 | 처리 방식 |
|---|---|
SELECT name FROM ... |
차단 또는 마스킹 자동 래핑 |
SELECT COUNT(*) GROUP BY name |
집계만 허용 |
WHERE name = '김철수' |
조건 허용, 결과에서 마스킹 |
| 마스킹 유형 | 원본 | 결과 |
|---|---|---|
name |
김철수 | 김OO |
phone |
010-1234-5678 | 010-****-5678 |
email |
user@example.com | u***@example.com |
| 공격 | 대응 |
|---|---|
| LLM이 DELETE/DROP 생성 | sqlparse 구문 타입 검사 + DB 역할에서 이중 차단 |
| 세미콜론 다중 구문 | sqlparse로 statement 분리, 2개 이상이면 거부 |
| 주석 기반 우회 | sqlparse 토큰 분석으로 주석 제거 후 재파싱 |
| UNION으로 시스템 테이블 조회 | 테이블 허용목록(주 통제)에서 차단 + UNION 패턴 검사 |
| 고비용 쿼리 (DoS) | EXPLAIN 비용 상한 + timeout 5초 + 행 제한 |
| PII 컬럼 alias 우회 | 결과 반환 전 PII 마스킹 후처리 (원본 컬럼 추적) |
| Hybrid 모드 컨텍스트 오염 | RAG 컨텍스트와 SQL 생성 프롬프트를 분리된 LLM 호출로 격리 |
| 반복 위반 질의 | 동일 user_id에서 연속 검증 실패 시 로그 경고 + 임시 차단 |
에러 코드 체계, 대체 응답 전략, 재시도 시 의미 보존 검증으로 안정적인 사용자 경험을 보장합니다.
| 에러 코드 | HTTP | 설명 | 사용자 메시지 |
|---|---|---|---|
SQL_VALIDATION_ERROR |
422 | SQL 검증 실패 | 질문을 SQL로 변환했으나 안전 검증에 실패했습니다. |
SQL_EXECUTION_ERROR |
500 | SQL 실행 오류 | SQL 실행 중 오류가 발생했습니다. |
SQL_TIMEOUT_ERROR |
504 | 타임아웃 (5초 초과) | 조회 시간이 초과되었습니다. |
SQL_COST_EXCEEDED |
422 | 비용 상한 초과 | 쿼리 비용이 높아 실행이 제한되었습니다. |
SQL_PERMISSION_ERROR |
403 | 권한 부족 | 해당 데이터에 대한 조회 권한이 없습니다. |
SQL_GENERATION_FAILED |
500 | SQL 생성 실패 | 질문을 SQL로 변환하지 못했습니다. |
SQL_NO_RESULTS |
200 | 결과 0건 | 조건에 해당하는 데이터가 없습니다. |
에러 컨텍스트를 포함하여 SQL 재생성. 재시도 시 의미 보존 검증 (기간조건, 상태값, 집계대상 불변 확인).
관련 테이블 1개로 제한하여 재시도. 복잡한 JOIN을 단순화합니다.
SQL 포기, 기존 RAG 파이프라인으로 자동 전환. "데이터 조회에 실패하여 문서 검색 결과로 응답합니다" 안내.
RAG도 결과 없으면 "다음과 같이 질문을 바꿔보세요: [예시]" 제안 메시지를 반환합니다.
| 보존 항목 | 검증 방법 |
|---|---|
| 기간 조건 | 원본 SQL의 DATE/TIMESTAMP 조건과 재생성 SQL의 조건 비교. 기간이 변경되었으면 거부. |
| 상태값 필터 | WHERE 절의 상태값(active, overdue 등) 유지 여부 확인 |
| 집계 대상 | SELECT 절의 집계 함수(COUNT, SUM 등)와 대상 컬럼 유지 여부 확인 |
| 대상 테이블 | 핵심 테이블이 제거되지 않았는지 확인. 변질 감지 시 Fallback 전환. |
신규 SQL 전용 엔드포인트 4개 + 기존 RAG 엔드포인트에 queryMode 확장.
/sql/query
독립 Text-to-SQL 조회
{
"question": "이번 달 신규 가입자 수는?",
"tables": ["subscribers"],
"maxRows": 100,
"offset": 0,
"model": null
}
{
"sql": "SELECT COUNT(*) AS ...",
"columns": ["신규가입자수"],
"rows": [[42]],
"row_count": 1,
"total_count": 42,
"natural_language_summary":
"이번 달 신규 가입자는 총 42명입니다.",
"caveats": ["이번 달 = 2026년 2월 기준"],
"execution_time_ms": 23.5,
"validation_passed": true,
"retries_used": 0
}
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| GET | /sql/schema |
사용 가능한 테이블 목록 + 설명 + 컬럼 수 + 행 수 추정값 |
| GET | /sql/schema/{table} |
테이블 상세 스키마 (컬럼 목록, PII 여부, 타입, 샘플 데이터, Few-shot 예제) |
| POST | /sql/validate |
SQL 검증만 수행 (실행 없음). 유효성, 위반 항목, 정제된 SQL, 예상 비용 반환 |
기본값. Query Router가 자동 판별
기존 RAG만 사용
Text-to-SQL만 사용
양쪽 결과 통합
SQL 생성 프롬프트 템플릿, Few-shot 선택 기준, 에러 보정 루프로 SQL 생성 정확도를 극대화합니다.
당신은 PostgreSQL SQL 전문가입니다.
사용자의 한국어 질문을 읽고 정확한 SQL SELECT 쿼리를 생성하세요.
### 규칙
1. SELECT 문만 생성하세요. INSERT/UPDATE/DELETE/DROP 등 금지.
2. PostgreSQL 문법을 사용하세요.
3. 반드시 아래 스키마에 존재하는 테이블과 컬럼만 사용하세요.
4. 한국어 문자열 값은 작은따옴표로 감싸세요.
5. 결과 행 수가 불확실하면 LIMIT 100을 추가하세요.
6. 컬럼 별칭은 한국어로 지정하세요 (예: COUNT(*) AS 건수).
7. SQL만 출력하세요. 설명이나 코드블록은 포함하지 마세요.
8. [마스킹 대상] 컬럼은 직접 SELECT하지 마세요.
{schema_context}
{few_shot_examples}
{retry_error_context}
### 질문
{question}
| 환경 | 모델 | VRAM |
|---|---|---|
| 개발 PC (GTX 1660 Ti 6GB) | qwen2.5:3b-instruct-q4_K_M (기존) |
~2GB |
| 운영 서버 | qwen2.5:7b-instruct-q4_K_M 또는 qwen2.5-coder:7b |
~4.5GB |
기존 파일 수정 범위, 환경별 설정 기준값, 롤백 시나리오를 포함한 운영 가이드입니다.
TEXT_TO_SQL_* 환경변수 18개 추가
Pydantic 모델 + 4개 신규 엔드포인트 + queryMode 추가
SQL 파이프라인 호출 + 컨텍스트 병합 + SSE 이벤트 추가
환경변수 추가 + sql_reader 역할 초기화 스크립트
sqlparse>=0.5.0 1개 패키지 추가
| 설정 | 개발 (PC) | 스테이징 | 운영 |
|---|---|---|---|
TIMEOUT_MS |
10000 | 5000 | 5000 |
MAX_ROWS |
1000 | 500 | 1000 |
MAX_COST |
50000 | 10000 | 10000 |
COST_CHECK_ENABLED |
false | true | true |
CONCURRENCY |
4 | 2 | 2 |
MODEL |
(기본모델) | (기본모델) | qwen2.5:7b |
| 로깅 레벨 | DEBUG | INFO | WARN |
# .env 수정
TEXT_TO_SQL_ENABLED=false
# API 컨테이너 재시작
docker compose restart api
SQL 관련 DB 테이블은 삭제하지 않음 (데이터 보존).
기존 RAG 파이프라인은 영향 없음.
재활성화: TEXT_TO_SQL_ENABLED=true 후 재시작.
| Step 2: 검증 항목 | 확인 방법 | 기대 결과 |
|---|---|---|
| SQL 엔드포인트 비활성화 | curl /sql/query |
404 또는 "기능 비활성화" 응답 |
| RAG queryMode=sql 무시 | POST /rag/query {"queryMode":"sql"} |
RAG로 처리, SQL 무시 |
| SSE 스트리밍 정상 | POST /rag/query/stream |
SQL 단계 이벤트 미발생 |
| 기존 RAG 정상 동작 | POST /rag/query {"question":"테스트"} |
기존과 동일한 응답 |
5개 Phase로 단계적 구현. 각 Phase는 독립적으로 검증 가능합니다.
settings.py 환경변수 추가, sql_store.py 생성, 시스템 테이블 3개 생성, sql_reader 역할 생성 + 권한 검증, 샘플 비즈니스 테이블 + 테스트 데이터, sql_schema_catalog 한국어 설명 등록.
text_to_sql.py 생성 — SchemaMetadataManager, SQLGenerator, SQLValidator (허용목록 주 통제), CostEstimator (환경별 정책), SQLExecutor (동시성 제어 + total_count), ResultFormatter (PII 마스킹), 의미 보존 검증. requirements.txt에 sqlparse 추가. 단위 테스트 작성.
api.py에 4개 엔드포인트 추가, Pydantic 모델, 에러 코드 체계 적용, sql_query_log 감사 로그 기록, Fallback 전략 구현, 통합 테스트.
query_router.py 생성, Query 모델에 queryMode 추가, rag_orchestrator.py 수정 (SQL 파이프라인 + 컨텍스트 병합 + 격리된 LLM 호출), Hybrid 충돌 해결, SSE 이벤트 추가.
프론트엔드 SQL 조회 탭/토글, 스키마 브라우저 UI, SQL 결과 테이블 렌더링, docker-compose.yml 환경변수, 환경별 설정 적용, 롤백 체크리스트 검증, 문서 정리.
POST /sql/query
SELECT COUNT(*) AS 미납건수 FROM payment_history WHERE payment_status = 'overdue' AND ...
4. SQLValidator → 단일 SELECT, 테이블 허용, PII 미접근 → 통과
5. CostEstimator → 비용 120 (상한 10000 이내) → 통과
6. SQLExecutor → 실행 → rows=[[7]], total_count=7
7. ResultFormatter → "이번 달 미납 건수는 총 7건입니다." + caveats
POST /rag/query {"queryMode": "auto"} → Router: "hybrid"
임베딩 → 하이브리드 검색 → 리랭킹 → "처리 절차" 관련 문서 컨텍스트 추출
스키마(관련 테이블만) → SQL 생성(독립 LLM 호출) → 검증 → 비용검사 → 실행 → "미납 현황" 데이터
SQL 실시간 데이터 + RAG 문서를 결합하여 LLM이 통합 답변 생성. answer + citations + sql_result 반환.
POST /sql/query → 복잡한 쿼리 → 검증 실패 → 재시도 → 비용 초과 → 재시도 → 성공
구현 시 해석 차이를 방지하기 위해 확정된 정책입니다.
| 정책 | 결정 | 환경변수 |
|---|---|---|
| SQL 실패 시 재시도 우선순위 | 프롬프트 수정 → 테이블 범위 축소 → RAG fallback | MAX_RETRIES=2 |
| queryMode=auto 분류 기준 | 규칙기반 단독으로 시작. 정확도 부족 시 LLM 보조 도입 | ROUTE_MODE=auto |
| 결과 요약 방식 | 단순 집계는 템플릿, 복합 결과(2개 이상 컬럼)는 LLM | - |
| 운영 모델 선택 | 단일 공용 모델로 시작. SQL 정확도 80% 미만 시 전용 모델 분리 | MODEL= |
| EXPLAIN 비용 검사 | 개발: off / 운영: on | COST_CHECK_ENABLED |
| 비용 초과 시 재시도 | 총 재시도 횟수에 포함 (별도 증가 없음) | MAX_RETRIES=2 |
| total_count 산출 | 결과 < LIMIT이면 행수, 아니면 별도 COUNT. 비용 높으면 null | - |
| 접근 통제 주 수단 | 앱 레벨 SQLValidator 허용목록 (DB 역할은 보조) | ALLOWED_TABLES |
| Hybrid 충돌 우선순위 | SQL(실시간 데이터) 우선, caveats에 안내 | - |
| 의미 보존 검증 | 재시도 시 기간/상태/집계 대상 변경 감지 → 변경 시 fallback | - |
기존 RAG 시스템에 정형 데이터 조회 기능을 추가하여 더욱 강력한 하이브리드 AI 시스템을 구축하세요.