Skip to content

Commit 823a3fe

Browse files
committed
fix: Add fix for jina embeddings
1 parent 206dd5e commit 823a3fe

5 files changed

Lines changed: 123 additions & 29 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Embeddings provider — one of: jina | voyage | openai | ollama
1+
# Embeddings provider — one of: jina | jina-api | voyage | openai | ollama
22
EMBEDDINGS_PROVIDER=jina
33

44
# Jina Code V2 via HuggingFace TEI (default)

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ blog.md
2727
config.yaml
2828

2929
# Claude Code local settings
30-
.claude/settings.local.json
30+
.claude/settings.local.json
31+
32+
memory/

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
QDRANT_URL := http://localhost:6333
22
SEMCODE_URL := http://localhost:8090
33

4-
.PHONY: qdrant-clean qdrant-dashboard index-code index-history \
4+
.PHONY: qdrant-clean qdrant-dashboard index-code index-history docker-build \
55
docker-build-restart docker-build-restart-jina docker-up docker-up-jina docker-logs docker-logs-semcode
66

77
qdrant-clean:
@@ -22,6 +22,9 @@ index-history:
2222
-H "Content-Type: application/json" \
2323
--no-buffer
2424

25+
docker-build:
26+
docker compose build
27+
2528
docker-build-restart:
2629
docker compose down && docker compose up --build -d
2730

server/embeddings/jina_api.py

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# uniform with the OpenAI/Voyage providers.
1616
_BATCH_SIZE = 128
1717
_BACKOFF_DELAYS = [10, 20, 30, 40]
18+
# Conservative character cap (~8 k tokens at ~4 chars/token) to avoid
19+
# "Failed to encode text" 400s on models with limited context windows.
20+
_MAX_TEXT_CHARS = 32_000
1821

1922
# Native output dimensions for known models. The jina-code-embeddings family
2023
# supports Matryoshka truncation via the `dimensions` API parameter —
@@ -35,6 +38,13 @@
3538
# is single-mode and rejects `task`, so we omit it.
3639
_TASK_AWARE_PREFIXES = ("jina-code-embeddings-",)
3740

41+
# jina-code-embeddings models use a different task vocabulary than the generic
42+
# "retrieval.*" tasks accepted by other Jina models.
43+
_JINA_CODE_TASK_MAP = {
44+
"retrieval.passage": "nl2code.passage",
45+
"retrieval.query": "nl2code.query",
46+
}
47+
3848

