Skip to content

Commit 9fa5db2

Browse files
Merge pull request #37 from GoodbyePlanet/feat/pluggable-embedding-providers
feat: Add pluggable embedding providers (Jina, Voyage, OpenAI, Ollama)
2 parents c41f71a + af45b92 commit 9fa5db2

22 files changed

Lines changed: 964 additions & 72 deletions

.env.example

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
1-
# Embeddings (Jina Code V2 via HuggingFace TEI)
2-
EMBEDDINGS_URL=http://localhost:8087
1+
# Embeddings provider — one of: jina | voyage | openai | ollama
2+
EMBEDDINGS_PROVIDER=jina
3+
4+
# Jina Code V2 via HuggingFace TEI (default)
5+
JINA_URL=http://localhost:8087
6+
JINA_MODEL=jinaai/jina-embeddings-v2-base-code
7+
JINA_DIMENSIONS=768
8+
9+
# Voyage AI — set EMBEDDINGS_PROVIDER=voyage to use
10+
# VOYAGE_API_KEY=
11+
# VOYAGE_MODEL=voyage-code-3
12+
# VOYAGE_DIMENSIONS= # optional override (e.g. 256, 512, 1024, 2048)
13+
14+
# OpenAI — set EMBEDDINGS_PROVIDER=openai to use
15+
# OPENAI_API_KEY=
16+
# OPENAI_EMBEDDING_MODEL=text-embedding-3-large
17+
# OPENAI_DIMENSIONS= # optional override
18+
19+
# Ollama — set EMBEDDINGS_PROVIDER=ollama to use
20+
# OLLAMA_URL=http://localhost:11434
21+
# OLLAMA_MODEL=nomic-embed-text
22+
# OLLAMA_DIMENSIONS= # required for models not in the built-in table
323

424
# Qdrant
525
QDRANT_URL=http://localhost:6333
@@ -14,4 +34,4 @@ MCP_PORT=8090
1434
CONFIG_PATH=./config.yaml
1535

1636
# GitHub token — requires repo:read (or contents:read for fine-grained tokens)
17-
GITHUB_TOKEN=
37+
GITHUB_TOKEN=

README.md

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ language queries or symbol name lookups.
1313

1414
1. Fetches source files from configured GitHub repositories
1515
2. Parses code symbols (functions, classes, methods, components) using Tree-sitter
16-
3. Generates embeddings via Jina Code V2 (served by HuggingFace TEI)
16+
3. Generates embeddings via a pluggable provider — Jina Code V2 (TEI) by default, or Voyage / OpenAI / Ollama
1717
4. Stores vectors in Qdrant for fast semantic search
1818
5. Optionally indexes commit history into a separate Qdrant collection
1919
6. Exposes search and indexing tools through the MCP protocol (and a small HTTP API)
@@ -97,7 +97,7 @@ chunk with a vector embedding and a rich payload.
9797
- **Change detection** — compares the file's Git blob SHA to the last indexed value; unchanged files are skipped
9898
- **Parsing** — Tree-sitter walks the AST and emits `CodeSymbol` objects per language
9999
- **Embedding text** — built from signature, docstring, annotations, parent name, and source (truncated at ~6000 chars)
100-
- **Batching** — symbols are embedded in batches of 32 against the TEI server
100+
- **Batching** — batched per-provider (32 for Jina/TEI and Ollama, 128 for Voyage and OpenAI)
101101
- **Upsert** — vectors stored in Qdrant under deterministic UUIDs (per service / file / symbol / line)
102102
- **Cleanup** — entries for files no longer in the repo are deleted
103103

@@ -158,19 +158,51 @@ any context where observing indexing progress matters.
158158

159159
## Environment variables
160160

