Semantic vectors
Live on Bun and SQLite through sqlite-vec plus any OpenAI-compatible embedding endpoint — an index that adds recall and can never become the source of truth.
Semantic retrieval is live on Bun with SQLite: sqlite-vec stores and searches the
vectors, and any OpenAI-compatible endpoint produces them. Titen never ships a model.
| Runtime | Store | Embedder | State |
|---|---|---|---|
| Bun + SQLite | sqlite-vec in its own database file |
any OpenAI-compatible /embeddings endpoint |
Live, verified with embeddinggemma at 768 dims |
| Cloudflare Workers | Vectorize | Workers AI | Planned. The adapter exists; no bindings are declared, so retrieval is lexical FTS5 |
Turn it on
Vector settings come from the environment, not from flags: one is a credential, and the rest are deployment facts that belong in a unit file.
[Service]
Environment=TITEN_EMBED_BASE_URL=http://127.0.0.1:11434/v1
Environment=TITEN_EMBED_MODEL=bge-m3
Environment=TITEN_EMBED_DIMS=1024
Environment=TITEN_VEC_DB_PATH=/var/lib/titen/vectors.db
TITEN_EMBED_BASE_URL required; the request goes to this value plus /embeddings
TITEN_EMBED_MODEL required; passed through as the request's model
TITEN_EMBED_DIMS required; every response is checked against it
TITEN_EMBED_API_KEY optional; sent as a bearer token when set
TITEN_VEC_DB_PATH optional; defaults to the canonical path plus .vec
All three required values must be present or the capability is not built at all and /readyz
reports vector: "disabled". Partial configuration never half-enables retrieval. The
prebuilt extension is per-platform: a platform without one degrades to FTS instead of
refusing to start.
The index is an index
Vectors live in a separate database file from canonical records, and that is the
load-bearing invariant rather than tidiness. Lose vectors.db and no evidence is lost — a
fresh index file is created on the next start. Lose the canonical database and nothing brings
the evidence back.
A vector hit only nominates claim ids. Eligibility is decided afterwards by the same organization, subject, project, status, temporal and visibility predicates the lexical path uses, so semantic proximity can widen what is found and never what may be read.
Canonical export carries no embeddings: changing embedding provider changes retrieval scores without touching evidence, claims or record ids.
A high similarity score is not authority. Trust, temporal validity and conflict state are still checked from canonical SQL after every candidate lookup.
Outbox rows are queued at write time only, so claims written before an index was lost are not re-embedded on their own, and no re-index command ships today. Consolidating a claim again queues it, which is the available repair. Compile degrades to lexical for anything missing from the index rather than returning nothing.
Only retrievable claims get embedded
The semantic unit is a retrievable claim — active or disputed — not every raw chat turn.
Claims are compact, typed, temporal and linked to evidence; embedding raw turns inflates the
index and raises the odds that stray instruction-shaped text dominates similarity.
An observation append and a consolidation each queue a row in index_outbox. The drain embeds
one batch, writes the index, and only then marks rows done, so a model outage leaves them
queued instead of dropping them. Non-claim rows are retired immediately so the queue cannot grow without bound, and so is
a claim that was superseded, revoked or expired since it was queued — it is no longer
retrievable.
curl -sX POST -H "authorization: Bearer $TITEN_API_KEY" \
'http://127.0.0.1:8787/v1/index/drain?limit=50'
The response reports drained, indexed, skipped, remaining, plus the model and
dimensions it used. On a deployment with an in-process maintenance timer you never call
this — see background repair.
Hybrid ranking, and why normalization matters
The index contributes recall, not only re-ranking: it runs even when lexical search
matched nothing, because the memory worth retrieving is often phrased nothing like the
question. A semantic score then competes with the lexical one as relevance —
max(lexical, semantic) — rather than becoming a sixth weighted term. With no vector
capability the value is zero and ranking is arithmetically identical to lexical-only.
Both signals are normalized inside the candidate set before they compete, because real
embedding models return cosines in a narrow absolute band and it is the spread that carries
the signal. Against embeddinggemma, three claims spanned 0.437 to 0.463; passing those raw
left an exactly-matching claim ranked last, because the confidence multiplier swung the
composite far more than a 0.026 difference could.
The sqlite-vec store derives its score from L2 distance as 1 / (1 + d). That is monotonic
— nearer always scores higher — but it is not a calibrated similarity, so a score from one
query means nothing next to a score from another.
What degrades, and how you see it
| Situation | What happens |
|---|---|
| No embedding configuration | vector: "disabled", model: "disabled"; retrieval is FTS5 |
| Extension will not load on this platform | Capability is not built; readiness reports it disabled |
| Model unreachable during compile | Pack still returns lexical results; meta.degraded.vector: "error" |
| Model unreachable during drain | 503 UNAVAILABLE, retryable: true, pending count reported, queue unchanged |
| Drain called with no capability | 400 VALIDATION_ERROR — “No vector capability is configured on this deployment.” |
| Endpoint returns the wrong width | The embedder throws embedding dimension mismatch: expected 1024, got 768 |
Compile never fails because a model failed. It degrades and says so in the same response:
"degraded": { "semantic": false, "vector": "used", "model": "enabled" }
vector is "used" when the index answered, "error" when the call failed, and
"disabled" when no capability is configured.
/readyz reports the store and the model as separate facts, because a deployment can
have an endpoint with no working index or an index with no reachable endpoint, and a single
merged flag would hide both.
The dimension is validated on every embedding response, which catches a misconfiguration on first use. A readiness check for a dimension mismatch is recorded as the upgrade path in src/core/vectors.ts. Until it exists, treat a change of model or dimension as an explicit re-index.