Mac Mini (Apple Silicon) 홈서버에서 MPS GPU를 활용한 고성능 텍스트 임베딩 서버.
BGE-M3 모델 기반, FastAPI + FlagEmbedding 구현.
Request
│
▼
FastAPI (async)
│
├─ /health ──────────────────────────────── 즉시 반환
│
└─ /embed ──── input validation
│
▼
asyncio.Semaphore ◄── GPU 동시 호출 수 제한 (OOM 방어)
│
▼
run_in_executor ◄─── blocking encode()를 thread pool에 위임
│
▼
BGEM3FlagModel.encode() ◄── MPS / CUDA / CPU 자동 선택
│
▼
dense_vecs (+sparse lexical_weights 선택)
│
▼
EmbeddingResponse (JSON)
embedding-server/
├── app/
│ ├── main.py # FastAPI app, lifespan 정의
│ ├── api/
│ │ └── routes.py # 엔드포인트
│ ├── core/
│ │ └── config.py # pydantic-settings 기반 설정
│ ├── models/
│ │ └── schema.py # Request / Response 스키마
│ └── services/
│ └── embedding_service.py # 모델 로딩, 추론, async 처리
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
sentence-transformers에서 FlagEmbedding으로 교체.
| 항목 | sentence-transformers | FlagEmbedding |
|---|---|---|
| BGE-M3 dense | O | O |
| Sparse (lexical) | X | O |
| ColBERT (multi-vec) | X | O |
| 공식 지원 | X | O (BAAI 직접 관리) |
현재 API는 dense를 기본으로 반환하고, return_sparse: true 요청 시 sparse weight도 함께 반환.
이후 하이브리드 검색(dense + sparse re-rank)으로 확장 가능.
# 우선순위: MPS > CUDA > CPU
if torch.backends.mps.is_available():
return "mps"
if torch.cuda.is_available():
return "cuda"
return "cpu"fp16은 CUDA에서만 활성화. MPS는 fp16 지원 op가 불완전하므로 기본 정밀도 사용.PYTORCH_ENABLE_MPS_FALLBACK=1환경변수로 MPS 미지원 op는 CPU로 자동 fallback.
주의: Docker Desktop on macOS는 Metal GPU를 컨테이너에 노출하지 않는다.
MPS 가속을 사용하려면 반드시 네이티브로 실행해야 한다. (→ 실행 방법)
model.encode()는 blocking call이다. async 핸들러에서 직접 호출하면 이벤트 루프 전체가 블로킹된다.
# 잘못된 방법 — 이벤트 루프 블로킹
async def embed(...):
result = self._model.encode(texts) # ❌
# 올바른 방법 — thread pool에 위임
async def embed(...):
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, encode_fn) # ✅여러 요청이 동시에 들어올 때 GPU에 encode()가 중첩 호출되면 OOM이 발생할 수 있다.
asyncio.Semaphore(max_concurrent)로 동시 추론 횟수를 제한한다.
max_concurrent = 2 (기본값)
Request A ──► Semaphore acquire ──► encode() ──► release
Request B ──► Semaphore acquire ──► encode() ──► release
Request C ──► Semaphore 대기 ──────────────────► acquire → encode()
Mac Mini M 시리즈는 통합 메모리이므로 max_concurrent를 낮게 유지하는 것이 안전하다.
모듈 레벨 전역 초기화(embedding_service = EmbeddingService()) 방식에서 FastAPI lifespan으로 교체.
@asynccontextmanager
async def lifespan(app: FastAPI):
loop = asyncio.get_event_loop()
app.state.embedding_service = await loop.run_in_executor(None, EmbeddingService)
yield- 모델 로딩이 완전히 끝나기 전에는 트래픽을 받지 않는다.
- 모델 인스턴스를
app.state에 저장하고 라우터는request.app.state에서 참조 → 전역 상태 없음.
pydantic-settings를 사용. EMBED_ prefix 환경변수 또는 .env 파일로 오버라이드 가능.
EMBED_MODEL_NAME=BAAI/bge-m3
EMBED_MAX_BATCH_SIZE=32
EMBED_MAX_TEXT_LENGTH=8192
EMBED_MAX_CONCURRENT=2
EMBED_MODEL_CACHE_DIR=/cache/models
기존의 /embed (단일 텍스트) + /embed/batch (복수 텍스트) 이중 구조를 /embed 하나로 통합.
단일 텍스트도 배열로 받으므로 클라이언트 코드가 단일/배치를 분기할 필요가 없다.
서버 상태 및 모델 정보 반환.
Response
{
"status": "ok",
"model": "BAAI/bge-m3",
"dimension": 1024,
"device": "mps"
}텍스트 배열을 임베딩 벡터로 변환.
Request
{
"texts": ["안녕하세요", "embedding server"],
"return_sparse": false
}| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
texts |
string[] |
O | 임베딩할 텍스트 목록 (1 ~ max_batch_size) |
return_sparse |
bool |
X | sparse weight 반환 여부 (기본값: false) |
Response
{
"model": "BAAI/bge-m3",
"dimension": 1024,
"embeddings": [[0.021, -0.043, ...]],
"sparse_embeddings": null
}return_sparse: true일 때 sparse_embeddings:
{
"sparse_embeddings": [
{"30001": 0.31, "9876": 0.18, ...}
]
}키는 token ID(str), 값은 lexical weight(float). Qdrant / Weaviate 하이브리드 검색에 바로 사용 가능.
| 환경변수 | 기본값 | 설명 |
|---|---|---|
EMBED_MODEL_NAME |
BAAI/bge-m3 |
HuggingFace 모델 ID |
EMBED_MODEL_CACHE_DIR |
~/.cache/huggingface |
모델 가중치 캐시 경로 |
EMBED_MAX_BATCH_SIZE |
32 |
요청당 최대 텍스트 수 |
EMBED_MAX_TEXT_LENGTH |
8192 |
텍스트 최대 문자 수 (초과 시 자름) |
EMBED_MAX_CONCURRENT |
2 |
동시 GPU 추론 수 (Semaphore 상한) |
.env 파일을 프로젝트 루트에 두면 자동으로 로드된다.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# 첫 실행 시 BGE-M3 모델 (~2.5GB) 자동 다운로드
PYTORCH_ENABLE_MPS_FALLBACK=1 uvicorn app.main:app --host 0.0.0.0 --port 8001백그라운드 상시 실행이 필요하면 launchd plist 또는 brew services로 등록한다.
docker compose up -d모델 가중치는 model-cache named volume에 저장되어 컨테이너 재빌드 후에도 유지된다.
# 헬스체크
curl http://localhost:8001/health
# 임베딩
curl -X POST http://localhost:8001/embed \
-H "Content-Type: application/json" \
-d '{"texts": ["Mac Mini is great", "벡터 검색"]}'| 네이티브 | Docker | |
|---|---|---|
| MPS (GPU) | O | X |
| 설치 편의성 | 가상환경 필요 | docker compose up |
| 모델 캐시 | ~/.cache/huggingface |
named volume |
| 권장 용도 | 운영 (홈서버) | 개발 / CI |