# Configure embeddings

> Switch embedding.provider between ollama and openai, set model and dimensions, store keys with tapes auth, and run or disable the embed worker.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `pkg/config/defaults.go`
- `pkg/embeddings/utils/new.go`
- `pkg/embeddings/openai/openai.go`
- `pkg/embeddings/ollama/ollama.go`
- `cmd/tapes/serve/embedworker/embedworker.go`
- `pkg/embedworker/worker.go`
- `cmd/tapes/auth/auth.go`

---

---
title: "Configure embeddings"
description: "Switch embedding.provider between ollama and openai, set model and dimensions, store keys with tapes auth, and run or disable the embed worker."
---

`embedding.provider` selects the backend that both writes span vectors and embeds search queries. Supported types are `ollama` and `openai`. Defaults are local Ollama (`embeddinggemma`, 768 dimensions, `http://localhost:11434`). Switching to `openai` remaps inherited local defaults to `text-embedding-3-large` at 1024 dimensions against `https://api.openai.com`. The embed worker is the only writer of `span_embeddings`; capture and derive never call a provider.

```mermaid
flowchart LR
  subgraph config [Config and secrets]
    TOML["config.toml embedding.*"]
    AUTH["credentials.toml or OPENAI_API_KEY"]
  end
  subgraph writers [Write path]
    EW["tapes serve or tapes serve embed-worker"]
    PASS["spanembed.Pass"]
    PROV["ollama /api/embed or openai /v1/embeddings"]
  end
  subgraph store [PostgreSQL]
    SPANS["spans kind=llm call_kind=main"]
    VEC["span_embeddings vector N"]
  end
  subgraph readers [Read path]
    API["GET /v1/search/spans"]
    CTL["tapesctl search"]
  end
  TOML --> EW
  AUTH --> EW
  TOML --> API
  AUTH --> API
  SPANS --> PASS
  EW --> PASS
  PASS --> PROV
  PASS --> VEC
  API --> PROV
  API --> VEC
  CTL --> API
```

<Note>
`tapes auth` keys are for server-side embedding and skill generation only. The capture proxy is transparent: agents send their own credentials, and those keys are never substituted.
</Note>

## Defaults

`tapes local up` writes Postgres and Ollama into `.tapes/config.toml` and pulls `embeddinggemma`. `tapes serve` then embeds eligible spans in-process unless you pass `--embed-spans=false`.

| Key | Ollama (built-in default) | OpenAI (after provider switch) |
| --- | --- | --- |
| `embedding.provider` | `ollama` | `openai` |
| `embedding.target` | `http://localhost:11434` | `https://api.openai.com` |
| `embedding.model` | `embeddinggemma` | `text-embedding-3-large` |
| `embedding.dimensions` | `768` | `1024` |

`ResolveEmbeddingConfig` applies those OpenAI values when the incoming target/model/dimensions still look like the local defaults. An explicit `--embedding-dimensions` (or a non-default model/target) is kept. The OpenAI client then appends `/v1` if the target URL has no `v1` path segment, so `https://api.openai.com` becomes `https://api.openai.com/v1` and POSTs `/embeddings`.

Any other `embedding.provider` string is rejected at embedder construction: `unsupported embedding provider: <name>`.

<ParamField body="embedding.provider" type="string">
`ollama` or `openai`. Flag: `--embedding-provider`. Env: `TAPES_EMBEDDING_PROVIDER`.
</ParamField>

<ParamField body="embedding.target" type="string">
Provider base URL. Ollama uses `{target}/api/embed`. OpenAI-compatible hosts use `{normalized}/embeddings`. Flag: `--embedding-target`. Env: `TAPES_EMBEDDING_TARGET`.
</ParamField>

<ParamField body="embedding.model" type="string">
Model name stored on each embedding row. Changing it re-embeds every eligible span. Flag: `--embedding-model`. Env: `TAPES_EMBEDDING_MODEL`.
</ParamField>

<ParamField body="embedding.dimensions" type="uint" required>
Must match the model's output and the existing `span_embeddings.embedding vector(N)` column. `0` is rejected. Upper bound is `16000`. Flag: `--embedding-dimensions`. Env: `TAPES_EMBEDDING_DIMENSIONS`.
</ParamField>

Only OpenAI sends `dimensions` on the request. Ollama ignores the config value at HTTP time; the worker still sizes the pgvector column from it and fail-fasts if the first vector length disagrees.

## Switch provider

Precedence for bound settings is flag, then `TAPES_…`, then `config.toml`, then built-in defaults.

