고객의 카드 이용 내역을 분석해 나와 소비 성향이 비슷한 고객들이 더 많이 이용하는 업종을 찾아내고, 해당 업종에 혜택이 있는 카드를 GPT-4o-mini로 자연어 추천하는 시스템입니다.
| 레이어 | 기술 |
|---|---|
| Frontend | HTML5, jQuery 3.7, Bootstrap 5.3 |
| Backend | Python 3.11, FastAPI 0.115 |
| ML | scikit-learn (MiniBatchKMeans, cosine_similarity) |
| LLM | OpenAI GPT-4o-mini |
| 고객 DB | MySQL 8.0 (읽기 전용) |
| 벡터 DB | ChromaDB 0.5 |
| 캐시 | joblib pkl |
Browser (jQuery)
│ GET /api/customer/{seq}
│ POST /api/recommend
▼
FastAPI (uvicorn)
│
├─ 서버 시작 시 (1회)
│ MySQL → 청크 스트리밍(50,000행) → ThreadPool 병렬 전처리
│ → MiniBatchKMeans 학습 → pkl 캐시 저장
│ (재시작 시 pkl 로드로 수 초 내 완료)
│
└─ 추천 요청마다
비율 벡터 생성
→ Stage 1: KMeans 클러스터링으로 후보군 압축 (~35,000명)
→ Stage 2: 코사인 유사도 Top-30 + 갭 분석 → 타겟 업종 추출
→ Stage 3: GPT-4o-mini 자연어 추천 생성
(RAG_ENABLED=true 면 ChromaDB 카드 문서 grounding)
대분류 10개 업종 금액을 그대로 비교하면 고소비 고객끼리만 유사해지는 문제가 발생합니다. 이를 막기 위해 소비 성향(구성 비중) 으로 변환합니다.
① NULL → 0 대체 (미사용 업종)
② log1p 변환 (금액 왜도 완화)
③ L1 정규화 (합이 1인 10차원 비율 벡터)
→ 전 업종 미사용이면 균등값(0.1) 할당
117만 명 전체와 유사도를 계산하면 요청마다 수십 초가 걸립니다. MiniBatchKMeans(K=100)로 전체를 클러스터링하고, 대상 고객이 속한 클러스터 + 인접 클러스터 2개로 후보군을 약 35,000명으로 압축합니다.
코사인 유사도로 후보군 내 Top-30명 선별
↓
업종별 갭 분석:
Top-30 중 해당 업종 실제 이용자(ratio > 0)만 필터링
→ 그 사람들의 평균 vs 대상 고객의 비율 = gap
→ 활성 이용자가 5명 미만이면 클러스터 centroid로 폴백
→ gap >= 0.01 인 업종만 후보, 미이용 업종은 +0.05 가산점
→ 상위 3개 업종을 타겟으로 선정
해석: "나와 소비 성향이 비슷한 고객 중 이 업종을 실제로 쓰는 사람들은 평균 X%p 더 이용합니다"
RAG_ENABLED 값에 따라 두 경로로 분기합니다.
| 경로 | 조건 | 특징 |
|---|---|---|
| RAG 경로 | RAG_ENABLED=true |
ChromaDB에서 카드 문서 검색 후 GPT에 제공 → 카드명 포함 추천 |
| 인사이트 경로 | RAG_ENABLED=false (기본값) |
소비 패턴 분석 + 방향성만 제시 → 할루시네이션 없음 |
두 경로 모두 "소비를 늘리세요" 같은 지시적 표현을 금지하고, "비슷한 분들은 이 업종을 더 즐기시더라고요" 관점으로 자연스럽게 안내합니다.
.
├── backend/
│ ├── main.py # FastAPI 앱, lifespan 초기화
│ ├── config.py # 환경변수 로드 (pydantic BaseSettings)
│ ├── parameters.py # 알고리즘 파라미터 전체 (하드코딩 상수)
│ ├── db/
│ │ ├── mysql.py # SQLAlchemy 엔진 (읽기 전용)
│ │ └── chroma.py # ChromaDB 클라이언트
│ ├── models/
│ │ ├── customer.py # ORM 모델 (CUSTOMER_USAGE 테이블)
│ │ └── *.pkl # 학습된 모델 캐시 (gitignore)
│ ├── routers/
│ │ ├── customer.py # GET /api/customer/{seq}
│ │ └── recommendation.py # POST /api/recommend
│ ├── services/
│ │ ├── preprocess.py # 비율 벡터 생성
│ │ ├── recall.py # Stage 1: KMeans 후보 생성
│ │ ├── ranking.py # Stage 2: 유사도 + 갭 분석
│ │ └── llm.py # Stage 3: LLM 추천 생성
│ └── utils/
│ ├── constants.py # 업종 컬럼명, 레이블, 코드 매핑
│ └── cluster_store.py # 청크 로딩, 병렬 전처리, pkl 캐싱, 싱글톤
├── frontend/
│ ├── index.html
│ ├── css/style.css
│ └── js/app.js
├── scripts/
│ ├── ingest_cards.py # PDF → ChromaDB 적재 (사전 1회 실행)
│ ├── evaluate.py # 추천 파이프라인 성능 평가
│ └── pdfs/ # 카드 상품 안내 PDF 보관 위치
└── .env.example
pip install -r requirements.txt.env.example을 복사해 .env를 만들고 값을 채웁니다.
# MySQL
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=password
MYSQL_DB=card_rec
# OpenAI
OPENAI_API_KEY=sk-...
# ChromaDB
CHROMA_PERSIST_DIR=./chroma_db
CHROMA_COLLECTION=card_benefits
# RAG 활성화 (카드 PDF 적재 완료 후 true로 변경)
RAG_ENABLED=falsebackend/parameters.py에서 모든 튜닝 값을 관리합니다.
KMEANS_N_CLUSTERS = 100 # 클러스터 수 (전체 회원 수 / 목표 클러스터 크기)
RECALL_N_NEIGHBOR_CLUSTERS = 2 # 인접 클러스터 탐색 수
RANKING_TOP_K = 30 # 코사인 유사도 상위 몇 명
GAP_ACTIVE_MIN_COUNT = 5 # 활성 사용자 최소 인원 (미달 시 centroid 폴백)
GAP_MIN_THRESHOLD = 0.01 # 최소 유의미한 갭 크기
GAP_TOP_N = 3 # 최종 추천 업종 수
GAP_NEW_SECTOR_BONUS = 0.05 # 미소비 업종 우선순위 가산점uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000최초 실행 시 MySQL에서 전체 데이터를 청크 로딩해 KMeans 학습 후 pkl로 저장합니다. 이후 재시작부터는 pkl을 로드해 수 초 내 완료됩니다.
http://localhost:8000
del backend\models\*.pkl
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000카드 상품 PDF를 ChromaDB에 적재하면 실제 카드명과 혜택을 포함한 추천이 가능합니다.
scripts/pdfs/ 폴더에 우리카드 상품 안내장 PDF를 넣습니다.
파일명이 카드명으로 사용됩니다. (예: 우리WON트래블.pdf)
python -m scripts.ingest_cards500자 청킹 후 품질 필터링(80자 미만, 한글 비율 15% 미만, 보일러플레이트 제거)을 거쳐 ChromaDB에 저장됩니다.
.env에서 RAG_ENABLED=true로 변경 후 서버를 재시작합니다.
python -m scripts.evaluate다음 세 가지 항목을 측정합니다.
| 항목 | 지표 | 기준 |
|---|---|---|
| 클러스터링 품질 | Silhouette Score (cosine) | >0.5 우수 |
| 클러스터링 품질 | Davies-Bouldin Index | <0.5 우수 |
| 갭 분석 품질 | Coverage (추천 업종이 1개 이상인 고객 비율) | ≥80% 우수 |
| RAG 검색 품질 | 업종별 ChromaDB 검색 성공률 | 10/10 우수 |
고객 구분 코드로 최신 분기 기준 고객 정보와 업종별 소비 내역을 반환합니다.
Response 200
{
"seq": "123456789",
"bas_yh": "2024Q4",
"age": "30",
"mbr_rk_label": "플래티넘",
"life_stage_label": "신혼",
"tot_use_am": 850,
"macro_usage": [
{"column": "FSBZ_AM", "label": "요식업", "amount": 210},
{"column": "TRVLEC_AM", "label": "여행/레저/문화", "amount": 0}
]
}멀티 스테이지 추천 파이프라인을 실행하고 자연어 추천 결과를 반환합니다.
Request
{"seq": "123456789"}Response 200
{
"seq": "123456789",
"similar_user_count": 28,
"target_sectors": [
{"column": "TRVLEC_AM", "label": "여행/레저/문화", "gap": 0.18, "is_new": true}
],
"recommendation": "비슷한 소비 성향을 가진 고객들은 여행/레저 업종을...",
"rag_used": false
}- MySQL DB는 읽기 전용입니다.
SELECT외 모든 쿼리는 금지합니다. backend/models/*.pkl은.gitignore처리되어 있습니다. 환경마다 서버 최초 실행 시 재학습됩니다.RAG_ENABLED=false(기본값)일 때 LLM은 카드 상품명과 혜택 수치를 생성하지 않습니다.- 고객 SEQ는 가명처리된 코드이며 실명 등 개인 식별 정보는 포함되지 않습니다.