File size: 4,160 Bytes
939c0c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | """
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")
|