# 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 | 초안 작성 |