MyPal / app /embeddings /model.py
KhaledSalehKL1's picture
Deploy 9XAIPal: Gradio+FastAPI app, backend, React build
1086e43 verified
Raw
History Blame Contribute Delete
9.11 kB
"""Embedding model wrapper: local Ollama or an OpenAI-compatible cloud API.
The backend is picked by app.llm.resolver (EMBEDDING_PROVIDER=auto: Ollama
when reachable, else OpenAI, else Gemini — the only clouds with embedding
APIs) and pinned for the process lifetime so one library is never embedded by
two different models within a run.
All embeddings are shaped to settings.vector_dimension before storage/search:
larger vectors are truncated and re-normalized — valid for MRL-trained models
(qwen3-embedding, text-embedding-3-*, gemini-embedding) — and smaller ones are
zero-padded (padding never changes cosine similarity). Keeping the stored
dimension ≤ 2000 is what allows the pgvector HNSW index to exist at all.
"""
import math
import httpx
from app.api.errors import ModelUnavailable
from app.core.config import settings
from app.core.logging import get_logger
from app.llm import resolver
from app.llm.resolver import EmbeddingTarget
logger = get_logger(__name__)
# Providers whose /embeddings endpoint accepts the OpenAI `dimensions`
# parameter (server-side MRL truncation). For others we only shape locally.
_DIMENSIONS_PARAM_PROVIDERS = {"openai", "gemini"}
async def active_embedding_model() -> str:
"""Model name embeddings are stored under (for the embedding_model column)."""
return (await resolver.resolve_embedding()).model
def active_embedding_model_sync() -> str:
"""Sync variant for Celery workers."""
return resolver.resolve_embedding_sync().model
def _cloud_headers(target: EmbeddingTarget) -> dict:
headers = {"Content-Type": "application/json"}
if target.api_key:
headers["Authorization"] = f"Bearer {target.api_key}"
return headers
def _ollama_headers(target: EmbeddingTarget) -> dict:
headers = {"Content-Type": "application/json"}
if target.api_key:
headers["Authorization"] = f"Bearer {target.api_key}"
return headers
def shape_embedding(vec: list[float]) -> list[float]:
"""Fit an embedding to settings.vector_dimension (truncate+renorm or pad)."""
dim = settings.vector_dimension
if len(vec) == dim:
return vec
if len(vec) > dim:
head = vec[:dim]
norm = math.sqrt(sum(x * x for x in head)) or 1.0
return [x / norm for x in head]
return list(vec) + [0.0] * (dim - len(vec))
def _cloud_payload(texts: list[str], target: EmbeddingTarget) -> dict:
payload: dict = {"model": target.model, "input": texts}
if target.provider in _DIMENSIONS_PARAM_PROVIDERS:
payload["dimensions"] = settings.vector_dimension
return payload
def _parse_cloud_embeddings(data: dict, expected: int, model: str) -> list[list[float]]:
rows = sorted(data.get("data") or [], key=lambda r: r.get("index", 0))
embeddings = [r.get("embedding") or [] for r in rows]
if len(embeddings) != expected:
raise ModelUnavailable(
f"{model} (returned {len(embeddings)} embeddings for {expected} inputs)"
)
return [shape_embedding(e) for e in embeddings]
async def get_embedding(text: str) -> list[float]:
"""Generate an embedding for a single text."""
return (await get_embeddings_batch([text]))[0]
async def get_embeddings_batch(texts: list[str]) -> list[list[float]]:
"""Generate embeddings for multiple texts."""
if not texts:
return []
target = await resolver.resolve_embedding()
if target.provider == "ollama":
url = f"{target.base_url}/api/embed"
payload = {
"model": target.model,
"input": texts,
# Keep the small embed model resident so a query embedding doesn't
# force a reload, and so it can coexist with the warm chat model.
"keep_alive": settings.ollama_keep_alive,
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload, headers=_ollama_headers(target))
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
logger.error("Ollama embedding error: %s", e)
raise ModelUnavailable(f"{target.model} (Ollama error: {e})")
embeddings = data.get("embeddings", [])
if len(embeddings) != len(texts):
raise ModelUnavailable(
f"{target.model} (returned {len(embeddings)} embeddings for {len(texts)} inputs)"
)
return [shape_embedding(e) for e in embeddings]
url = f"{target.base_url}/embeddings"
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=_cloud_payload(texts, target), headers=_cloud_headers(target))
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
body = e.response.text[:300]
logger.error("%s embedding HTTP %d: %s", target.provider, e.response.status_code, body)
raise ModelUnavailable(f"{target.model} ({e.response.status_code}: {body})")
except httpx.RequestError as e:
raise ModelUnavailable(f"{target.model} (network error: {e})")
return _parse_cloud_embeddings(data, len(texts), target.model)
async def get_query_embedding(query: str) -> list[float]:
"""Generate an embedding for a search query."""
return await get_embedding(query)
def _embed_one_sync(
client: "httpx.Client", url: str, model: str, text: str, target: EmbeddingTarget
) -> list[float] | None:
r = client.post(
url,
json={"model": model, "input": text, "keep_alive": settings.ollama_keep_alive},
headers=_ollama_headers(target),
)
r.raise_for_status()
embs = r.json().get("embeddings", [])
return embs[0] if embs else None
def get_embeddings_batch_sync(texts: list[str]) -> list[list[float]]:
"""Generate embeddings for multiple texts synchronously (Celery workers).
Ollama's /api/embed enforces the model context window across the WHOLE
batch and returns a hard 400 ("input length exceeds the context length")
if the combined inputs are too large — it does not truncate. So we try the
fast batched request first, and on any failure fall back to one request per
text with progressive truncation. A chunk that still can't embed gets a
zero vector (harmless in cosine search) so ingestion can never stall again.
"""
if not texts:
return []
target = resolver.resolve_embedding_sync()
if target.provider != "ollama":
url = f"{target.base_url}/embeddings"
try:
with httpx.Client(timeout=120.0) as client:
response = client.post(url, json=_cloud_payload(texts, target), headers=_cloud_headers(target))
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
body = e.response.text[:300]
logger.error("[sync] %s embedding HTTP %d: %s", target.provider, e.response.status_code, body)
raise ModelUnavailable(f"{target.model} ({e.response.status_code}: {body})")
except httpx.RequestError as e:
raise ModelUnavailable(f"{target.model} (network error: {e})")
return _parse_cloud_embeddings(data, len(texts), target.model)
url = f"{target.base_url}/api/embed"
model = target.model
with httpx.Client(timeout=120.0) as client:
# Fast path: the whole batch in one request.
try:
r = client.post(
url,
json={"model": model, "input": texts, "keep_alive": settings.ollama_keep_alive},
headers=_ollama_headers(target),
)
r.raise_for_status()
embs = r.json().get("embeddings", [])
if len(embs) == len(texts):
return [shape_embedding(e) for e in embs]
except httpx.HTTPStatusError:
pass # fall through to the resilient per-item path
except httpx.ConnectError as e:
logger.error("Ollama embedding batch sync connect error: %s", e)
raise ModelUnavailable(f"{model} (Ollama unreachable: {e})")
out: list[list[float]] = []
for t in texts:
emb: list[float] | None = None
for cap in (None, 2000, 1000, 400):
try:
emb = _embed_one_sync(
client, url, model, t if cap is None else t[:cap], target
)
if emb:
break
except httpx.HTTPStatusError:
emb = None
except httpx.ConnectError as e:
logger.error("Ollama embedding sync connect error: %s", e)
raise ModelUnavailable(f"{model} (Ollama unreachable: {e})")
if not emb:
logger.warning("Embedding failed for a chunk even after truncation; storing zero vector.")
emb = [0.0] * settings.vector_dimension
out.append(shape_embedding(emb))
return out