3949
class JinaApiEmbeddingProvider(EmbeddingProvider):
4050
"""Jina AI hosted embeddings — see https://jina.ai/embeddings/."""
@@ -58,6 +68,7 @@ def __init__(self) -> None:
5868
f"({', '.join(sorted(_NATIVE_DIMENSIONS))})."
5969
)
6070
self._supports_task = self._model.startswith(_TASK_AWARE_PREFIXES)
71+
self._uses_code_tasks = self._model.startswith("jina-code-embeddings-")
6172
self._client = httpx.AsyncClient(
6273
timeout=120.0,
6374
headers={
@@ -77,31 +88,105 @@ async def embed_query(self, text: str) -> list[float]:
7788
vectors = await self._embed([text], task="retrieval.query")
7889
return vectors[0] if vectors else []
7990

91+
def _sanitize(self, text: str) -> str:
92+
# Encode to UTF-8 replacing lone surrogates and other unencodable
93+
# code points, then decode back — this removes anything that would
94+
# cause Jina's tokenizer to return 400 "Failed to encode text".
95+
cleaned = text.encode("utf-8", errors="replace").decode("utf-8")
96+
cleaned = "".join(ch for ch in cleaned if ch >= " " or ch in "\t\n\r")
97+
return cleaned[:_MAX_TEXT_CHARS].strip() or "."
98+
99+
def _make_body(self, inputs: list[str], task: str) -> dict:
100+
body: dict = {"model": self._model, "input": inputs}
101+
if self._supports_task:
102+
body["task"] = (
103+
_JINA_CODE_TASK_MAP.get(task, task) if self._uses_code_tasks else task
104+
)
105+
if self._dims_override is not None:
106+
body["dimensions"] = self._dims_override
107+
return body
108+
109+
async def _post_with_retry(self, body: dict) -> dict:
110+
for attempt in range(4):
111+
resp = await self._client.post(_API_URL, json=body)
112+
if resp.status_code != 429:
113+
break
114+
retry_after = float(resp.headers.get("Retry-After", 0))
115+
wait = retry_after if retry_after > 0 else _BACKOFF_DELAYS[attempt]
116+
logger.warning(
117+
"Jina rate-limited (429) — retrying in %.0fs (attempt %d/4)",
118+
wait,
119+
attempt + 1,
120+
)
121+
await asyncio.sleep(wait)
122+
if resp.status_code >= 400:
123+
logger.error("Jina API error %d: %s", resp.status_code, resp.text[:500])
124+
resp.raise_for_status()
125+
return resp.json()
126+
127+
async def _embed_batch_with_fallback(
128+
self, batch: list[str], task: str
129+
) -> list[list[float]]:
130+
"""Embed one item at a time, halving on failure, substituting '.' only as last resort."""
131+
vectors: list[list[float]] = []
132+
for idx, text in enumerate(batch):
133+
candidate = text
134+
embedded = False
135+
while candidate:
136+
try:
137+
data = await self._post_with_retry(
138+
self._make_body([candidate], task)
139+
)
140+
vectors.append(data["data"][0]["embedding"])
141+
if len(candidate) < len(text):
142+
logger.info(
143+
"Encoded truncated text at batch index %d (%d → %d chars)",
144+
idx,
145+
len(text),
146+
len(candidate),
147+
)
148+
embedded = True
149+
break
150+
except Exception:
151+
half = len(candidate) // 2
152+
if half < 64:
153+
break
154+
logger.warning(
155+
"Text at batch index %d (len=%d) failed — retrying with first %d chars",
156+
idx,
157+
len(candidate),
158+
half,
159+
)
160+
candidate = candidate[:half]
161+
if not embedded:
162+
logger.warning(
163+
"Skipping unencodable text at batch index %d (original len=%d), using placeholder.",
164+
idx,
165+
len(text),
166+
)
167+
data = await self._post_with_retry(self._make_body(["."], task))
168+
vectors.append(data["data"][0]["embedding"])
169+
return vectors
170+
80171
async def _embed(self, texts: list[str], task: str) -> list[list[float]]:
81172
if not texts:
82173
return []
174+
sanitized = [self._sanitize(t) for t in texts]
83175
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()
176+
for i in range(0, len(sanitized), _BATCH_SIZE):
177+
batch = sanitized[i : i + _BATCH_SIZE]
178+
try:
179+
data = await self._post_with_retry(self._make_body(batch, task))
180+
except Exception as exc:
181+
if "400" in str(exc):
182+
logger.warning(
183+
"Batch of %d failed with 400 — retrying one-by-one", len(batch)
184+
)
185+
all_vectors.extend(
186+
await self._embed_batch_with_fallback(batch, task)
187+
)
188+
continue
189+
raise
105190
batch_vectors = [item["embedding"] for item in data.get("data", [])]
106191
if len(batch_vectors) != len(batch):
107192
raise ValueError(

server/routes/reindex.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def register_http_routes(mcp: FastMCP) -> None:
2020

2121
@mcp.custom_route("/reindex", methods=["POST"])
2222
async def reindex(request: Request) -> StreamingResponse:
23-
"""POST /reindex/streamstreaming variant of /reindex, returns NDJSON.
23+
"""POST /reindex — reindex code symbols, returns NDJSON stream.
2424
2525
Emits progress frames while indexing, followed by a final summary frame:
2626
{"type": "progress", "phase": "discovery"|"upserting"|"cleanup",
@@ -33,7 +33,9 @@ async def reindex(request: Request) -> StreamingResponse:
3333
"""
3434
body: dict = {}
3535
if request.headers.get("content-type", "").startswith("application/json"):
36-
body = await request.json()
36+
raw = await request.body()
37+
if raw:
38+
body = json.loads(raw)
3739

3840
service: str | None = body.get("service")
3941
force: bool = bool(body.get("force", False))
@@ -87,7 +89,7 @@ async def run() -> None:
8789

8890
@mcp.custom_route("/reindex-history", methods=["POST"])
8991
async def reindex_history(request: Request) -> StreamingResponse:
90-
"""POST /reindex-history/streamstreaming variant of /reindex-history, returns NDJSON.
92+
"""POST /reindex-history — index git commit history, returns NDJSON stream.
9193
9294
Emits progress frames while indexing, followed by a final summary frame:
9395
{"type": "progress", "phase": "discovery"|"embedding"|"upserting",
@@ -100,14 +102,16 @@ async def reindex_history(request: Request) -> StreamingResponse:
100102
"""
101103
body: dict = {}
102104
if request.headers.get("content-type", "").startswith("application/json"):
103-
body = await request.json()
105+
raw = await request.body()
106+
if raw:
107+
body = json.loads(raw)
104108

105109
service: str | None = body.get("service")
106110
force: bool = bool(body.get("force", False))
107111

108112
pipeline = GitHistoryPipeline(get_commit_store())
109113
logger.info(
110-
"Reindex-history/stream started: service=%s force=%s",
114+
"Reindex-history started: service=%s force=%s",
111115
service or "ALL",
112116
force,
113117
)

0 commit comments

Comments
 (0)