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

[NL-to-SQL] Quest 1: 환경 세팅 + DB + 시맨틱 레이어 + pgvector RAG

···

이번 Quest 1에서 한 것

환경 구성부터 RAG 검색까지 전체 파이프라인의 기반을 완성했습니다.

단계내용
환경 세팅uv + Docker Compose (pgvector/pgvector:pg17)
DB 스키마이커머스 18개 테이블 설계 + Faker 더미 데이터 8,786행
시맨틱 레이어Snowflake 스타일 YAML 5개 작성
엔티티 분해YAML → 165개 독립 문서 (decomposer.py)
임베딩 적재Solar Embedding → pgvector UPSERT (embedder.py)
검색코사인 유사도 + 1-hop 팽창 (retriever.py)
평가 장치질문 36개 + Entity/Table Recall@K 자동 산출

환경 세팅: uv + Docker Compose

패키지 매니저는 uv로 사용했습니다. 모두가 아시다시피 Rust로 만들어져서 pip 대비 속도가 굉장히 빠르고 pip + venv + pip-compile + pyenv 역할을 하나로 통합해줘서 올인원이라 사용하기가 좋아서 사용했습니다. DB는 pgvector 공식 이미지로 간단하게~!

# docker-compose.yml (핵심 부분)
services:
  postgres:
    image: pgvector/pgvector:pg17
    container_name: nl2sql-postgres
    environment:
      POSTGRES_DB: nl2sql
      POSTGRES_USER: nl2sql
      POSTGRES_PASSWORD: nl2sql
    ports:
      - "5432:5432"

핵심 의존성은 이렇습니다:

# pyproject.toml dependencies
dependencies = [
    "openai>=1.0",           # Upstage Solar API (OpenAI 호환)
    "psycopg2-binary",       # PostgreSQL 드라이버
    "pgvector",              # pgvector Python 어댑터
    "python-dotenv",         # .env 환경변수
    "rich",                  # CLI 출력 예쁘게
    "pyyaml",                # 시맨틱 레이어 YAML 파싱
    "faker",                 # 더미 데이터 생성
    "jsonschema>=4.26.0",    # 문서 유효성 검사
    "textual>=8.2.4",        # TUI (Quest 2~)
]

.env는 아래와 같이 구성했습니다. 처음엔 임베딩 모델 변수가 하나였는데, 나중에 두 개로 쪼개지게 됩니다. (이유는 밑에서 설명합니다 — 삽질 2번)

UPSTAGE_API_KEY=your_api_key_here
UPSTAGE_BASE_URL=https://api.upstage.ai/v1
UPSTAGE_CHAT_MODEL=solar-pro
UPSTAGE_EMBEDDING_MODEL_PASSAGE=solar-embedding-1-large-passage
UPSTAGE_EMBEDDING_MODEL_QUERY=solar-embedding-1-large-query
DB_HOST=localhost
DB_PORT=5432
DB_NAME=nl2sql
DB_USER=nl2sql
DB_PASSWORD=nl2sql

공공데이터 대신 이커머스로 방향 전환

프롤로그에서 “서울 열린데이터 복지/관광/문화 멀티 도메인”으로 가겠다고 했는데, 시작하자마자 계획을 바꿨습니다 😅 적절한 공공데이터를 찾아야 하고, 팩트 체크도 별도로 필요하기 때문에 여기에 리소스 소모가 적지 않다고 판단해서 그냥 특정 도메인을 지정해서 직접 만드는 게 더 낫겠다고 생각했습니다.

그래서 이커머스 도메인을 지정해서 더미의 테이블 구조를 만들어서 진행하기로 했습니다. 이미 학습이 잘 되어 있는 LLM의 힘을 빌리면 아주 간단하기 때문이죠. 시간 절약 굳 그래서 다음과 같이 최종적으로 18개 테이블을 설계했습니다.

