# vecna — Vectors na Vectors

**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](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.

---

## Why

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.

---

## Install

```sh
go install github.com/Warky-Devs/vecna.git/cmd/vecna@latest
```

Or build from source:

```sh
make build        # outputs ./bin/vecna
```

---

## Quick start

```sh
# 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"}'
```

---

## Commands

| 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` |

---

## Query

Send text directly to a forwarding target and print the adapted vector as JSON.

```sh
# 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.json
```

Status info (target, model, dims, tokens) is written to stderr; stdout is clean JSON.

---

## Configuration

Default config path: `~/vecna.json` (created by `onboard` or `editconfig`).

Override with `--config path/to/file.yaml` or env vars prefixed `VECNA_`.

```json
{
  "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": ""
  }
}
```

---

## Important: quality and consistency warnings

### Upscaling reduces quality — use the highest-dimension model you can

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.

### Changing any adapter setting requires regenerating all stored embeddings

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_dim` or `target_dim`
- `truncate_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.

---

## Adapter types

| 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

`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.

```json
"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_maps` entry requires re-embedding all vectors indexed through that endpoint.

---

## Truncation and padding modes

### `truncate_mode` — which part of the vector is kept when downscaling

| 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.

### `pad_mode` — where zeros are inserted when upscaling

| 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.

### Common combinations

| 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.

---

## API endpoints

### OpenAI-compatible

```
POST /v1/embeddings
GET  /v1/embeddings?input=text&input=text2&model=nomic-embed-text
Authorization: Bearer <api_key>
Content-Type: application/json

{"input": "text or array of texts", "model": "nomic-embed-text"}
```

Embeddings are the only endpoint that gets dimension adaptation. Everything below is
forwarded verbatim (no adaptation) — request/response shape is whatever the backing model's
API defines.

### Google Gemini-compatible

```
POST /v1/models/{model}:embedContent
POST /v1/models/{model}:batchEmbedContents
```

### Generic passthrough

Target is chosen from the request body's `"model"` field (must name a key under
`forward.targets`), falling back to `forward.default`. `"stream": true` requests are
relayed as SSE without buffering.

| Method | Path                          | Notes                        |
|--------|-------------------------------|-------------------------------|
| GET    | `/v1/models`                  | uses `forward.default`        |
| GET    | `/v1/models/{model}`          |                                |
| POST   | `/v1/chat/completions`        | streaming supported           |
| POST   | `/v1/completions`             | streaming supported           |
| POST   | `/v1/moderations`             |                                |
| POST   | `/v1/images/generations`      |                                |
| POST   | `/v1/images/edits`            | multipart/form-data           |
| POST   | `/v1/images/variations`       | multipart/form-data           |
| POST   | `/v1/audio/speech`            |                                |
| POST   | `/v1/audio/transcriptions`    | multipart/form-data           |
| POST   | `/v1/audio/translations`      | multipart/form-data           |
| POST   | `/v1/rerank`                  | Cohere/Infinity/vLLM-style     |

### Extra-map routes

Serve the same backing model with a different adapter (or forced target) per endpoint.
The `{mapping}` segment matches a key in `extra_maps`. Every route above — embeddings,
Google, and generic passthrough — also exists under `/map/{mapping}/...`, e.g.:

```
POST /map/{mapping}/v1/embeddings
POST /map/{mapping}/v1/models/{model}:embedContent
POST /map/{mapping}/v1/models/{model}:batchEmbedContents
POST /map/{mapping}/v1/chat/completions
GET  /map/{mapping}/v1/models
```

All extra-map routes require the same authentication as the standard API routes.

### OpenAPI spec and docs

```
GET /openapi.yaml
GET /docs
```

---

## Response tracing headers

| 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 |

---

## Prometheus metrics

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`) |

### Dashboard

`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.

---

## Development

```sh
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 | major
```

---

## Docker

### Build

```sh
docker build -t vecna .
```

### First-time setup with docker compose

```sh
cp docker-compose.example.yml docker-compose.yml
docker compose up -d
```

Starts vecna and an Ollama instance. The `vecna_config` named volume persists the config across container rebuilds.

### Onboard (interactive setup)

```sh
docker compose run --rm -it vecna onboard
```

Ollama is reachable by hostname on the Docker network — the scanner will find it automatically. After onboarding, restart the proxy:

```sh
docker compose restart vecna
```

### Query

```sh
docker compose run --rm vecna query --compact "hello world"
```

### Test endpoints

```sh
# report latency and dims
docker compose run --rm vecna test

# test and remove failing endpoints
docker compose run --rm vecna test --remove-broken
```

### Edit config manually

```sh
docker compose run --rm -it vecna sh -c "vi /config/vecna.json"
```

### With Prometheus

```sh
docker compose --profile metrics up -d
```

Scrape config is in `prometheus.example.yml`. Set `bearer_token` if `metrics.api_key` is configured.

---

© Hein Puth — [Warky Devs (Pty) Ltd](https://github.com/Warky-Devs)