161-
| Variable | Default | Description |
162-
|-----------------------------|---------------------------------------|--------------------------------------------|
163-
| `GITHUB_TOKEN` | *(required)* | GitHub token with repo read access |
164-
| `QDRANT_URL` | `http://localhost:6333` | Qdrant connection URL |
165-
| `QDRANT_COLLECTION` | `code_symbols` | Collection name for code symbol vectors |
166-
| `QDRANT_COMMITS_COLLECTION` | `git_commits` | Collection name for commit message vectors |
167-
| `EMBEDDINGS_URL` | `http://localhost:8087` | Jina TEI URL |
168-
| `EMBEDDINGS_MODEL` | `jinaai/jina-embeddings-v2-base-code` | Embedding model ID |
169-
| `EMBEDDINGS_DIMENSIONS` | `768` | Vector dimensions |
170-
| `GIT_HISTORY_MAX_COMMITS` | `500` | Max commits indexed per service |
171-
| `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` |
172-
| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `8090` | Server bind address |
173-
| `CONFIG_PATH` | `./config.yaml` | Path to the services config file |
161+
| Variable | Default | Description |
162+
|-----------------------------|-------------------------|--------------------------------------------|
163+
| `GITHUB_TOKEN` | *(required)* | GitHub token with repo read access |
164+
| `QDRANT_URL` | `http://localhost:6333` | Qdrant connection URL |
165+
| `QDRANT_COLLECTION` | `code_symbols` | Collection name for code symbol vectors |
166+
| `QDRANT_COMMITS_COLLECTION` | `git_commits` | Collection name for commit message vectors |
167+
| `EMBEDDINGS_PROVIDER` | `jina` | One of `jina`, `voyage`, `openai`, `ollama` — see *Embedding providers* below |
168+
| `GIT_HISTORY_MAX_COMMITS` | `500` | Max commits indexed per service |
169+
| `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` |
170+
| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `8090` | Server bind address |
171+
| `CONFIG_PATH` | `./config.yaml` | Path to the services config file |
172+
173+
## Embedding providers
174+
175+
The embedding backend is selectable via `EMBEDDINGS_PROVIDER`. Default is `jina` so existing
176+
deployments keep working unchanged. Each provider derives its own vector dimensions from the
177+
configured model — no need to set dimensions manually unless you want to override.
178+
179+
| Variable | Default | Applies to | Description |
180+
|--------------------------|---------------------------------------|------------|-----------------------------------------------------------------------------------|
181+
| `JINA_URL` | `http://localhost:8087` | `jina` | TEI base URL |
182+
| `JINA_MODEL` | `jinaai/jina-embeddings-v2-base-code` | `jina` | Model ID served by TEI (informational — the running TEI container chooses) |
183+
| `JINA_DIMENSIONS` | `768` | `jina` | Vector dimensions of the TEI model |
184+
| `VOYAGE_API_KEY` | *(required if provider=voyage)* | `voyage` | Voyage AI API key |
185+
| `VOYAGE_MODEL` | `voyage-code-3` | `voyage` | Voyage embedding model |
186+
| `VOYAGE_DIMENSIONS` | *(native)* | `voyage` | Optional override — Voyage code-3 supports `256` / `512` / `1024` / `2048` |
187+
| `OPENAI_API_KEY` | *(required if provider=openai)* | `openai` | OpenAI API key |
188+
| `OPENAI_EMBEDDING_MODEL` | `text-embedding-3-large` | `openai` | OpenAI embedding model |
189+
| `OPENAI_DIMENSIONS` | *(native)* | `openai` | Optional override (text-embedding-3-* models support shrinking) |
190+
| `OLLAMA_URL` | `http://localhost:11434` | `ollama` | Ollama base URL |
191+
| `OLLAMA_MODEL` | `nomic-embed-text` | `ollama` | Ollama embedding model |
192+
| `OLLAMA_DIMENSIONS` | *(native)* | `ollama` | Required if using a model not in the built-in dimensions table |
193+
194+
`voyage-code-3` outperforms `jinaai/jina-embeddings-v2-base-code` on most code retrieval benchmarks,
195+
so switching to Voyage is also a quality lever, not just a flexibility one.
196+
197+
**Switching providers against an existing index:** if the new provider's vector size differs from
198+
the existing Qdrant collection, the server fails fast at startup with a clear error pointing at the
199+
offending collection. To switch, drop both collections (`code_symbols` and `git_commits`) via the
200+
Qdrant UI or API, then reindex. There is no automatic migration.
201+
202+
**Hosted-only setup (no local TEI container):** in `docker-compose.yaml`, comment out the entire
203+
`jina-embeddings` service block, the `jina-embeddings` entry under `semcode.depends_on`, and the
204+
`JINA_URL` line under `semcode.environment`. Then set `EMBEDDINGS_PROVIDER` and the relevant API
205+
key in `.env`.
174206

175207
## Qdrant collections
176208

docker-compose.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ services:
1212
timeout: 5s
1313
retries: 5
1414

