> ## Documentation Index
> Fetch the complete documentation index at: https://vitruvio.sh/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Embeddings and the vector index

> Two spaces, the model tag, and why the layer travels.

```bash theme={null}
vitruvio config embedder list
vitruvio config embedder test --text "a periodic function"
vitruvio config embedder use local-st intfloat/multilingual-e5-base --model-id intfloat/multilingual-e5-base
```

| provider | uri | extra | modalities |
| - | - | - | - |
| `hashing` | `hashing:bow` | — | text — **the default**, and its tag says `hashing/bow` so nobody mistakes it for semantics |
| `fake` | `fake:deterministic` | — | text, image — sha256-derived, bit-exact, for tests |
| `ollama` | `ollama:nomic-embed-text` | `[api]` | text — a model already running on your machine |
| `openrouter` | `openrouter:openai/text-embedding-3-large` | `[api]` | text — one key across several vendors |
| `local-st` | `local-st:intfloat/multilingual-e5-base` | `[local]` | text — multilingual, because material and the questions asked of it need not share a language |
| `local-siglip` | `local-siglip:google/siglip-base-patch16-224` | `[vision]` | text, image |
| `openai` / `voyage` / `cohere` | `openai:text-embedding-3-large` | `[api]` | text |

## The OpenAI-shaped providers

`openai`, `openrouter`, `ollama`, `cohere`, `voyage`, and `openai-compatible` share the `/embeddings` transport.
Each adapter supplies the endpoint and any model-specific request fields, such as Voyage's query/document role.

```toml theme={null}
[embedding.text]
provider = "ollama"
model = "nomic-embed-text"
# base_url = "http://gpu-box:11434/v1"   # another machine, if it is not this one
```

```toml theme={null}
[embedding.text]
provider = "openrouter"
model = "openai/text-embedding-3-large"
options = { provider = { order = ["openai"], allow_fallbacks = false, data_collection = "deny" } }
```

`OPENROUTER_API_KEY` (or `VITRUVIO_OPENROUTER_API_KEY`) authenticates the second. Ollama authenticates nobody.

Three things the base gets right, and each one fails *silently* if it does not:

* **Order.** The response carries an `index` per item and is not promised to arrive sorted. Zipping it against the
  request pairs text with the wrong vector, and an index built that way looks completely healthy.
* **Width.** Every response is checked against the width the model tag claims. A tag that says 768 over 1024-wide
  vectors is the exact failure the tag exists to prevent.
* **Truncation.** Bounded before the request, and the bound is named in the tag, because a truncation policy changes
  which string got embedded.

### The width has to be in configuration

The model tag carries the dimensionality, and vitruvio refuses to guess it: a tag whose width came from whatever the
network answered that day is not a reproducible artifact. Common models are already known, and for anything else:

```console theme={null}
vitruvio config embedder test
```

It embeds one phrase and reports the width to write into `dims`, the elapsed time, and whether the vectors come back
normalized. `vitruvio config embedder list` shows every provider and — the column to read — whether each is
*semantic* or hashed.

### `base_url` is not in the tag

Which host answered does not change where the vector lands. In the tag, it would make a brain unpublishable between
two machines that reach the same model by different routes — one through a local Ollama, one through a gateway.

### Choose a model that speaks your language

Measured on Spanish text, `nomic-embed-text` puts a genuine paraphrase at 0.579 cosine and an unrelated sentence at
0.512 — it sees the relationship that hashing cannot see at all (`0.000`), but the margin is thin. It is an
English-centric model. For Spanish, prefer `bge-m3` locally or a multilingual model through OpenRouter.

## Two spaces, one index

