1293 lines
58 KiB
Markdown
1293 lines
58 KiB
Markdown
# 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 | 초안 작성 |
|