vecna is an OpenAI- and Google-compatible HTTP proxy that sits between your application and a local or remote embedding model. It forwards text to the backing model, receives the raw embedding vector, and re-shapes it to the dimension your vector database or application expects — without any changes to your client code.
New to embeddings? → docs/what_is_embeddings.md
Every time I install a tool that needs vector embeddings, it assumes OpenAI's dimensions. There are very few local models that match those exact dimensions. Most tools don't give you an option to change them, and some vector databases are hardcoded to fixed dimensions entirely. Many open source models do stick to the same dimensions, which helps — but I run embedding models across several machines and it's always been a pain to use them effectively. This is where vecna helps me.
Most vector databases are initialized with a fixed dimension (e.g. 1536 for pgvector defaults, 768 for many HNSW indexes). If you switch embedding models — or run a smaller local model that produces 768-dim vectors — your existing index breaks.
vecna solves this by translating dimensions at the proxy layer:
- Downscale: 3072 → 1536, 768 → 256 (truncation or random projection)
- Upscale: 384 → 1536, 768 → 1536 (zero-padding)
- Same-dim pass-through: no transformation, just proxy and auth
All output vectors are L2-normalized so cosine similarity remains valid.
go install github.com/Warky-Devs/vecna.git/cmd/vecna@latestOr build from source:
make build # outputs ./bin/vecna# Interactive setup: discovers local servers, configures adapter, writes config
vecna onboard
# Start the proxy
vecna serve
# Test a request (OpenAI-compatible)
curl -s http://localhost:8080/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"input": "hello world", "model": "nomic-embed-text"}'| Command | Description |
|---|---|
vecna onboard |
Interactive wizard: discover servers → detect dims → configure → test → write config |
vecna serve |
Start the proxy server |
vecna query <text> |
Embed text and print the resulting vector as JSON |
vecna convert |
Convert vectors from file/stdin using the configured adapter |
vecna search |
Scan LAN for embedding servers and add one to config |
vecna models |
List models available on each configured forwarder |
vecna test |
Test each configured endpoint; --remove-broken prunes failing ones |
vecna editconfig |
Print config path and open it in $EDITOR |
Send text directly to a forwarding target and print the adapted vector as JSON.
# uses forward.default target
vecna query "hello world"
# specific target
vecna query --target ollama "hello world"
# skip the adapter — raw model output
vecna query --raw "hello world"
# compact single-line output (pipe-friendly)
vecna query --compact "hello world"
# read text from stdin
echo "hello world" | vecna query -
# inspect a single dimension
vecna query --compact "hello world" | jq '.[0]'
# save to file
vecna query --compact "hello world" > vector.jsonStatus info (target, model, dims, tokens) is written to stderr; stdout is clean JSON.
Default config path: ~/vecna.json (created by onboard or editconfig).
Override with --config path/to/file.yaml or env vars prefixed VECNA_.
{
"server": {
"port": 8080,
"host": "0.0.0.0",
"api_keys": ["sk-vecna-abc123"]
},
"forward": {
"default": "ollama",
"targets": {
"ollama": {
"api_type": "openai",
"model": "nomic-embed-text",
"api_key": "",
"timeout_secs": 30,
"cooldown_secs": 60,
"priority_decay": 2,
"priority_recovery": 5,
"endpoints": [
{"url": "http://localhost:11434", "priority": 10}
]
}
}
},
"adapter": {
"type": "truncate",
"source_dim": 768,
"target_dim": 1536,
"truncate_mode": "from_end",
"pad_mode": "at_end"
},
"extra_maps": {
"512": { "target_dim": 512 },
"256": { "target_dim": 256, "type": "random", "seed": 42 },
"fast": { "target_dim": 768, "forward_target": "small-model" }
},
"metrics": {
"enabled": true,
"path": "/metrics",
"api_key": ""
}
}Upscaling (e.g. 768 → 1536) does not add information. The extra dimensions are zeros (truncate adapter) or linear combinations of existing values (random/projection). The resulting vectors occupy a 1536-dim space but carry no more semantic content than the original 768-dim ones.
The model's native output dimension is the ceiling of quality. If your vector database requires 1536 dims, use a model that natively produces 1536 dims. Use vecna's upscaling only as a compatibility shim when you cannot change the index schema — not as a way to improve retrieval quality.
- Downscale (higher → lower): small, controlled quality loss. Acceptable for MRL models.
- Upscale (lower → higher): no quality gain, only compatibility. Replace the model when possible.
vecna's adapter is applied at query time and at indexing time. If you change any of the following, every vector already stored in your database is now in a different space and comparisons against new queries will silently return wrong results:
type(truncate / random / projection)source_dimortarget_dimtruncate_mode(from_end / from_start)pad_mode(at_end / at_start)seed(random adapter)- the backing model itself
When you change adapter settings: stop ingestion, re-embed your entire corpus through vecna with the new settings, repopulate the index, then resume.
There is no partial migration path — a mixed index produces degraded or incorrect search results.
| Type | Description |
|---|---|
truncate |
Slice or zero-pad the vector. Fast, deterministic. Best for MRL-trained models. |
random |
Seeded Gaussian projection matrix. Preserves distances (Johnson-Lindenstrauss). |
projection |
Learned linear matrix from a JSON file. Highest quality, requires pre-training. |
extra_maps lets you expose multiple adapter configurations on a single vecna instance. Each entry is a named AdapterConfig whose unset fields fall back to the global adapter values.
"adapter": { "type": "truncate", "source_dim": 1024, "target_dim": 1536 },
"extra_maps": {
"512": { "target_dim": 512 },
"256": { "target_dim": 256, "type": "random", "seed": 42 },
"openai-alt": { "target_dim": 1536, "forward_target": "openai" }
}| Route | Forwarder | Adapter |
|---|---|---|
POST /v1/embeddings |
global default | global adapter |
POST /map/512/v1/embeddings |
global default | extra_maps["512"] — target 512, rest from global |
POST /map/256/v1/embeddings |
global default | extra_maps["256"] — random projection to 256 |
POST /map/openai-alt/v1/embeddings |
openai target |
extra_maps["openai-alt"] adapter |
All fields are overridable per map entry:
| Field | Description |
|---|---|
forward_target |
Named target from forward.targets; empty = global default |
type |
truncate / random / projection |
source_dim |
Source dimension; falls back to global adapter.source_dim |
target_dim |
Target dimension |
truncate_mode |
from_end / from_start |
pad_mode |
at_end / at_start |
seed |
Seed for random projection |
matrix_file |
Path to projection matrix JSON |
The same re-embedding warning applies per map — changing any setting for an
extra_mapsentry requires re-embedding all vectors indexed through that endpoint.
| Value | Keeps |
|---|---|
from_end (default) |
first N dimensions |
from_start |
last N dimensions |
from_end — use for Matryoshka Representation Learning (MRL) models. The most important information is packed into the first dimensions.
Models: nomic-embed-text, mxbai-embed-large, text-embedding-3-small, text-embedding-3-large, snowflake-arctic-embed, e5-mistral-7b-instruct.
from_start — use when task-specific information is at the end of the vector. Try this if from_end gives poor retrieval on a non-MRL model.
Models: some fine-tuned BERT variants, domain-specific models with task heads appended after base dimensions.
| Value | Zeros go |
|---|---|
at_end (default) |
after the real values |
at_start |
before the real values |
at_end — almost always correct. Keeps the original vector in the first N positions.
at_start — use if your index expects meaningful content at the end of the vector.
| Scenario | truncate_mode |
pad_mode |
|---|---|---|
| MRL model downscale | from_end |
at_end |
| MRL model upscale (e.g. 768→1536) | from_end |
at_end |
| Non-MRL BERT fine-tune | from_start |
at_end |
| Custom index with leading-zeros convention | from_end |
at_start |
When unsure, run vecna test before and after and compare the reported L2 norm.
POST /v1/embeddings
Authorization: Bearer <api_key>
Content-Type: application/json
{"input": "text or array of texts", "model": "nomic-embed-text"}
POST /v1/models/{model}:embedContent
POST /v1/models/{model}:batchEmbedContents
Serve the same backing model with a different adapter per endpoint. The {mapping} segment matches a key in extra_maps.
POST /map/{mapping}/v1/embeddings
POST /map/{mapping}/v1/models/{model}:embedContent
POST /map/{mapping}/v1/models/{model}:batchEmbedContents
All extra-map routes require the same authentication as the standard API routes.
GET /openapi.yaml
GET /docs
| Header | Value |
|---|---|
X-Vecna-Forward-Ms |
Time waiting on the backing model |
X-Vecna-Translate-Ms |
Time in the adapter |
X-Vecna-Total-Ms |
Total request wall time |
Enable in config: metrics.enabled: true. Scrape at GET /metrics. Human-readable dashboard at GET /dashboard.
| Metric | Type | Description |
|---|---|---|
vecna_requests_total |
counter | Requests served, by endpoint and status |
vecna_request_duration_seconds |
histogram | Total request wall time |
vecna_forward_duration_seconds |
histogram | Time waiting on the backing model |
vecna_translate_duration_seconds |
histogram | Time in the adapter |
vecna_endpoint_priority |
gauge | Current dynamic routing priority per endpoint |
vecna_endpoint_inflight |
gauge | Active in-flight requests per endpoint |
vecna_endpoint_errors_total |
counter | Forwarding failures by error type |
vecna_tokens_total |
counter | Tokens consumed, by target, model, and type (prompt/total) |
GET /dashboard renders a live HTML view of all metrics. Counters show request counts with status-code badges, histograms show p50/p95/p99 latencies, gauges show current endpoint priority and inflight counts.
Auth: if metrics.api_key is set, both /metrics and /dashboard require that key (Bearer token) and ignore server-level api_keys. If metrics.api_key is blank, both routes are fully public — no auth headers are checked.
make build # compile
make test # unit tests
make lint # golangci-lint
make fmt # goimports + gofmt
# Integration tests against a live server
make test-integration TEST_URL=http://localhost:11434 TEST_MODEL=nomic-embed-text
# Tag and push a release
make release-version BUMP=patch # patch | minor | majordocker build -t vecna .cp docker-compose.example.yml docker-compose.yml
docker compose up -dStarts vecna and an Ollama instance. The vecna_config named volume persists the config across container rebuilds.
docker compose run --rm -it vecna onboardOllama is reachable by hostname on the Docker network — the scanner will find it automatically. After onboarding, restart the proxy:
docker compose restart vecnadocker compose run --rm vecna query --compact "hello world"# report latency and dims
docker compose run --rm vecna test
# test and remove failing endpoints
docker compose run --rm vecna test --remove-brokendocker compose run --rm -it vecna sh -c "vi /config/vecna.json"docker compose --profile metrics up -dScrape config is in prometheus.example.yml. Set bearer_token if metrics.api_key is configured.
© Hein Puth — Warky Devs (Pty) Ltd