`Space.TEXT` for text, `Space.MULTIMODAL` (SigLIP's image tower) for images. A text query is embedded into **both** and
both are searched; results fuse by rank with the space visible on every hit.

Not one SigLIP space for everything: its text tower is trained on alt-text of at most 64 tokens and measurably degrades
pure text retrieval, which is most of the traffic. Not two indices: the protocol has one vector index per module and
packs one travelling layer.

The honest consequence is that there is **no score comparable across modalities**, and that is exactly why fusion is by
rank rather than by score.

## The model tag, and why a mismatch is not a degradation

```
intfloat/multilingual-e5-base@a1b2c3d4#d768,f32,l2,model,model,cut24000,vitruvio-projection-4,vitruvio-chunker-1
```

Everything that changes *where a vector lands in the space* is in there: provider, model, revision, dimensions, dtype,
normalization, pooling, prompts, preprocessing, projection, chunker. A different chunker embeds different strings, so a
consumer must reject it as firmly as a different model.

A mismatched tag means the index is **never consulted**. Not degraded — *wrong*: the two spaces have no relationship,
so the cosines would be noise that silently poisons fusion, which is worse than having no index at all. `explain` names
which field differs, `index verify` reports the sidecar as `model_mismatch` rather than as empty, and `inspect doctor`
reads the tag from the sidecar header — so it says so even on a machine that cannot construct the embedder the index
was built with.

### Shared model, local runtime

`[embedding.text]` declares the model used by writes, explicit `index build`, and publication. `model_id` names
its canonical owner and model; `provider` and `runtime_model` describe how a machine reaches it. For example,
OpenRouter serving `openai/text-embedding-3-small` now carries an `openai/text-embedding-3-small` tag rather than
an `openrouter/openai--text-embedding-3-small` tag.

`config embedder use` saves a per-brain choice in local XDG state without changing that shared declaration.
When identities match, omitted revision and dimensions are inherited. Credentials stay in environment variables.
Skills guide the choice using the model tags shown by `dist plan-pull` in both human and JSON output.

Query copies and deterministic fallbacks live under `.vitruvio/indices/local/`. A fallback refreshes when the module
root changes. Opening a default hashing brain for reading does not build a missing canonical vector sidecar.
Changing the shared model requires `index build --force`, which replaces the canonical sidecar used by publication.

Older semantic layers without stored reference vectors remain readable when their complete model tag matches.
Vitruvio establishes reference probes for later serializations. For an unambiguous old runtime tag, retrieval checks
up to three original passage vectors against the selected runtime before saving a local copy under the new tag.
It can recover that copy directly from the snapshot-bound travelling blob after a pull, without altering the published
blob. A failed comparison is `model_mismatch`; an offline or unconfigured runtime is `embedder_unavailable`.

Installation transfers the snapshot and travelling layer without standing up an embedding runtime. Structural
indices are rebuilt on the next indexed open; immediately after a pull, `inspect doctor` may report `indices.unbuilt`.

## The embedding cache is not optional

The SDK rebuilds every index on every commit *and* on every open. Without a cache that is a full re-embed each time.

The cache lives at `.vitruvio/embeddings/<hash of model tag>.sqlite` and is keyed by the hash of the **string actually
embedded** rather than by block id. So two blocks that project identically share one vector, re-registering identical
content is free, and editing a field that is not projected costs nothing. A commit adding 5 blocks to a module of
50 000 costs 5 embeddings.

## Chunking is by characters

1600 characters, 200 overlap, preferring a blank line, then a sentence end, then a word boundary.

Not by tokens, and this is a correctness decision rather than a preference: a token-based chunker depends on which
tokenizer happens to be installed, so chunk boundaries — and with them the cache keys and the identity of every vector
— would differ between an install with `[vision]` and a full one.

The payoff is the locator: `chunk:3#1600-3200`. Citations point at spans, not at whole PDFs.

## Why the vector layer travels

Embedding vectors and HNSW structure are not byte-reproducible across machines, and no design fixes it. So the layer is
published inside the artifact rather than rebuilt, and vitruvio refuses to publish an empty one: an artifact whose layer
claims a vector index and carries none is worse than one that omits the layer, because a consumer can detect an absence
and cannot detect an emptiness.

Round-trip tests therefore assert equality of *results*, never of bytes. `threads > 1` is allowed and HNSW internals
are never asserted.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.