orders, products, customers, payments 같은 친숙한 테이블 구조는 JOIN 경로와 집계 패턴이 명확해서 SQL 생성 품질을 평가하기 좋습니다.

그룹테이블
상품brands, product_categories, products, inventory, inventory_movements
고객customers, addresses, sellers
주문orders, order_items, coupons, coupon_usage
결제/배송payments, shipping, shipping_events, returns, refunds
리뷰reviews

seed.py로 Faker 기반 더미 데이터 8,786행을 생성했습니다. 실제 쇼핑몰과 비슷한 비율로 주문, 결제, 리뷰 데이터를 분산시켰습니다.

Faker 라이브러리

Faker는 그럴듯한(?) 더미 데이터를 짧은 코드로 뽑아주는 파이썬 라이브러리입니다. 이 프로젝트에서 Faker를 쓴 이유는 “많이” 만들기보다 “자연스럽게” 만들기 위함입니다. 고객명·회사명·주소·상품 설명처럼 사람이 읽었을 때 어색하지 않아야 테스트 경험이 좋아지므로, 특히 Faker(“ko_KR”)로 한국 로케일을 썼기 때문에 이름/주소가 한국형 데이터에 가까워서 도메인 몰입감이 훨씬 좋습니다.

역할 분리:

도구담당
Faker이름, 이메일, 회사명, 주소, 설명문 등 텍스트 값
random상태 분포, 가격, 수량, 주문 개수 등 확률/숫자
시드 로직테이블별 생성 건수, 테이블 간 관계 연결

시드 로직이 뼈대를 만들고, Faker는 그 안을 자연스러운 값으로 채우는 역할입니다. 실제 seed.py의 고객 생성 부분을 줄이면 이런 모양입니다:

import random
from faker import Faker

random.seed(42)
Faker.seed(42)
fake = Faker("ko_KR")

def seed_customers(n=500):
    rows = []
    for _ in range(n):
        rows.append({
            "name": fake.name(),
            "email": fake.unique.email(),
            "phone": f"010-{random.randint(1000, 9999)}-{random.randint(1000, 9999)}",
            "address": fake.street_address(),
            "signup_source": random.choice(["web", "mobile", "referral"]),
        })
    return rows

Snowflake 스타일 시맨틱 레이어 YAML

18개 테이블을 비즈니스 관점으로 묶어서 5개 YAML 뷰를 작성했습니다:

  • ecommerce_orders — 주문/고객/결제
  • ecommerce_products — 상품/브랜드/카테고리/판매자
  • ecommerce_marketing — 쿠폰/할인 효과
  • ecommerce_reviews — 리뷰/평점
  • ecommerce_shipping — 배송/반품/환불

시맨틱 레이어의 핵심 가치는 LLM이 컬럼명이 아니라 비즈니스 의미로 데이터베이스를 이해하도록 돕는 것입니다. created_at이 주문일인지, 가입일인지, 리뷰 작성일인지 — 컬럼명만으론 알 수 없지만 시맨틱 레이어에서 order_date, join_date로 이름을 붙이고 synonyms를 달아두면 LLM이 올바른 컬럼을 선택할 확률이 훨씬 높아집니다.

orders.yaml 일부를 보면:

name: ecommerce_orders
description: 이커머스 주문, 고객, 주문 상품, 결제 관련 시맨틱 뷰

tables:
  - name: orders
    base_table:
      database: nl2sql
      schema: ecommerce
      table: orders

    dimensions:
      - name: order_status
        synonyms: [주문상태, 상태]
        description: 주문 상태 (pending, processing, shipped, delivered, cancelled, refunded)
        expr: order_status
        data_type: string
        is_enum: true

    time_dimensions:
      - name: order_date
        synonyms: [주문일, 주문일시, 주문날짜]
        description: 주문 생성 일시
        expr: created_at
        data_type: timestamp

    metrics:
      - name: revenue
        synonyms: [매출, 매출액, 총매출]
        description: 총 매출액
        expr: SUM(total_amount)

    filters:
      - name: completed_orders
        synonyms: [완료된 주문, 배송완료 주문]
        description: 배송 완료된 주문만 필터
        expr: order_status = 'delivered'

