58 KiB
58 KiB
LangGraph 기반 태스크 파이프라인 설계서 (메인)
Celery 기반 가사/노래/비디오 생성 파이프라인을 LangGraph + RAG로 전환한 설계안
목차
- 개요 및 핵심 차이점
- LangGraph 아키텍처 설계
- RAG 파이프라인 상세
- 마케팅 검색 및 외부 데이터 수집
- 지역 정보 검색 파이프라인
- 임베딩 저장 및 재사용
- 코드 구현
- 프롬프트 및 RAG 최적화 전략
- 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 | 초안 작성 |