# Ephemera — Vector search on Cloudflare (Vectorize + CLI)

> Self-executing Markdown. The **Cloudflare binding** of the *vector-search* intent — a Vectorize index for
> similarity search / RAG. The cloud is the source of truth; this file is intent + ledger + audit.

> **Provides / Requires**: **Provides** `vectorize-index(INDEX_NAME)` — a consumer Worker
> (`service.cloudflare.md`, `inference.cloudflare.md`) **Requires** it via `wrangler vectorize get`. The index's
> `DIMENSIONS` **Requires** matching the embedding model in `inference.cloudflare.md` (`MODEL_CLASS=embeddings`).

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing
- Stop at every 🔴 GATE and 💥 for human "go"
- Write realized values + verify results back into Live State
- Teardown deletes only the index this plan created
- Use only the commands in this plan

> **Candor:** authored, **not yet dogfooded** — confirm `wrangler vectorize` subcommands against `--help` live
> (the CLI is the source of truth; insert/query surfaces have changed across wrangler versions).

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

A vector index: store embeddings (each a `DIMENSIONS`-length float array + an id + metadata), then query by a
vector and get the nearest neighbors by a distance `METRIC`. The backbone of semantic search and RAG —
embeddings come from `inference.cloudflare.md` (`MODEL_CLASS=embeddings`), the dimension and metric must match
how they were produced. The query path runs from a Worker via the `VECTORIZE` binding.

**Shared acceptance contract** (every vector-search binding — Vectorize / AWS OpenSearch-kNN / GCP Vector
Search — must pass):
1. the index exists with the **resolved `DIMENSIONS` + `METRIC`**
2. insert a vector, then query a near-identical vector → it returns as the **top-1** match (semantic round-trip)
3. **(`METADATA_INDEX=filtered`)** a filtered query restricts results by metadata
4. a **dimension-mismatch insert is rejected** (negative — wrong-length vector errors, not silently stored)

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Vector dimension | free-text int — **must match the embedding model** | `768` | `DIMENSIONS` | §1 `--dimensions` |
| 2 | Distance metric | `cosine` / `euclidean` / `dot-product` | `cosine` | `METRIC` | §1 `--metric` |
| 3 | Filter by metadata? | `none` / `filtered` | `none` | `METADATA_INDEX` | §2 (metadata index) + acceptance 3 |
| 4 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | every resource name (`*-${ENV}`) |

**Why dimension + metric are immutable and must match the model:** Vectorize fixes both at create time. The
dimension is the embedding model's output width (`bge-base-en-v1.5` → **768**, `bge-large` → 1024,
OpenAI `text-embedding-3-small` → 1536). The metric must match how the model was trained — **`cosine`** for
most sentence embeddings (bge), `dot-product` for some, `euclidean` for raw distance. Mismatch silently
degrades recall; choosing them is a one-way door — changing either means a **new index + re-embed** (a
migration), so they are Provisioning Inputs, not edits.

```yaml
# → written into Live State once resolved
resolved_inputs:
  dimensions:     768          # must match the embedding model output width
  metric:         cosine       # cosine | euclidean | dot-product
  metadata_index: none         # none | filtered
  env:            dev
  resolved_by:    <human who confirmed>
  resolved_at:    <timestamp>
```

## Tags & provenance (binding asymmetry)

**Cloudflare has no resource-tag API** — the Vectorize index (`INDEX_NAME`) takes no tags. Provenance is the
**naming convention** (`${SVC}-${ENV}`) + the binding Worker's `[vars]`. The index carries no `vars` of its own.

## 0. Variables

```bash
export ENV="dev"
export DIMENSIONS="768" METRIC="cosine" METADATA_INDEX="none"
export SVC="content"                              # free-text identity
export INDEX_NAME="${SVC}-${ENV}"                 # e.g. content-dev
# NOTE: `command wrangler` bypasses the wrangler shell-fn so the ambient CLOUDFLARE_API_TOKEN is used.
```

## Dependency frontier

```
index (§1, dimensions+metric immutable) ─> (METADATA_INDEX) metadata index (§2) ─> binding+insert/query (§3) ─> ✔ acceptance
       ▲ DIMENSIONS Requires the embedding model's width (inference.cloudflare.md, MODEL_CLASS=embeddings)
```
Non-negotiable edges: **the index exists before metadata indexes or inserts**; **metadata indexes must be
created before inserting the vectors you'll filter on** (Vectorize indexes metadata at write time). Teardown
deletes the index (metadata indexes go with it).

## 1. Vectorize index  🟢

```bash
command wrangler vectorize create "$INDEX_NAME" --dimensions="$DIMENSIONS" --metric="$METRIC"
```
```bash
# ✔ verify — dimensions + metric as resolved
command wrangler vectorize get "$INDEX_NAME" 2>&1 | grep -iE "dimensions|metric"
```
> → Live State: INDEX_NAME, dimensions, metric, status: live.

## 2. Metadata index  🟡  *(`METADATA_INDEX=filtered` — enable filtered queries)*

> Create **before** inserting the vectors you intend to filter on — Vectorize indexes metadata at write time,
> so vectors inserted earlier won't be filterable on a later-added property.

```bash
command wrangler vectorize create-metadata-index "$INDEX_NAME" --property-name=category --type=string
command wrangler vectorize list-metadata-index "$INDEX_NAME"     # ✔ verify
```
> → Live State: metadata index (category:string).

