AI

sQuiry: 오픈소스 LLM과 RAG 기반 자연어-SQL 변환 서비스

블체. 2026. 2. 9. 00:10

서론

자연어로 쿼리를 생성하자

 

데이터베이스를 다루다 보면 비개발자가 원하는 데이터를 조회하기 위해 개발자에게 요청하거나, SQL을 배워야 하는 상황이 발생한다. "지난 달 매출이 가장 높은 상위 10개 제품을 보여줘"라는 간단한 요청도 복잡한 SQL 쿼리로 변환해야 한다.

이런 문제를 해결하기 위해 자연어를 SQL로 변환하는 시스템 sQuiry를 개발했다.

특히 상용 API 대신 Huggingface의 오픈소스 모델을 로컬에서 실행하고, RAG 기법으로 데이터베이스 스키마와 예제를 학습시켜 정확도를 높이는 것이 핵심이다.

 

프로젝트 개요

sQuiry는 자연어 질문을 SQL 쿼리로 변환하고 실행하는 API 서비스다. 사용자는 "지난 주 주문 수는?"과 같이 자연어로 질문하면, 시스템이 이를 SQL로 변환하여 실행하고 결과를 반환한다.

핵심 특징

  • 오픈소스 LLM을 로컬에서 실행 (비용 절감)
  • RAG 기반 스키마 학습 (정확도 향상)
  • Few-shot learning 지원
  • FastAPI 기반 RESTful API

기술 스택

  • Python 3.x
  • FastAPI - 웹 프레임워크
  • Vanna.ai - SQL 생성 프레임워크
  • Ollama - 로컬 LLM 실행 환경
  • ChromaDB - 벡터 데이터베이스
  • MariaDB - 타겟 데이터베이스
  • PyMySQL - DB 커넥터

 

아키텍처 설계

sQuiry의 전체 아키텍처는 다음과 같다.

사용자 질문 (자연어)
    ↓
FastAPI (엔드포인트)
    ↓
Vanna Engine (SQL 생성)
    ↓ (유사 예제 검색)
ChromaDB (RAG)
    ↓ (프롬프트 구성)
Ollama (LLM 추론)
    ↓
생성된 SQL
    ↓
MariaDB (실행)
    ↓
결과 반환 (JSON)

 

데이터 흐름

  1. 사용자가 자연어 질문을 API로 전송
  2. ChromaDB에서 유사한 질문-SQL 쌍 검색 (RAG)
  3. 데이터베이스 스키마 정보 조회
  4. 검색된 예제와 스키마로 프롬프트 구성
  5. Ollama를 통해 LLM에 전송
  6. 생성된 SQL을 MariaDB에서 실행
  7. 결과를 JSON으로 반환

 

핵심 구현 내용

1. Vanna.ai 커스터마이징

Vanna.ai는 SQL 생성을 위한 프레임워크다. 하지만 기본적으로 OpenAI API를 사용하기 때문에, Ollama를 사용하도록 커스터마이징했다.

class OllamaLLM:
    """Ollama LLM 래퍼"""
    
    def __init__(self, host: str, model: str):
        self.host = host
        self.model = model
    
    def generate(self, prompt: str) -> str:
        """Ollama API를 통해 텍스트 생성"""
        try:
            url = f"{self.host}/api/generate"
            payload = {
                "model": self.model,
                "prompt": prompt,
                "stream": False
            }
            
            response = requests.post(url, json=payload, timeout=120)
            response.raise_for_status()
            
            result = response.json()
            return result.get('response', '')
            
        except Exception as e:
            raise Exception(f"Ollama 생성 실패: {str(e)}")

Ollama는 HTTP API를 제공하므로 간단한 POST 요청으로 LLM을 호출할 수 있다. 타임아웃은 120초로 설정하여 대용량 쿼리 생성도 안정적으로 처리했다.

 

2. ChromaDB 기반 RAG 구현

ChromaDB는 벡터 데이터베이스로, 임베딩된 질문-SQL 쌍을 저장하고 유사도 검색을 수행한다.

