chopratejas--headroom
0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
1044 行
37 KiB
Python
1044 行
37 KiB
Python
"""Embedder implementations for Headroom Memory.
|
|
|
|
Provides embedding generation via multiple backends:
|
|
- LocalEmbedder: sentence-transformers (local, no API needed)
|
|
- OpenAIEmbedder: OpenAI API (cloud, requires API key)
|
|
- OllamaEmbedder: Ollama API (local server)
|
|
|
|
All embedders return normalized float32 vectors for cosine similarity.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import warnings
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from functools import cached_property
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import numpy as np
|
|
|
|
from headroom.models.config import ML_MODEL_DEFAULTS
|
|
from headroom.onnx_runtime import create_cpu_session_options, hf_hub_download_local_first
|
|
|
|
if TYPE_CHECKING:
|
|
from sentence_transformers import SentenceTransformer
|
|
|
|
# Suppress HuggingFace Hub warnings about missing tokens and rate limits.
|
|
# These appear whenever hf_hub_download is called without HF_TOKEN set.
|
|
# We operate in an authenticated-optional mode; warnings are not actionable.
|
|
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1")
|
|
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub")
|
|
# Also silence the huggingface_hub logger which emits rate-limit advisory messages.
|
|
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
|
# sentence_transformers uses httpx to check model file manifests on every startup.
|
|
# These HEAD/GET requests generate INFO lines per worker; suppress to WARNING.
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Local (torch / sentence-transformers) CPU thread cap — issue #198
|
|
# =============================================================================
|
|
# A long-lived proxy serves many requests concurrently. Each torch ``encode()``
|
|
# fans out to BLAS (MKL / OpenBLAS / Accelerate) + OpenMP worker threads, which
|
|
# default to roughly ``os.cpu_count()``. Under concurrency this oversubscribes
|
|
# the CPU — N in-flight encodes x ~cpu_count threads each thrash the scheduler
|
|
# and starve the asyncio event loop, so liveness probes (``/livez``) spike to
|
|
# multiple seconds even though the loop itself is idle (issue #198).
|
|
#
|
|
# Capping intra-op parallelism makes a single encode modestly slower but lets
|
|
# concurrent encodes scale linearly without thread-pool thrash — the standard
|
|
# trade-off for serving torch models inside an async server. The ONNX embedder
|
|
# already caps its threads (see ``onnx_runtime.create_cpu_session_options``);
|
|
# this brings the torch path to parity.
|
|
#
|
|
# torch's OpenMP thread count is per-thread, and encodes run on executor worker
|
|
# threads, so a one-shot cap would miss most workers. Instead, CPU encodes run
|
|
# on a dedicated, size-limited executor whose ``initializer`` pins each worker's
|
|
# thread pool once. Total embedding threads are then bounded by
|
|
# ``workers (HEADROOM_EMBED_CONCURRENCY) x threads-per-encode
|
|
# (HEADROOM_EMBED_NUM_THREADS)``. Applies to the CPU device only (GPU/MPS do
|
|
# their compute off-CPU).
|
|
_EMBED_THREADS_ENV = "HEADROOM_EMBED_NUM_THREADS"
|
|
_DEFAULT_EMBED_THREADS = 1
|
|
_EMBED_CONCURRENCY_ENV = "HEADROOM_EMBED_CONCURRENCY"
|
|
_DEFAULT_EMBED_CONCURRENCY = 4
|
|
_BLAS_THREAD_ENV_VARS = (
|
|
"OMP_NUM_THREADS",
|
|
"OPENBLAS_NUM_THREADS",
|
|
"MKL_NUM_THREADS",
|
|
"NUMEXPR_NUM_THREADS",
|
|
"VECLIB_MAXIMUM_THREADS",
|
|
)
|
|
|
|
|
|
def _resolve_positive_int_env(env_var: str, default: int) -> int:
|
|
"""Read a positive integer from ``env_var``, falling back to ``default``.
|
|
|
|
A non-positive or unparseable value logs a warning and returns a safe value
|
|
(>= 1) rather than disabling the limit.
|
|
"""
|
|
raw = os.environ.get(env_var)
|
|
if raw is None:
|
|
return default
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
logger.warning("Invalid %s=%r; falling back to %d.", env_var, raw, default)
|
|
return default
|
|
if value < 1:
|
|
logger.warning("%s=%d is below 1; using 1.", env_var, value)
|
|
return 1
|
|
return value
|
|
|
|
|
|
def _resolve_embed_thread_cap() -> int:
|
|
"""Resolve the per-encode CPU thread cap (``HEADROOM_EMBED_NUM_THREADS``)."""
|
|
return _resolve_positive_int_env(_EMBED_THREADS_ENV, _DEFAULT_EMBED_THREADS)
|
|
|
|
|
|
def _resolve_embed_concurrency() -> int:
|
|
"""Resolve the max concurrent CPU encodes (``HEADROOM_EMBED_CONCURRENCY``).
|
|
|
|
Defaults to ``min(4, os.cpu_count())`` so embedding cannot occupy every core
|
|
and starve the event loop, while still allowing useful parallelism.
|
|
"""
|
|
cpu = os.cpu_count() or 1
|
|
raw = os.environ.get(_EMBED_CONCURRENCY_ENV)
|
|
if raw is None:
|
|
return max(1, min(_DEFAULT_EMBED_CONCURRENCY, cpu))
|
|
return _resolve_positive_int_env(
|
|
_EMBED_CONCURRENCY_ENV, max(1, min(_DEFAULT_EMBED_CONCURRENCY, cpu))
|
|
)
|
|
|
|
|
|
def _init_cpu_embed_worker() -> None:
|
|
"""Pin a CPU embed worker's thread pool (runs once per worker; issue #198).
|
|
|
|
Sets BLAS/OpenMP env defaults (``setdefault`` never overrides an operator's
|
|
explicit setting) and bounds torch's intra-op pool for this worker thread.
|
|
Best-effort: failures never block embedding.
|
|
"""
|
|
n = _resolve_embed_thread_cap()
|
|
for var in _BLAS_THREAD_ENV_VARS:
|
|
os.environ.setdefault(var, str(n))
|
|
try:
|
|
import torch
|
|
|
|
torch.set_num_threads(n)
|
|
except ImportError:
|
|
pass
|
|
except Exception as exc: # pragma: no cover - defensive, never block embedding
|
|
logger.debug("Could not cap torch intra-op thread pool: %s", exc)
|
|
|
|
|
|
def _normalize_embedding(embedding: np.ndarray) -> np.ndarray:
|
|
"""Normalize embedding to unit vector for cosine similarity.
|
|
|
|
Args:
|
|
embedding: The embedding vector to normalize.
|
|
|
|
Returns:
|
|
Normalized embedding with L2 norm of 1.0.
|
|
"""
|
|
norm = np.linalg.norm(embedding)
|
|
if norm > 0:
|
|
result: np.ndarray = (embedding / norm).astype(np.float32)
|
|
return result
|
|
result = embedding.astype(np.float32)
|
|
return result
|
|
|
|
|
|
def _normalize_embeddings_batch(embeddings: np.ndarray) -> np.ndarray:
|
|
"""Normalize a batch of embeddings to unit vectors.
|
|
|
|
Args:
|
|
embeddings: 2D array of embeddings (batch_size, dimension).
|
|
|
|
Returns:
|
|
Normalized embeddings with L2 norm of 1.0 per row.
|
|
"""
|
|
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
|
# Avoid division by zero
|
|
norms = np.where(norms > 0, norms, 1.0)
|
|
result: np.ndarray = (embeddings / norms).astype(np.float32)
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# LocalEmbedder - sentence-transformers
|
|
# =============================================================================
|
|
|
|
|
|
class LocalEmbedder:
|
|
"""Local embedding using sentence-transformers.
|
|
|
|
Uses the sentence-transformers library for local embedding generation.
|
|
No API calls needed - runs entirely on local hardware.
|
|
|
|
Features:
|
|
- Lazy model loading (loads on first use)
|
|
- Automatic device selection (CUDA > MPS > CPU)
|
|
- Batch embedding support
|
|
- Returns normalized float32 vectors
|
|
|
|
Default model: all-MiniLM-L6-v2 (384 dimensions)
|
|
|
|
Usage:
|
|
embedder = LocalEmbedder()
|
|
embedding = await embedder.embed("Hello world")
|
|
embeddings = await embedder.embed_batch(["Hello", "World"])
|
|
"""
|
|
|
|
DEFAULT_DIMENSION = 384
|
|
DEFAULT_MAX_TOKENS = 256
|
|
|
|
def __init__(
|
|
self,
|
|
model_name: str | None = None,
|
|
device: str | None = None,
|
|
) -> None:
|
|
"""Initialize the local embedder.
|
|
|
|
Args:
|
|
model_name: Name of the sentence-transformers model to use.
|
|
Defaults to config's sentence_transformer setting.
|
|
device: Device to run on ("cuda", "mps", "cpu", or None for auto).
|
|
If None, automatically selects the best available device.
|
|
|
|
Raises:
|
|
ImportError: If sentence-transformers is not installed.
|
|
"""
|
|
self._model_name = model_name or ML_MODEL_DEFAULTS.sentence_transformer
|
|
self._requested_device = device
|
|
self._model: SentenceTransformer | None = None
|
|
self._device: str | None = None
|
|
self._dimension: int | None = None
|
|
self._lock = asyncio.Lock()
|
|
# Dedicated single-worker executor, created only when the resolved device
|
|
# is MPS (see _load_model). torch-MPS is not thread-safe, so every encode()
|
|
# must run on one thread. Stays None for CPU/CUDA → default shared executor.
|
|
self._executor: ThreadPoolExecutor | None = None
|
|
|
|
def _check_dependencies(self) -> None:
|
|
"""Check that required dependencies are installed."""
|
|
try:
|
|
import sentence_transformers # noqa: F401
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"sentence-transformers is required for LocalEmbedder. "
|
|
"Install it with: pip install sentence-transformers"
|
|
) from e
|
|
|
|
def _detect_device(self) -> str:
|
|
"""Auto-detect the best available device.
|
|
|
|
Returns:
|
|
Device string: "cuda", "mps", or "cpu".
|
|
"""
|
|
import torch
|
|
|
|
if torch.cuda.is_available():
|
|
logger.info("CUDA device detected, using GPU")
|
|
return "cuda"
|
|
elif torch.backends.mps.is_available():
|
|
logger.info("MPS device detected, using Apple Silicon GPU")
|
|
return "mps"
|
|
else:
|
|
logger.info("No GPU detected, using CPU")
|
|
return "cpu"
|
|
|
|
def _load_model(self) -> None:
|
|
"""Load the sentence-transformers model lazily via MLModelRegistry."""
|
|
if self._model is not None:
|
|
return
|
|
|
|
self._check_dependencies()
|
|
from headroom.models.ml_models import MLModelRegistry
|
|
|
|
# Determine device
|
|
if self._requested_device:
|
|
self._device = self._requested_device
|
|
else:
|
|
self._device = self._detect_device()
|
|
|
|
# CPU: run encodes on a dedicated, size-limited executor whose workers
|
|
# each pin their torch/BLAS/OpenMP thread pool (issue #198). Without this,
|
|
# N concurrent encodes on the shared default executor each fan out to
|
|
# ~os.cpu_count() BLAS threads and starve the asyncio event loop, spiking
|
|
# /livez latency. Total embed threads are bounded by workers x per-encode
|
|
# threads.
|
|
if self._device == "cpu" and self._executor is None:
|
|
self._executor = ThreadPoolExecutor(
|
|
max_workers=_resolve_embed_concurrency(),
|
|
thread_name_prefix="cpu-embed",
|
|
initializer=_init_cpu_embed_worker,
|
|
)
|
|
|
|
# torch-MPS is not thread-safe: concurrent encode() calls from the default
|
|
# multi-worker executor abort with "commit an already committed command
|
|
# buffer" (verified). Funnel every encode through one worker thread when on
|
|
# MPS so calls serialize; other devices keep the shared default executor.
|
|
if self._device == "mps" and self._executor is None:
|
|
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="mps-embed")
|
|
|
|
# Use centralized registry for shared model instances
|
|
self._model = MLModelRegistry.get_sentence_transformer(self._model_name, self._device)
|
|
|
|
# Get actual dimension from loaded model
|
|
self._dimension = self._model.get_sentence_embedding_dimension()
|
|
logger.info(
|
|
f"Model loaded (shared): {self._model_name}, dimension={self._dimension}, device={self._device}"
|
|
)
|
|
|
|
async def embed(self, text: str) -> np.ndarray:
|
|
"""Generate an embedding for a single text.
|
|
|
|
Args:
|
|
text: The text to embed.
|
|
|
|
Returns:
|
|
Normalized embedding vector as float32 numpy array.
|
|
"""
|
|
async with self._lock:
|
|
# Load model if not already loaded
|
|
if self._model is None:
|
|
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
|
|
|
# Handle empty string
|
|
if not text or not text.strip():
|
|
return np.zeros(self.dimension, dtype=np.float32)
|
|
|
|
# Run encoding in executor to avoid blocking
|
|
# Model is guaranteed to be loaded after the lock check above
|
|
assert self._model is not None
|
|
model = self._model # Local reference for lambda closure
|
|
loop = asyncio.get_event_loop()
|
|
embedding = await loop.run_in_executor(
|
|
self._executor,
|
|
lambda: model.encode(text, convert_to_numpy=True, normalize_embeddings=False),
|
|
)
|
|
|
|
return _normalize_embedding(embedding)
|
|
|
|
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
|
"""Generate embeddings for multiple texts.
|
|
|
|
Args:
|
|
texts: List of texts to embed.
|
|
|
|
Returns:
|
|
List of normalized embedding vectors.
|
|
"""
|
|
if not texts:
|
|
return []
|
|
|
|
async with self._lock:
|
|
# Load model if not already loaded
|
|
if self._model is None:
|
|
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
|
|
|
# Handle empty strings by tracking their indices
|
|
non_empty_indices = []
|
|
non_empty_texts = []
|
|
for i, text in enumerate(texts):
|
|
if text and text.strip():
|
|
non_empty_indices.append(i)
|
|
non_empty_texts.append(text)
|
|
|
|
# Initialize results with zeros for empty strings
|
|
results: list[np.ndarray] = [
|
|
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
|
]
|
|
|
|
if non_empty_texts:
|
|
# Run batch encoding in executor
|
|
# Model is guaranteed to be loaded after the lock check above
|
|
assert self._model is not None
|
|
model = self._model # Local reference for lambda closure
|
|
loop = asyncio.get_event_loop()
|
|
embeddings = await loop.run_in_executor(
|
|
self._executor,
|
|
lambda: model.encode(
|
|
non_empty_texts, convert_to_numpy=True, normalize_embeddings=False
|
|
),
|
|
)
|
|
|
|
# Normalize batch
|
|
normalized = _normalize_embeddings_batch(embeddings)
|
|
|
|
# Place results at correct indices
|
|
for idx, emb in zip(non_empty_indices, normalized):
|
|
results[idx] = emb
|
|
|
|
return results
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
"""Return the dimension of generated embeddings."""
|
|
if self._dimension is not None:
|
|
return self._dimension
|
|
# Return default dimension before model is loaded
|
|
return self.DEFAULT_DIMENSION
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
"""Return the name of the embedding model."""
|
|
return self._model_name
|
|
|
|
@property
|
|
def max_tokens(self) -> int:
|
|
"""Return the maximum number of tokens the model can process."""
|
|
return self.DEFAULT_MAX_TOKENS
|
|
|
|
async def close(self) -> None:
|
|
"""Close resources: shut down the MPS serialization executor and drop the
|
|
cached model reference so a later embed() fully re-initializes (and
|
|
re-creates the serialized executor) instead of encoding on a torn-down pool.
|
|
"""
|
|
if self._executor is not None:
|
|
self._executor.shutdown(wait=False)
|
|
self._executor = None
|
|
self._model = None
|
|
|
|
|
|
# =============================================================================
|
|
# OnnxLocalEmbedder - ONNX Runtime (no torch/sentence-transformers needed)
|
|
# =============================================================================
|
|
|
|
|
|
class OnnxLocalEmbedder:
|
|
"""Local embedding using ONNX Runtime — no torch dependency.
|
|
|
|
Uses the same all-MiniLM-L6-v2 model as LocalEmbedder, but loaded
|
|
via ONNX Runtime (~86 MB) instead of sentence-transformers + PyTorch (~2 GB).
|
|
|
|
Dependencies: onnxruntime, tokenizers, huggingface_hub (all in proxy extras).
|
|
Model auto-downloaded from HuggingFace on first use.
|
|
|
|
Usage:
|
|
embedder = OnnxLocalEmbedder()
|
|
embedding = await embedder.embed("Hello world")
|
|
"""
|
|
|
|
DEFAULT_DIMENSION = 384
|
|
DEFAULT_MAX_TOKENS = 256
|
|
ONNX_REPO = "Qdrant/all-MiniLM-L6-v2-onnx"
|
|
MAX_BATCH_SIZE = 2
|
|
|
|
def __init__(self, max_length: int = 256) -> None:
|
|
self._max_length = max_length
|
|
self._session: Any = None
|
|
self._tokenizer: Any = None
|
|
self._input_names: list[str] = []
|
|
self._lock = asyncio.Lock()
|
|
|
|
def _load_model(self) -> None:
|
|
"""Lazy-load the ONNX model and tokenizer."""
|
|
if self._session is not None:
|
|
return
|
|
|
|
import onnxruntime as ort
|
|
from tokenizers import Tokenizer
|
|
|
|
logger.info("Loading ONNX embedding model (all-MiniLM-L6-v2, ~86MB)...")
|
|
|
|
# Prefer local cache to avoid a redundant network HEAD on warm starts.
|
|
model_path = hf_hub_download_local_first(self.ONNX_REPO, "model.onnx")
|
|
tok_path = hf_hub_download_local_first(self.ONNX_REPO, "tokenizer.json")
|
|
|
|
# Keep a small thread pool for Docker compatibility and disable ORT's
|
|
# CPU memory arena/pattern caches so long-running proxy workers do not
|
|
# retain large anonymous heaps after embedding bursts.
|
|
sess_options = create_cpu_session_options(
|
|
ort,
|
|
intra_op_num_threads=1,
|
|
inter_op_num_threads=1,
|
|
)
|
|
self._session = ort.InferenceSession(
|
|
model_path, sess_options, providers=["CPUExecutionProvider"]
|
|
)
|
|
self._tokenizer = Tokenizer.from_file(tok_path)
|
|
self._tokenizer.enable_truncation(max_length=self._max_length)
|
|
self._tokenizer.enable_padding(length=self._max_length)
|
|
self._input_names = [inp.name for inp in self._session.get_inputs()]
|
|
|
|
logger.info("ONNX embedding model loaded (384-dim, no torch)")
|
|
|
|
def _build_feeds(
|
|
self,
|
|
input_ids: np.ndarray,
|
|
attention_mask: np.ndarray,
|
|
) -> dict[str, np.ndarray]:
|
|
"""Build ONNX feeds for a token batch."""
|
|
token_type_ids = np.zeros_like(input_ids, dtype=np.int64)
|
|
|
|
feeds: dict[str, np.ndarray] = {}
|
|
for name in self._input_names:
|
|
if "input_ids" in name:
|
|
feeds[name] = input_ids
|
|
elif "attention_mask" in name:
|
|
feeds[name] = attention_mask
|
|
elif "token_type_ids" in name:
|
|
feeds[name] = token_type_ids
|
|
|
|
return feeds
|
|
|
|
def _embed_many(self, texts: list[str]) -> np.ndarray:
|
|
"""Embed multiple non-empty text strings in one ONNX pass."""
|
|
assert self._session is not None
|
|
assert self._tokenizer is not None
|
|
|
|
encodings = self._tokenizer.encode_batch(texts)
|
|
input_ids = np.array([encoding.ids for encoding in encodings], dtype=np.int64)
|
|
attention_mask = np.array(
|
|
[encoding.attention_mask for encoding in encodings], dtype=np.int64
|
|
)
|
|
|
|
outputs = self._session.run(None, self._build_feeds(input_ids, attention_mask))
|
|
token_embeddings = outputs[0] # (batch, seq_len, 384)
|
|
|
|
# Mean pooling over non-padding tokens
|
|
mask_expanded = attention_mask[:, :, np.newaxis].astype(np.float32)
|
|
summed = np.sum(token_embeddings * mask_expanded, axis=1)
|
|
counts = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
|
|
embeddings = summed / counts
|
|
|
|
return _normalize_embeddings_batch(embeddings)
|
|
|
|
def _embed_single(self, text: str) -> np.ndarray:
|
|
"""Embed a single text string."""
|
|
if not text or not text.strip():
|
|
return np.zeros(self.DEFAULT_DIMENSION, dtype=np.float32)
|
|
|
|
embedding = self._embed_many([text])[0]
|
|
return cast(np.ndarray, embedding)
|
|
|
|
async def embed(self, text: str) -> np.ndarray:
|
|
"""Generate an embedding for a single text."""
|
|
async with self._lock:
|
|
if self._session is None:
|
|
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
|
|
|
loop = asyncio.get_event_loop()
|
|
embedding = await loop.run_in_executor(None, self._embed_single, text)
|
|
return cast(np.ndarray, embedding)
|
|
|
|
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
|
"""Generate embeddings for multiple texts."""
|
|
if not texts:
|
|
return []
|
|
|
|
async with self._lock:
|
|
if self._session is None:
|
|
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
|
|
|
non_empty_indices: list[int] = []
|
|
non_empty_texts: list[str] = []
|
|
for i, text in enumerate(texts):
|
|
if text and text.strip():
|
|
non_empty_indices.append(i)
|
|
non_empty_texts.append(text)
|
|
|
|
results: list[np.ndarray] = [
|
|
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
|
]
|
|
if not non_empty_texts:
|
|
return results
|
|
|
|
loop = asyncio.get_event_loop()
|
|
for start in range(0, len(non_empty_texts), self.MAX_BATCH_SIZE):
|
|
batch_texts = non_empty_texts[start : start + self.MAX_BATCH_SIZE]
|
|
batch_indices = non_empty_indices[start : start + self.MAX_BATCH_SIZE]
|
|
embeddings = await loop.run_in_executor(None, self._embed_many, batch_texts)
|
|
for idx, embedding in zip(batch_indices, embeddings):
|
|
results[idx] = embedding
|
|
|
|
return results
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
return self.DEFAULT_DIMENSION
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return "all-MiniLM-L6-v2-onnx"
|
|
|
|
@property
|
|
def max_tokens(self) -> int:
|
|
return self._max_length
|
|
|
|
async def close(self) -> None:
|
|
"""Close resources."""
|
|
self._session = None
|
|
self._tokenizer = None
|
|
|
|
|
|
# =============================================================================
|
|
# OpenAIEmbedder - OpenAI API
|
|
# =============================================================================
|
|
|
|
|
|
class OpenAIEmbedder:
|
|
"""OpenAI API-based embedding generation.
|
|
|
|
Uses OpenAI's text-embedding-3-small model for high-quality embeddings.
|
|
Requires an API key (constructor parameter or OPENAI_API_KEY env var).
|
|
|
|
Features:
|
|
- Async API calls with retry logic
|
|
- Batch support with automatic rate limiting
|
|
- Returns normalized float32 vectors
|
|
|
|
Default model: text-embedding-3-small (1536 dimensions)
|
|
|
|
Usage:
|
|
embedder = OpenAIEmbedder(api_key="sk-...")
|
|
# Or use OPENAI_API_KEY environment variable
|
|
embedder = OpenAIEmbedder()
|
|
embedding = await embedder.embed("Hello world")
|
|
"""
|
|
|
|
DEFAULT_MODEL = "text-embedding-3-small"
|
|
DEFAULT_DIMENSION = 1536
|
|
DEFAULT_MAX_TOKENS = 8191
|
|
MAX_BATCH_SIZE = 2048 # OpenAI's limit
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY_BASE = 1.0 # Base delay in seconds for exponential backoff
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str | None = None,
|
|
model_name: str | None = None,
|
|
max_retries: int | None = None,
|
|
) -> None:
|
|
"""Initialize the OpenAI embedder.
|
|
|
|
Args:
|
|
api_key: OpenAI API key. If not provided, will use OPENAI_API_KEY
|
|
environment variable.
|
|
model_name: Model to use. Defaults to "text-embedding-3-small".
|
|
max_retries: Maximum number of retries for transient failures.
|
|
|
|
Raises:
|
|
ImportError: If openai library is not installed.
|
|
ValueError: If no API key is provided or found in environment.
|
|
"""
|
|
self._check_dependencies()
|
|
|
|
self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
if not self._api_key:
|
|
raise ValueError(
|
|
"OpenAI API key required. Provide api_key parameter or set "
|
|
"OPENAI_API_KEY environment variable."
|
|
)
|
|
|
|
self._model_name = model_name or self.DEFAULT_MODEL
|
|
self._max_retries = max_retries if max_retries is not None else self.MAX_RETRIES
|
|
self._client = None
|
|
|
|
def _check_dependencies(self) -> None:
|
|
"""Check that required dependencies are installed."""
|
|
try:
|
|
import openai # noqa: F401
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"openai is required for OpenAIEmbedder. Install it with: pip install openai"
|
|
) from e
|
|
|
|
@cached_property
|
|
def _async_client(self) -> Any:
|
|
"""Lazy initialization of async OpenAI client."""
|
|
from openai import AsyncOpenAI
|
|
|
|
return AsyncOpenAI(api_key=self._api_key)
|
|
|
|
async def _embed_with_retry(self, texts: list[str]) -> list[np.ndarray]:
|
|
"""Call OpenAI API with retry logic for transient failures.
|
|
|
|
Args:
|
|
texts: List of texts to embed.
|
|
|
|
Returns:
|
|
List of embedding vectors.
|
|
|
|
Raises:
|
|
ConnectionError: If all retries fail.
|
|
"""
|
|
from openai import APIConnectionError, APITimeoutError, RateLimitError
|
|
|
|
last_error = None
|
|
|
|
for attempt in range(self._max_retries):
|
|
try:
|
|
response = await self._async_client.embeddings.create(
|
|
model=self._model_name,
|
|
input=texts,
|
|
)
|
|
# Extract embeddings in order
|
|
embeddings = [np.array(item.embedding, dtype=np.float32) for item in response.data]
|
|
return embeddings
|
|
|
|
except (APIConnectionError, APITimeoutError, RateLimitError) as e:
|
|
last_error = e
|
|
delay = self.RETRY_DELAY_BASE * (2**attempt)
|
|
logger.warning(
|
|
f"OpenAI API error (attempt {attempt + 1}/{self._max_retries}): {e}. "
|
|
f"Retrying in {delay:.1f}s..."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
|
|
except Exception as e:
|
|
# Non-retryable error
|
|
raise ConnectionError(f"OpenAI API error: {e}") from e
|
|
|
|
# All retries exhausted
|
|
raise ConnectionError(
|
|
f"OpenAI API failed after {self._max_retries} retries: {last_error}"
|
|
) from last_error
|
|
|
|
async def embed(self, text: str) -> np.ndarray:
|
|
"""Generate an embedding for a single text.
|
|
|
|
Args:
|
|
text: The text to embed.
|
|
|
|
Returns:
|
|
Normalized embedding vector as float32 numpy array.
|
|
|
|
Raises:
|
|
ConnectionError: If API call fails after retries.
|
|
"""
|
|
# Handle empty string
|
|
if not text or not text.strip():
|
|
return np.zeros(self.dimension, dtype=np.float32)
|
|
|
|
embeddings = await self._embed_with_retry([text])
|
|
return _normalize_embedding(embeddings[0])
|
|
|
|
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
|
"""Generate embeddings for multiple texts.
|
|
|
|
Automatically handles batching for large inputs.
|
|
|
|
Args:
|
|
texts: List of texts to embed.
|
|
|
|
Returns:
|
|
List of normalized embedding vectors.
|
|
|
|
Raises:
|
|
ConnectionError: If API call fails after retries.
|
|
"""
|
|
if not texts:
|
|
return []
|
|
|
|
# Handle empty strings by tracking their indices
|
|
non_empty_indices = []
|
|
non_empty_texts = []
|
|
for i, text in enumerate(texts):
|
|
if text and text.strip():
|
|
non_empty_indices.append(i)
|
|
non_empty_texts.append(text)
|
|
|
|
# Initialize results with zeros for empty strings
|
|
results: list[np.ndarray] = [
|
|
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
|
]
|
|
|
|
if not non_empty_texts:
|
|
return results
|
|
|
|
# Process in batches
|
|
all_embeddings: list[np.ndarray] = []
|
|
for batch_start in range(0, len(non_empty_texts), self.MAX_BATCH_SIZE):
|
|
batch_end = min(batch_start + self.MAX_BATCH_SIZE, len(non_empty_texts))
|
|
batch = non_empty_texts[batch_start:batch_end]
|
|
|
|
batch_embeddings = await self._embed_with_retry(batch)
|
|
all_embeddings.extend(batch_embeddings)
|
|
|
|
# Normalize and place results at correct indices
|
|
for idx, emb in zip(non_empty_indices, all_embeddings):
|
|
results[idx] = _normalize_embedding(emb)
|
|
|
|
return results
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
"""Return the dimension of generated embeddings."""
|
|
return self.DEFAULT_DIMENSION
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
"""Return the name of the embedding model."""
|
|
return self._model_name
|
|
|
|
@property
|
|
def max_tokens(self) -> int:
|
|
"""Return the maximum number of tokens the model can process."""
|
|
return self.DEFAULT_MAX_TOKENS
|
|
|
|
async def close(self) -> None:
|
|
"""Close the OpenAI async client and its underlying httpx connection."""
|
|
if "_async_client" in self.__dict__:
|
|
await self._async_client.close()
|
|
# Remove from cache to allow re-creation if needed
|
|
del self.__dict__["_async_client"]
|
|
|
|
|
|
# =============================================================================
|
|
# OllamaEmbedder - Ollama API
|
|
# =============================================================================
|
|
|
|
|
|
class OllamaEmbedder:
|
|
"""Ollama API-based embedding generation.
|
|
|
|
Uses a local Ollama server for embedding generation. No cloud API needed.
|
|
|
|
Features:
|
|
- Async HTTP calls via httpx
|
|
- Batch support
|
|
- Retry logic for transient failures
|
|
- Returns normalized float32 vectors
|
|
|
|
Default model: nomic-embed-text (768 dimensions)
|
|
|
|
Usage:
|
|
embedder = OllamaEmbedder() # Uses localhost:11434
|
|
embedder = OllamaEmbedder(base_url="http://remote:11434")
|
|
embedding = await embedder.embed("Hello world")
|
|
"""
|
|
|
|
DEFAULT_MODEL = "nomic-embed-text"
|
|
DEFAULT_DIMENSION = 768
|
|
DEFAULT_MAX_TOKENS = 8192
|
|
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY_BASE = 0.5 # Base delay in seconds for exponential backoff
|
|
REQUEST_TIMEOUT = 60.0 # Timeout for API requests
|
|
|
|
# Known model dimensions (for models that don't report their dimension)
|
|
KNOWN_DIMENSIONS = {
|
|
"nomic-embed-text": 768,
|
|
"all-minilm": 384,
|
|
"mxbai-embed-large": 1024,
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
model_name: str | None = None,
|
|
base_url: str | None = None,
|
|
max_retries: int | None = None,
|
|
dimension: int | None = None,
|
|
) -> None:
|
|
"""Initialize the Ollama embedder.
|
|
|
|
Args:
|
|
model_name: Model to use. Defaults to "nomic-embed-text".
|
|
base_url: Ollama server URL. Defaults to "http://localhost:11434".
|
|
max_retries: Maximum number of retries for transient failures.
|
|
dimension: Override embedding dimension. If not provided, uses
|
|
known dimension for model or probes the API.
|
|
|
|
Raises:
|
|
ImportError: If httpx library is not installed.
|
|
"""
|
|
self._check_dependencies()
|
|
|
|
self._model_name = model_name or self.DEFAULT_MODEL
|
|
self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
self._max_retries = max_retries if max_retries is not None else self.MAX_RETRIES
|
|
self._explicit_dimension = dimension
|
|
self._detected_dimension: int | None = None
|
|
self._client: Any = None # httpx.AsyncClient when initialized
|
|
self._lock = asyncio.Lock()
|
|
|
|
def _check_dependencies(self) -> None:
|
|
"""Check that required dependencies are installed."""
|
|
try:
|
|
import httpx # noqa: F401
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"httpx is required for OllamaEmbedder. Install it with: pip install httpx"
|
|
) from e
|
|
|
|
async def _get_client(self) -> Any:
|
|
"""Get or create the httpx async client."""
|
|
if self._client is None:
|
|
import httpx
|
|
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self._base_url,
|
|
timeout=self.REQUEST_TIMEOUT,
|
|
)
|
|
return self._client
|
|
|
|
async def _embed_single_with_retry(self, text: str) -> np.ndarray:
|
|
"""Call Ollama API with retry logic for a single text.
|
|
|
|
Args:
|
|
text: Text to embed.
|
|
|
|
Returns:
|
|
Embedding vector.
|
|
|
|
Raises:
|
|
ConnectionError: If all retries fail.
|
|
"""
|
|
import httpx
|
|
|
|
client = await self._get_client()
|
|
last_error = None
|
|
|
|
for attempt in range(self._max_retries):
|
|
try:
|
|
response = await client.post(
|
|
"/api/embeddings",
|
|
json={
|
|
"model": self._model_name,
|
|
"prompt": text,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
embedding = np.array(data["embedding"], dtype=np.float32)
|
|
|
|
# Detect dimension from first successful response
|
|
if self._detected_dimension is None:
|
|
self._detected_dimension = len(embedding)
|
|
|
|
return embedding
|
|
|
|
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
|
|
last_error = e
|
|
delay = self.RETRY_DELAY_BASE * (2**attempt)
|
|
logger.warning(
|
|
f"Ollama API error (attempt {attempt + 1}/{self._max_retries}): {e}. "
|
|
f"Retrying in {delay:.1f}s..."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
|
|
except Exception as e:
|
|
# Non-retryable error
|
|
raise ConnectionError(f"Ollama API error: {e}") from e
|
|
|
|
# All retries exhausted
|
|
raise ConnectionError(
|
|
f"Ollama API failed after {self._max_retries} retries: {last_error}"
|
|
) from last_error
|
|
|
|
async def embed(self, text: str) -> np.ndarray:
|
|
"""Generate an embedding for a single text.
|
|
|
|
Args:
|
|
text: The text to embed.
|
|
|
|
Returns:
|
|
Normalized embedding vector as float32 numpy array.
|
|
|
|
Raises:
|
|
ConnectionError: If API call fails after retries.
|
|
"""
|
|
# Handle empty string
|
|
if not text or not text.strip():
|
|
return np.zeros(self.dimension, dtype=np.float32)
|
|
|
|
embedding = await self._embed_single_with_retry(text)
|
|
return _normalize_embedding(embedding)
|
|
|
|
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
|
"""Generate embeddings for multiple texts.
|
|
|
|
Ollama API doesn't support batch embedding natively,
|
|
so we make concurrent requests.
|
|
|
|
Args:
|
|
texts: List of texts to embed.
|
|
|
|
Returns:
|
|
List of normalized embedding vectors.
|
|
|
|
Raises:
|
|
ConnectionError: If API call fails after retries.
|
|
"""
|
|
if not texts:
|
|
return []
|
|
|
|
# Handle empty strings by tracking their indices
|
|
non_empty_indices = []
|
|
non_empty_texts = []
|
|
for i, text in enumerate(texts):
|
|
if text and text.strip():
|
|
non_empty_indices.append(i)
|
|
non_empty_texts.append(text)
|
|
|
|
# Initialize results with zeros for empty strings
|
|
results: list[np.ndarray] = [
|
|
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
|
]
|
|
|
|
if not non_empty_texts:
|
|
return results
|
|
|
|
# Make concurrent requests for non-empty texts
|
|
# Use a semaphore to limit concurrency and avoid overwhelming the server
|
|
semaphore = asyncio.Semaphore(10)
|
|
|
|
async def embed_with_semaphore(text: str) -> np.ndarray:
|
|
async with semaphore:
|
|
return await self._embed_single_with_retry(text)
|
|
|
|
tasks = [embed_with_semaphore(text) for text in non_empty_texts]
|
|
embeddings = await asyncio.gather(*tasks)
|
|
|
|
# Normalize and place results at correct indices
|
|
for idx, emb in zip(non_empty_indices, embeddings):
|
|
results[idx] = _normalize_embedding(emb)
|
|
|
|
return results
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
"""Return the dimension of generated embeddings."""
|
|
# Priority: explicit > detected > known > default
|
|
if self._explicit_dimension is not None:
|
|
return self._explicit_dimension
|
|
if self._detected_dimension is not None:
|
|
return self._detected_dimension
|
|
if self._model_name in self.KNOWN_DIMENSIONS:
|
|
return self.KNOWN_DIMENSIONS[self._model_name]
|
|
return self.DEFAULT_DIMENSION
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
"""Return the name of the embedding model."""
|
|
return self._model_name
|
|
|
|
@property
|
|
def max_tokens(self) -> int:
|
|
"""Return the maximum number of tokens the model can process."""
|
|
return self.DEFAULT_MAX_TOKENS
|
|
|
|
async def close(self) -> None:
|
|
"""Close the HTTP client."""
|
|
if self._client is not None:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
async def __aenter__(self) -> OllamaEmbedder:
|
|
"""Async context manager entry."""
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
"""Async context manager exit."""
|
|
await self.close()
|