[AI] test_redis - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
ADR 101-105: 검색 고도화 및 서비스 확장
작성일: 2026-02-26 상태: 승인됨 (Accepted)
📚 목차
- ADR-101: Tavily Search API 도입 — 채용 트렌드 Phase 2 크롤링 고도화
- ADR-102: CPU-bound 비동기 태스크 Celery 이관 — text_extract/masking 504 타임아웃 해결
- ADR-103: Tavily Search API 전체 구현 요약
- ADR-104: RAG 리랭커 선정 — FlashRank 도입
- ADR-105: AI 서비스 Redis 사용 현황 및 AWS ElastiCache 연동 가이드
ADR-101: Tavily Search API 도입 — 채용 트렌드 Phase 2 크롤링 고도화
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-26 |
| 결정자 | AI팀 |
| 관련 기능 | 채용 트렌드 크롤링, VectorDB 적재, Celery 스케줄링 |
| 관련 ADR | ADR-094 (WebBaseLoader 크롤링 Phase 1), ADR-095 (크롤링 로드맵), ADR-096 (Celery/Redis) |
🎯 컨텍스트 (Context)
ADR-095에서 채용 트렌드 크롤링을 2단계 로드맵으로 수립했다.
Phase 1 (ADR-094, 완료) 한계
| 항목 | 문제점 | 영향 |
|---|---|---|
| URL 수동 관리 | TREND_CRAWL_URLS 환경변수에 직접 URL 나열 필요 |
새 채용 트렌드 URL 발견 불가 |
| JS 렌더링 불가 | WebBaseLoader는 정적 HTML만 파싱 | 사람인, 잡코리아 등 SPA 사이트 수집 불가 |
| 메타데이터 사후 추출 | LLM으로 텍스트에서 메타데이터 추출 | 추출 실패율 + Gemini API 비용 발생 |
| 레이트 제한 없음 | 서버 직접 요청 → IP 차단 위험 | 안정성 낮음 |
Phase 2 목표 (ADR-101, 이번 결정)
- 검색 쿼리 기반 자동 발견:
TREND_CRAWL_QUERIES에 검색어만 설정하면 관련 URL 자동 발견 - 메타데이터 자동 제공: Tavily가 title, URL, relevance score 자동 반환 → LLM 추출 불필요
- JS 렌더링 지원:
search_depth=advanced로 SPA 사이트 본문 추출 - 기존 Phase 1 병행: URL 기반 WebBaseLoader 크롤링은 그대로 유지 (하위 호환)
🔍 선택지 분석 (Options)
Option 1: LangChain Tavily 통합 (langchain-tavily) ⭐ 권장
from langchain_tavily import TavilySearch
tool = TavilySearch(
api_key=settings.tavily_api_key,
max_results=5,
search_depth="advanced", # 전체 본문 추출
include_raw_content=True,
)
results = await tool.ainvoke({"query": "2026 백엔드 채용 트렌드"})
| 장점 | 단점 |
|---|---|
| ✅ LangChain 생태계 일관성 | ⚠️ 월 $49-149 비용 (무료 티어 포함) |
| ✅ 검색 + 본문 추출 일체형 | ⚠️ TAVILY_API_KEY 환경변수 필수 |
| ✅ 메타데이터 자동 제공 | - |
| ✅ JS 렌더링 지원 (advanced) | - |
| ✅ 중복 필터링 내장 | - |
Option 2: Tavily Python SDK (tavily-python) 직접 사용
from tavily import AsyncTavilyClient
client = AsyncTavilyClient(api_key="...")
results = await client.search("쿼리", search_depth="advanced")
→ 기각: LangChain Tavily가 동일 기능 제공, 생태계 일관성 저하.
Option 3: SerpAPI / Bing Search API 사용
→ 기각: 채용 트렌드 특화 검색 품질 및 본문 추출 기능 미흡.
✅ 결정 (Decision)
Option 1 채택 — langchain-tavily 패키지를 통한 Tavily Search API 통합.
구현 전략:
기존 Phase 1 유지 신규 Phase 2 추가
──────────────────────────────── ──────────────────────────────────
TREND_CRAWL_URLS (URL 목록) → TREND_CRAWL_QUERIES (검색 쿼리 목록)
WebBaseLoader 크롤링 → Tavily 검색 + 본문 추출
LLM 메타데이터 추출 → Tavily 메타데이터 자동 제공
/crawl/trend (POST) → /crawl/trend/search (POST) 추가
crawl-trend-weekly (Beat) → search-trend-weekly (Beat) 추가 (30분 후)
metadata.source = "webbaseloader" → metadata.source = "tavily"
병행 운영: TREND_CRAWL_URLS 미설정 → Phase 1 스킵, TREND_CRAWL_QUERIES 미설정 → Phase 2 스킵. 두 모드 독립적으로 동작.
📁 구현 파일
| 파일 | 변경 | 내용 |
|---|---|---|
pyproject.toml |
수정 | langchain-tavily = "^0.1.0" 추가 |
app/config/settings.py |
수정 | Tavily 설정 4개 필드 추가 (ADR-101 섹션) |
app/services/tavily_search_service.py |
신규 | TavilySearchService 클래스 |
app/services/trend_crawler_service.py |
수정 | Phase 2 메서드 추가 (get_configured_queries, search_by_queries, trigger_tavily_task) |
app/tasks/trend_tasks.py |
수정 | search_trend_queries_task Celery 태스크 추가 |
app/tasks/celery_app.py |
수정 | search-trend-weekly Beat 스케줄 추가 |
app/api/routes/v2/crawl.py |
수정 | 3개 엔드포인트 추가 (/crawl/trend/search, /crawl/trigger/search, /crawl/queries) |
🔌 신규 API 엔드포인트
| 메서드 | 경로 | 설명 |
|---|---|---|
POST |
/ai/crawl/trend/search |
Tavily 쿼리 검색 + 적재 (수동) |
POST |
/ai/crawl/trigger/search |
Tavily Celery 태스크 수동 트리거 |
GET |
/ai/crawl/queries |
TREND_CRAWL_QUERIES 환경변수 조회 |
POST /ai/crawl/trend/search 요청/응답:
// 요청
{
"queries": ["2026 백엔드 채용 트렌드", "AI 엔지니어 채용 공고"],
"ingest_multi_vector": false
}
// 응답
{
"total_urls": 10,
"success_count": 9,
"documents": [
{"id": "uuid", "text_length": 3420, "metadata": {"source": "tavily", "title": "...", "score": 0.87}}
],
"multi_vector_ingested": 0
}
에러 응답:
| 상황 | HTTP | code |
|---|---|---|
TAVILY_API_KEY 미설정 |
503 | TAVILY_UNAVAILABLE |
| 검색 중 Tavily API 오류 | 500 | - |
📊 Phase 1 vs Phase 2 비교
| 항목 | Phase 1 (ADR-094) | Phase 2 (ADR-101) |
|---|---|---|
| 입력 | URL 목록 (TREND_CRAWL_URLS) |
검색 쿼리 (TREND_CRAWL_QUERIES) |
| URL 발견 | 수동 설정 | 자동 발견 |
| JS 렌더링 | ❌ 불가 | ✅ 가능 (advanced) |
| 메타데이터 | LLM 사후 추출 | Tavily 자동 제공 |
| 비용 | $0 | 월 $0~$149 (사용량에 따라) |
| 안정성 | 서버 차단 위험 | 보장된 레이트 제한 |
| source 태그 | webbaseloader |
tavily |
| Beat 스케줄 | crawl-trend-weekly (월 09:00) |
search-trend-weekly (월 09:30) |
⚙️ 환경변수 설정 가이드
# Tavily Search API (필수)
TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 검색 설정 (선택, 기본값 있음)
TAVILY_SEARCH_DEPTH=advanced # basic(빠름, 요약) / advanced(전체 본문)
TAVILY_MAX_RESULTS=5 # 쿼리당 최대 결과 수
# 검색 쿼리 목록 (쉼표 구분)
TREND_CRAWL_QUERIES=2026 백엔드 채용 트렌드,AI 엔지니어 채용 공고,주요 IT 기업 기술 면접 트렌드,프론트엔드 개발자 채용 동향
# Phase 1 URL 크롤링도 병행 가능 (선택)
TREND_CRAWL_URLS=https://example.com/tech-trends,...
Tavily API 키 발급: https://tavily.com
- 무료 플랜: 월 1,000 크레딧 (advanced 검색 1회 ≈ 1~5 크레딧)
- 기본 플랜: 월 $49 (무제한)
📊 결과 (Consequences)
긍정적 영향:
- 운영 편의성 향상: URL 수동 관리 → 검색 쿼리만 설정하면 자동 발견
- 수집 품질 향상: JS 렌더링 지원으로 SPA 사이트 본문 수집 가능
- LLM 비용 절감: 메타데이터 추출용 Gemini 호출 제거 (Tavily 자동 제공)
- 기존 호환 유지: Phase 1 그대로 병행 운영, 기존 API 미변경
부정적 영향 / 트레이드오프:
- 추가 비용: Tavily API 월 $0~$149 (쿼리 수 및 검색 깊이에 따라)
- 외부 의존성 추가: Tavily 서비스 장애 시 Phase 2 크롤링 불가 (Phase 1은 영향 없음)
- 환경변수 추가:
TAVILY_API_KEY미설정 시 503 에러 → AWS Parameter Store 등록 필요
후속 작업:
-
langchain-tavily패키지 추가 (pyproject.toml) -
TavilySearchService구현 (app/services/tavily_search_service.py) -
TrendCrawlerServicePhase 2 메서드 추가 - Celery 태스크 및 Beat 스케줄 추가
- API 엔드포인트 추가 (
/crawl/trend/search) - AWS Parameter Store
/Dev/AI/TAVILY_API_KEY등록 -
TREND_CRAWL_QUERIES환경변수 설정 (채용 트렌드 쿼리 목록) - 첫 실행 후 ChromaDB
trend_data컬렉션source=tavily문서 확인 - poetry.lock 업데이트 (
poetry lock --no-update)
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-26 | 초기 작성 — ADR-095 Phase 2 구체화, Tavily Search API 도입 결정. langchain-tavily 통합, 신규 API 3개 추가, Celery Beat 스케줄 추가. |
ADR-102: CPU-bound 비동기 태스크 Celery 이관 — text_extract/masking 504 타임아웃 해결
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | 🟡 제안됨 (Proposed) |
| 작성일 | 2026-02-26 |
| 결정자 | AI팀 |
| 관련 기능 | 이력서/채용공고 텍스트 추출, 개인정보 마스킹, 면접 QA 적재 |
| 관련 ADR | ADR-026 (Gunicorn+Uvicorn), ADR-094 (Celery 도입), ADR-096 (Redis/ElastiCache) |
🎯 컨텍스트 (Context)
프로덕션 환경에서 504 Gateway Timeout 오류가 빈번하게 발생하고 있다.
현재 상태
# app/api/routes/v2/text_extract.py (478번 줄)
asyncio.create_task(process_text_extract(task_storage))
# app/api/routes/v2/masking.py (206번 줄)
task = asyncio.create_task(process_masking(task_storage))
# app/api/routes/v2/evaluation.py (208번 줄)
asyncio.create_task(_ingest_interview_qa_best_effort(request))
┌─────────────────────────────────────────────────────────────────┐
│ 현재 아키텍처 (문제 상황) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Client] │
│ │ │
│ ▼ │
│ [Uvicorn 단일 Worker] ← 이벤트 루프 1개 │
│ │ │
│ ├─ POST /text-extract → asyncio.create_task(OCR 처리) │
│ │ ↓ │
│ │ [OCR + LLM 호출] ← CPU-bound, 30초~2분 소요 │
│ │ ↓ │
│ │ 이벤트 루프 블로킹! ❌ │
│ │ │
│ ├─ GET /task/{id} → 폴링 요청 (상태 확인) │
│ │ ↓ │
│ │ 대기... 대기... 504 Timeout! ❌ │
│ │ │
│ └─ 다른 요청들도 모두 블로킹 │
│ │
└─────────────────────────────────────────────────────────────────┘
Dockerfile 현재 설정
# Uvicorn 단독, 단일 프로세스
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
문제점:
asyncio.create_task()는 동일 이벤트 루프에서 실행됨- OCR/LLM 호출은 CPU-bound 또는 긴 외부 API 호출 (30초~2분)
- 단일 Uvicorn Worker에서 해당 작업 실행 중 다른 요청 처리 불가
- 폴링 요청(
GET /ai/task/{id})도 같은 Worker에 도착 시 대기 → 504 Timeout
요구사항:
- CPU-bound 작업을 별도 프로세스에서 실행하여 이벤트 루프 블로킹 방지
- 기존 폴링 API (
GET /ai/task/{id}) 호환 유지 - 이미 구축된 Celery/Redis 인프라 활용 (ADR-094, ADR-096)
🔍 선택지 분석 (Options)
Option 1: Docker Replicas 증가 (수평 스케일링)
# docker-compose.yml
ai-endpoint:
...
deploy:
replicas: 4 # 컨테이너 4개로 증가
┌─────────────────────────────────────────────────────────────────┐
│ Option 1: Docker Replicas │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Load Balancer] │
│ ├─ Container 1 (Uvicorn) ← OCR 처리 중 (블로킹) │
│ ├─ Container 2 (Uvicorn) ← 폴링 요청 처리 가능 ✅ │
│ ├─ Container 3 (Uvicorn) ← 다른 요청 처리 가능 ✅ │
│ └─ Container 4 (Uvicorn) ← 다른 요청 처리 가능 ✅ │
│ │
│ 장점: 설정 간단, 즉시 적용 가능 │
│ 단점: 메모리 4배 증가, 각 컨테이너 내부는 여전히 블로킹 │
│ │
└─────────────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 설정 간단 (1줄 추가) | 메모리 사용량 N배 증가 |
| 즉시 적용 가능 | 각 컨테이너 내부는 여전히 블로킹 |
| 기존 코드 변경 없음 | 동시 요청 수 제한 (컨테이너 수만큼) |
Option 2: Gunicorn + Uvicorn Workers (수직 스케일링) — 비컨테이너 환경 전용
⚠️ Docker/Kubernetes 환경에서는 불필요: Gunicorn은 단일 서버에서 멀티 프로세스를 관리하는 도구입니다. Docker Compose, Kubernetes 등 컨테이너 오케스트레이션 환경에서는 replicas로 수평 확장하므로 Gunicorn이 필요 없습니다. Docker가 프로세스 관리/재시작을 대신 담당합니다.
# 비컨테이너 환경 (단일 서버 직접 배포 시)
gunicorn app.main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
┌─────────────────────────────────────────────────────────────────┐
│ Option 2: Gunicorn + Uvicorn Workers (비컨테이너 환경) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Gunicorn Master] │
│ ├─ Worker 1 (Uvicorn) ← OCR 처리 중 (블로킹) │
│ ├─ Worker 2 (Uvicorn) ← 폴링 요청 처리 가능 ✅ │
│ ├─ Worker 3 (Uvicorn) ← 다른 요청 처리 가능 ✅ │
│ └─ Worker 4 (Uvicorn) ← 다른 요청 처리 가능 ✅ │
│ │
│ 적용 환경: Docker/Kubernetes 없이 단일 서버에 직접 배포 시 │
│ 현재 프로젝트: Docker Compose 사용 → Option 1 (replicas) 적용 │
│ │
└─────────────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 메모리 효율적 (프로세스 간 공유) | gunicorn 의존성 추가 필요 |
| 단일 서버에서 멀티 프로세스 관리 | 각 Worker 내부는 여전히 블로킹 |
| Worker 재시작 (장애 복구) | Docker 환경에서는 불필요 |
현재 프로젝트 적용 여부: ❌ 미적용 (Docker Compose 사용 중이므로 replicas로 대체)
Option 3: Celery 태스크 이관 (근본 해결) ⭐ 권장
# 현재 (문제)
asyncio.create_task(process_text_extract(task_storage))
# 개선 (Celery)
from app.tasks.text_extract_tasks import process_text_extract_task
process_text_extract_task.delay(task_id, request_data)
┌─────────────────────────────────────────────────────────────────┐
│ Option 3: Celery 태스크 이관 (권장) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Client] │
│ │ │
│ ▼ │
│ [FastAPI / Uvicorn] │
│ │ │
│ ├─ POST /text-extract │
│ │ ↓ │
│ │ task.delay(task_id, data) → Redis Queue 전송 │
│ │ ↓ │
│ │ 즉시 202 Accepted 반환 ✅ (블로킹 없음) │
│ │ │
│ ├─ GET /task/{id} → Redis에서 상태 조회 │
│ │ ↓ │
│ │ 즉시 응답 ✅ (블로킹 없음) │
│ │ │
│ [Celery Worker] (별도 프로세스/컨테이너) │
│ │ │
│ └─ process_text_extract_task 실행 │
│ ↓ │
│ OCR + LLM 호출 (CPU-bound, 30초~2분) │
│ ↓ │
│ Redis에 결과 저장 │
│ │
│ 장점: 이벤트 루프 완전 분리, 기존 인프라 활용 │
│ 단점: 코드 리팩토링 필요, 태스크 직렬화 고려 │
│ │
└─────────────────────────────────────────────────────────────────┘
| 장점 | 단점 |
|---|---|
| 이벤트 루프 완전 분리 (근본 해결) | 코드 리팩토링 필요 |
| 기존 Celery/Redis 인프라 활용 (ADR-094, 096) | 태스크 인자 직렬화 고려 필요 |
| 독립 스케일링 (Worker 수 조절) | 디버깅 복잡도 증가 |
| 재시도/실패 처리 내장 | - |
| 트렌드 크롤링과 동일 패턴 | - |
✅ 결정 (Decision)
Option 3 (Celery 태스크 이관)을 선택합니다.
단, 단계적 적용을 권장합니다:
| 단계 | 내용 | 효과 |
|---|---|---|
| Phase 0 (즉시) | Docker replicas 2~4개 증가 | 임시 완화 |
| Phase 1 (단기) | text_extract, masking Celery 이관 |
504 근본 해결 |
| Phase 2 (중기) | evaluation QA 적재 Celery 이관 |
완전 분리 |
ℹ️ Gunicorn 미적용: 현재 Docker Compose 환경에서는 replicas로 수평 확장하므로 Gunicorn + Uvicorn Workers 구성이 불필요합니다. Gunicorn은 Docker/Kubernetes 없이 단일 서버에 직접 배포할 때 사용합니다.
근거:
- 근본 원인 해결:
asyncio.create_task()의 이벤트 루프 블로킹 문제를 완전히 해결 - 기존 인프라 활용: ADR-094, ADR-096에서 이미 Celery/Redis 구축 완료
- 일관된 패턴: 트렌드 크롤링(
trend_tasks.py)과 동일한 패턴 적용 - 독립 스케일링: CPU-bound 작업량에 따라 Celery Worker만 증설 가능
🎯 최종 구현 요약 (한눈에 보기)
핵심 변경 사항
┌─────────────────────────────────────────────────────────────────┐
│ 🔴 현재 (504 타임아웃 발생) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ POST /text-extract 요청 │
│ ↓ │
│ FastAPI (Uvicorn 단일 Worker) │
│ ↓ │
│ asyncio.create_task(process_text_extract) ← 같은 이벤트 루프 │
│ ↓ │
│ OCR + LLM 처리 (30초~2분) ← 이벤트 루프 블로킹! │
│ ↓ │
│ GET /task/{id} 폴링 요청 → 대기... → 504 Timeout ❌ │
│ │
└─────────────────────────────────────────────────────────────────┘
↓ 변경 후 ↓
┌─────────────────────────────────────────────────────────────────┐
│ 🟢 개선 (504 해결) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ POST /text-extract 요청 │
│ ↓ │
│ FastAPI (Uvicorn) │
│ ↓ │
│ task.delay(task_id, data) → Redis Queue 전송 (즉시 반환) ✅ │
│ ↓ │
│ 202 Accepted 응답 (블로킹 없음) │
│ │
│ [별도 프로세스] Celery Worker │
│ ↓ │
│ OCR + LLM 처리 (30초~2분) ← FastAPI와 완전 분리 │
│ ↓ │
│ Redis에 결과 저장 │
│ │
│ GET /task/{id} 폴링 요청 → Redis 조회 → 즉시 응답 ✅ │
│ │
└─────────────────────────────────────────────────────────────────┘
구체적 코드 변경 예시
Before (text_extract.py):
# 478번 줄 — 문제의 코드
asyncio.create_task(process_text_extract(task_storage))
return {"task_id": task_id, "status": "processing"}
After (text_extract.py):
# Celery 태스크로 변경
from app.tasks.text_extract_tasks import process_text_extract_task
# Redis에 태스크 등록 (즉시 반환, 블로킹 없음)
process_text_extract_task.delay(
task_id=task_id,
file_urls=request.file_urls,
ocr_provider=request.ocr_provider,
# ... 직렬화 가능한 데이터만 전달
)
return {"task_id": task_id, "status": "processing"}
신규 파일 (text_extract_tasks.py):
from app.tasks.celery_app import celery_app
@celery_app.task(bind=True, name="app.tasks.text_extract_tasks.process_text_extract_task")
def process_text_extract_task(self, task_id: str, file_urls: list, ocr_provider: str, ...):
"""텍스트 추출 Celery 태스크 — 별도 Worker 프로세스에서 실행."""
# 기존 process_text_extract 로직을 여기로 이동
# Redis에 진행 상태 업데이트
# 완료 시 Redis에 결과 저장
docker-compose.yml 변경
# 기존 celery-worker (트렌드 크롤링 전용)
celery-worker:
command: celery -A app.tasks.celery_app worker --loglevel=info -Q trend_crawl
# 추가: text_extract, masking 전용 Worker
celery-worker-extract:
build:
context: .
dockerfile: Dockerfile
image: ai-endpoint:v2
container_name: celery_worker_extract
command: celery -A app.tasks.celery_app worker --loglevel=info -Q text_extract,masking,evaluation
environment:
# ... (ai-endpoint와 동일한 환경변수)
depends_on:
- vectordb
networks:
- ai_network
요약: 무엇이 바뀌나?
| 항목 | 현재 | 변경 후 |
|---|---|---|
| OCR/LLM 실행 위치 | FastAPI 프로세스 내부 | Celery Worker (별도 프로세스) |
| POST /text-extract 응답 | 작업 시작 후 반환 | 즉시 반환 (Redis에 등록만) |
| GET /task/{id} 응답 | 블로킹 위험 | 즉시 응답 (Redis 조회만) |
| 504 타임아웃 | 발생 | 해결 |
| docker-compose.yml | celery-worker 1개 | celery-worker-extract 추가 |
📁 구현 파일
| 파일 | 변경 | 내용 |
|---|---|---|
app/tasks/text_extract_tasks.py |
신규 | process_text_extract_task Celery 태스크 |
app/tasks/masking_tasks.py |
신규 | process_masking_task Celery 태스크 |
app/tasks/evaluation_tasks.py |
신규 | ingest_interview_qa_task Celery 태스크 |
app/tasks/celery_app.py |
수정 | 새 태스크 등록 |
app/api/routes/v2/text_extract.py |
수정 | asyncio.create_task → task.delay |
app/api/routes/v2/masking.py |
수정 | asyncio.create_task → task.delay |
app/api/routes/v2/evaluation.py |
수정 | asyncio.create_task → task.delay |
docker-compose.yml |
수정 | Celery Worker 큐 추가 (-Q text_extract,masking,evaluation) |
📊 현재 vs 개선 비교
| 항목 | 현재 (asyncio.create_task) |
개선 (Celery) |
|---|---|---|
| 실행 위치 | FastAPI 이벤트 루프 (동일 프로세스) | Celery Worker (별도 프로세스) |
| 블로킹 | ❌ 이벤트 루프 블로킹 | ✅ 블로킹 없음 |
| 폴링 응답 | 504 Timeout 위험 | 즉시 응답 |
| 스케일링 | Uvicorn Worker 수 제한 | Celery Worker 독립 스케일링 |
| 재시도 | 수동 구현 필요 | Celery 내장 |
| 모니터링 | 제한적 | Flower, Prometheus 연동 |
📊 Celery 적용 현황
| 기능 | 현재 방식 | 개선 후 |
|---|---|---|
| 트렌드 크롤링 | ✅ Celery 태스크 | ✅ 유지 |
| text_extract | ❌ asyncio.create_task() |
✅ Celery 태스크 |
| masking | ❌ asyncio.create_task() |
✅ Celery 태스크 |
| evaluation (QA 적재) | ❌ asyncio.create_task() |
✅ Celery 태스크 |
📊 결과 (Consequences)
긍정적 영향:
- 504 Timeout 해결: CPU-bound 작업이 별도 프로세스에서 실행되어 이벤트 루프 블로킹 제거
- 폴링 응답 즉시화:
GET /ai/task/{id}요청이 Redis 조회만으로 즉시 응답 - 독립 스케일링: 작업량 증가 시 Celery Worker만 증설 (FastAPI 서버 영향 없음)
- 일관된 아키텍처: 모든 백그라운드 작업이 Celery로 통일
부정적 영향 / 트레이드오프:
- 코드 리팩토링 필요: 기존
asyncio.create_task()호출부 수정 - 태스크 직렬화: 복잡한 객체(예:
TaskStorage)는 JSON 직렬화 가능한 형태로 변환 필요 - 디버깅 복잡도: 분산 시스템 특성상 로그 추적이 복잡해질 수 있음
후속 작업:
- Phase 0:
docker-compose.yml에replicas: 2추가 (즉시 완화) - Phase 1:
text_extract_tasks.py구현 및text_extract.py수정 - Phase 1:
masking_tasks.py구현 및masking.py수정 - Phase 2:
evaluation_tasks.py구현 및evaluation.py수정 - Celery Worker 큐 분리 (
-Q text_extract,masking,evaluation) -
celery_app.py에 태스크 라우팅 설정 추가 -
docker-compose.yml에celery-worker-extract서비스 추가 - Flower 대시보드 연동 (태스크 모니터링)
- 프로덕션 배포 후 504 타임아웃 해결 확인
ℹ️ Gunicorn + Uvicorn Workers 전환은 Docker 환경에서 불필요하므로 제외.
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-26 | 초기 작성 — 504 타임아웃 원인 분석, asyncio.create_task → Celery 이관 결정. 단계적 적용 계획 수립. |
| 2026-02-26 | Phase 0~2 구현 완료 — text_extract, masking, evaluation 태스크 Celery 이관. docker-compose.yml 수정. |
ADR-103: Tavily Search API 전체 구현 요약
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-26 |
| 결정자 | AI팀 |
| 관련 기능 | 채용 트렌드 크롤링 Phase 2, VectorDB 적재, Celery 스케줄링, FastAPI 엔드포인트 |
| 관련 ADR | ADR-101 (Tavily 도입 결정), ADR-094 (Phase 1 WebBaseLoader), ADR-096 (Celery/Redis) |
🎯 목적 (Purpose)
ADR-101에서 Tavily 도입을 결정했다면, ADR-103은 실제로 무엇을 어떻게 구현했는지를 기록한다.
- ADR-101: 왜 Tavily를 선택했는가 (Decision Record — 선택지 분석, 트레이드오프)
- ADR-103: 무엇을 어떻게 만들었는가 (Implementation Summary — 클래스 설계, API, 설정)
📁 구현 파일 전체 목록
| 파일 | 변경 | 주요 내용 |
|---|---|---|
pyproject.toml |
수정 | langchain-tavily = "^0.1.0" 의존성 추가 |
app/config/settings.py |
수정 | Tavily 설정 4개 필드 추가 (ADR-101 섹션) |
app/services/tavily_search_service.py |
신규 | TavilySearchResult, TavilySearchStats, TavilySearchService |
app/services/trend_crawler_service.py |
수정 | get_configured_queries, search_by_queries, trigger_tavily_task 추가 |
app/tasks/trend_tasks.py |
수정 | _get_trend_queries, search_trend_queries_task, _search_queries_async 추가 |
app/tasks/celery_app.py |
수정 | search-trend-weekly Beat 스케줄 추가 |
app/api/routes/v2/crawl.py |
수정 | 3개 엔드포인트 추가 (/crawl/trend/search, /crawl/trigger/search, /crawl/queries) |
🏗️ 핵심 설계 결정
1. TavilySearchService — 독립 서비스 클래스
# app/services/tavily_search_service.py
@dataclass
class TavilySearchResult:
title: str
url: str
content: str # raw_content 우선, content 폴백
score: float # relevance score (0.0 ~ 1.0)
published_date: str | None = None
query: str = "" # 어느 쿼리에서 발견됐는지 추적
@dataclass
class TavilySearchStats:
total_queries: int
total_results: int # 중복 제거 전 총 결과 수
deduplicated_results: int # 중복 제거 후 결과 수
failed_queries: list[str] # 실패한 쿼리 목록
class TavilySearchService:
def __init__(self, settings=None):
# TAVILY_API_KEY 미설정 시 ValueError → API에서 503으로 처리
if not self.settings.tavily_api_key:
raise ValueError("TAVILY_API_KEY 환경변수가 설정되지 않았습니다.")
async def search(self, query: str) -> list[TavilySearchResult]:
"""단일 쿼리 검색. langchain_tavily.TavilySearch 사용."""
async def search_multiple(self, queries: list[str]) -> tuple[list[TavilySearchResult], TavilySearchStats]:
"""asyncio.gather 병렬 검색 + seen_urls set으로 중복 URL 제거."""
설계 포인트:
search_multiple():asyncio.gather(*tasks, return_exceptions=True)— 쿼리 1개 실패해도 나머지 계속 진행include_raw_content=True,search_depth=advanced— 전체 본문 추출 (요약 아닌 원문)- SAST: 쿼리/URL 로그에 직접 포함하지 않음
2. 에러 처리 계층
TavilySearchService.__init__()
└── TAVILY_API_KEY 없음 → ValueError 발생
/crawl/trend/search (FastAPI)
└── ValueError catch → HTTP 503 + code: "TAVILY_UNAVAILABLE"
└── 그 외 Exception → HTTP 500
search_trend_queries_task (Celery)
└── Exception → retry 3회 (countdown=300초)
└── TREND_CRAWL_QUERIES 비설정 → {"status": "skipped"} 정상 반환
3. ChromaDB 호환성
# trend_crawler_service.py — None 값 제거로 ChromaDB 저장 오류 방지
metadata = {k: v for k, v in metadata.items() if v is not None}
4. Phase 1 / Phase 2 독립 병행
환경변수 미설정 시 동작:
TREND_CRAWL_URLS 비설정 → Phase 1 스킵 (에러 없음, "no_urls_configured" 반환)
TREND_CRAWL_QUERIES 비설정 → Phase 2 스킵 (에러 없음, "no_queries_configured" 반환)
TAVILY_API_KEY 비설정 → Phase 2 API 호출 시 503 반환
Beat 스케줄 타이밍:
crawl-trend-weekly → 월요일 09:00 (Phase 1 URL 크롤링)
search-trend-weekly → 월요일 09:30 (Phase 2 Tavily 검색, 30분 후 실행)
🔌 신규 API 엔드포인트
| 메서드 | 경로 | 설명 |
|---|---|---|
POST |
/ai/crawl/trend/search |
Tavily 쿼리 검색 + trend_data 적재 (수동) |
POST |
/ai/crawl/trigger/search |
Tavily Celery 태스크 수동 트리거 |
GET |
/ai/crawl/queries |
TREND_CRAWL_QUERIES 환경변수 조회 |
POST /ai/crawl/trend/search 요청/응답:
// 요청
{
"queries": ["2026 백엔드 채용 트렌드", "AI 엔지니어 채용 공고"],
"ingest_multi_vector": false
}
// 응답
{
"total_urls": 10,
"success_count": 9,
"documents": [
{
"id": "uuid",
"text_length": 3420,
"metadata": {"source": "tavily", "title": "...", "score": 0.87}
}
],
"multi_vector_ingested": 0
}
에러 응답:
| 상황 | HTTP | code |
|---|---|---|
TAVILY_API_KEY 미설정 |
503 | TAVILY_UNAVAILABLE |
| 검색 중 Tavily API 오류 | 500 | - |
⚙️ 설정 필드 (settings.py)
# ADR-101 섹션 — 4개 필드 추가
tavily_api_key: str = Field(default="", description="...") # 필수
tavily_search_depth: str = Field(default="advanced", ...) # basic/advanced
tavily_max_results: int = Field(default=5, ...) # 쿼리당 최대 결과 수
trend_crawl_queries: str = Field(default="", ...) # 쉼표 구분 쿼리 목록
환경변수 설정 예시:
TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TAVILY_SEARCH_DEPTH=advanced
TAVILY_MAX_RESULTS=5
TREND_CRAWL_QUERIES=2026 백엔드 채용 트렌드,AI 엔지니어 채용 공고,주요 IT 기업 기술 면접 트렌드
🔍 코드 리뷰 결과 (2026-02-26)
| 항목 | 상태 | 비고 |
|---|---|---|
부분 실패 허용 (asyncio.gather + return_exceptions) |
✅ 구현 완료 | |
| SAST 준수 (쿼리/URL 로그 미포함) | ✅ 구현 완료 | # SAST: 주석 명시 |
| ChromaDB None 값 방어 | ✅ 구현 완료 | |
503 / TAVILY_UNAVAILABLE 에러 코드 |
✅ 구현 완료 | |
TavilySearchService.__init__ 타입 힌트 누락 |
⚠️ 개선 권장 | settings=None → settings: Settings | None = None |
settings.py tavily_available property 미구현 |
⚠️ 개선 권장 | langfuse_available 등 기존 패턴과 불일치 |
📊 결과 (Consequences)
긍정적 영향:
- 운영 편의성: URL 수동 관리 불필요 — 검색 쿼리만 설정하면 관련 URL 자동 발견
- 수집 품질: JS 렌더링 지원(SPA 사이트), raw_content 전체 본문 추출
- LLM 비용 절감: Tavily 메타데이터 자동 제공으로 Gemini 메타데이터 추출 호출 제거
- 기존 호환: Phase 1 코드 전혀 수정 없음, 기존 API 미변경
부정적 영향 / 트레이드오프:
- 추가 비용: Tavily API 월 $0~$149 (쿼리 수 및
advanced검색 빈도에 따라) - 외부 의존성: Tavily 장애 시 Phase 2 크롤링 불가 (Phase 1은 영향 없음)
후속 작업:
- AWS Parameter Store
/Dev/AI/TAVILY_API_KEY등록 -
TREND_CRAWL_QUERIES환경변수 설정 (운영 환경) -
poetry lock --no-update실행 후poetry.lock커밋 - 첫 실행 후 ChromaDB
trend_data컬렉션source=tavily문서 적재 확인 -
TavilySearchService.__init__타입 힌트 보완 (코드 리뷰 권장 사항)
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-26 | 초기 작성 — ADR-101 결정 기반 전체 구현 완료 요약. TavilySearchService 설계, 에러 처리 계층, API 3개, Celery Beat 추가, 코드 리뷰 결과 포함. |
ADR-104: RAG 리랭커 선정 — FlashRank 도입
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-26 |
| 결정자 | AI팀 |
| 관련 기능 | RAG 파이프라인, 검색 결과 재정렬, 응답 품질 개선 |
| 관련 ADR | ADR-069 (앙상블 리트리버 BM25+MMR+RRF), ADR-076 (ParentDocumentRetriever), ADR-091 (LLM-as-Judge) |
🎯 컨텍스트 (Context)
ADR-069에서 BM25+MMR 앙상블 리트리버 + RRF 머지를 도입했다. 현재 RAG 파이프라인의 재정렬(rerank) 단계는 단순 top_k 자르기만 수행하고 있어, 의미적 재정렬이 이루어지지 않는다.
현재 상태:
# app/services/rag_service.py:189-193
def _rerank(pairs: list[tuple[str, Document]], top_k: int) -> list[tuple[str, Document]]:
"""ADR-069: 재정렬 단계. 현재는 top_k 자르기만 적용 (cross-encoder 등은 후속)."""
if top_k <= 0:
return pairs
return pairs[:top_k]
현재 RAG 파이프라인 흐름:
User Query
↓
Dense (MMR) + Sparse (BM25) 검색
↓
RRF Merge (dense=0.7, sparse=0.3)
↓
_rerank() ← 🔴 단순 top_k 자르기 (의미적 재정렬 없음)
↓
Context → LLM → Response
문제점:
- RRF 머지 후 순위가 검색 순서 기반이지, 쿼리-문서 의미 유사도 기반이 아님
- 면접 준비 도메인에서 질문과 무관한 문서가 상위에 위치할 가능성
- LLM-as-Judge(ADR-091) 평가에서 relevance 점수가 낮은 응답이 발생하는 원인 중 하나
요구사항:
- RRF 머지 후 쿼리-문서 의미 유사도 기반 재정렬
- 현재 인프라(CPU 서버, Docker Compose)에서 추가 비용 없이 동작
- 레이턴시 증가 최소화 (실시간 채팅 응답에 영향 없어야 함)
rag_service.py의_rerank()함수만 수정하는 최소 변경
🔍 선택지 분석 (Options)
Option 1: Cross-Encoder 리랭커 (sentence-transformers)
from sentence_transformers import CrossEncoder
model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
scores = model.predict([(query, doc.page_content) for _, doc in pairs])
# 점수순 정렬
| 장점 | 단점 |
|---|---|
| 가장 높은 정확도 (MS MARCO 벤치마크 1위권) | GPU 없으면 매우 느림 (CPU에서 문서 10개 기준 2~5초) |
| 다양한 사전학습 모델 선택 가능 | sentence-transformers 패키지 무거움 (~500MB+) |
| 오프라인 동작 (API 불필요) | PyTorch 의존성 → Docker 이미지 크기 증가 (~2GB+) |
| 영어 기반 모델은 풍부 | 한국어 특화 모델 부족 (ms-marco 계열은 영어 중심) |
벤치마크 (CPU, 문서 10개 기준):
| 모델 | 레이턴시 | 크기 |
|---|---|---|
ms-marco-MiniLM-L-6-v2 |
~2.5초 | 80MB |
ms-marco-MiniLM-L-12-v2 |
~4.0초 | 120MB |
bge-reranker-base (중국어/영어) |
~3.0초 | 278MB |
⚠️ 현재 서버는 GPU 없는 CPU 환경 (AWS EC2 t3/t4). Cross-Encoder 레이턴시가 실시간 채팅에 부적합.
Option 2: Cohere Rerank API
from langchain_cohere import CohereRerank
compressor = CohereRerank(model="rerank-v3.5", top_n=5)
results = compressor.compress_documents(docs, query)
| 장점 | 단점 |
|---|---|
| 매우 높은 정확도 (rerank-v3.5) | 유료 API (월 $0 ~ $83+, 요청당 과금) |
| 다국어 지원 (한국어 포함) | COHERE_API_KEY 추가 환경변수 필요 |
| 레이턴시 빠름 (API 서버 GPU 처리) | 외부 API 의존 → 네트워크 장애 시 리랭킹 불가 |
| LangChain 공식 통합 | 이미 Gemini + OpenAI + Tavily 3개 외부 API 사용 중 → 의존성 과다 |
비용 구조:
| 플랜 | 비용 | 호출 제한 |
|---|---|---|
| Trial | 무료 | 월 100회 |
| Production | $0.001/검색 | 무제한 |
| Enterprise | 별도 협의 | 무제한 |
⚠️ 채팅 1회당 리랭킹 1회 × 일 평균 100회 = 월 ~3,000회, 약 $3/월. 비용은 적지만 추가 API 키 관리 부담.
Option 3: Jina Reranker API
import requests
url = "https://api.jina.ai/v1/rerank"
headers = {"Authorization": "Bearer jina_xxx"}
payload = {
"model": "jina-reranker-v2-base-multilingual",
"query": query,
"documents": [doc.page_content for _, doc in pairs],
"top_n": 5,
}
result = requests.post(url, json=payload, headers=headers)
| 장점 | 단점 |
|---|---|
| 다국어 특화 (jina-reranker-v2-base-multilingual) | 유료 API ($0.018/1M 토큰) |
| 한국어 성능 상위권 | JINA_API_KEY 추가 환경변수 필요 |
| 무료 티어 있음 (월 1M 토큰) | LangChain 공식 통합 미비 (직접 HTTP 호출 필요) |
코드 검색 리랭킹 지원 (jina-reranker-v2-base-code) |
외부 API 의존 |
⚠️ 무료 티어(1M 토큰)로 커버 가능하나, LangChain 통합이 없어 직접 HTTP 클라이언트 구현 필요.
Option 4: FlashRank 리랭커 ⭐ 권장
from flashrank import Ranker, RerankRequest
ranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2", cache_dir="./flashrank_cache")
rerank_request = RerankRequest(query=query, passages=[{"text": doc.page_content} for _, doc in pairs])
results = ranker.rerank(rerank_request)
# results: [{"text": ..., "score": 0.95, "meta": {...}}, ...]
| 장점 | 단점 |
|---|---|
| 완전 무료 (Apache 2.0 라이센스) | Cross-Encoder 대비 정확도 약간 낮음 |
| CPU에서 매우 빠름 (~50ms, 문서 10개 기준) | 한국어 특화 모델은 없음 (영어 중심, 다만 다국어 추론 가능) |
| 경량 (~60MB, ONNX Runtime 기반) | 모델 다운로드 필요 (최초 1회, 캐시됨) |
설치 간단 (pip install flashrank) |
- |
| 오프라인 동작 (API 키 불필요) | - |
LangChain 통합 (FlashrankRerank) |
- |
| PyTorch 의존성 없음 (ONNX) | - |
| Docker 이미지 크기 거의 미증가 | - |
벤치마크 (CPU, 문서 10개 기준):
| 모델 | 레이턴시 | 크기 | NDCG@10 (MS MARCO) |
|---|---|---|---|
ms-marco-MiniLM-L-12-v2 (기본) |
~50ms | 60MB | 0.39 |
ms-marco-MultiBERT-L-12 (다국어) |
~70ms | 100MB | 0.36 |
rank-T5-flan (T5 기반) |
~120ms | 110MB | 0.40 |
FlashRank의 핵심: Cross-Encoder의 정확도를 ONNX 최적화로 CPU에서도 빠르게 달성. PyTorch 없이 onnxruntime만 사용.
🔢 4가지 리랭커 종합 비교
| 항목 | Cross-Encoder | Cohere | Jina | FlashRank |
|---|---|---|---|---|
| 비용 | 🟢 무료 (로컬) | 🔴 유료 API | 🟡 무료 티어 (제한) | 🟢 무료 (로컬) |
| CPU 레이턴시 | 🔴 2~5초 | 🟢 ~200ms (API) | 🟢 ~200ms (API) | 🟢 ~50ms |
| GPU 필요 | ⚠️ 권장 | ❌ | ❌ | ❌ |
| 정확도 (NDCG@10) | 🟢 0.42 | 🟢 0.41 | 🟢 0.40 | 🟡 0.39 |
| 한국어 지원 | 🟡 제한적 | 🟢 다국어 | 🟢 다국어 특화 | 🟡 영어 중심 (다국어 추론) |
| 설치 크기 | 🔴 ~2GB (PyTorch) | 🟢 SDK만 | 🟡 HTTP 직접 | 🟢 ~60MB (ONNX) |
| API 키 필요 | ❌ | ⚠️ COHERE_API_KEY | ⚠️ JINA_API_KEY | ❌ |
| 오프라인 | ✅ | ❌ | ❌ | ✅ |
| LangChain 통합 | ✅ | ✅ 공식 | ❌ 미비 | ✅ FlashrankRerank |
| Docker 이미지 | 🔴 크기 대폭 증가 | 🟢 변화 없음 | 🟢 변화 없음 | 🟢 미미한 증가 |
✅ 결정 (Decision)
Option 4 (FlashRank)를 선택합니다.
근거:
- 비용 제로: 이미 Gemini + OpenAI + Tavily 3개 유료 API를 사용 중. 추가 API 키/비용 부담 없음
- CPU 환경 최적: 현재 서버(AWS EC2 t3/t4)에 GPU 없음. Cross-Encoder는 CPU에서 2~5초로 실시간 채팅에 부적합. FlashRank는 ONNX 최적화로 ~50ms
- 최소 변경:
_rerank()함수 하나만 수정,pip install flashrank추가면 완료 - Docker 친화: PyTorch 의존성 없음 → Docker 이미지 크기 거의 미증가
- 정확도 충분: NDCG@10 0.39로 Cross-Encoder(0.42) 대비 7% 낮지만, RRF 머지 후의 재정렬 목적으로는 충분. 단순 top_k 대비 큰 품질 향상
- Langfuse 추적 가능: LLM-as-Judge(ADR-091) 점수로 리랭커 도입 전후 품질 변화를 정량 측정 가능
한국어 성능 보완 전략:
- FlashRank는 영어 중심 모델이지만, 현재 검색 대상이 이력서/채용공고 (한영 혼합 기술 문서)이므로 영어 모델로도 충분한 리랭킹 효과 기대
- 향후 한국어 성능이 부족하면
ms-marco-MultiBERT-L-12(다국어 모델)로 교체 가능 (FlashRank 내에서 모델만 변경) - 최종적으로 한국어 전용 리랭킹이 필요하면 Jina Reranker API로 전환 검토 (무료 티어 활용)
📁 구현 파일
| 파일 | 변경 | 내용 |
|---|---|---|
pyproject.toml |
수정 | flashrank 의존성 추가 |
app/services/rag_service.py |
수정 | _rerank() 함수에 FlashRank 리랭킹 로직 추가 |
app/config/settings.py |
수정 | rag_reranker_model 설정 필드 추가 (선택적) |
🔧 구현 예시
변경 전 (_rerank):
def _rerank(pairs: list[tuple[str, Document]], top_k: int) -> list[tuple[str, Document]]:
"""ADR-069: 재정렬 단계. 현재는 top_k 자르기만 적용."""
if top_k <= 0:
return pairs
return pairs[:top_k]
변경 후 (_rerank):
import asyncio
from flashrank import Ranker, RerankRequest
# 모듈 레벨 싱글톤 (모델 로딩 1회만)
_flashrank_ranker: Ranker | None = None
def _get_flashrank_ranker(model_name: str = "ms-marco-MiniLM-L-12-v2") -> Ranker:
global _flashrank_ranker
if _flashrank_ranker is None:
_flashrank_ranker = Ranker(model_name=model_name, cache_dir="./flashrank_cache")
return _flashrank_ranker
async def _rerank(
pairs: list[tuple[str, Document]],
top_k: int,
query: str = "",
) -> list[tuple[str, Document]]:
"""ADR-104: FlashRank 리랭커로 의미적 재정렬 후 top_k 반환."""
if top_k <= 0 or not pairs:
return pairs
if not query:
# 쿼리 없으면 기존 방식 (단순 자르기)
return pairs[:top_k]
try:
ranker = _get_flashrank_ranker()
passages = [
{"id": idx, "text": doc.page_content[:1000]}
for idx, (_, doc) in enumerate(pairs)
]
request = RerankRequest(query=query, passages=passages)
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(None, ranker.rerank, request)
# FlashRank 점수순으로 pairs 재정렬
id_to_score = {r["id"]: r["score"] for r in results}
scored_pairs = [(id_to_score.get(i, 0.0), pair) for i, pair in enumerate(pairs)]
scored_pairs.sort(key=lambda x: x[0], reverse=True)
reranked = [pair for _, pair in scored_pairs[:top_k]]
logger.info(
"ADR-104: FlashRank reranked %d → %d docs (top score=%.3f)",
len(pairs), len(reranked),
scored_pairs[0][0] if scored_pairs else 0,
)
return reranked
except Exception as e:
logger.warning("ADR-104: FlashRank failed, fallback to top_k: %s", e)
return pairs[:top_k]
호출부 변경 (retrieve_context):
# 기존
merged = _rerank(merged, rerank_k)
# 변경
merged = await _rerank(merged, rerank_k, query=query)
📊 기대 효과 (Langfuse 측정 예정)
| 지표 | 현재 (top_k 자르기) | 도입 후 (FlashRank) | 측정 방법 |
|---|---|---|---|
| Relevance 점수 | ~3.5/5.0 (추정) | ~4.0+/5.0 (목표) | LLM-as-Judge (ADR-091) |
| Rerank 레이턴시 | 0ms (자르기만) | ~50ms (FlashRank) | Langfuse span |
| 전체 RAG 레이턴시 | ~1.5초 | ~1.55초 (+50ms) | Langfuse trace |
| Context 품질 | RRF 순서 의존 | 쿼리 의미 기반 정렬 | 수동 비교 |
Langfuse 대시보드에서
pipeline_stage=rag의judge_relevance점수 변화를 A/B 비교 예정.
📊 결과 (Consequences)
긍정적 영향:
- 검색 품질 향상: 단순 순서 기반 → 쿼리-문서 의미 유사도 기반 재정렬
- 비용 제로: 로컬 실행, API 키 불필요, Docker 이미지 미미한 증가
- 레이턴시 미미: CPU에서 ~50ms 추가 — 실시간 채팅에 영향 없음
- 정량 측정 가능: Langfuse + LLM-as-Judge로 도입 전후 품질 비교
- 점진적 업그레이드 경로: FlashRank → 다국어 모델 → Jina API 순서로 필요 시 교체 가능
부정적 영향 / 트레이드오프:
- 한국어 특화 아님: 영어 중심 모델 → 순수 한국어 문서 리랭킹 정확도 제한적
- 모델 다운로드: 최초 실행 시 ~60MB 모델 다운로드 필요 (이후 캐시)
- ONNX Runtime 의존:
onnxruntime패키지 추가 (~30MB)
후속 작업:
-
pip install flashrank/pyproject.toml의존성 추가 -
_rerank()함수 FlashRank 로직 구현 -
retrieve_context()호출부query인자 전달 수정 - Langfuse에서 리랭커 도입 전후
judge_relevance점수 비교 - 한국어 성능 부족 시
ms-marco-MultiBERT-L-12다국어 모델로 교체 검토 -
.gitignore에flashrank_cache/추가
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-26 | 초기 작성 — Cross-Encoder / Cohere / Jina / FlashRank 4가지 리랭커 비교 분석, FlashRank 선정 (비용 제로 + CPU 최적 + 최소 변경). |
ADR-105: AI 서비스 Redis 사용 현황 및 AWS ElastiCache 연동 가이드
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-26 |
| 결정자 | AI팀 |
| 관련 팀 | AI팀, 클라우드팀 (CL) |
| 관련 기능 | 세션 저장소, Celery 비동기 큐, 작업 결과 저장, AWS ElastiCache |
| 관련 ADR | ADR-096 (Celery/Redis 도입), ADR-102 (Celery 이관) |
🎯 컨텍스트 (Context)
CL팀이 AWS ElastiCache 구성 중 클러스터 모드(샤딩) vs 레플리케이션 모드 선택을 논의하면서 AI 서비스의 Redis 사용처를 정리할 필요가 생겼다.
[Slack 논의 요약 — 2026-02-26]
henry.myeong(CL): 레디스 클러스터링이 어디에 쓰이는지 알 수 있을까요?
현재 ElastiCache는 클러스터링 비활성화 상태입니다.
estar.yoon(AI): Redis 쓰는 곳들이 늘어나고 있어서 정리해드릴게요.
henry.myeong(CL): 샤딩(클러스터 모드) vs 다중 노드 레플리카 구분해서 정리 부탁드립니다.
(다중 노드 레플리카는 이미 적용, 클러스터 모드는 미적용 상태)
📊 AI 서비스 Redis 사용처 전체 정리
DB 구분
AI 서비스는 Redis DB를 3개로 분리하여 사용한다.
| DB | 환경변수 | 용도 | TTL | 클래스 |
|---|---|---|---|---|
| DB 0 | REDIS_URL |
채팅·면접 세션 저장소 | 1시간 | RedisSessionStore |
| DB 1 | CELERY_BROKER_URL |
Celery 메시지 브로커 (비동기 작업 큐) | Celery 자동 관리 | Celery 내부 |
| DB 2 | CELERY_RESULT_BACKEND |
Celery 작업 결과 저장 | 24시간 | CeleryTaskQueue |
🗂️ DB 0 — 세션 저장소
클래스: app/infrastructure/session/redis.py → RedisSessionStore
저장 데이터: 채팅/면접 세션 (JSON 직렬화)
- 대화 메시지 히스토리, RAG 컨텍스트 참조, 면접 Q&A 상태
키 패턴:
session:chat_{user_id}_{session_id}
session:interview_{user_id}_{session_id}
데이터 크기: 1~50KB (대화 히스토리 포함)
연결 방식:
self._redis = redis.from_url(settings.redis_url, decode_responses=True)
# TTL 갱신: 요청마다 setex(key, 3600, json.dumps(data))
📬 DB 1 — Celery 메시지 브로커 (작업 큐)
설정: app/tasks/celery_app.py
운영 중인 큐 4종:
| 큐 이름 | Worker 컨테이너 | 용도 | 실행 빈도 |
|---|---|---|---|
trend_crawl |
celery-worker-trend |
채용 트렌드 크롤링 (ADR-094/101) | 주 1회 (Beat) |
text_extract |
celery-worker-extract |
이력서/채용공고 OCR + 텍스트 추출 (ADR-102) | API 요청마다 |
masking |
celery-worker-extract |
개인정보 마스킹 (ADR-102) | API 요청마다 |
evaluation |
celery-worker-extract |
면접 Q&A 임베딩 적재 (ADR-102) | 면접 완료마다 |
키 패턴: Celery 내부 자동 관리 (celery-task-{id}, celery.queue.{name})
데이터 크기: 1~10KB (태스크 메시지 메타데이터만 저장)
📦 DB 2 — Celery 작업 결과 저장
클래스: app/infrastructure/queue/celery_queue.py → CeleryTaskQueue
저장 데이터: 비동기 작업 상태 + 결과
# 키 패턴
ai_task:{task_id}
# 저장 구조
{
"task_id": "extract_abc123",
"task_type": "text_extract",
"status": "COMPLETED", # PENDING / PROCESSING / COMPLETED / FAILED
"progress": 100,
"result": { /* OCR 결과 */ },
"error": null
}
데이터 크기: 5KB~1MB (분석 결과 포함 시)
TTL: 24시간 (setex(key, 86400, ...))
사용 API: GET /ai/v2/task/{task_id} — 폴링으로 상태 조회
🔌 동시 연결 수 예측
┌─────────────────────────────────────────────────────────┐
│ 컨테이너별 Redis 연결 수 (최대 기준) │
├─────────────────────────────────────────────────────────┤
│ │
│ ai-endpoint (replicas: 4) │
│ └─ DB 0 (세션): 10~20개 │
│ │
│ celery-worker-extract (concurrency: 2) │
│ ├─ DB 1 (브로커): 2~3개 │
│ └─ DB 2 (결과): 3~5개 │
│ │
│ celery-worker-trend + celery-beat │
│ ├─ DB 1: 1~2개 │
│ └─ DB 2: 1개 │
│ │
│ 합계: 18~35개 (ElastiCache default maxclients 65,536) │
│ → 연결 수 부족 위험 없음 │
│ │
└─────────────────────────────────────────────────────────┘
⚠️ 클러스터 모드 전환 시 영향 분석
핵심: Redis Cluster Mode(샤딩)는 DB 0만 지원 —
SELECT명령 불가
| 구분 | 레플리케이션 모드 (현재) | 클러스터 모드 (샤딩) |
|---|---|---|
| 멀티 DB 지원 | ✅ DB 0/1/2 모두 사용 가능 | ❌ DB 0만 지원 |
| 현재 AI 코드 | ✅ 변경 없이 동작 | ⚠️ 코드 수정 필요 |
| Celery 연동 | ✅ 정상 | ⚠️ 설정 변경 필요 |
클러스터 모드 전환 시 필요한 AI 코드 변경:
# 현재: DB별로 분리
REDIS_URL = redis://host:6379/0
CELERY_BROKER_URL = redis://host:6379/1
CELERY_RESULT_BACKEND = redis://host:6379/2
# 클러스터 모드 전환 후: DB 0으로 통합 + 키 prefix로 분리
REDIS_URL = redis://host:6379/0 # 그대로
CELERY_BROKER_URL = redis://host:6379/0 # DB 1 → DB 0 변경
CELERY_RESULT_BACKEND = redis://host:6379/0 # DB 2 → DB 0 변경
# Celery 키 prefix 설정 추가
celery_app.conf.update(
result_key_prefix="celery-result:",
task_default_queue="celery",
)
결론: 현재 레플리케이션 모드(다중 노드 레플리카)는 멀티 DB를 지원하므로 AI 코드 변경 없이 연동 가능. 클러스터 모드(샤딩) 전환 계획 시 반드시 AI팀에 사전 통보 필요.
⚙️ AWS ElastiCache 연동 설정
Parameter Store 등록 값 (Dev 환경 기준)
/Dev/AI/REDIS_URL = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/0
/Dev/AI/CELERY_BROKER_URL = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/1
/Dev/AI/CELERY_RESULT_BACKEND = redis://:${AUTH_TOKEN}@${ELASTICACHE_ENDPOINT}:6379/2
docker-compose.yml 환경변수 주입 (현재 구조)
# ai-endpoint 서비스
environment:
- REDIS_URL=${REDIS_URL:-redis://localhost:6379/0}
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://localhost:6379/1}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://localhost:6379/2}
# celery-worker-extract 서비스 (동일하게 주입 필요)
environment:
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://localhost:6379/1}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://localhost:6379/2}
주의:
celery-worker-extract와ai-endpoint모두에 동일한 Redis URL이 주입되어야 함. Worker가 다른 Redis를 바라보면 태스크 결과를 찾을 수 없음.
📊 결과 (Consequences)
긍정적 영향:
- 고가용성: ElastiCache 레플리케이션(다중 노드)으로 Redis 장애 시 자동 Failover
- 운영 부담 감소: AWS 관리형 서비스로 Redis 패치/백업 자동화
- 성능: ElastiCache는 로컬 Redis 대비 낮은 레이턴시 보장
주의사항:
- 멀티 DB 의존: AI 서비스는 DB 0/1/2를 분리 사용 → 클러스터 모드 비활성 유지 권장
- AUTH 토큰 필수: ElastiCache TLS + AUTH 활성화 시 모든 Redis URL에
:auth_token@포함 필요 - VPC 내부 통신: ElastiCache는 VPC 내부에서만 접근 가능 — AI EC2와 동일 VPC/Security Group 설정 확인
후속 작업:
- AI 서비스 Redis 사용처 현황 파악 및 CL팀 공유
- AWS Parameter Store
/Dev/AI/REDIS_URL,/Dev/AI/CELERY_BROKER_URL,/Dev/AI/CELERY_RESULT_BACKEND등록 - ElastiCache 엔드포인트로 배포 후 Celery Worker 태스크 정상 처리 확인
- 클러스터 모드 전환 계획 시 AI팀 사전 협의 진행
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-26 | 초기 작성 — CL팀 Redis 클러스터링 논의 기반, AI 서비스 Redis 사용처(DB 0/1/2) 전체 정리. ElastiCache 레플리케이션 vs 클러스터 모드 영향 분석 포함. |