# 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.

Section: Deploy · Source: https://titen.dev/docs/vectors
Derived from: https://github.com/RamaAditya49/titen/blob/main/src/core/vectors.ts

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

<div class="table-wrap">

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

</div>

## 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.

<figure class="code"><figcaption>/etc/systemd/system/titen.service.d/vectors.conf<b>ini</b></figcaption>

```ini
[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
```

</figure>

<div class="kv">
<strong>ENVIRONMENT</strong>

<p><code>TITEN_EMBED_BASE_URL</code> required; the request goes to this value plus <code>/embeddings</code></p>
<p><code>TITEN_EMBED_MODEL</code> required; passed through as the request's <code>model</code></p>
<p><code>TITEN_EMBED_DIMS</code> required; every response is checked against it</p>
<p><code>TITEN_EMBED_API_KEY</code> optional; sent as a bearer token when set</p>
<p><code>TITEN_VEC_DB_PATH</code> optional; defaults to the canonical path plus <code>.vec</code></p>
</div>

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.

<div class="rules">

<p>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 <em>found</em>, never what may be <em>read</em>.</p>
<p>Canonical export carries no embeddings: changing embedding provider changes retrieval scores without touching evidence, claims or record ids.</p>
<p>Trust, temporal validity and conflict state are still checked from canonical SQL after every candidate lookup.</p>

</div>

<div class="callout">
<strong>Rebuildable is not self-healing</strong>

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.
</div>

## 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.

```sh
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](/docs/deploy-vps).

## 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.

<div class="callout">
<strong>Scores are for ordering, not comparison</strong>

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.
</div>

## What degrades

<div class="table-wrap">

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

</div>

Compile never fails because a model failed. It degrades and says so in the same response:

```json
"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.

<div class="callout">
<strong>Planned: a dimension check at readiness</strong>

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](https://github.com/RamaAditya49/titen/blob/main/src/core/vectors.ts).
Until it exists, treat a change of model or dimension as an explicit re-index.
</div>

## Next

<div class="cards">

<a href="/docs/compile"><strong>Compile context</strong><span>How candidates are scored, budgeted and reported — with or without vectors.</span></a>
<a href="/docs/deploy-vps"><strong>Bun + SQLite (VPS)</strong><span>Where the maintenance timer that drains the index lives.</span></a>
<a href="/docs/backup-export"><strong>Backup &amp; export</strong><span>Why the index never appears in an export, and what a restore does guarantee.</span></a>

</div>