## 3. Binding + insert/query (how a consumer uses it)  🟡

> The index is the resource; reads/writes run from a Worker via the binding. Add this to the consumer's config:

```jsonc
{ "vectorize": [ { "binding": "VECTORIZE", "index_name": "content-dev" } ] }
```
```js
// insert (id + values[DIMENSIONS] + metadata) and query (topK nearest by METRIC):
await env.VECTORIZE.insert([{ id: 'doc-1', values: embedding, metadata: { category: 'guide' } }]);
const hits = await env.VECTORIZE.query(queryEmbedding, { topK: 3, filter: { category: 'guide' } });
```
> Bulk load from NDJSON without a Worker:
```bash
# each line: {"id":"doc-1","values":[...768 floats...],"metadata":{"category":"guide"}}
command wrangler vectorize insert "$INDEX_NAME" --file /tmp/vectors.ndjson
```

## Acceptance verify  ✔  *(the shared contract)*

```bash
# 1: index shape
command wrangler vectorize get "$INDEX_NAME" 2>&1 | grep -qiE "768|$DIMENSIONS" && echo "1: dims OK"
# 2: semantic round-trip — insert a known vector, query a near-identical one, expect it top-1.
#    (run via the bound Worker, or wrangler vectorize insert + query; confirm the exact query surface live)
# 4: NEGATIVE — a wrong-dimension insert must ERROR, not store:
echo '{"id":"bad","values":[0.1,0.2,0.3]}' > /tmp/baddim.ndjson    # 3 != 768
command wrangler vectorize insert "$INDEX_NAME" --file /tmp/baddim.ndjson 2>&1 | grep -qiE "dimension|mismatch|invalid" && echo "4: dim-mismatch rejected OK"
```
> → write Live State: status: live; fill verify rows (incl. the negative — a silently-stored wrong-dim vector
> is the corruption this plan guards against).

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored — Vectorize index (dimensions/metric/metadata-index knobs)
last_verified: —
resolved_inputs: { dimensions: 768, metric: cosine, metadata_index: none, env: dev }
```

| key         | value (filled on apply) |
|-------------|-------------------------|
| INDEX_NAME  | `${SVC}-${ENV}` |
| DIMENSIONS  | `768` |
| METRIC      | `cosine` |
| metadata    | `—` (only if `METADATA_INDEX=filtered`) |

| ✔ check                      | expected                                     | observed | result |
|------------------------------|----------------------------------------------|----------|--------|
| index exists (dims+metric)   | `get` returns resolved dims + metric          | —        | — |
| semantic round-trip          | near-identical query vector → top-1            | —        | — |
| filtered query (metadata)    | metadata filter restricts results              | —        | — |
| dim-mismatch rejected        | wrong-length insert errors (negative)          | —        | — |

## Update (idempotent reconcile)

- Add/refresh vectors → re-`insert` (insert is upsert-by-id; same id overwrites).
- Add a filterable property → `create-metadata-index` then **re-insert** affected vectors (metadata is indexed
  at write time).
- **Change dimension or metric → not an edit.** Create a new index, re-embed, swap the binding (a migration).

## Teardown (observe-first, resumable)  💥

> 💥 Human go. Deleting the index drops all vectors + metadata indexes. Observe first.

```bash
command wrangler vectorize list 2>&1 | grep -q "$INDEX_NAME" && command wrangler vectorize delete "$INDEX_NAME"
```
```bash
command wrangler vectorize get "$INDEX_NAME" 2>&1 | grep -qi "not found\|does not exist" && echo "index gone"  # ✔
```
> → Live State: status: gone; clear realized ids.

---

## Portability ledger — same intent, three bindings

| | AWS (`vector.aws.md`, OpenSearch kNN / pgvector) | Cloudflare (`vector.cloudflare.md`, Vectorize) | GCP (`vector.gcp.md`, Vertex Vector Search) |
|---|---|---|---|
| Provisioning | OpenSearch domain / RDS+pgvector — sizeable | `wrangler vectorize create` — one command | index + index-endpoint deploy (slow) |
| Wiring to compute | VPC + IAM + endpoint | **binding by name** | SA + endpoint |
| Metric choice | per-index (cosine/l2/dot) | `--metric cosine\|euclidean\|dot-product` | per-index |
| Metadata filter | mapping + filter query | `create-metadata-index` + `filter` | namespace/restricts |
| Embeddings source | Bedrock / external | Workers AI (`inference.cloudflare.md`) | Vertex |
| Cost shape | running cluster (hourly) | per-query + per-stored-dimension | running endpoint (hourly) |
| Tags | resource tags | **none** — naming + `[vars]` | labels |

## Deliberately not included

- **The embedding step itself** — that's `inference.cloudflare.md` (`MODEL_CLASS=embeddings`); this plan stores
  and queries vectors, it doesn't produce them. The RAG recipe composes the two.
- **Namespaces / multi-tenant partitions** — Vectorize namespaces partition an index; a knob for later.
- **Bulk re-embed pipelines** — a re-embed on model change belongs behind `task-runner.cloudflare.md`.
- **Hybrid (keyword + vector) search** — combine with D1 FTS; a composition concern, not a single binding.
