o2o-castad-backend/docs/plan/celery/celery-plan_랭그래프.md

1293 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# LangGraph 기반 태스크 파이프라인 설계서 (메인)
> **Celery 기반 가사/노래/비디오 생성 파이프라인을 LangGraph + RAG로 전환한 설계안**
---
## 목차
1. [개요 및 핵심 차이점](#1-개요-및-핵심-차이점)
2. [LangGraph 아키텍처 설계](#2-langgraph-아키텍처-설계)
3. [RAG 파이프라인 상세](#3-rag-파이프라인-상세)
4. [마케팅 검색 및 외부 데이터 수집](#4-마케팅-검색-및-외부-데이터-수집)
5. [지역 정보 검색 파이프라인](#5-지역-정보-검색-파이프라인)
6. [임베딩 저장 및 재사용](#6-임베딩-저장-및-재사용)
7. [코드 구현](#7-코드-구현)
8. [프롬프트 및 RAG 최적화 전략](#8-프롬프트-및-rag-최적화-전략)
9. [Celery 대비 비교](#9-celery-대비-비교)
---
## 1. 개요 및 핵심 차이점
### 1.1 설계 철학
기존 Celery 설계안은 태스크 큐 기반의 **작업 분배와 순차 실행**에 초점을 맞춥니다.
LangGraph 전환은 **상태 기반 그래프 오케스트레이션 + RAG 지식 강화**로 패러다임을 전환합니다.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Celery vs LangGraph 패러다임 비교 │
├──────────────────────────────────┬──────────────────────────────────────────┤
│ Celery (기존) │ LangGraph (전환) │
├──────────────────────────────────┼──────────────────────────────────────────┤
│ 태스크 큐 기반 │ 상태 그래프 기반 │
│ 워커가 작업을 소비 │ 노드가 상태를 변환 │
│ Redis 브로커로 메시지 전달 │ State 객체로 데이터 전달 │
│ 단순 프롬프트 → LLM 호출 │ RAG 강화 프롬프트 → LLM 호출 │
│ 정적 프롬프트 템플릿 │ 동적 컨텍스트 주입 + 검증 │
│ 실패 시 재시도 │ 조건부 분기 + 자기 수정 │
│ 결과 저장 후 다음 단계 발행 │ 상태 전이 + 체크포인트 자동 저장 │
└──────────────────────────────────┴──────────────────────────────────────────┘
```
### 1.2 핵심 이점
```
┌─────────────────────────────────────────────────────────────────┐
│ LangGraph 전환 핵심 이점 │
├─────────────────────────────────────────────────────────────────┤
│ 1. RAG 강화: 외부 검색 + 벡터 DB로 프롬프트 품질 대폭 향상 │
│ 2. 조건부 분기: 가사 품질 검증 후 재생성/통과 자동 결정 │
│ 3. 체크포인팅: 실패 시 정확한 지점부터 재시작 │
│ 4. Human-in-the-loop: 필요 시 사람 승인 후 진행 가능 │
│ 5. Pydantic 정형화: LLM 출력을 구조화된 데이터로 자동 변환 │
│ 6. 스트리밍: 실시간 진행 상황 전달 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. LangGraph 아키텍처 설계
### 2.1 전체 그래프 구조
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ LangGraph 파이프라인 전체 그래프 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────┐
│ Client │
└──────┬──────┘
│ POST /pipeline/start
┌────────────────────┐
│ FastAPI │
│ LangGraph invoke │
└─────────┬──────────┘
┌─────────────────────────────────────┐
│ LangGraph StateGraph │
│ │
│ ┌─────────────────────────────┐ │
│ │ START │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ marketing_search_node │ │
│ │ (외부 키워드 30건 검색) │ │
│ │ (리랭크 → 상위 스코어 필터) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ local_search_node │ │
│ │ (랜드마크/축제/여행지 검색) │ │
│ │ (각 10건 → 상위 3건 선택) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ rag_retrieval_node │ │
│ │ (벡터 DB에서 유사 문서 검색) │ │
│ │ (기존 검색 결과 재활용) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ embedding_store_node │ │
│ │ (새 검색 결과 임베딩 저장) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ lyric_generation_node │ │
│ │ (RAG 컨텍스트 + ChatGPT) │ │
│ │ (Pydantic 출력 정형화) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ lyric_validation_node │ │
│ │ (품질 검증 + 스코어링) │ │
│ └──────┬──────────┬────────────┘ │
│ [통과] ▼ ▼ [재생성] │
│ │ lyric_generation_node │
│ ▼ (루프백) │
│ ┌─────────────────────────────┐ │
│ │ song_generation_node │ │
│ │ (Suno API 호출 + 폴링) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ video_generation_node │ │
│ │ (Creatomate 렌더링) │ │
│ └──────────┬────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ END │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
### 2.2 State 정의 (TypedDict)
```python
from typing import TypedDict, Optional, Annotated
from pydantic import BaseModel, Field
import operator
class PipelineState(TypedDict):
"""LangGraph 파이프라인 상태 객체"""
# 기본 정보
task_id: str
customer_name: str
region: str
detail_region_info: str
language: str
orientation: str
genre: str
# RAG 컨텍스트
marketing_docs: list[dict] # 마케팅 검색 결과 (30건 → 필터링)
local_landmarks: list[dict] # 인근 랜드마크 (10건 → 3건)
local_festivals: list[dict] # 인근 축제 (10건 → 3건)
local_travel: list[dict] # 추천 여행지 (10건 → 3건)
rag_similar_docs: list[dict] # 벡터 DB 유사 문서
enriched_context: str # 최종 통합 컨텍스트
# 생성 결과
lyric_result: Optional[str]
lyric_structured: Optional[dict] # Pydantic 정형화 결과
lyric_score: float # 품질 점수
lyric_retry_count: int # 재생성 횟수
song_result_url: Optional[str]
song_duration: Optional[float]
video_result_url: Optional[str]
# 메타데이터
current_stage: str
error_message: Optional[str]
messages: Annotated[list, operator.add] # 로그 메시지 누적
```
### 2.3 Pydantic 출력 스키마
```python
from pydantic import BaseModel, Field
from typing import Optional
class LyricLine(BaseModel):
"""가사 한 줄"""
line_number: int = Field(description="줄 번호")
text: str = Field(description="가사 텍스트")
section: str = Field(description="섹션 (verse/chorus/bridge/outro)")
class LyricOutput(BaseModel):
"""LLM 가사 생성 정형화 출력"""
title: str = Field(description="곡 제목")
theme: str = Field(description="곡 테마/컨셉")
mood: str = Field(description="분위기 (예: 따뜻한, 활기찬)")
lines: list[LyricLine] = Field(description="가사 라인 목록")
total_lines: int = Field(description="총 가사 라인 수")
marketing_keywords: list[str] = Field(description="마케팅 키워드 3~5개")
local_references: list[str] = Field(description="지역 참조 요소")
language: str = Field(description="언어")
class SearchResultDoc(BaseModel):
"""검색 결과 문서"""
title: str
content: str
url: Optional[str] = None
relevance_score: float = Field(ge=0.0, le=1.0)
source: str = Field(description="출처 (web/rag/local)")
class MarketingContext(BaseModel):
"""마케팅 컨텍스트 정형화"""
business_name: str
business_category: str
target_keywords: list[str]
competitor_keywords: list[str]
regional_highlights: list[str]
recommended_tone: str
```
---
## 3. RAG 파이프라인 상세
### 3.1 문서 로드 → 청크 → 임베딩 → 저장
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ RAG 파이프라인 전체 흐름 │
└─────────────────────────────────────────────────────────────────────────────┘
[1단계: Document Loading]
━━━━━━━━━━━━━━━━━━━━━━━
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Web Search │ │ Local Files │ │ Vector DB │
│ (Tavily API) │ │ (기존 가사) │ │ (기존 임베딩) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
│ Document Loader (통합) │
│ - WebBaseLoader (외부 검색 결과) │
│ - TextLoader (기존 프롬프트/가사) │
│ - SQLDatabaseLoader (DB 저장 문서) │
└──────────────────────┬───────────────────────────┘
[2단계: Text Chunking]
━━━━━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ RecursiveCharacterTextSplitter │
│ │
│ chunk_size: 500 (마케팅 문서) │
│ chunk_overlap: 100 │
│ separators: ["\n\n", "\n", ".", " "] │
│ │
│ ※ 가사 전용 splitter: │
│ chunk_size: 200 (가사 라인 단위) │
│ separators: ["\n\n", "\n"] │
└──────────────────────┬───────────────────────────┘
[3단계: Embedding]
━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ OpenAI text-embedding-3-small │
│ │
│ dimensions: 1536 │
│ batch_size: 100 │
│ │
│ ※ 한국어 특화 시: multilingual-e5-large 고려 │
└──────────────────────┬───────────────────────────┘
[4단계: Vector Store]
━━━━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Chroma / Qdrant │
│ │
│ collection: "castad_marketing" │
│ metadata: { │
│ "source": "web_search | local | user", │
│ "region": "군산", │
│ "business": "스테이 머뭄", │
│ "category": "landmark | festival | travel", │
│ "created_at": "2024-01-01", │
│ "relevance_score": 0.85 │
│ } │
└──────────────────────────────────────────────────┘
```
### 3.2 검색 → 리랭크 → 검증
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Retrieval + Reranking + Validation │
└─────────────────────────────────────────────────────────────────────────────┘
[검색 단계]
━━━━━━━━━
쿼리: "군산 스테이 머뭄 카페 마케팅"
├── Vector Search (Top-K=20)
│ Chroma similarity_search_with_score()
├── Keyword Search (BM25)
│ BM25Retriever (키워드 기반 보완)
└── Ensemble (RRF 결합)
EnsembleRetriever(
retrievers=[vector, bm25],
weights=[0.6, 0.4]
)
[리랭크 단계]
━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Cross-Encoder Reranker │
│ │
│ 모델: BAAI/bge-reranker-v2-m3 │
│ 또는: Cohere Rerank API │
│ │
│ 입력: 쿼리 + 검색된 20개 문서 │
│ 출력: 관련성 점수 재계산 → 상위 K개 선택 │
│ │
│ threshold: 0.7 (이 이상만 통과) │
│ max_docs: 10 │
└──────────────────────┬───────────────────────────┘
[검증 단계]
━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Document Validation │
│ │
│ 1. 중복 제거 (cosine similarity > 0.95) │
│ 2. 최소 길이 검증 (50자 이상) │
│ 3. 언어 일치 검증 (한국어/영어) │
│ 4. 관련성 최종 확인 (LLM 판정) │
│ "이 문서가 {customer_name} 마케팅에 │
│ 관련이 있는가?" → yes/no │
│ 5. 최신성 검증 (1년 이내 우선) │
└──────────────────────────────────────────────────┘
```
### 3.3 프롬프트 템플릿 (RAG 컨텍스트 주입)
```python
LYRIC_GENERATION_TEMPLATE = """
당신은 O2O 마케팅 가사 전문 작사가입니다.
## 업체 정보
- 상호명: {customer_name}
- 지역: {region}
- 상세 위치: {detail_region_info}
- 언어: {language}
## 마케팅 참조 자료 (외부 검색)
{marketing_context}
## 지역 정보 참조
### 인근 랜드마크
{landmark_context}
### 인근 축제/행사
{festival_context}
### 추천 여행지
{travel_context}
## 유사 성공 사례 (RAG)
{rag_similar_context}
## 작성 지침
1. 위 참조 자료를 자연스럽게 녹여 가사에 반영하세요
2. 지역 랜드마크나 축제를 1~2개 언급하여 지역성을 살리세요
3. 마케팅 키워드를 자연스럽게 포함하세요
4. 4절 구성 (verse1 → chorus → verse2 → chorus → bridge → outro)
5. 각 절은 4~6줄로 구성하세요
## 출력 형식
반드시 아래 JSON 형식으로 출력하세요:
{output_schema}
"""
```
---
## 4. 마케팅 검색 및 외부 데이터 수집
### 4.1 마케팅 키워드 검색 (30건)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 마케팅 외부 검색 파이프라인 │
└─────────────────────────────────────────────────────────────────────────────┘
입력: customer_name="스테이 머뭄", region="군산"
[1단계: 키워드 확장]
━━━━━━━━━━━━━━━━━
LLM을 사용하여 검색 키워드 생성:
- 기본 키워드: "스테이 머뭄 군산"
- 확장 키워드: "군산 카페 마케팅", "군산 숙박 광고", "전북 카페 프로모션"
- 연관 키워드: "카페 노래 마케팅", "숙박업소 홍보 음악", "지역 맛집 CM송"
[2단계: 외부 검색 (30건 수집)]
━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Tavily Search API (또는 SerpAPI) │
│ │
│ 검색 쿼리 5개 × 각 6건 = 30건 │
│ │
│ Query 1: "{customer_name} 마케팅 사례" │
│ Query 2: "{region} 카페/숙박 프로모션" │
│ Query 3: "{region} 관광 마케팅 트렌드" │
│ Query 4: "O2O 마케팅 음악 광고 사례" │
│ Query 5: "{customer_name} {region} 리뷰" │
│ │
│ 각 쿼리당 max_results=6 │
└──────────────────────┬───────────────────────────┘
[3단계: 스코어링 + 필터링]
━━━━━━━━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Cross-Encoder Reranker │
│ │
│ 입력: 30건의 검색 결과 │
│ 기준 쿼리: "{customer_name} {region} 마케팅 가사" │
│ │
│ 출력: relevance_score 기준 정렬 │
│ 필터: score >= 0.6인 문서만 선택 │
│ 최종: 상위 10~15건을 프롬프트 참조 데이터로 사용 │
└──────────────────────────────────────────────────┘
```
### 4.2 검색 노드 구현
```python
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain.retrievers import EnsembleRetriever
from sentence_transformers import CrossEncoder
class MarketingSearchNode:
"""마케팅 외부 검색 노드"""
def __init__(self):
self.search_tool = TavilySearchResults(max_results=6)
self.reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
async def __call__(self, state: PipelineState) -> dict:
customer_name = state["customer_name"]
region = state["region"]
# 1. 검색 쿼리 생성 (5개)
queries = [
f"{customer_name} 마케팅 사례",
f"{region} 카페 숙박 프로모션 광고",
f"{region} 관광 마케팅 트렌드 2024",
f"O2O 마케팅 음악 광고 CM송 사례",
f"{customer_name} {region} 후기 리뷰",
]
# 2. 검색 실행 (30건)
all_results = []
for query in queries:
results = await self.search_tool.ainvoke(query)
all_results.extend(results)
# 3. 리랭크 (Cross-Encoder)
rerank_query = f"{customer_name} {region} 마케팅 가사 작성"
pairs = [(rerank_query, doc["content"]) for doc in all_results]
scores = self.reranker.predict(pairs)
# 4. 스코어 기준 필터링
scored_docs = []
for doc, score in zip(all_results, scores):
if score >= 0.6:
doc["relevance_score"] = float(score)
scored_docs.append(doc)
scored_docs.sort(key=lambda x: x["relevance_score"], reverse=True)
return {"marketing_docs": scored_docs[:15]}
```
---
## 5. 지역 정보 검색 파이프라인
### 5.1 상호명 기반 지역 정보 검색
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 지역 정보 검색 파이프라인 │
└─────────────────────────────────────────────────────────────────────────────┘
입력: customer_name="스테이 머뭄", region="군산"
[1단계: 랜드마크 검색 (10건)]
━━━━━━━━━━━━━━━━━━━━━━━━━━
검색 쿼리: "{region} 인근 랜드마크 관광지 명소"
→ Tavily 검색 10건
→ Cross-Encoder 리랭크
→ 상위 3건 선택 (score >= 0.5)
결과 예시:
1. "군산 경암동 철길마을" (score: 0.92)
2. "군산 근대역사박물관" (score: 0.88)
3. "군산 월명공원" (score: 0.81)
[2단계: 축제/행사 검색 (10건)]
━━━━━━━━━━━━━━━━━━━━━━━━━━━
검색 쿼리: "{region} 축제 행사 이벤트 {current_year}"
→ Tavily 검색 10건
→ Cross-Encoder 리랭크
→ 상위 3건 선택
결과 예시:
1. "군산 시간여행 축제" (score: 0.89)
2. "군산 세계철새축제" (score: 0.76)
3. "군산 벚꽃축제" (score: 0.72)
[3단계: 추천 여행지 검색 (10건)]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
검색 쿼리: "{region} 추천 여행지 가볼만한 곳"
→ Tavily 검색 10건
→ Cross-Encoder 리랭크
→ 상위 3건 선택
결과 예시:
1. "군산 신흥동 일본식 가옥" (score: 0.91)
2. "군산 선유도 해수욕장" (score: 0.85)
3. "군산 이성당 빵집" (score: 0.79)
[4단계: 통합 컨텍스트 생성]
━━━━━━━━━━━━━━━━━━━━━━━━
3개 카테고리 × 3건 = 9건의 참조 데이터
→ 프롬프트에 구조화된 형태로 주입
```
### 5.2 지역 검색 노드 구현
```python
class LocalSearchNode:
"""지역 정보 검색 노드"""
SEARCH_CATEGORIES = [
("landmarks", "{region} 인근 랜드마크 관광지 명소 볼거리"),
("festivals", "{region} 축제 행사 이벤트 문화행사"),
("travel", "{region} 추천 여행지 가볼만한 곳 맛집"),
]
def __init__(self):
self.search_tool = TavilySearchResults(max_results=10)
self.reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
async def __call__(self, state: PipelineState) -> dict:
region = state["region"]
customer_name = state["customer_name"]
results = {}
for category, query_template in self.SEARCH_CATEGORIES:
query = query_template.format(region=region)
# 10건 검색
search_results = await self.search_tool.ainvoke(query)
# 리랭크
rerank_query = f"{customer_name} {region} {category}"
pairs = [(rerank_query, doc["content"]) for doc in search_results]
scores = self.reranker.predict(pairs)
# 상위 3건 선택
scored = [
{**doc, "relevance_score": float(score)}
for doc, score in zip(search_results, scores)
]
scored.sort(key=lambda x: x["relevance_score"], reverse=True)
results[f"local_{category}"] = scored[:3]
return results
```
---
## 6. 임베딩 저장 및 재사용
### 6.1 검색 결과 임베딩 저장 전략
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 검색 결과 임베딩 저장 + 재사용 전략 │
└─────────────────────────────────────────────────────────────────────────────┘
[저장 시점]
━━━━━━━━━
마케팅 검색 30건 + 지역 검색 30건 = 총 60건의 검색 결과
├── 스코어 >= 0.5인 문서만 저장 대상
[임베딩 + 메타데이터 저장]
━━━━━━━━━━━━━━━━━━━━━━━
┌──────────────────────────────────────────────────┐
│ Chroma / Qdrant 벡터 DB │
│ │
│ Document: │
│ content: "군산 경암동 철길마을은..." │
│ metadata: │
│ source: "tavily_search" │
│ category: "landmark" │
│ region: "군산" │
│ business_name: "스테이 머뭄" │
│ relevance_score: 0.92 │
│ search_query: "군산 인근 랜드마크" │
│ created_at: "2024-01-15" │
│ ttl_days: 90 (90일 후 만료) │
└──────────────────────────────────────────────────┘
[재사용 시점]
━━━━━━━━━━━
새로운 요청: customer_name="카페 봄날", region="군산"
├── 벡터 DB 검색: region="군산" AND category="landmark"
│ → 이미 저장된 "군산 경암동 철길마을" 문서 발견!
│ → 외부 검색 생략 가능 (캐시 히트)
├── 만료 확인: created_at이 90일 이내인지 확인
│ → 만료되었으면 새로 검색
│ → 유효하면 재사용
└── 결과: 동일 지역 요청 시 검색 API 호출 절약
```
### 6.2 임베딩 저장 노드 구현
```python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain.schema import Document
class EmbeddingStoreNode:
"""검색 결과 임베딩 저장 노드"""
def __init__(self):
self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
self.vectorstore = Chroma(
collection_name="castad_marketing",
embedding_function=self.embeddings,
persist_directory="./chroma_db",
)
async def __call__(self, state: PipelineState) -> dict:
documents = []
# 마케팅 검색 결과 저장
for doc in state.get("marketing_docs", []):
documents.append(Document(
page_content=doc["content"],
metadata={
"source": "web_search",
"category": "marketing",
"region": state["region"],
"business_name": state["customer_name"],
"relevance_score": doc.get("relevance_score", 0),
"search_query": doc.get("query", ""),
"url": doc.get("url", ""),
},
))
# 지역 검색 결과 저장
for category in ["landmarks", "festivals", "travel"]:
for doc in state.get(f"local_{category}", []):
documents.append(Document(
page_content=doc["content"],
metadata={
"source": "web_search",
"category": category,
"region": state["region"],
"business_name": state["customer_name"],
"relevance_score": doc.get("relevance_score", 0),
},
))
# 벡터 DB에 저장 (중복 체크 후)
if documents:
self.vectorstore.add_documents(documents)
return {"messages": [f"임베딩 저장 완료: {len(documents)}건"]}
```
### 6.3 RAG 검색 노드 (캐시 우선)
```python
class RAGRetrievalNode:
"""벡터 DB에서 유사 문서 검색 (캐시 우선)"""
def __init__(self):
self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
self.vectorstore = Chroma(
collection_name="castad_marketing",
embedding_function=self.embeddings,
persist_directory="./chroma_db",
)
async def __call__(self, state: PipelineState) -> dict:
query = f"{state['customer_name']} {state['region']} 마케팅 가사"
# 메타데이터 필터로 동일 지역 문서 우선 검색
results = self.vectorstore.similarity_search_with_score(
query,
k=20,
filter={"region": state["region"]},
)
# 스코어 기준 필터
similar_docs = [
{
"content": doc.page_content,
"score": float(score),
"metadata": doc.metadata,
}
for doc, score in results
if score <= 0.8 # Chroma는 거리 기반이므로 낮을수록 유사
]
return {"rag_similar_docs": similar_docs[:10]}
```
---
## 7. 코드 구현
### 7.1 LangGraph 그래프 정의
```python
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
def build_pipeline_graph() -> StateGraph:
"""파이프라인 그래프 구성"""
graph = StateGraph(PipelineState)
# 노드 등록
graph.add_node("marketing_search", MarketingSearchNode())
graph.add_node("local_search", LocalSearchNode())
graph.add_node("rag_retrieval", RAGRetrievalNode())
graph.add_node("embedding_store", EmbeddingStoreNode())
graph.add_node("lyric_generation", LyricGenerationNode())
graph.add_node("lyric_validation", LyricValidationNode())
graph.add_node("song_generation", SongGenerationNode())
graph.add_node("video_generation", VideoGenerationNode())
graph.add_node("save_to_db", SaveToDBNode())
# 엣지 연결
graph.set_entry_point("marketing_search")
graph.add_edge("marketing_search", "local_search")
graph.add_edge("local_search", "rag_retrieval")
graph.add_edge("rag_retrieval", "embedding_store")
graph.add_edge("embedding_store", "lyric_generation")
graph.add_edge("lyric_generation", "lyric_validation")
# 조건부 엣지: 가사 검증 결과에 따라 분기
graph.add_conditional_edges(
"lyric_validation",
route_after_validation,
{
"pass": "song_generation",
"retry": "lyric_generation",
"fail": "save_to_db",
},
)
graph.add_edge("song_generation", "video_generation")
graph.add_edge("video_generation", "save_to_db")
graph.add_edge("save_to_db", END)
# 체크포인터 (실패 복구용)
checkpointer = SqliteSaver.from_conn_string("./checkpoints.db")
return graph.compile(checkpointer=checkpointer)
def route_after_validation(state: PipelineState) -> str:
"""가사 검증 후 라우팅"""
if state["lyric_score"] >= 0.7:
return "pass"
elif state["lyric_retry_count"] < 3:
return "retry"
else:
return "fail"
```
### 7.2 가사 생성 노드 (RAG 강화)
```python
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
class LyricGenerationNode:
"""RAG 강화 가사 생성 노드"""
def __init__(self):
self.llm = ChatOpenAI(
model="gpt-4o",
temperature=0.8,
).with_structured_output(LyricOutput)
async def __call__(self, state: PipelineState) -> dict:
# RAG 컨텍스트 조합
marketing_ctx = self._format_marketing(state["marketing_docs"])
landmark_ctx = self._format_local(state.get("local_landmarks", []))
festival_ctx = self._format_local(state.get("local_festivals", []))
travel_ctx = self._format_local(state.get("local_travel", []))
rag_ctx = self._format_rag(state.get("rag_similar_docs", []))
prompt = ChatPromptTemplate.from_template(LYRIC_GENERATION_TEMPLATE)
# Structured Output (Pydantic 정형화)
result: LyricOutput = await self.llm.ainvoke(
prompt.format(
customer_name=state["customer_name"],
region=state["region"],
detail_region_info=state["detail_region_info"],
language=state["language"],
marketing_context=marketing_ctx,
landmark_context=landmark_ctx,
festival_context=festival_ctx,
travel_context=travel_ctx,
rag_similar_context=rag_ctx,
output_schema=LyricOutput.model_json_schema(),
)
)
# 가사 텍스트 조합
lyric_text = "\n".join(line.text for line in result.lines)
return {
"lyric_result": lyric_text,
"lyric_structured": result.model_dump(),
"lyric_retry_count": state.get("lyric_retry_count", 0) + 1,
"current_stage": "lyric_completed",
}
def _format_marketing(self, docs: list[dict]) -> str:
if not docs:
return "(마케팅 참조 자료 없음)"
lines = []
for i, doc in enumerate(docs[:5], 1):
lines.append(f"{i}. [{doc.get('title', '무제')}] {doc['content'][:200]}")
return "\n".join(lines)
def _format_local(self, docs: list[dict]) -> str:
if not docs:
return "(지역 정보 없음)"
lines = []
for i, doc in enumerate(docs[:3], 1):
lines.append(f"{i}. {doc['content'][:150]}")
return "\n".join(lines)
def _format_rag(self, docs: list[dict]) -> str:
if not docs:
return "(유사 사례 없음)"
lines = []
for i, doc in enumerate(docs[:3], 1):
lines.append(f"{i}. {doc['content'][:200]}")
return "\n".join(lines)
```
### 7.3 가사 검증 노드
```python
class LyricValidationNode:
"""가사 품질 검증 노드"""
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
async def __call__(self, state: PipelineState) -> dict:
lyric = state["lyric_result"]
structured = state.get("lyric_structured", {})
# 규칙 기반 검증
rule_score = self._rule_based_check(lyric, state)
# LLM 기반 검증
llm_score = await self._llm_based_check(lyric, state)
# 종합 점수
final_score = rule_score * 0.4 + llm_score * 0.6
return {
"lyric_score": final_score,
"messages": [f"가사 검증 점수: {final_score:.2f}"],
}
def _rule_based_check(self, lyric: str, state: dict) -> float:
score = 1.0
lines = lyric.strip().split("\n")
# 최소 줄 수
if len(lines) < 12:
score -= 0.3
# 상호명 포함
if state["customer_name"] not in lyric:
score -= 0.2
# 지역명 포함
if state["region"] not in lyric:
score -= 0.1
# 최소 길이
if len(lyric) < 200:
score -= 0.3
return max(score, 0.0)
async def _llm_based_check(self, lyric: str, state: dict) -> float:
prompt = f"""
다음 마케팅 가사의 품질을 0.0~1.0 점수로 평가하세요.
업체: {state['customer_name']}
지역: {state['region']}
가사:
{lyric}
평가 기준:
1. 마케팅 메시지 전달력 (0.3)
2. 지역 특색 반영 (0.2)
3. 리듬감/운율 (0.2)
4. 감성적 호소력 (0.2)
5. 문법/자연스러움 (0.1)
숫자만 출력하세요 (예: 0.85):
"""
result = await self.llm.ainvoke(prompt)
try:
return float(result.content.strip())
except ValueError:
return 0.5
```
### 7.4 FastAPI 통합
```python
# app/api/routers/v1/pipeline.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel
router = APIRouter(prefix="/pipeline", tags=["Pipeline"])
# 그래프 초기화
pipeline_graph = build_pipeline_graph()
class StartPipelineRequest(BaseModel):
task_id: str
customer_name: str
region: str
detail_region_info: str
language: str = "Korean"
orientation: str = "vertical"
genre: str = "pop, ambient"
@router.post("/start")
async def start_pipeline(request: StartPipelineRequest):
initial_state: PipelineState = {
"task_id": request.task_id,
"customer_name": request.customer_name,
"region": request.region,
"detail_region_info": request.detail_region_info,
"language": request.language,
"orientation": request.orientation,
"genre": request.genre,
"marketing_docs": [],
"local_landmarks": [],
"local_festivals": [],
"local_travel": [],
"rag_similar_docs": [],
"enriched_context": "",
"lyric_result": None,
"lyric_structured": None,
"lyric_score": 0.0,
"lyric_retry_count": 0,
"song_result_url": None,
"song_duration": None,
"video_result_url": None,
"current_stage": "started",
"error_message": None,
"messages": [],
}
# 비동기 실행 (thread_id로 체크포인트 관리)
config = {"configurable": {"thread_id": request.task_id}}
result = await pipeline_graph.ainvoke(initial_state, config)
return {
"task_id": request.task_id,
"status": result["current_stage"],
"video_url": result.get("video_result_url"),
}
```
---
## 8. 프롬프트 및 RAG 최적화 전략
### 8.1 프롬프트 최적화
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 프롬프트 최적화 전략 (총 12가지) │
└─────────────────────────────────────────────────────────────────────────────┘
[A. 구조 최적화]
━━━━━━━━━━━━━━
1. Few-Shot 예시 주입
─────────────────
성공적인 가사 사례 3~5개를 프롬프트에 포함
벡터 DB에서 높은 평가를 받은 기존 가사를 자동 선택
→ 예상 효과: 품질 15~25% 향상
2. Chain-of-Thought (CoT) 적용
────────────────────────────
"먼저 업체 특성을 분석하고, 지역 특색을 파악한 후,
타겟 고객을 정의하고, 마케팅 메시지를 구성한 뒤,
가사를 작성하세요."
→ 예상 효과: 논리적 일관성 20% 향상
3. Role Prompting 강화
─────────────────────
"당신은 10년 경력의 O2O 마케팅 전문 작사가입니다.
CMC 광고음악 대상을 3회 수상한 경험이 있습니다."
→ 예상 효과: 전문성 있는 결과물
4. Output Formatting 명시
───────────────────────
Pydantic 스키마를 프롬프트에 포함하여
JSON 형식으로 정형화된 출력을 보장
→ 파싱 실패율 90% 이상 감소
[B. 컨텍스트 최적화]
━━━━━━━━━━━━━━━━━━
5. 동적 컨텍스트 윈도우
─────────────────────
LLM 토큰 제한에 맞춰 컨텍스트 양을 동적 조절
중요도 높은 문서를 앞에 배치 (Primacy Effect)
→ 토큰 낭비 방지 + 핵심 정보 전달
6. 컨텍스트 압축 (Contextual Compression)
──────────────────────────────────────
LLMChainExtractor로 검색된 문서에서
관련 부분만 추출하여 프롬프트에 포함
→ 노이즈 제거, 토큰 효율 50% 향상
7. 메타 프롬프트 (Self-Reflecting Prompt)
──────────────────────────────────────
생성 후 자가 평가 프롬프트를 추가:
"생성한 가사가 아래 기준에 부합하는지 평가하고,
미달 항목이 있다면 수정하세요."
→ 자가 품질 관리
[C. RAG 최적화]
━━━━━━━━━━━━━
8. Hybrid Search (Vector + BM25)
──────────────────────────────
벡터 검색(의미)과 BM25 검색(키워드)을 결합
EnsembleRetriever(weights=[0.6, 0.4])
→ 검색 재현율 25~35% 향상
9. Query Expansion (쿼리 확장)
───────────────────────────
LLM으로 원본 쿼리를 3~5개로 확장:
"군산 카페" → "군산 카페 문화", "전북 감성 카페",
"군산 신흥동 카페거리", "군산 카페 추천"
→ 검색 범위 확대, 누락 감소
10. Adaptive Retrieval (적응형 검색)
────────────────────────────────
첫 검색 결과의 품질이 낮으면 자동으로:
- 쿼리 재구성
- 검색 범위 확대
- 다른 검색 엔진 시도
→ 검색 실패 시 자동 복구
11. Time-Decay Scoring
──────────────────
검색 결과에 시간 가중치 적용:
score_final = relevance_score × time_decay_factor
time_decay = exp(-0.001 × days_since_creation)
→ 최신 정보 우선 반영
12. Feedback Loop (피드백 루프)
───────────────────────────
사용자 평가가 높았던 가사의 프롬프트/컨텍스트를
벡터 DB에 "성공 사례"로 저장
이후 유사 요청 시 참조 데이터로 우선 사용
→ 시간이 갈수록 품질 향상
```
### 8.2 RAG 성능 최적화 상세
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ RAG 성능 최적화 상세 (총 10가지) │
└─────────────────────────────────────────────────────────────────────────────┘
[1] 청킹 전략 최적화
━━━━━━━━━━━━━━━━━━━
현재: 고정 크기 청킹 (500자)
개선: Semantic Chunking (의미 단위 분할)
from langchain_experimental.text_splitter import SemanticChunker
chunker = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
→ 의미적으로 연관된 내용이 하나의 청크에 포함
→ 검색 정확도 15~20% 향상
[2] 임베딩 모델 튜닝
━━━━━━━━━━━━━━━━━━━
옵션 A: OpenAI text-embedding-3-large (3072차원)
- 높은 정확도, 높은 비용
옵션 B: multilingual-e5-large (한국어 특화)
- 한국어 성능 최적화, 로컬 실행 가능
옵션 C: text-embedding-3-small + Matryoshka (256차원)
- 저장 공간 80% 절약, 검색 속도 향상
→ 한국어 마케팅 도메인: multilingual-e5-large 권장
[3] 멀티 인덱스 전략
━━━━━━━━━━━━━━━━━━━
컬렉션을 목적별로 분리:
castad_marketing: 마케팅 문서 (외부 검색 결과)
castad_lyrics: 과거 생성된 가사 (성공 사례)
castad_local_info: 지역 정보 (랜드마크, 축제 등)
castad_templates: 프롬프트 템플릿 및 예시
→ 검색 시 목적에 맞는 인덱스만 조회하여 정확도 향상
[4] 메타데이터 필터링 강화
━━━━━━━━━━━━━━━━━━━━━━━━
검색 시 메타데이터 필터 적극 활용:
results = vectorstore.similarity_search(
query,
k=20,
filter={
"region": "군산",
"category": {"$in": ["marketing", "landmark"]},
"created_at": {"$gte": "2024-01-01"},
"relevance_score": {"$gte": 0.6},
}
)
→ 불필요한 문서 사전 제거, 검색 품질 향상
[5] Parent Document Retriever
━━━━━━━━━━━━━━━━━━━━━━━━━━━
작은 청크로 검색하되, 원본 (부모) 문서를 반환:
from langchain.retrievers import ParentDocumentRetriever
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(),
child_splitter=RecursiveCharacterTextSplitter(chunk_size=200),
parent_splitter=RecursiveCharacterTextSplitter(chunk_size=1000),
)
→ 정밀한 검색 + 풍부한 컨텍스트
[6] Contextual Retrieval (Anthropic 방식)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
각 청크에 문서 전체 맥락을 설명하는 프리픽스 추가:
원본 청크: "경암동 철길마을은 1944년에 개통된..."
보강 청크: "[이 문서는 군산의 관광명소를 소개하는 여행 가이드에서
발췌한 내용으로, 경암동 철길마을에 대한 설명입니다]
경암동 철길마을은 1944년에 개통된..."
→ 검색 정확도 49% 향상 (Anthropic 공식 벤치마크)
[7] Re-ranking 파이프라인 고도화
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1차: Vector Search (Top-50)
2차: BM25 Re-scoring
3차: Cross-Encoder Re-ranking (Top-20)
4차: LLM 기반 최종 필터링 (Top-10)
5차: 다양성 보장 (MMR - Maximal Marginal Relevance)
→ 단계별 필터링으로 최고 품질 문서만 선택
[8] 캐싱 전략
━━━━━━━━━━━
Redis에 검색 결과 캐싱:
cache_key = f"search:{region}:{category}:{hash(query)}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# 검색 실행 후 캐싱
redis.setex(cache_key, ttl=86400, value=json.dumps(results))
→ 동일 지역 반복 요청 시 API 호출 90% 절감
[9] Evaluation 파이프라인
━━━━━━━━━━━━━━━━━━━━━━━
RAG 품질을 자동 평가하는 파이프라인:
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
)
results = evaluate(
dataset=eval_dataset,
metrics=[faithfulness, answer_relevancy,
context_precision, context_recall],
)
→ 정기적 RAG 품질 모니터링 및 회귀 방지
[10] A/B 테스팅 프레임워크
━━━━━━━━━━━━━━━━━━━━━━━━
다양한 RAG 설정을 비교 실험:
Variant A: chunk_size=500, top_k=10, reranker=cross-encoder
Variant B: chunk_size=300, top_k=15, reranker=cohere
Variant C: semantic_chunk, top_k=20, reranker=none
→ 데이터 기반으로 최적 설정 도출
```
---
## 9. Celery 대비 비교
```
┌──────────────────────────┬──────────────────────┬──────────────────────────┐
│ 기준 │ Celery (기존) │ LangGraph + RAG (전환) │
├──────────────────────────┼──────────────────────┼──────────────────────────┤
│ 프롬프트 품질 │ 정적 템플릿 │ RAG 강화 동적 컨텍스트 │
│ 외부 데이터 활용 │ 없음 │ 검색 30건 + 지역 30건 │
│ 출력 정형화 │ 텍스트 파싱 │ Pydantic 자동 정형화 │
│ 품질 검증 │ 길이 체크만 │ 규칙 + LLM 점수화 │
│ 재생성 로직 │ 수동 재시도 │ 조건부 자동 루프백 │
│ 실패 복구 │ 재시도 (최대 3회) │ 체크포인트 기반 정밀 복구 │
│ 지식 축적 │ 없음 │ 벡터 DB 자동 축적 │
│ 스트리밍 │ 폴링 │ 실시간 스트리밍 │
│ 분산 실행 │ 워커 기반 수평 확장 │ 단일 프로세스 (외부 API) │
│ 인프라 복잡도 │ Redis + Worker 관리 │ 벡터 DB + LLM API │
│ 비용 │ 서버 비용 중심 │ LLM API 비용 중심 │
│ 적합한 상황 │ 대량 처리, 높은 동시성│ 품질 중심, 지식 강화 │
└──────────────────────────┴──────────────────────┴──────────────────────────┘
```
---
## 문서 버전
| 버전 | 날짜 | 변경 내용 |
|------|------|-----------|
| 1.0 | 2024-XX-XX | 초안 작성 |