Skip to content

Commit 33253e2

Browse files
GoodbyePlanetclaude
andcommitted
feat: Add Jina hosted API as an embedding provider
Adds `jina-api` as a new EMBEDDINGS_PROVIDER alongside the existing self-hosted TEI `jina`. Defaults to `jina-embeddings-v2-base-code` (768 dim) and supports v3 and the jina-code-embeddings family with the asymmetric `task` parameter and optional Matryoshka dimension override. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 011e19e commit 33253e2

6 files changed

Lines changed: 379 additions & 3 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ JINA_URL=http://localhost:8087
66
JINA_MODEL=jinaai/jina-embeddings-v2-base-code
77
JINA_DIMENSIONS=768
88

9+
# Jina hosted API (api.jina.ai)
10+
# JINA_API_KEY=
11+
# JINA_API_MODEL=jina-embeddings-v2-base-code
12+
# JINA_API_DIMENSIONS=
13+
914
# Voyage AI — set EMBEDDINGS_PROVIDER=voyage to use
1015
# VOYAGE_API_KEY=
1116
# VOYAGE_MODEL=voyage-code-3

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ For `/reindex-history` the `phase` value is `discovery|embedding|upserting` and
228228
| `QDRANT_URL` | `http://localhost:6333` | Qdrant connection URL |
229229
| `QDRANT_COLLECTION` | `code_symbols` | Collection name for code symbol vectors |
230230
| `QDRANT_COMMITS_COLLECTION` | `git_commits` | Collection name for commit message vectors |
231-
| `EMBEDDINGS_PROVIDER` | `jina` | One of `jina`, `voyage`, `openai`, `ollama` — see *Embedding providers* below |
231+
| `EMBEDDINGS_PROVIDER` | `jina` | One of `jina`, `jina-api`, `voyage`, `openai`, `ollama` — see *Embedding providers* below |
232232
| `GIT_HISTORY_MAX_COMMITS` | `500` | Max commits indexed per service |
233233
| `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` |
234234
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8090` | Server bind address |
@@ -245,6 +245,9 @@ configured model — no need to set dimensions manually unless you want to overr
245245
| `JINA_URL` | `http://localhost:8087` | `jina` | TEI base URL |
246246
| `JINA_MODEL` | `jinaai/jina-embeddings-v2-base-code` | `jina` | Informational only — the TEI container's `--model-id` flag is what actually loads. Edit `docker-compose.yaml` to change models. |
247247
| `JINA_DIMENSIONS` | `768` | `jina` | Vector dimensions of the TEI model |
248+
| `JINA_API_KEY` | *(required if provider=jina-api)* | `jina-api` | Jina AI API key (hosted endpoint at `api.jina.ai`) |
249+
| `JINA_API_MODEL` | `jina-embeddings-v2-base-code` | `jina-api` | Hosted Jina model — also supports `jina-embeddings-v3`, `jina-code-embeddings-0.5b`, `jina-code-embeddings-1.5b` |
250+
| `JINA_API_DIMENSIONS` | *(native)* | `jina-api` | Optional Matryoshka override (v3 and code-embeddings models support shrinking); required for models without a native default |
248251
| `VOYAGE_API_KEY` | *(required if provider=voyage)* | `voyage` | Voyage AI API key |
249252
| `VOYAGE_MODEL` | `voyage-code-3` | `voyage` | Voyage embedding model |
250253
| `VOYAGE_DIMENSIONS` | *(native)* | `voyage` | Optional override — Voyage code-3 supports `256` / `512` / `1024` / `2048` |

server/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(
2323
self.exclude = exclude
2424

2525

26-
EmbeddingsProviderName = Literal["jina", "voyage", "openai", "ollama"]
26+
EmbeddingsProviderName = Literal["jina", "jina-api", "voyage", "openai", "ollama"]
2727

2828

2929
class Settings(BaseSettings):
@@ -40,6 +40,13 @@ class Settings(BaseSettings):
4040
)
4141
jina_dimensions: int = Field(default=768, alias="JINA_DIMENSIONS")
4242

43+
# Jina AI (hosted API at api.jina.ai)
44+
jina_api_key: str = Field(default="", alias="JINA_API_KEY")
45+
jina_api_model: str = Field(
46+
default="jina-embeddings-v2-base-code", alias="JINA_API_MODEL"
47+
)
48+
jina_api_dimensions: int | None = Field(default=None, alias="JINA_API_DIMENSIONS")
49+
4350
# Voyage AI
4451
voyage_api_key: str = Field(default="", alias="VOYAGE_API_KEY")
4552
voyage_model: str = Field(default="voyage-code-3", alias="VOYAGE_MODEL")

server/embeddings/factory.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ def get_embedding_provider() -> EmbeddingProvider:
1616
from server.embeddings.jina import JinaEmbeddingProvider
1717

1818
_provider = JinaEmbeddingProvider()
19+
elif name == "jina-api":
20+
from server.embeddings.jina_api import JinaApiEmbeddingProvider
21+
22+
_provider = JinaApiEmbeddingProvider()
1923
elif name == "voyage":
2024
from server.embeddings.voyage import VoyageEmbeddingProvider
2125