<Tabs>
<Tab title="Ollama">

```bash
tapes local up
tapes config set embedding.provider ollama
tapes config set embedding.model embeddinggemma
tapes config set embedding.dimensions 768
tapes serve
```

No API key. Confirm the model is present:

```bash
curl http://localhost:11434/api/tags
```

</Tab>
<Tab title="OpenAI">

```bash
tapes auth openai
tapes config set embedding.provider openai
tapes serve
```

`OPENAI_API_KEY` wins over `credentials.toml`. If the env var is unset, `tapes` loads the stored `openai` key. Missing both fails embedder construction with `OPENAI_API_KEY is required for openai embeddings` — including `tapes serve --embed-spans=false`, because the API still embeds search queries.

Override model or shortened dimensions after the switch:

```bash
tapes config set embedding.model text-embedding-3-large
tapes config set embedding.dimensions 1024
```

Or one-shot flags:

```bash
tapes serve \
  --embedding-provider openai \
  --embedding-target https://api.openai.com \
  --embedding-model text-embedding-3-large \
  --embedding-dimensions 1024
```

</Tab>
</Tabs>

<CodeGroup>

```toml title="config.toml"
[embedding]
provider = "openai"
target = "https://api.openai.com"
model = "text-embedding-3-large"
dimensions = 1024
```

```bash title="Environment"
export TAPES_EMBEDDING_PROVIDER=openai
export TAPES_EMBEDDING_TARGET=https://api.openai.com
export TAPES_EMBEDDING_MODEL=text-embedding-3-large
export TAPES_EMBEDDING_DIMENSIONS=1024
export OPENAI_API_KEY=sk-...
```

</CodeGroup>

`embedding.target` can point at an OpenAI-compatible endpoint. The client normalizes the base URL (requires scheme and host) and adds `/v1` when that path segment is absent. Ollama stays a separate client and always posts to `/api/embed`.

<Warning>
Changing `embedding.dimensions` after `span_embeddings` exists fails schema ensure: the column cannot be resized in place. Re-embed into a new table or drop the old one. Changing `embedding.model` at the same dimension re-embeds in place (content hash plus model gate).
</Warning>

## Store keys with tapes auth

```bash
tapes auth openai
echo "$OPENAI_API_KEY" | tapes auth openai
tapes auth --list
tapes auth --remove openai
```

Keys land in `.tapes/credentials.toml` (`0600`). Do not put secrets in `config.toml`.

```toml
version = 0

[providers.openai]
api_key = "sk-..."
```

`tapes auth` accepts `openai` and `anthropic`. Only `openai` is an embedding provider. Anthropic credentials are for other server-side LLM features (skill generation), not span vectors.

Resolution used by `tapes serve`, `tapes serve embed-worker`, `tapes serve api`, and `tapes dev embed-spans`:

1. If `OPENAI_API_KEY` is set, that value is used (the stored key is ignored).
2. Otherwise the `openai` entry in `credentials.toml` is used.
3. If both are empty and `embedding.provider` is `openai`, startup fails.

## What gets embedded

The pass pages `spans` where `kind = 'llm'` and `call_kind = 'main'`. Shadow LLM calls (permission checks, title generation) and tool/event spans are excluded. Embedded text is the span's delta-only text blocks (fresh input plus response), with harness tags stripped. Tool payloads, thinking blocks, images, and re-sent history are not embedded.

Writes go to `span_embeddings` (`vector(N)` plus HNSW cosine index). Rows are keyed by `(org_id, trace_id, span_id, chunk_idx)` and gated by a SHA-256 of the rendered text plus the configured model. Oversized spans are chunked; text above `--max-text-bytes` (default 1 MiB) is recorded as `too_large` instead of being chunked. Deterministic provider rejections are recorded and not retried until content or model changes. Transient failures stay un-embedded and retry on the next pass.

The Postgres database must already have the `vector` extension. The worker does not create extensions.

## Run the embed worker

### In-process (`tapes serve`)

`tapes serve` starts an in-process embed loop on a 10s interval so local capture → search stays short. Schema or pass-construction errors log `span embedding disabled` and leave search unavailable; they do not stop proxy, ingest, API, or derive.

```bash
tapes serve
tapes serve --embed-spans=false
```

`--embed-spans` exists only on the combined `tapes serve` command (default `true`).

### Standalone (`tapes serve embed-worker`)

In a split stack the dedicated process is the single writer. It requires `--postgres` / `storage.postgres_dsn`. It runs one pass immediately, then every `--interval` (default `1m`).

