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