[NL-to-SQL] Quest 1: 환경 세팅 + DB + 시맨틱 레이어 + pgvector RAG
NL-to-SQL Agent (4부작)
- 1
- 2 [NL-to-SQL] Quest 1: 환경 세팅 + DB + 시맨틱 레이어 + pgvector RAG (현재 글)
- 3
- 4
이번 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의
4096차원 임베딩에서 ANN 인덱스를 쓰고 싶다면
pgvector 0.7+에서는 halfvec 타입(halfvec(4096))을 사용하면 HNSW 인덱스를 걸 수 있습니다. 또는 PCA로 차원을 줄여 2000 이하로 만드는 방법도 있어요. 이번엔 165개 문서 규모라서 sequential scan이 충분히 빠르기 때문에 인덱스를 스킵했습니다.
삽질 2: Solar 임베딩 모델 passage/query 분리
.env에 UPSTAGE_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_PASSAGE와 UPSTAGE_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.json에 36개 한국어 질문을 작성했고, 각 질문마다 expected_doc_ids와 expected_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을 생성해보도록 하겠습니다.