| """Local embeddings via FastEmbed (ONNX, CPU, zero cost). |
| |
| Nothing leaves the process. That is the compliance answer for a UAE government or legal |
| client β the corpus and every query stay inside the deployment boundary β and it is why |
| inference cost per query is dominated by one Claude call rather than by an embedding API. |
| |
| `bge-small-en-v1.5` is asymmetric: queries are embedded with an instruction prefix and |
| documents without one. Getting that backwards costs real recall, so the two directions |
| are separate functions here rather than one `embed(text)` that callers must remember to |
| parameterise. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import functools |
| import logging |
| import threading |
| from collections.abc import Iterable, Sequence |
|
|
| from fastembed import TextEmbedding |
|
|
| from app.core.settings import Settings, get_settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _LOCK = threading.Lock() |
| _MODEL: TextEmbedding | None = None |
|
|
|
|
| def get_embedding_model(settings: Settings | None = None) -> TextEmbedding: |
| """Process-wide embedding model. |
| |
| Loaded once and shared: the ONNX session is thread-safe for inference, and holding a |
| single instance keeps a cold container from paying the load cost per request. |
| """ |
| global _MODEL |
| if _MODEL is not None: |
| return _MODEL |
| cfg = settings or get_settings() |
| with _LOCK: |
| if _MODEL is None: |
| cfg.models_cache_dir.mkdir(parents=True, exist_ok=True) |
| logger.info("loading embedding model %s", cfg.embedding_model) |
| _MODEL = TextEmbedding( |
| model_name=cfg.embedding_model, |
| cache_dir=str(cfg.models_cache_dir), |
| ) |
| return _MODEL |
|
|
|
|
| def embed_documents(texts: Sequence[str], settings: Settings | None = None) -> list[list[float]]: |
| """Embed corpus passages. No instruction prefix β this is the document side.""" |
| if not texts: |
| return [] |
| model = get_embedding_model(settings) |
| return [vector.tolist() for vector in model.embed(list(texts))] |
|
|
|
|
| def embed_query(text: str, settings: Settings | None = None) -> list[float]: |
| """Embed a search query, with the instruction prefix bge was trained to expect.""" |
| cfg = settings or get_settings() |
| return _embed_query_cached(f"{cfg.embedding_query_prefix}{text}") |
|
|
|
|
| @functools.lru_cache(maxsize=512) |
| def _embed_query_cached(prefixed: str) -> list[float]: |
| """Cache identical queries. |
| |
| Repeated questions are the norm in a demo, and the eval harness embeds the same |
| query once per configuration. The key is the fully prefixed string, so changing the |
| prefix can never return a vector built with the old one. |
| |
| ``TextEmbedding.embed`` is used deliberately in place of ``query_embed``: measured |
| against this build of FastEmbed, ``query_embed(q)`` is bit-identical to ``embed(q)`` |
| (cosine 1.000000) β it does *not* add bge's instruction prefix, despite the name. |
| Relying on it to do so would leave every query embedded on the document side of an |
| asymmetric model, losing recall with nothing in the logs to show for it. |
| """ |
| model = get_embedding_model() |
| vectors: Iterable[object] = model.embed([prefixed]) |
| for vector in vectors: |
| return list(vector.tolist()) |
| raise RuntimeError("embedding model returned no vector") |
|
|
|
|
| def reset_embedding_model() -> None: |
| """Drop the cached model and query cache. Used by tests.""" |
| global _MODEL |
| with _LOCK: |
| _MODEL = None |
| _embed_query_cached.cache_clear() |
|
|