```bash
tapes serve embed-worker --postgres "$TAPES_STORAGE_POSTGRES_DSN"
```

<ParamField body="--interval" type="duration">
Viper: `embed_worker.interval`. Env: `TAPES_EMBED_WORKER_INTERVAL`. Empty uses `1m`.
</ParamField>

<ParamField body="--metrics-listen" type="string">
Serves `/metrics`, `/healthz` (liveness, always 200), `/readyz` (503 until Postgres pings), and `/ping`. Empty disables the listener.
</ParamField>

<ParamField body="--wait-for-db" type="bool">
Retry unreachable Postgres at startup. Default is fail fast.
</ParamField>

<ParamField body="--batch-size" type="int">
Candidate page size (default `100` when `0`).
</ParamField>

<ParamField body="--max-text-bytes" type="int">
Per-span rendered-text cap. `0` uses 1 MiB. Negative disables the cap.
</ParamField>

<ParamField body="--org" type="uuid">
Embed only that org. Default: all orgs.
</ParamField>

Infrastructure pass failures back off exponentially from the interval, jittered, capped at 5m. SIGTERM/SIGINT drains the in-flight pass for 30s; a second signal kills immediately.

A one-shot backfill (same pass, no loop):

```bash
tapes dev embed-spans \
  --postgres "$TAPES_STORAGE_POSTGRES_DSN" \
  --embedding-provider ollama \
  --embedding-target http://localhost:11434 \
  --embedding-model embeddinggemma \
  --embedding-dimensions 768
```

Successful startup logs `span embedding enabled` (or `span embedding enabled (in-process)`) with provider, target, model, and dimensions.

## Disable embedding

| Goal | Action |
| --- | --- |
| Combined process, no background writes | `tapes serve --embed-spans=false` |
| Split stack, no writes | Do not run `tapes serve embed-worker` |
| Search still needed later | Keep the same `embedding.model` / `embedding.dimensions` when you start a writer again |
| Query embedding unused | Standalone `tapes serve api` skips the embedder when `vector_store.target` (and thus the Postgres DSN default) is unset; search then returns HTTP `503` |

Disabling the worker does not drop `span_embeddings`. Existing rows stay until a later pass prunes orphans after a re-derive.

## Verify

<Steps>
<Step title="Confirm resolved settings">
```bash
tapes config get embedding.provider
tapes config get embedding.model
tapes config get embedding.dimensions
tapes config get embedding.target
```
</Step>
<Step title="Confirm the writer is up">
Look for `span embedding enabled` or `embed worker starting`. On `--metrics-listen`, `GET /readyz` is `200` only after Postgres is reachable.
</Step>
<Step title="Confirm search can embed a query">
```bash
curl --get http://localhost:8081/v1/search/spans \
  --data-urlencode 'query=how was authentication fixed?' \
  --data-urlencode 'top_k=5'
```
An uninitialized table returns HTTP `503` with `span embeddings not initialized: run the embed pass (tapes serve embed-worker or tapes dev embed-spans)`. A missing query embedder returns `503` with `span search is not configured`. Empty hits are not an error.
</Step>
</Steps>

## Failure modes

| Symptom | Cause |
| --- | --- |
| Startup: `OPENAI_API_KEY is required for openai embeddings` | Provider is `openai` and neither `OPENAI_API_KEY` nor `tapes auth openai` is set |
| Startup: `existing table span_embeddings stores vector(X) … but Y dimensions are configured` | Dimension mismatch; drop or replace the table, then re-embed |
| Startup: `vector extension is not installed` | Provisioning did not install `pgvector` |
| Startup: `embed worker requires a postgres DSN` | Standalone worker without `--postgres` / `storage.postgres_dsn` |
| Search `503` not initialized | Writer has never successfully called `EnsureSchema` |
| Search `500` `failed to embed query` | Query-time provider error (Ollama down, OpenAI 4xx/5xx) |
| Spans never appear in search | Writer disabled; span is not `llm`/`main`; empty delta text; `too_large` / poisoned failure; model/dims mismatch |

Ollama HTTP timeout is 120s. OpenAI HTTP timeout is 60s. Per-span provider errors are counted and logged; they never abort a pass.

## Next

<CardGroup>
<Card title="Search spans" href="/search-spans">
Query the projection with GET /v1/search/spans and tapesctl search.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Dotdir resolution, TAPES_ env names, and the full config.toml key set.
</Card>
<Card title="Split the stack" href="/split-the-stack">
Run embed-worker as its own process next to derive-worker and api.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Missing OpenAI embed keys and other operator failures.
</Card>
</CardGroup>