relationships:
  - name: order_customer
    left_table: orders
    right_table: customers
    relationship_columns:
      - left_column: customer_id
        right_column: id

verified_queries:
  - name: monthly_revenue
    question: 월별 매출 추이
    sql: >
      SELECT DATE_TRUNC('month', o.created_at) AS month,
             SUM(o.total_amount) AS revenue
      FROM ecommerce.orders o
      WHERE o.order_status = 'delivered'
      GROUP BY 1 ORDER BY 1

각 섹션의 역할을 한 줄씩 정리하면:

섹션역할
dimensions필터/GROUP BY에 쓰이는 속성 컬럼 + 동의어
time_dimensions날짜/시간 컬럼 (DATE_TRUNC, 기간 필터용)
facts집계 전 숫자 컬럼 (수량, 금액 등)
metrics집계 표현식 SUM/COUNT/AVG
filters자주 쓰는 WHERE 조건 재사용
relationships테이블 간 JOIN 경로
verified_queries검증된 SQL 예시 (Few-Shot용)

엔티티 분해 전략: YAML 1개 → N개 문서

YAML 전체를 통으로 임베딩하면 어떤 문제가 생길까요??? “월별 매출”이라는 질문이 들어올 때, orders.yaml 전체가 하나의 임베딩으로 뭉쳐 있으면 revenue metric이 적절히 랭킹될 수가 없습니다. shipping.yaml의 배송 정보와 섞이면서 점수가 희석되거든요 😮‍💨

그래서 decomposer.py는 YAML을 엔티티 단위로 쪼갭니다:

  • table — 테이블 설명 + base_table 정보
  • dimension / time_dimension / fact — 컬럼 1개씩
  • metric — 집계 표현식 1개씩
  • filter — WHERE 조건 1개씩
  • relationship — JOIN 경로 1개씩
  • verified_query — 질문-SQL 페어 1개씩

doc_id 규칙은 {view_name}.{entity_type}.{entity_name} 형태입니다.

예를 들어 ecommerce_orders.metric.revenue, ecommerce_orders.relationship.order_customer 같은 식으로 했습니다. 그리고 각 문서에 related_doc_ids를 달아서 엔티티 간 그래프를 연결합니다. 여기서 그래프DB를 사용하면 그래프RAG

revenue metric 문서는 order_date time_dimension, order_customer relationship 문서를 related로 갖고 있어서, 검색 후 1-hop 팽창으로 컨텍스트를 자동 확장할 수 있습니다 !

5개 YAML을 분해한 결과: 총 165개 문서가 생성됩니다.

pgvector 적재 + 삽질 2건 ⭐

embedder.py의 흐름은 간단합니다:

# embedder.py 핵심 흐름
all_docs = decompose_all(semantic_dir)                 # YAML → 165개 문서
texts = [build_embedding_input(doc) for doc in batch]  # title + text + keywords 합치기
embeddings = embed_batch(client, model, texts)         # Solar Embedding API 호출
upsert_documents(cur, batch, embeddings)               # ON CONFLICT DO UPDATE

execute_values로 배치 UPSERT를 처리해서 API 오류 시 배치 단위로 재시도 가능하도록 만들었습니다. 실제 실행하면 rich 프로그레스 바로 진행 상황이 보입니다:

$ uv run python -m src.knowledge.embedder
Embedding docs  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00
Upserted 165 docs into semantic_docs
{'total': 165, 'upserted': 165, 'skipped': 0, 'errors': 0}

그런데 적재 과정에서 두 가지 삽질이 있었습니다 😂

삽질 1: pgvector HNSW/IVFFlat 인덱스 2000차원 제한

ERROR: column cannot have more than 2000 dimensions for hnsw index

