NL-to-SQL AI-Agent PostgreSQL pgvector RAG Python

자연어로 데이터베이스에게 말 걸기 — NL-to-SQL 에이전트 만들기

···

RAG는 주로 “비정형 텍스트”를 검색해서 컨텍스트로 주입하는 방식인데, 실무에서 마주치는 데이터 대부분은 정형 데이터, 즉 DB에 있는 경우가 많습니다.

사용자가 이런 질문을 던질 때:

ex)

강남구에 있는 장애인 거주시설 전체 목록 뽑아줘! 서울에서 아직 운영 중인 노인복지시설은 몇 개야?

SQL을 잘 모르는 비개발 직군의 사용자들도 원하는 데이터를 바로 조회할 수 해주는게 NL-to-SQL 에이전트의 목표입니다. 사용자에 따라 Text-to-SQL 에이전트라고 부르기도 합니다.

이번엔 공공데이터 + PostgreSQL

RAG Playground에서도 공공데이터포털을 썼는데, 이번에도 같은 출발점입니다. 다만 접근 방식이 조금 다릅니다.

이전엔 공공데이터 API를 실시간으로 호출해서 벡터 DB에 넣었다면, 이번엔 공공데이터포털에서 CSV/JSON/SQL 파일로 직접 내려받아 스키마를 설계하고 PostgreSQL에 적재하는 방식으로 진행됩니다.

서울 열린데이터에서 복지, 관광, 문화 같은 도메인별 데이터를 골라 멀티 도메인 구조로 15~20개 테이블을 설계할 예정입니다 💪

왜 PostgreSQL이냐면:

  • 최근 실무에서 가장 많이 쓰이고 라이센스로 부터 자유로운 RDBMS
  • pgvector 익스텐션으로 벡터 검색까지 한 DB에서 처리 가능 (All-in-One)

SQLite로 시작하는 것도 고려했지만, pgvector를 이전부터 사용해보고 싶기도 하고 간편하게 함께 쓰려면 PostgreSQL이 맞는 선택이라고 봤어요 !

RAG + NL-to-SQL, 왜 같이?

LLM이 SQL을 생성하려면 테이블 스키마를 알아야 하는데, 테이블이 수십 개라면 전부 프롬프트에 넣으면 LLM 이 잘못된 테이블을 선택할 가능성이 커지고, 토큰도 낭비고, 오히려 노이즈가 됩니다.

그래서 RAG로 관련 테이블 스키마만 검색해서 주입하는 방식을 씁니다. 여기서 핵심이 되는 게 시맨틱 레이어 입니다 📣

시맨틱 레이어가 뭔가?

이번 프로젝트의 핵심 개념 중 하나입니다. 테이블 스키마만 LLM에 넣으면, LLM은 FCLT_NM이 시설 이름인지, JRSD_SGG_NM이 관할 자치구인지 사람도 알기 어렵죠?? 컬럼명만 보고 의미를 추론해야 하니까 불확실성이 커질 수 있습니다.

시맨틱 레이어는 복잡한 원시 데이터 구조를 비즈니스 친화적인 용어와 일관된 지표 정의로 바꿔, 사용자와 분석 도구가 같은 의미로 데이터를 해석하도록 돕는 추상화 계층입니다. 그리고 Snowflake의 Semantic Views YAML Spec에서 YAML로 시맨틱 정보를 관리하는 방식을 레퍼런스로 삼았습니다 :)

ex)

# semantic/welfare/facilities.yaml
name: welfare_facilities
description: 서울시 장애인 거주시설 정보

tables:
  - name: welfare_facilities
    description: 서울시 장애인 거주시설 정보
    base_table:
      database: public_data
      schema: welfare
      table: welfare_facilities

    dimensions:
      - name: district
        synonyms: [자치구, ]
        description: 관할 자치구 (강남구, 송파구 등)
        expr: JRSD_SGG_NM
        data_type: string
      - name: facility_type
        synonyms: [시설종류, 거주시설유형]
        description: 시설 종류 (장애유형별 거주시설, 중증장애인 거주시설 등)
        expr: DISABLED_RESIDE_FCLT_KIND_NM
        data_type: string

    time_dimensions:
      - name: established_date
        synonyms: [설립일, 개소일]
        description: 시설 설립 일시
        expr: ESTBL_DATE
        data_type: timestamp

    facts:
      - name: capacity
        synonyms: [정원, 수용인원]
        description: 시설 정원
        expr: CAPACITY
        data_type: integer

    metrics:
      - name: facility_count
        synonyms: [시설 수, 시설 개수]
        description: 시설 수
        expr: COUNT(*)

    filters:
      - name: active_facilities
        synonyms: [운영 중인 시설]
        description: 현재 운영 중인 시설만 필터
        expr: status = 'active'

relationships:
  - name: facility_programs
    left_table: welfare_facilities
    right_table: welfare_programs
    relationship_columns:
      - left_column: id
        right_column: facility_id