class VannaGenerator(ChromaDB_VectorStore):
    """Vanna.ai 기반 SQL 생성기 (ChromaDB + Ollama)"""
    
    def __init__(self, config=None):
        ChromaDB_VectorStore.__init__(self, config=config)
        self.llm = OllamaLLM(
            host=config.get('ollama_host', 'http://localhost:11434'),
            model=config.get('ollama_model', 'llama3.1:8b')
        )
    
    def generate_sql(self, question: str, **kwargs) -> str:
        """자연어 질문으로부터 SQL 생성"""
        # 1. 관련 문서/스키마 검색 (RAG)
        similar_docs = self.get_similar_question_sql(question)
        
        # 2. 데이터베이스 스키마 정보 가져오기
        schemas = db_connection.get_table_schemas()
        
        # 3. 프롬프트 구성
        context = ""
        if similar_docs:
            context = "\n참고할 수 있는 유사한 예제:\n"
            for doc in similar_docs[:3]:
                context += f"Q: {doc.get('question', '')}\n"
                context += f"SQL: {doc.get('sql', '')}\n\n"

RAG의 핵심은 유사한 질문-SQL 쌍을 검색하여 프롬프트에 포함시키는 것이다. 이렇게 하면 LLM이 데이터베이스 구조와 쿼리 패턴을 더 잘 이해할 수 있다.

 

3. 프롬프트 엔지니어링

정확한 SQL을 생성하기 위해 프롬프트를 세밀하게 설계했다.

prompt = f"""당신은 SQL 전문가입니다. 사용자의 자연어 질문을 SQL 쿼리로 변환하세요.

{schema_text}
{context}

데이터베이스: MariaDB
질문: {question}

중요한 규칙:
1. 반드시 위에 명시된 테이블과 컬럼만 사용하세요
2. 존재하지 않는 테이블이나 컬럼을 절대 사용하지 마세요
3. 테이블명과 컬럼명을 정확히 확인하세요
4. SQL 쿼리만 반환하세요 (설명이나 다른 텍스트 없이)
5. 마크다운 코드 블록 없이 순수 SQL만 작성하세요
6. SELECT, INSERT, UPDATE, DELETE 등 표준 SQL을 사용하세요

SQL:"""

프롬프트에는 다음 정보를 포함시켰다.

  1. 스키마 정보 - 사용 가능한 테이블과 컬럼 목록
  2. 유사 예제 - RAG로 검색된 질문-SQL 쌍
  3. 명확한 규칙 - 환각을 방지하기 위한 제약사항

특히 "반드시 명시된 테이블만 사용"과 같은 규칙으로 LLM이 존재하지 않는 테이블을 생성하는 것을 방지했다.

 

4. 자동 스키마 학습

데이터베이스의 모든 테이블 스키마를 자동으로 학습시키는 기능을 구현했다.

def train_on_database_schema(self) -> bool:
    """데이터베이스 스키마를 자동으로 학습"""
    try:
        schemas = db_connection.get_table_schemas()
        
        for table_name, columns in schemas.items():
            # DDL 형식으로 변환
            ddl = f"CREATE TABLE {table_name} (\n"
            column_defs = []
            
            for col in columns:
                col_def = f"  {col['column_name']} {col['column_type']}"
                
                if col['is_nullable'] == 'NO':
                    col_def += " NOT NULL"
                
                if col['column_key'] == 'PRI':
                    col_def += " PRIMARY KEY"
                
                if col['extra']:
                    col_def += f" {col['extra']}"
                
                if col['comment']:
                    col_def += f" COMMENT '{col['comment']}'"
                
                column_defs.append(col_def)
            
            ddl += ",\n".join(column_defs)
            ddl += "\n);"
            
            self.train_on_ddl(ddl)
        
        return True

스키마 정보를 DDL 형식으로 변환하여 ChromaDB에 저장한다. 이를 통해 LLM이 테이블 구조를 정확히 파악할 수 있다.

 

5. Few-shot Learning

사용자가 질문-SQL 쌍을 직접 학습시킬 수 있는 기능을 제공했다.

def train_on_sql(self, question: str, sql: str) -> bool:
    """질문-SQL 쌍을 학습시킨 (Few-shot learning)"""
    try:
        self.vanna.train(question=question, sql=sql)
        return True
    except Exception as e:
        return False

