# 370. 임베딩 서비스 분리 아키텍처 ## 개요 각 로빙이 독립적으로 ONNX 임베딩 모델을 로드하던 구조에서 중앙 임베딩 서비스를 공유하는 구조로 전환하여, 메모리 사용량을 극적으로 감소시키고 확장성을 확보했습니다. > "모든 로빙이 하나의 임베딩 서비스를 공유하면, 메모리는 절약되고 성능은 유지된다." ## 왜 임베딩 서비스를 분리했나? ### 임베딩이란? **임베딩 = 텍스트를 숫자(벡터)로 변환하는 과정** - "안녕하세요" → [0.1, 0.3, -0.5, ...] (384개의 숫자) - 비슷한 의미의 텍스트는 비슷한 숫자 패턴을 가짐 - 로빙이 "의미"를 이해하는 핵심 기술 ### 분리 전 문제점 1. **메모리 낭비**: 각 로빙이 동일한 모델(449MB)을 각자 로드 - 로빙 10개 = 4.5GB 메모리 낭비 - 로빙 100개 = 45GB (서버 메모리 초과) 2. **시작 시간 지연**: 각 로빙이 시작할 때마다 모델 로드 (5-10초) 3. **업데이트 어려움**: 모델 업데이트 시 모든 로빙 재시작 필요 ### 분리 후 장점 1. **메모리 절약**: 모델 하나만 로드하고 모두가 공유 2. **빠른 시작**: 로빙은 API 호출만 하면 됨 (모델 로드 불필요) 3. **쉬운 관리**: 임베딩 서비스만 업데이트하면 모든 로빙이 자동 적용 **비유**: 각자 사전을 들고 다니던 것 → 도서관에 사전 하나 두고 필요할 때 찾아보기 **다른 선택지와 비교**: - 각자 모델 로드: 메모리 낭비, 관리 복잡 - 클라우드 API 사용: 비용 발생, 네트워크 의존 - CPU 임베딩: 너무 느림 (10배 이상) ## 해결책: 중앙 임베딩 서비스 ### 아키텍처 ``` 이전: 이후: ┌─────────────┐ ┌─────────────┐ │ rb10508 │ │ rb10508 │ │ ONNX Model │ │ (118MB) │──┐ │ (988MB) │ └─────────────┘ │ └─────────────┘ │ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ rb8001 │ │ rb8001 │ │ skill-embedding │ │ ONNX Model │ → │ (200MB) │──│ (874.4MB) │ │ (416MB) │ └─────────────┘ │ - ONNX Model │ └─────────────┘ │ - Port 8015 │ ▼ └─────────────────┘ ┌─────────────┐ ┌─────────────┐ │ rb10408 │ │ rb10408 │ │ ONNX Model │ │ (30MB) │──┘ │ (55MB) │ └─────────────┘ └─────────────┘ ``` ### 기술 스택 선택 이유 - **FastAPI + Uvicorn**: - 왜? Python 기반 최속 웹 프레임워크, 비동기 처리로 동시 요청 효율적 처리 - 다른 선택지: Flask (동기 처리로 느림), Django (너무 무거움) - **ONNX Runtime**: - 왜? PyTorch보다 3배 빠르고 메모리 50% 절약 - 다른 선택지: PyTorch (메모리 2배 사용), TensorFlow (구성 복잡) - ONNX = Open Neural Network Exchange (어떤 프레임워크에서도 사용 가능) - **multilingual-MiniLM-L12-v2**: - 왜? 한국어 포함 100개 언어 지원, 크기 대비 성능 우수 (134MB) - 다른 선택지: ko-sroberta (한국어만, 400MB), BERT-large (너무 큼, 1.3GB) ### API 설계 (간단명료한 인터페이스) ```python # POST /embed - 텍스트를 벡터로 변환 # 요청: { "texts": ["안녕하세요", "오늘 날씨가 좋네요"] } # 응답: (각 텍스트가 384개 숫자로 변환됨) { "embeddings": [ [0.1, 0.2, ...], # "안녕하세요"의 벡터 표현 [0.3, 0.4, ...] # "오늘 날씨가 좋네요"의 벡터 표현 ] } # GET /health { "status": "healthy", "service": "skill-embedding", "model": "multilingual-MiniLM-L12-v2", "uptime": 3600.5 } ``` ## 구현 가이드 ### 1. 로빙 측 변경사항 (코드 주석 강화) ```python # 기존: ONNX 모델을 각 로빙이 직접 로드 (메모리 낭비) from onnx_embedder import ONNXEmbedder embedder = ONNXEmbedder("/models/onnx/...") # 449MB 모델 로드 # 변경: 임베딩 서비스에 HTTP 요청만 (경량화) class HTTPEmbeddingFunction(EmbeddingFunction): def __init__(self): # 임베딩 서비스 URL (환경변수로 설정 가능) self.url = f"{os.getenv('SKILL_EMBEDDING_URL', 'http://localhost:8015')}/embed" def __call__(self, input: List[str]) -> List[List[float]]: # 텍스트를 서비스로 보내고 벡터 받아오기 response = requests.post(self.url, json={"texts": input}, timeout=30) return response.json()["embeddings"] # 384차원 벡터 반환 ``` ### 2. ChromaDB 통합 (로빙의 기억 저장소) ```python # memory.py - 로빙의 기억을 저장하는 코드 self.episodic = self.client.get_or_create_collection( name=f"{self.robing_id}_episodic", # 각 로빙마다 독립된 기억 공간 embedding_function=HTTPEmbeddingFunction() # 임베딩은 공유 서비스 사용 ) # 결과: 기억은 각자, 임베딩 엔진은 공유 ``` ### 3. Docker 구성 변경 ```yaml # 불필요한 볼륨 제거 # - /opt/models:/models:ro # 더 이상 필요 없음 # 환경변수 추가 environment: - SKILL_EMBEDDING_URL=http://localhost:8015 ``` ## 성능 분석 ### 메모리 절감 (실측 데이터) ``` 로빙 이름 | 이전 메모리 | 이후 메모리 | 절감량 ------------|------------|------------|-------- rb10508 | 988MB | 118MB | -870MB (88%↓) rb8001 | 416MB | 200MB | -216MB (52%↓) rb10408 | 55MB | 30MB | -25MB (45%↓) 예상 절감: - 10개 로빙: 8.7GB 절약 - 100개 로빙: 87GB 절약 (서버 1대 가격) ``` ### 속도 영향 - **HTTP 통신 추가 시간**: +7ms (전체의 0.2%) - **실제 사용자 체감**: 차이 없음 (전체 응답 1-3초) ### 확장성 - 단일 임베딩 서비스로 수백 개 로빙 지원 - 수평 확장 가능 (로드밸런서 적용 시) ## 모니터링 ```python # 주요 메트릭 - 임베딩 생성 요청 수 - 평균 응답 시간 - 메모리 사용량 - 에러율 # 헬스체크 curl http://localhost:8015/health ``` ## 다음 단계 1. **캐싱 레이어 추가**: Redis로 자주 사용되는 임베딩 캐싱 2. **배치 처리 최적화**: 대량 텍스트 임베딩 성능 개선 3. **모델 업데이트**: L12 → L6 모델로 추가 경량화 검토 4. **다른 로빙 적용**: rb8001, rb10408에 순차 적용 ## 코드로 보는 효과 ```python # 전체 시스템 메모리 사용량 계산 def calculate_memory_usage(num_robeings: int, shared_embedding: bool): """ 임베딩 서비스 공유 여부에 따른 메모리 사용량 계산 """ base_memory_per_robeing = 118 # MB (임베딩 제외한 기본 메모리) embedding_model_size = 449 # MB (임베딩 모델 크기) if shared_embedding: # 임베딩 서비스 공유: 모델 한 번만 로드 total = (num_robeings * base_memory_per_robeing) + embedding_model_size else: # 각자 로드: 모든 로빙이 모델 복사 total = num_robeings * (base_memory_per_robeing + embedding_model_size) return total # 비교 예시 print(f"10개 로빙 (공유 X): {calculate_memory_usage(10, False)}MB") # 5,670MB print(f"10개 로빙 (공유 O): {calculate_memory_usage(10, True)}MB") # 1,629MB print(f"절약률: 71%") ``` ## 교훈 - **중복 제거의 힘**: 동일한 기능을 서비스로 분리하면 극적인 효율성 향상 - **간단한 구현**: 12줄의 HTTPEmbeddingFunction으로 870MB 절약 - **점진적 적용**: 하나의 로빙에서 성공 후 확산 - **공유와 독립의 균형**: 임베딩 엔진은 공유, 기억과 개성은 독립 유지 --- *"임베딩은 공유하되, 기억은 분리하라"*