ultron1 / packages /memory /embedder.py
ghostdrive1's picture
feat(memory): Phase 3 β€” embedder, zilliz, raptor, worker, __init__
58ab44f
Raw
History Blame Contribute Delete
3.63 kB
"""
packages/memory/embedder.py
Batch embedding layer using sentence-transformers all-MiniLM-L6-v2.
384-dim cosine-space vectors. CPU-only (HF Spaces free tier).
Design:
- Lazy model load: model downloaded/loaded on first encode() call, not at import.
- Async-safe: encode() runs in executor to avoid blocking the event loop.
- Batch: encode list[str] β†’ list[list[float]] in one model.encode() call.
- Normalise: L2-norm so cosine sim == dot product (Zilliz COSINE metric).
Pre-registered gut-feel bugs (other files that could break this one):
E1 [HIGH] raptor.py calls encode() inside asyncio.gather() across multiple
user_ids simultaneously. sentence-transformers model.encode() is
NOT thread-safe when batch_size > 1. Fix: asyncio.Lock on encode().
E2 [MED] HF Space free tier has ~2GB RAM. all-MiniLM-L6-v2 uses ~90MB.
If browser_agent.py Playwright is also loaded, OOM risk.
Fix: track peak RAM at startup, warn via /health endpoint.
E3 [LOW] model.encode() returns numpy arrays. ZillizStore expects list[float].
Implicit .tolist() missing β†’ pymilvus TypeError on upsert.
Fix: explicit .tolist() in encode() return.
"""
import asyncio
import logging
from functools import lru_cache
from typing import List
logger = logging.getLogger(__name__)
MODEL_NAME = "all-MiniLM-L6-v2"
EMBED_DIM = 384
@lru_cache(maxsize=1)
def _load_model():
"""Load model once, cache in process. Called inside executor thread."""
from sentence_transformers import SentenceTransformer # type: ignore
logger.info(f"Loading embedding model: {MODEL_NAME}")
model = SentenceTransformer(MODEL_NAME)
return model
class Embedder:
"""
Async batch embedder.
Usage:
embedder = Embedder()
vecs = await embedder.encode(["hello world", "foo bar"])
# vecs: List[List[float]], shape (2, 384)
"""
def __init__(self):
self._lock = asyncio.Lock() # E1 guard: single encode at a time
self._loop: asyncio.AbstractEventLoop | None = None
async def encode(self, texts: List[str]) -> List[List[float]]:
"""
Encode a batch of texts to 384-dim normalised float vectors.
Args:
texts: Non-empty list of strings.
Returns:
List of float lists, one per input text.
Raises:
ValueError: If texts is empty.
"""
if not texts:
raise ValueError("encode() called with empty texts list")
async with self._lock: # E1: serialise concurrent encode calls
loop = asyncio.get_running_loop()
# Run CPU-bound model.encode in thread pool β€” avoids blocking event loop
embeddings = await loop.run_in_executor(
None, self._sync_encode, texts
)
return embeddings
def _sync_encode(self, texts: List[str]) -> List[List[float]]:
"""Blocking encode β€” called inside executor thread."""
model = _load_model()
# normalize_embeddings=True β†’ L2 norm β†’ cosine sim == dot product
vecs = model.encode(
texts,
batch_size=64,
normalize_embeddings=True,
show_progress_bar=False,
)
return [v.tolist() for v in vecs] # E3: explicit .tolist()
async def encode_one(self, text: str) -> List[float]:
"""Convenience: encode a single string."""
results = await self.encode([text])
return results[0]
@property
def dim(self) -> int:
return EMBED_DIM