15+
# Jina Code V2 served by HuggingFace Text Embeddings Inference. Default
16+
# embedding backend. To use a hosted provider instead (Voyage / OpenAI) or a
17+
# local Ollama install, set EMBEDDINGS_PROVIDER + the relevant API key in
18+
# .env, then comment out:
19+
# 1. this entire `jina-embeddings` service
20+
# 2. the `jina-embeddings` entry under `semcode.depends_on`
21+
# 3. the JINA_URL line under `semcode.environment`
1522
jina-embeddings:
1623
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
1724
platform: linux/amd64
@@ -35,7 +42,7 @@ services:
3542
env_file:
3643
- .env
3744
environment:
38-
EMBEDDINGS_URL: http://jina-embeddings:80
45+
JINA_URL: http://jina-embeddings:80
3946
QDRANT_URL: http://qdrant:6333
4047
MCP_HOST: 0.0.0.0
4148
FASTEMBED_CACHE_PATH: /fastembed_cache

server/config.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,39 @@ def __init__(
2323
self.exclude = exclude
2424

2525

26+
EmbeddingsProviderName = Literal["jina", "voyage", "openai", "ollama"]
27+
28+
2629
class Settings(BaseSettings):
2730
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
2831

29-
embeddings_url: str = Field(default="http://localhost:8087", alias="EMBEDDINGS_URL")
30-
embeddings_model: str = Field(
31-
default="jinaai/jina-embeddings-v2-base-code", alias="EMBEDDINGS_MODEL"
32+
embeddings_provider: EmbeddingsProviderName = Field(
33+
default="jina", alias="EMBEDDINGS_PROVIDER"
34+
)
35+
36+
# Jina (TEI / self-hosted HuggingFace text-embeddings-inference)
37+
jina_url: str = Field(default="http://localhost:8087", alias="JINA_URL")
38+
jina_model: str = Field(
39+
default="jinaai/jina-embeddings-v2-base-code", alias="JINA_MODEL"
3240
)
33-
embeddings_dimensions: int = Field(default=768, alias="EMBEDDINGS_DIMENSIONS")
41+
jina_dimensions: int = Field(default=768, alias="JINA_DIMENSIONS")
42+
43+
# Voyage AI
44+
voyage_api_key: str = Field(default="", alias="VOYAGE_API_KEY")
45+
voyage_model: str = Field(default="voyage-code-3", alias="VOYAGE_MODEL")
46+
voyage_dimensions: int | None = Field(default=None, alias="VOYAGE_DIMENSIONS")
47+
48+
# OpenAI
49+
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
50+
openai_embedding_model: str = Field(
51+
default="text-embedding-3-large", alias="OPENAI_EMBEDDING_MODEL"
52+
)
53+
openai_dimensions: int | None = Field(default=None, alias="OPENAI_DIMENSIONS")
54+
55+
# Ollama
56+
ollama_url: str = Field(default="http://localhost:11434", alias="OLLAMA_URL")
57+
ollama_model: str = Field(default="nomic-embed-text", alias="OLLAMA_MODEL")
58+
ollama_dimensions: int | None = Field(default=None, alias="OLLAMA_DIMENSIONS")
3459

3560
qdrant_url: str = Field(default="http://localhost:6333", alias="QDRANT_URL")
3661
qdrant_collection: str = Field(default="code_symbols", alias="QDRANT_COLLECTION")

server/embeddings/factory.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from server.config import settings
4+
from server.embeddings.base import EmbeddingProvider
5+
6+
_provider: EmbeddingProvider | None = None
7+
8+
9+
def get_embedding_provider() -> EmbeddingProvider:
10+
global _provider
11+
if _provider is not None:
12+
return _provider
13+
14+
name = settings.embeddings_provider
15+
if name == "jina":
16+
from server.embeddings.jina import JinaEmbeddingProvider
17+
18+
_provider = JinaEmbeddingProvider()
19+
elif name == "voyage":
20+
from server.embeddings.voyage import VoyageEmbeddingProvider
21+
22+
_provider = VoyageEmbeddingProvider()
23+
elif name == "openai":
24+
from server.embeddings.openai import OpenAIEmbeddingProvider
25+
26+
_provider = OpenAIEmbeddingProvider()
27+
elif name == "ollama":
28+
from server.embeddings.ollama import OllamaEmbeddingProvider
29+
30+
_provider = OllamaEmbeddingProvider()
31+
else:
32+
raise ValueError(
33+
f"Unknown EMBEDDINGS_PROVIDER {name!r}. "
34+
"Expected one of: jina, voyage, openai, ollama."
35+
)
36+
return _provider
37+
38+
39+
async def close_embedding_provider() -> None:
40+
global _provider
41+
if _provider is None:
42+
return
43+
close = getattr(_provider, "close", None)
44+
if close is not None:
45+
await close()
46+
_provider = None

server/embeddings/jina.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ class JinaEmbeddingProvider(EmbeddingProvider):
1818
"""Calls the self-hosted Jina Code V2 model via HuggingFace Text Embeddings Inference."""
1919

2020
def __init__(self) -> None:
21-
self._base_url = settings.embeddings_url.rstrip("/")
22-
self._dims = settings.embeddings_dimensions
21+
self._base_url = settings.jina_url.rstrip("/")
22+
self._dims = settings.jina_dimensions
2323
self._client = httpx.AsyncClient(timeout=120.0)
2424

2525
@property
@@ -58,20 +58,3 @@ async def embed_query(self, text: str) -> list[float]:
5858

5959
async def close(self) -> None:
6060
await self._client.aclose()
61-
62-
63-
_provider: JinaEmbeddingProvider | None = None
64-
65-
66-
def get_embedding_provider() -> JinaEmbeddingProvider:
67-
global _provider
68-
if _provider is None:
69-
_provider = JinaEmbeddingProvider()
70-
return _provider
71-
72-
73-
async def close_embedding_provider() -> None:
74-
global _provider
75-
if _provider is not None:
76-
await _provider.close()
77-
_provider = None

server/embeddings/ollama.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
import httpx
6+
7+
from server.config import settings
8+
from server.embeddings.base import EmbeddingProvider
9+
10+
logger = logging.getLogger(__name__)
11+
12+
_EMBED_PATH = "/api/embed"
13+
_BATCH_SIZE = 32
14+
15+
_NATIVE_DIMENSIONS: dict[str, int] = {
16+
"nomic-embed-text": 768,
17+
"mxbai-embed-large": 1024,
18+
"all-minilm": 384,
19+
"snowflake-arctic-embed": 1024,
20+
"bge-m3": 1024,
21+
}
22+
23+
24+
class OllamaEmbeddingProvider(EmbeddingProvider):
25+
"""Ollama embeddings — see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings."""
26+
27+
def __init__(self) -> None:
28+
self._base_url = settings.ollama_url.rstrip("/")
29+
self._model = settings.ollama_model
30+
self._dims_override = settings.ollama_dimensions
31+
if self._dims_override is not None:
32+
self._dims = self._dims_override
33+
elif self._model in _NATIVE_DIMENSIONS:
34+
self._dims = _NATIVE_DIMENSIONS[self._model]
35+
else:
36+
raise RuntimeError(
37+
f"Unknown Ollama model {self._model!r} — set OLLAMA_DIMENSIONS "
38+
"to declare the output size, or use a known model "
39+
f"({', '.join(sorted(_NATIVE_DIMENSIONS))})."
40+
)
41+
self._client = httpx.AsyncClient(timeout=120.0)
42+
43+
@property
44+
def dimensions(self) -> int:
45+
return self._dims
46+
47+
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
48+
if not texts:
49+
return []
50+
all_vectors: list[list[float]] = []
51+
for i in range(0, len(texts), _BATCH_SIZE):
52+
batch = texts[i : i + _BATCH_SIZE]
53+
resp = await self._client.post(
54+
f"{self._base_url}{_EMBED_PATH}",
55+
json={"model": self._model, "input": batch},
56+
)
57+
resp.raise_for_status()
58+
data = resp.json()
59+
batch_vectors = data.get("embeddings", [])
60+
if len(batch_vectors) != len(batch):
61+
raise ValueError(
62+
f"Ollama returned {len(batch_vectors)} vectors for "
63+
f"{len(batch)} inputs — response may be malformed"
64+
)
65+
all_vectors.extend(batch_vectors)
66+
return all_vectors
67+
68+
async def embed_query(self, text: str) -> list[float]:
69+
vectors = await self.embed_batch([text])
70+
return vectors[0] if vectors else []
71+
72+
async def close(self) -> None:
73+
await self._client.aclose()

0 commit comments

Comments
 (0)