자주 사용되는 복잡한 쿼리나 도메인 특화 쿼리를 학습시키면, 유사한 질문에 대해 더 정확한 SQL을 생성할 수 있다.

 

6. SQL 후처리

LLM이 생성한 응답에서 순수한 SQL만 추출하는 로직을 구현했다.

def _extract_sql(self, text: str) -> str:
    """응답에서 SQL 추출"""
    import re
    
    # 마크다운 코드 블록 제거
    text = re.sub(r'```sql\n?', '', text)
    text = re.sub(r'```\n?', '', text)
    
    # 앞뒤 공백 제거
    text = text.strip()
    
    # SQL 키워드로 시작하는 라인만 추출
    lines = text.split('\n')
    sql_lines = []
    for line in lines:
        line = line.strip()
        if any(line.upper().startswith(kw) for kw in ['SELECT', 'INSERT', 'UPDATE', 'DELETE']):
            sql_lines.append(line)
        elif sql_lines:
            sql_lines.append(line)
    
    if sql_lines:
        return ' '.join(sql_lines)
    
    return text

LLM이 종종 마크다운 코드 블록이나 설명 텍스트를 포함하여 응답하기 때문에, 이를 제거하고 순수한 SQL만 추출했다.

 

API 설계

RESTful API는 FastAPI로 구현했다. 주요 엔드포인트는 다음과 같다.

1. SQL 생성 (실행 없이)

