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

58 KiB
Raw Blame History

LangGraph 기반 태스크 파이프라인 설계서 (메인)

Celery 기반 가사/노래/비디오 생성 파이프라인을 LangGraph + RAG로 전환한 설계안


목차

  1. 개요 및 핵심 차이점
  2. LangGraph 아키텍처 설계
  3. RAG 파이프라인 상세
  4. 마케팅 검색 및 외부 데이터 수집
  5. 지역 정보 검색 파이프라인
  6. 임베딩 저장 및 재사용
  7. 코드 구현
  8. 프롬프트 및 RAG 최적화 전략
  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)

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 출력 스키마

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 컨텍스트 주입)

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 검색 노드 구현

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 지역 검색 노드 구현

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 임베딩 저장 노드 구현

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 검색 노드 (캐시 우선)

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 그래프 정의

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 강화)

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 가사 검증 노드

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 통합

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