Semantic vectors
Live on Bun and SQLite through sqlite-vec plus any OpenAI-compatible embedding endpoint; the index adds recall and is never 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, because one of them is a credential and belongs in a unit file rather than a shell history.
[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 and /readyz
reports vector: "disabled". 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. 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 widens what is found, never what may be read.
Canonical export carries no embeddings: changing embedding provider changes retrieval scores without touching evidence, claims or record ids.
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. Compile degrades to lexical for anything missing from the index.
Only retrievable claims get embedded
The semantic unit is a retrievable claim: active or disputed, not every raw chat turn.
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, and so is a claim
that was superseded, revoked or expired since it was queued.
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. With an in-process maintenance timer you never call this; see
background repair.
Hybrid ranking and normalization
The index adds recall: 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), instead of adding a
sixth weighted term to the five in score_components. 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 not a calibrated similarity, so a score from one query
means nothing next to a score from another.
What degrades
| 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: a deployment can have an
endpoint with no working index, or an index with no reachable endpoint.
The dimension is validated on every embedding response, so a misconfiguration surfaces 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.