| """ |
| Embedding Service |
| ================= |
| Generates dense vector embeddings using sentence-transformers (100% free, runs locally). |
| Model: all-MiniLM-L6-v2 (384-dim, fast, great quality for enterprise text) |
| |
| Singleton pattern: model loads once on first call and stays in memory. |
| Thread-safe for concurrent FastAPI requests. |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import threading |
| from typing import List, Optional |
|
|
| import numpy as np |
| import structlog |
|
|
| logger = structlog.get_logger(__name__) |
|
|
|
|
| class EmbeddingService: |
| """ |
| Wraps sentence-transformers SentenceTransformer for async-compatible embedding. |
| Falls back gracefully if the model is not yet downloaded (first run). |
| """ |
|
|
| def __init__(self, model_name: str = "all-MiniLM-L6-v2"): |
| self.model_name = model_name |
| self._model = None |
| self._lock = threading.Lock() |
| self._dim: Optional[int] = None |
|
|
| def _load_model(self): |
| """Lazy-load the embedding model (downloads on first use).""" |
| if self._model is None: |
| with self._lock: |
| if self._model is None: |
| logger.info( |
| "Loading embedding model", |
| model=self.model_name, |
| note="Downloading on first run, ~90MB", |
| ) |
| from sentence_transformers import SentenceTransformer |
| self._model = SentenceTransformer(self.model_name) |
| |
| test = self._model.encode(["test"], show_progress_bar=False) |
| self._dim = test.shape[1] |
| logger.info( |
| "Embedding model ready", |
| model=self.model_name, |
| dim=self._dim, |
| ) |
|
|
| @property |
| def dim(self) -> int: |
| """Return embedding dimensions (loaded lazily).""" |
| if self._dim is None: |
| self._load_model() |
| return self._dim |
|
|
| def encode_sync( |
| self, |
| texts: List[str], |
| batch_size: int = 64, |
| normalize: bool = True, |
| ) -> np.ndarray: |
| """ |
| Synchronous encoding β for use in background tasks. |
| |
| Args: |
| texts: List of strings to embed |
| batch_size: Batch size for encoding (larger = faster on GPU) |
| normalize: L2-normalize embeddings (recommended for cosine similarity) |
| |
| Returns: |
| numpy array of shape (len(texts), dim) |
| """ |
| self._load_model() |
| embeddings = self._model.encode( |
| texts, |
| batch_size=batch_size, |
| normalize_embeddings=normalize, |
| show_progress_bar=False, |
| ) |
| return embeddings |
|
|
| async def encode( |
| self, |
| texts: List[str], |
| batch_size: int = 64, |
| normalize: bool = True, |
| ) -> np.ndarray: |
| """ |
| Async encoding β runs encoding in a thread pool to avoid blocking the event loop. |
| |
| Args: |
| texts: List of strings to embed |
| batch_size: Batch size for encoding |
| normalize: L2-normalize embeddings |
| |
| Returns: |
| numpy array of shape (len(texts), dim) |
| """ |
| loop = asyncio.get_event_loop() |
| return await loop.run_in_executor( |
| None, lambda: self.encode_sync(texts, batch_size, normalize) |
| ) |
|
|
| async def encode_query(self, query: str) -> np.ndarray: |
| """Encode a single query string and return 1D array.""" |
| result = await self.encode([query]) |
| return result[0] |
|
|
| async def encode_documents(self, texts: List[str]) -> np.ndarray: |
| """ |
| Encode a list of document chunks. Adds a prefix per MTEB best practice. |
| """ |
| prefixed = [f"passage: {t}" for t in texts] |
| return await self.encode(prefixed) |
|
|
|
|
| |
| embedding_service = EmbeddingService(model_name="all-MiniLM-L6-v2") |
|
|