@router.post("/generate-sql", response_model=SQLResponse)
async def generate_sql(request: QuestionRequest):
    """자연어 질문으로부터 SQL을 생성합니다."""
    try:
        start_time = time.time()
        sql = sql_generator.generate_sql(request.question)
        execution_time = time.time() - start_time
        
        return SQLResponse(
            question=request.question,
            sql=sql,
            execution_time=execution_time
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

사용자가 생성된 SQL을 검토한 후 직접 실행하고 싶을 때 사용한다.

 

2. SQL 생성 및 실행

@router.post("/execute", response_model=QueryResult)
async def execute_query(request: QuestionRequest):
    """자연어 질문으로부터 SQL을 생성하고 실행하여 결과를 반환합니다."""
    try:
        result = sql_generator.generate_and_execute(request.question)
        
        return QueryResult(
            question=result["question"],
            sql=result["sql"],
            data=result["data"],
            row_count=result["row_count"],
            execution_time=result["execution_time"]
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

SQL 생성과 실행을 한 번에 처리한다.

 

3. 스키마 학습

@router.post("/train/schema")
async def train_schema():
    """데이터베이스 스키마를 자동으로 학습합니다."""
    try:
        result = vanna_trainer.train_from_database()
        
        if result["status"] == "error":
            raise HTTPException(status_code=500, detail=result["message"])
        
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

 

4. 예제 학습

@router.post("/train/examples")
async def train_examples(request: TrainingRequest):
    """질문-SQL 쌍을 학습시켜 Few-shot learning을 수행합니다."""
    try:
        results = []
        for example in request.examples:
            success = vanna_trainer.train_question_sql(
                question=example.question,
                sql=example.sql
            )
            results.append({
                "question": example.question,
                "success": success
            })
        
        return {
            "status": "success",
            "trained_count": len([r for r in results if r["success"]]),
            "total_count": len(results),
            "details": results
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

 

 

개발 과정의 고민

1. 상용 API vs 오픈소스 모델

처음에는 OpenAI API를 사용하려 했다. 하지만 비용 문제와 데이터 프라이버시를 고려하여 오픈소스 모델을 선택했다.

장점:

  • 비용 절감 (무제한 사용 가능)
  • 데이터 외부 유출 없음
  • 커스터마이징 가능

단점:

  • 초기 설정 복잡도
  • 하드웨어 요구사항 (GPU 권장)
  • 모델 성능이 GPT-4보다 낮을 수 있음

Ollama를 사용함으로써 로컬에서 Llama 3.1, Mistral 등의 오픈소스 모델을 간편하게 실행할 수 있었다.

 

2. RAG의 중요성

처음에는 RAG 없이 스키마 정보만 프롬프트에 포함시켰다. 하지만 복잡한 조인이나 집계 쿼리에서 정확도가 떨어졌다.

RAG를 도입한 후:

  • 유사한 예제를 참고하여 더 정확한 쿼리 생성
  • Few-shot learning으로 도메인 특화 쿼리 개선
  • 컬럼명 오타나 존재하지 않는 테이블 사용 감소

ChromaDB는 벡터 검색이 빠르고 설정이 간단하여 RAG 구현에 적합했다.

 

3. 환각 방지

LLM은 종종 존재하지 않는 테이블이나 컬럼을 사용한다. 이를 방지하기 위해:

  1. 명시적 스키마 제공 - 사용 가능한 테이블과 컬럼을 명확히 나열
  2. 엄격한 규칙 - "명시된 테이블만 사용하라"는 지시
  3. SQL 검증 - 생성된 SQL을 실행 전에 구문 검사
  4. 에러 피드백 - 실패한 쿼리를 학습 데이터로 활용
  5.  

4. 프롬프트 최적화

프롬프트 설계가 SQL 품질에 가장 큰 영향을 미쳤다. 여러 번의 실험을 통해:

  • 규칙의 명시성 - 애매한 표현 대신 구체적인 지시
  • 예제 품질 - 잘못된 예제는 오히려 성능을 낮춤
  • 적절한 컨텍스트 - 너무 많은 정보는 혼란을 초래

최종적으로 스키마 + 상위 3개 예제 조합이 최적이었다.

 

5. 모델 선택

여러 오픈소스 모델을 테스트했다.

모델 크기 속도 SQL 정확도 비고

llama3.1:8b 4.7GB 빠름 우수 추천
mistral:7b 4.1GB 매우 빠름 양호 간단한 쿼리용
llama3.1:70b 40GB 느림 매우 우수 GPU 필수
codellama:13b 7.4GB 보통 우수 코드 특화

llama3.1:8b가 속도와 정확도의 균형이 가장 좋아 기본 모델로 선택했다.

 

성능 최적화

1. 비동기 처리

FastAPI의 async/await를 활용하여 동시 요청 처리 성능을 개선했다.

2. 타임아웃 설정

Ollama 요청에 120초 타임아웃을 설정하여 무한 대기를 방지했다.

 

향후 개선 계획

쿼리 히스토리 관리

실행된 쿼리와 결과를 데이터베이스에 저장하여 재사용하고 분석할 수 있도록 개선할 예정이다.

결과 시각화

JSON 결과를 차트나 그래프로 자동 시각화하는 기능을 추가할 계획이다.

멀티 데이터베이스 지원

현재는 MariaDB만 지원하지만, PostgreSQL, MySQL, Oracle 등 다양한 데이터베이스를 지원하도록 확장할 예정이다.

보안 강화

  • SQL 인젝션 방지
  • 읽기 전용 쿼리 제한
  • 사용자 인증 및 권한 관리
  • 쿼리 실행 로그 감사

Fine-tuning

도메인 특화 데이터로 오픈소스 모델을 Fine-tuning하여 정확도를 더 높일 수 있을 것 같다.

 

결론

sQuiry를 개발하면서 오픈소스 LLM과 RAG를 활용한 실용적인 서비스 구축의 가능성을 확인했다.

상용 API 대신 로컬 LLM을 사용함으로써 비용을 절감하고 데이터 프라이버시를 지킬 수 있었다.

특히 RAG를 통한 Few-shot learning이 SQL 생성 정확도에 큰 영향을 미친다는 것을 알게 되었다.

단순히 LLM을 호출하는 것이 아니라, 적절한 컨텍스트를 제공하는 것이 핵심이다.

 

프롬프트 엔지니어링에 대해서도 많이 배울 수 있었다. 단순히 페르소나를 부여하는 것 뿐만 아니라, AI가 데이터를 다루는 방식이나 속도에도 큰 영향을 끼친다는 것을 배웠다. 생각보다 훨씬 중요한 부분이었다.

 

앞으로도 지속적으로 기능을 개선하고, 다양한 도메인에 적용할 수 있는 범용적인 시스템으로 발전시킬 계획이다.

참고 자료

반응형