Solar Embedding이 4096차원을 반환하는데, pgvector의 HNSWHNSWHierarchical Navigable Small World의 약자입니다. 벡터를 계층적 그래프로 연결해 상위 계층에서 빠르게 이웃을 탐색하고 하위 계층에서 세밀하게 좁혀가는 ANN(근사 최근접 이웃) 인덱스입니다. 검색 속도/정확도가 우수해 벡터 DB에서 널리 쓰이지만 메모리 사용량이 큽니다. 인덱스는 최대 2000차원까지만 지원합니다. “그럼 IVFFlatIVFFlatInverted File with Flat compression의 약자입니다. 벡터 공간을 여러 클러스터로 나눈 뒤, 질의 벡터와 가까운 클러스터 내부만 순차 스캔하는 ANN(근사 최근접 이웃) 인덱스입니다. HNSW보다 메모리는 적게 쓰지만 빌드 전 충분한 학습 데이터가 필요합니다.은?”이라고 생각해서 바꿔봤더니 IVFFlat도 동일한 2000차원 제한이 있더라고요..ㅠ

4096차원 임베딩에서 ANN 인덱스를 쓰고 싶다면

pgvector 0.7+에서는 halfvec 타입(halfvec(4096))을 사용하면 HNSW 인덱스를 걸 수 있습니다. 또는 PCA로 차원을 줄여 2000 이하로 만드는 방법도 있어요. 이번엔 165개 문서 규모라서 sequential scan이 충분히 빠르기 때문에 인덱스를 스킵했습니다.

삽질 2: Solar 임베딩 모델 passage/query 분리

.envUPSTAGE_EMBEDDING_MODEL=solar-embedding-1-large를 넣고 테스트를 돌렸더니:

BadRequestError: Error code 400 - model 'solar-embedding-1-large' does not exist

API를 찔러보니 Upstage가 단일 모델에서 passage/query 분리 모델로 업데이트를 해뒀더라고요?? (언제 바꿨는지는 모르겠지만 공식 레퍼런스에서 확인했습니다)

Solar Embedding 모델명 주의

solar-embedding-1-large는 더 이상 존재하지 않습니다. 대신 두 가지 모델을 용도에 맞게 분리해서 써야 합니다:

  • solar-embedding-1-large-passage — 문서 인덱싱 시 사용 (embedder)
  • solar-embedding-1-large-query — 검색 쿼리 임베딩 시 사용 (retriever)

asymmetric embedding이라서 passage/query를 바꿔 쓰면 검색 품질이 크게 떨어질 수 있다고 합니다 !

환경변수를 UPSTAGE_EMBEDDING_MODEL_PASSAGEUPSTAGE_EMBEDDING_MODEL_QUERY 두 개로 쪼개고, embedder와 retriever에서 각각 다른 변수를 읽도록 수정했습니다.

검색 동작 확인

retriever.py의 동작은 세 단계입니다:

# 1. query 임베딩 (query 모델 사용)
query_vec = client.embeddings.create(model=QUERY_MODEL, input=query).data[0].embedding

# 2. 코사인 유사도 + 메타데이터 필터로 primary top-K 검색
SELECT ... FROM semantic_docs
WHERE (metadata->>'domain' = %s OR %s IS NULL)
  AND (entity_type = ANY(%s) OR %s IS NULL)
ORDER BY embedding <=> %s::vector
LIMIT %s

# 3. related_doc_ids 팽창 — primary 결과에 없는 관련 문서 추가 fetch

실제로 "월별 매출 추이"를 질의해보면:

