from typing import Any import httpx from app.config import Settings class EmbeddingServiceError(RuntimeError): pass class JinaEmbeddingService: def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: self.settings = settings self._client = client @property def client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) return self._client async def embed_query(self, text: str) -> list[float]: embeddings = await self.embed_texts([text], task=self.settings.jina_query_task) return embeddings[0] async def embed_passages(self, texts: list[str]) -> list[list[float]]: return await self.embed_texts(texts, task=self.settings.jina_passage_task) async def embed_texts(self, texts: list[str], *, task: str) -> list[list[float]]: cleaned = [text.strip() for text in texts if text and text.strip()] if not cleaned: return [] payload: dict[str, Any] = { "model": self.settings.jina_embedding_model, "input": [ _with_retrieval_prefix(text, task, self.settings.jina_embedding_model) for text in cleaned ], "dimensions": self.settings.jina_embedding_dimensions, "task": task, "truncate": True, } headers = { "Authorization": f"Bearer {self.settings.jina_api_key}", "Content-Type": "application/json", } try: response = await self.client.post( self.settings.jina_embedding_endpoint, headers=headers, json=payload, ) response.raise_for_status() except httpx.HTTPError as exc: raise EmbeddingServiceError(f"Jina embedding request failed: {exc}") from exc body = response.json() data = body.get("data") if not isinstance(data, list): raise EmbeddingServiceError("Jina embedding response did not include a data array") embeddings = [_embedding_from_item(item) for item in sorted(data, key=_embedding_index)] if len(embeddings) != len(cleaned): raise EmbeddingServiceError("Jina embedding response count did not match input count") return embeddings def _with_retrieval_prefix(text: str, task: str, model: str) -> str: if "v5" not in model: return text if task == "retrieval.query" and not text.startswith("Query:"): return f"Query: {text}" if task == "retrieval.passage" and not text.startswith("Document:"): return f"Document: {text}" return text def _embedding_index(item: Any) -> int: if isinstance(item, dict): return int(item.get("index") or 0) return 0 def _embedding_from_item(item: Any) -> list[float]: if not isinstance(item, dict) or not isinstance(item.get("embedding"), list): raise EmbeddingServiceError("Jina embedding item did not include an embedding array") return [float(value) for value in item["embedding"]]