verified_queries:
  - name: district_facility_count
    question: 자치구별 장애인 거주시설 수
    use_as_onboarding_question: true
    sql: >
      SELECT JRSD_SGG_NM AS district, COUNT(*) AS facility_count
      FROM welfare.welfare_facilities
      GROUP BY JRSD_SGG_NM ORDER BY facility_count DESC

이 YAML을 그대로 통째로 임베딩하지 않고, table, dimension, metric, relationship 단위로 분해해서 각각 독립 임베딩합니다.

사용자가 “강남구 장애인 시설”이라고 질문하면 메타데이터 필터 + pgvector 검색으로 district dimension과 facility_count metric 문서가 검색되고, related_doc_ids로 관련 relationship 문서까지 자동 팽창되어 LLM이 JRSD_SGG_NM = '강남구'로 필터해야 한다는 걸 알 수 있겠죠 😃

사용자 질문: "송파구 장애인 거주시설 수"

[메타데이터 필터] → domain=welfare, entity_type IN [metric, dimension, table]

[pgvector 벡터 검색] → facility_count(metric), district(dimension), welfare_facilities(table) 동시 후보

[컨텍스트 팽창] → related_doc_ids로 relationship 문서 자동 포함

[JOIN 경로 추론] → relationship 문서 기반

[pgvector 예시 검색] → verified_queries + 유사 질문-SQL 페어 선택

[SQL 생성] → Solar LLM으로 SQL 생성 (metrics, dimensions, relationships 컨텍스트 포함)

[SQL 검증] → EXPLAIN 체크 → 실패시 Self-Correction (최대 3회)

[PostgreSQL 실행] → 결과 반환

[결과 해석] → 자연어로 결과 요약

RAG가 “어느 테이블을 봐야 하는가”를 결정하고, 시맨틱 레이어가 “이 컬럼이 뭘 의미하는가”를 알려주고, NL-to-SQL이 “어떻게 조회할 것인가”를 담당합니다.

무엇을 만들 건가?

이번에도 퀘스트 형태로 단계적으로 발전시켜 나갈 예정이에요.

#퀘스트핵심 내용
1프로젝트 세팅 + DB + 시맨틱 레이어환경 구성, 멀티 도메인 DB, Snowflake 스타일 시맨틱 레이어, 엔티티 분해 + pgvector RAG
2Naive Text-to-SQL (Baseline)전체 스키마 주입 → Zero-shot SQL 생성 → 성능 측정
3시맨틱 RAG + Few-ShotRAG 기반 스키마 검색 + 동적 예시 선택 → 정확도 향상
4SQL 검증 + 자동 교정EXPLAIN 기반 검증 + 에러 피드백 Self-Correction 루프
5Multi-Turn 대화대화 히스토리 유지 + 후속 질문 (대명사/생략 해석)
6Agentic 통합ReAct 에이전트가 전 과정 자율 수행
7정량 평가 + 웹 앱EM/EX/Table Recall/Join Accuracy + Streamlit UI

RAG Playground 때는 검색 품질만 올리면 됐는데, 이번에는 검색 → SQL 생성 → 검증 → 실행 → 해석까지 파이프라인이 훨씬 깁니다.

한 단계만 틀어져도 쿼리가 터지니까, Self-Correction이나 에이전트 오케스트레이션이 왜 필요한지 체감할 수 있을 것 같습니다 👍

기술 스택

  • DB: PostgreSQL 17 + pgvector
  • LLM/Embedding: Upstage Solar (solar-pro + solar-embedding-1-large)
  • 시맨틱 레이어: Snowflake Semantic View 레퍼런스 기반 YAML
  • 데이터: 서울 열린데이터 + 공공데이터포털 CSV/JSON 다운로드
  • 패키지 매니저: uv
  • 컨테이너: Docker Compose
  • Web UI: Streamlit (마지막 퀘스트)

RAG Playground 때는 OpenAI와 Qdrant를 썼는데, 이번에는 다른 스택으로 가봅니다. Upstage Solar는 한국어 특화 모델이라 공공데이터 질의에 유리할 것 같고, pgvector를 쓰면 별도의 벡터 DB 없이 PostgreSQL 하나로 데이터 + 임베딩을 같이 관리할 수 있습니다 :)

이 시리즈에서 기록할 것들

RAG Playground와 마찬가지로, 잘 정리된 튜토리얼보다는 과정의 기록을 남기고 싶어요.

  • 어떤 공공데이터를 골랐고, 멀티 도메인 스키마를 어떻게 설계했는지
  • 시맨틱 레이어 YAML을 작성하면서 고민했던 것들
  • Baseline 대비 RAG가 정확도를 얼마나 올리는지 (숫자로)
  • Self-Correction이 실제로 동작하는 에러 케이스들
  • 에이전트가 스스로 판단하다 삽질하는 과정

NL-to-SQL은 엣지 케이스가 많은 분야라, 직접 부딪혀봐야 진짜 문제가 뭔지 보일 것 같습니다.


다음 포스트에서는 환경 세팅과 함께, 공공데이터 선정 및 PostgreSQL 멀티 도메인 스키마 설계부터 시작해볼게요~~!