$ uv run python -m src.knowledge.retriever "월별 매출 추이"

 Rank  entity_type      doc_id                                            similarity  title
    1  metric           ecommerce_orders.metric.revenue                       0.2639  총 매출액 (revenue)
    2  filter           ecommerce_orders.filter.recent_30_days                0.2535  최근 30일 주문 (recent_30_days)
    3  filter           ecommerce_orders.filter.recent_90_days                0.2531  최근 90일 주문 (recent_90_days)
    4  time_dimension   ecommerce_shipping.time_dimension.order_date          0.2494  주문 일시 (order_date)
    5  time_dimension   ecommerce_orders.time_dimension.order_date            0.2449  주문 생성 일시 (order_date)
    6  metric           ecommerce_orders.metric.revenue_per_customer          0.2283  고객당 평균 매출액 (revenue_per_customer)
    7  time_dimension   ecommerce_products.time_dimension.created_date        0.2261  상품 등록 일시 (created_date)
    8  time_dimension   ecommerce_marketing.time_dimension.added_at           0.2251  장바구니 추가 일시 (added_at)
    9  metric           ecommerce_orders.metric.order_count                   0.2179  주문 건수 (order_count)
   10  time_dimension   ecommerce_reviews.time_dimension.review_date          0.2131  리뷰 작성 일시 (review_date)
   -- 이하 expanded (related_doc_ids 팽창) --
   11  metric           ecommerce_marketing.metric.cart_item_count            expanded  장바구니 상품 건수
   12  table            ecommerce_marketing.table.cart_items                  expanded  장바구니 상품 정보
   13  dimension        ecommerce_orders.dimension.customer_id                expanded  주문한 고객 ID
   14  dimension        ecommerce_orders.dimension.order_status               expanded  주문 상태
   15  metric           ecommerce_orders.metric.avg_order_value               expanded  평균 주문 금액
   16  table            ecommerce_orders.table.orders                         expanded  고객 주문 정보
   ...
   27  table            ecommerce_shipping.table.orders                       expanded  주문 정보 (배송 참조용)

primary 10개에 revenue metric, order_date time_dimension, 날짜 필터들이 상위에 올라왔고, related_doc_ids 팽창으로 orders 테이블, order_status dimension 등 관련 문서들이 자동 포함되어 총 27개 문서가 컨텍스트로 모이는 것을 확인할 수 있습니다.

질문 세트 + 품질 평가 장치

나중에 Quest 2~3 비교를 위해 미리 평가 인프라를 만들어뒀습니다.

data/questions.json36개 한국어 질문을 작성했고, 각 질문마다 expected_doc_idsexpected_tables를 정답 라벨로 달아뒀습니다:

{
  "id": "Q001",
  "difficulty": "easy",
  "question": "월별 매출 추이를 보여줘",
  "expected_tables": ["orders"],
  "expected_doc_ids": [
    "ecommerce_orders.metric.revenue",
    "ecommerce_orders.time_dimension.order_date",
    "ecommerce_orders.verified_query.monthly_revenue"
  ]
}

난이도 분포는 easy 12개 / medium 16개 / hard 8개입니다. hard는 multi-hop JOIN이 필요한 질문들 (예: “리뷰 평점 4점 이상 상품의 최근 3개월 매출 상위 5개 브랜드”)로 채웠습니다.

eval_retrieval.py는 Entity Recall@K와 Table Recall@K를 자동으로 산출합니다. 실제 숫자는 Quest 2에서 Naive baseline과 Quest 3 RAG 버전을 비교하는 맥락에서 제대로 보여드릴 예정입니다.

마무리 + 다음 퀘스트 예고

Quest 1에서 갖춰진 것들:

  • PostgreSQL 17 + pgvector 데이터베이스
  • 18개 테이블 이커머스 스키마 + 8,786행 더미 데이터
  • Snowflake 스타일 시맨틱 레이어 YAML 5개
  • 165개 엔티티 문서로 분해 + 4096차원 Solar 임베딩
  • 코사인 유사도 + 1-hop 팽창 검색 (27개 컨텍스트 문서)
  • 36개 질문 + Recall@K 자동 평가 장치

다음은 Quest 2: Naive Text-to-SQL Baseline — RAG 없이 전체 스키마를 프롬프트에 통으로 때려박고 SQL을 생성해보도록 하겠습니다.