Punit1's picture
Initial commit
939c0c0
Raw
History Blame Contribute Delete
4.16 kB
"""
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)
# Warm up with a test sentence
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 # type: ignore
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( # type: ignore
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)
# ── Singleton instance ─────────────────────────────────────────
embedding_service = EmbeddingService(model_name="all-MiniLM-L6-v2")