Skip to main content

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

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.