| """Local multilingual embeddings via fastembed (ONNX, no torch). |
| |
| Default model: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 |
| (384-dim, multilingual incl. ES/PT/EN). If an e5 model is configured, the |
| "query: " / "passage: " prefixes it expects are applied automatically. |
| Inference runs in a worker thread; the model is loaded lazily and cached. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import anyio |
|
|
| from app.config import get_settings |
|
|
| _model = None |
|
|
|
|
| def _get_model(): |
| global _model |
| if _model is None: |
| from fastembed import TextEmbedding |
|
|
| _model = TextEmbedding( |
| model_name=get_settings().embedding_model, cache_dir=".fastembed_cache" |
| ) |
| return _model |
|
|
|
|
| def _embed_sync(texts: list[str]) -> list[list[float]]: |
| model = _get_model() |
| return [[float(x) for x in vec] for vec in model.embed(texts)] |
|
|
|
|
| def _apply_prefix(kind: str, texts: list[str]) -> list[str]: |
| |
| if "e5" in get_settings().embedding_model.lower(): |
| return [f"{kind}: {t}" for t in texts] |
| return texts |
|
|
|
|
| async def embed_texts(texts: list[str], *, kind: str = "passage") -> list[list[float]]: |
| if not texts: |
| return [] |
| return await anyio.to_thread.run_sync(_embed_sync, _apply_prefix(kind, texts)) |
|
|
|
|
| async def embed_query(text: str) -> list[float]: |
| result = await embed_texts([text], kind="query") |
| return result[0] |
|
|
|
|
| def cosine(a: list[float], b: list[float]) -> float: |
| if not a or not b: |
| return 0.0 |
| dot = sum(x * y for x, y in zip(a, b, strict=False)) |
| na = math.sqrt(sum(x * x for x in a)) |
| nb = math.sqrt(sum(y * y for y in b)) |
| if na == 0 or nb == 0: |
| return 0.0 |
| return dot / (na * nb) |
|
|