@@ -31,7 +35,7 @@ def get_embedding_provider() -> EmbeddingProvider:
3135
else:
3236
raise ValueError(
3337
f"Unknown EMBEDDINGS_PROVIDER {name!r}. "
34-
"Expected one of: jina, voyage, openai, ollama."
38+
"Expected one of: jina, jina-api, voyage, openai, ollama."
3539
)
3640
return _provider
3741

server/embeddings/jina_api.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
6+
import httpx
7+
8+
from server.config import settings
9+
from server.embeddings.base import EmbeddingProvider
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_API_URL = "https://api.jina.ai/v1/embeddings"
14+
# Jina's hosted API accepts up to 2048 inputs per request; 128 keeps us
15+
# uniform with the OpenAI/Voyage providers.
16+
_BATCH_SIZE = 128
17+
_BACKOFF_DELAYS = [10, 20, 30, 40]
18+
19+
# Native output dimensions for known models. v3 and the jina-code-embeddings
20+
# family support Matryoshka truncation via the `dimensions` API parameter —
21+
# override with JINA_API_DIMENSIONS.
22+
#
23+
# Other code-tuned models available on the hosted API:
24+
# - jina-code-embeddings-0.5b
25+
# - jina-code-embeddings-1.5b
26+
# Their native dimensions vary; set JINA_API_DIMENSIONS to declare the size.
27+
_NATIVE_DIMENSIONS: dict[str, int] = {
28+
"jina-embeddings-v2-base-code": 768,
29+
"jina-embeddings-v2-base-en": 768,
30+
"jina-embeddings-v3": 1024,
31+
"jina-clip-v2": 1024,
32+
}
33+
34+
# Models that accept the `task` parameter (asymmetric retrieval). v2 models
35+
# are single-mode and reject `task`, so we omit it for them.
36+
_TASK_AWARE_PREFIXES = ("jina-embeddings-v3", "jina-code-embeddings-")
37+
38+
39+
class JinaApiEmbeddingProvider(EmbeddingProvider):
40+
"""Jina AI hosted embeddings — see https://jina.ai/embeddings/."""
41+
42+
def __init__(self) -> None:
43+
if not settings.jina_api_key:
44+
raise RuntimeError(
45+
"JINA_API_KEY is not set but EMBEDDINGS_PROVIDER=jina-api."
46+
)
47+
self._api_key = settings.jina_api_key
48+
self._model = settings.jina_api_model
49+
self._dims_override = settings.jina_api_dimensions
50+
if self._dims_override is not None:
51+
self._dims = self._dims_override
52+
elif self._model in _NATIVE_DIMENSIONS:
53+
self._dims = _NATIVE_DIMENSIONS[self._model]
54+
else:
55+
raise RuntimeError(
56+
f"Unknown Jina model {self._model!r} — set JINA_API_DIMENSIONS "
57+
"to declare the output size, or use a known model "
58+
f"({', '.join(sorted(_NATIVE_DIMENSIONS))})."
59+
)
60+
self._supports_task = self._model.startswith(_TASK_AWARE_PREFIXES)
61+
self._client = httpx.AsyncClient(
62+
timeout=120.0,
63+
headers={
64+
"Authorization": f"Bearer {self._api_key}",
65+
"Content-Type": "application/json",
66+
},
67+
)
68+
69+
@property
70+
def dimensions(self) -> int:
71+
return self._dims
72+
73+
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
74+
return await self._embed(texts, task="retrieval.passage")
75+
76+
async def embed_query(self, text: str) -> list[float]:
77+
vectors = await self._embed([text], task="retrieval.query")
78+
return vectors[0] if vectors else []
79+
80+
async def _embed(self, texts: list[str], task: str) -> list[list[float]]:
81+
if not texts:
82+
return []
83+
all_vectors: list[list[float]] = []
84+
for i in range(0, len(texts), _BATCH_SIZE):
85+
batch = texts[i : i + _BATCH_SIZE]
86+
body: dict = {"model": self._model, "input": batch}
87+
if self._supports_task:
88+
body["task"] = task
89+
if self._dims_override is not None:
90+
body["dimensions"] = self._dims_override
91+
for attempt in range(4):
92+
resp = await self._client.post(_API_URL, json=body)
93+
if resp.status_code != 429:
94+
break
95+
retry_after = float(resp.headers.get("Retry-After", 0))
96+
wait = retry_after if retry_after > 0 else _BACKOFF_DELAYS[attempt]
97+
logger.warning(
98+
"Jina rate-limited (429) — retrying in %.0fs (attempt %d/4)",
99+
wait,
100+
attempt + 1,
101+
)
102+
await asyncio.sleep(wait)
103+
resp.raise_for_status()
104+
data = resp.json()
105+
batch_vectors = [item["embedding"] for item in data.get("data", [])]
106+
if len(batch_vectors) != len(batch):
107+
raise ValueError(
108+
f"Jina returned {len(batch_vectors)} vectors for "
109+
f"{len(batch)} inputs — response may be malformed"
110+
)
111+
all_vectors.extend(batch_vectors)
112+
return all_vectors
113+
114+
async def close(self) -> None:
115+
await self._client.aclose()

0 commit comments

Comments
 (0)