Files
immich-photo-flow/docs/superpowers/specs/2026-06-27-pgvector-embedding-spike-design.md
T
m038 d9ba194b30 docs(spike): add runbook + definition of done
Consolidate the scattered run instructions and pass/fail criteria into one place:
- design spec gains 'Running the spike' (venv bootstrap + flags + DB prereqs) and
  'Definition of done' (Req 0-4 are the bar; coverage does not gate done-ness;
  the only follow-up at ~100% CLIP coverage is one no-flag re-run to snapshot the
  final coverage into the contract doc) sections, and the status line now reads
  'passed 2026-06-27'.
- script docstring Usage now includes the missing 'python3 -m venv .venv' bootstrap
  a fresh checkout needs, and points to the spec's definition of done.
2026-06-27 22:31:26 +02:00

203 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pgvector embedding feasibility spike — design
**Date:** 2026-06-27
**Milestone dependency:** unblocks **M1.5** (visual similarity). See `docs/ROADMAP.md`.
**Status:** passed 2026-06-27 — Req 04 answered (see findings doc). Final coverage snapshot pending CLIP re-run completion; see "Running the spike" / "Definition of done" below.
## Why this exists
The roadmap narrative claims M1 "runs a read-only feasibility spike" and that M1.5's
dependency is "verified in M1." It is not: the M1 plan explicitly deferred the spike
(`docs/superpowers/plans/2026-06-27-immich-photo-flow-m1.md:3710` — *"pgvector spike →
out of this plan by decision (tracked separately; M1.5 dependency)"*). So M1.5 is blocked
on a prerequisite that never ran. This spec defines that prerequisite.
M1.5 wants to cluster photos by visual similarity using the CLIP embeddings Immich already
computes — the rescue signal for the GPS-poor old library where timestamp/GPS are weakest.
The working assumption is that Immich's REST API does not cleanly expose raw embedding
vectors, so the viable path is **read-only access to Immich's Postgres pgvector embeddings**.
That assumption is load-bearing — it must be *tested*, not asserted (see Requirement 0). Before
M1.5 can build clustering on that path, this spike must prove the path exists and pin its shape.
## Scope
**Feasibility only.** Read-only. Throwaway probe. The spike:
- does **not** build clustering, a tested module, or any `shared/` reader;
- does **not** write to Immich or its database (only `SELECT`);
- exists to turn unknowns into **pinned facts** and hand M1.5 a verified contract.
## Requirements (pass/fail)
The spike succeeds when it answers all of the following against the **live** database and
records the answers in the findings doc. Phrased as plain questions:
0. **Does M1.5 actually need raw vectors (vs. a REST similarity query)?** Before relying on
DB access, state what M1.5's clustering needs — raw embedding vectors or a pairwise
similarity-neighbor query — and record which Immich REST endpoints were checked (e.g.
`/api/search/smart`, any asset-similarity/duplicate endpoint) and *why each is
insufficient*. This turns the load-bearing "REST can't expose embeddings" premise into a
documented finding; if a REST path suffices, the DB-access path below is unnecessary.
1. **Can we read the CLIP embeddings at all?** Connect read-only over the LAN and read the
embedding vectors Immich stores. *This de-risks **access** only: if the embeddings are not
readable, M1.5 needs a different plan. It does **not** prove the signal is useful (see the
signal-usefulness note below).*
2. **Can we join each embedding back to a photo we already track?** Confirm **inside
Postgres** that each embedding row's key references `assets.id` — the join that must hold
for the vectors to be usable. That asset ID is the same key our SQLite store uses, so a
cross-check against SQLite (`shared/photoflow/core`) is *optional* confirmation and must
degrade gracefully when the store is unpopulated (a fresh checkout has no `ingest` run),
rather than failing this requirement for the wrong reason.
3. **What are the exact shapes?** Record the table name, embedding column, **vector
dimension**, and the correct pgvector **distance operator** for similarity. These are
**model- and version-dependent** (and have drifted across Immich versions), so they must be
read from *this* DB, not assumed — and the recorded shape is valid only for the model +
Immich version observed (see Requirement 4 / Risks).
4. **What is the coverage?** What fraction of the **embeddable** library currently has an
embedding. Compute against the image/embeddable population, not all assets —
`count(distinct embedding.assetId) / count(assets WHERE type = 'IMAGE')` (or Immich's
equivalent asset-type filter) — since videos and other non-image rows CLIP never embeds
would otherwise deflate the ratio. Record both the raw and image-only ratios. *Tells M1.5
how much it can lean on the signal — but the user is re-running CLIP with a stronger model,
so coverage (and the dimension in Requirement 3) is a snapshot of whichever model is live
when the probe runs.*
**Signal-usefulness is M1.5's first task, not this spike's.** Passing Requirements 14 proves
the embeddings are reachable and well-shaped; it does **not** prove visual similarity actually
rescues trip detection in the GPS-poor library. Before building clustering, M1.5 must validate
the signal (e.g. eyeball nearest-neighbour quality on a sample of the GPS-poor set).
## What we already know (to verify, not assume)
Immich historically stores CLIP vectors in a **`smart_search`** table with an **`embedding`**
column of pgvector type `vector`, keyed by `assetId` referencing `assets.id`; similarity uses
**cosine** distance (`<=>`); the legacy default model (`ViT-B-32`) produced **512-dim**
vectors. Names and dimension have changed across versions and the user's re-run uses a
stronger model, so the probe **discovers** these rather than trusting them — the list above is
only the set of candidates to probe first.
This is Immich's **undocumented, internal** schema with no deprecation contract: it can be
renamed or restructured on any Immich upgrade. The probe therefore records the exact Immich
version observed alongside the shape, and M1.5 must budget a re-probe / version-guard on every
Immich upgrade — the "contract" the findings doc hands M1.5 is version-pinned, not durable.
## Approach
Disposable read-only probe + two durable artifacts (chosen over a throwaway-only or a
build-the-module-now approach: feasibility-only honors the roadmap, but capturing the DSN and
a written contract is the cheap part that saves M1.5 from guessing).
### Connection
- New optional env var **`IMMICH_DB_URL`** (a Postgres DSN), added to `.env.example` and
loaded by `config.py` as an **optional** field (REST creds stay required; the DSN is only
needed for the spike / M1.5). `config.py` reads only `os.environ`, and the repo loads `.env`
solely via docker-compose's `env_file`, so a standalone host-run probe must populate the
environment itself — either run it via `docker compose run` (so `env_file` applies) or load
`.env` explicitly first (e.g. `set -a; source .env; set +a`). If run in-container, confirm
container-to-Immich-Postgres network reachability.
- Read-only must be **enforced at the DB/session layer**, not just by which statements the
script issues: open the session read-only (`SET default_transaction_read_only = on` /
`SET TRANSACTION READ ONLY`) or connect via a role granted only `SELECT`. The simplest
credentials that work are Immich's existing Postgres user, but that user is write/DDL-capable
against the **source-of-truth** DB, so the session-level guard is required to remove write
capability while the spike runs. A dedicated least-privilege read-only role remains
recommended hardening for M1.5.
- Driver: `psycopg` (psycopg3), plus the **`pgvector`** Python package. psycopg3 returns a
`vector` column as a *string* unless the adapter is registered, so call
`pgvector.psycopg.register_vector(conn)` after connect — or read the dimension server-side
(`SELECT vector_dims(embedding) …` / catalog `atttypmod`) and run the `<=>` check in SQL,
which needs no adapter. Add these to the spike's dependencies; M1.5 will formalize them.
### The probe
A single disposable script, **`scripts/pgvector_spike.py`**, that is idempotent and
read-only. It:
1. connects using `IMMICH_DB_URL`;
2. discovers candidate embedding tables/columns from the catalog (probe `smart_search` /
`embedding` first, then fall back to scanning `information_schema` for `vector`-typed
columns) — so it survives version drift;
3. reads one embedding and reports its **dimension** — via the registered `pgvector` adapter
or server-side `vector_dims(embedding)`, **not** the length of a raw string — and a sample
of the **distance operator** working (e.g. a `... ORDER BY embedding <=> embedding LIMIT 5`
self-similarity sanity check, casting literals to `::vector` as needed);
4. verifies the **asset-ID join inside Postgres**: the embedding row's key references
`assets.id`. As *optional* confirmation it also checks whether that ID is one we store in
SQLite (`shared/photoflow/core`), degrading gracefully (not failing the check) when the
store is unpopulated;
5. computes **coverage** against the embeddable population:
`count(distinct embedding.assetId) / count(assets WHERE type = 'IMAGE')` (or Immich's
equivalent asset-type filter), recording **both** the raw and image-only ratios;
6. prints a human-readable report **and** writes/refreshes the findings doc.
It hardcodes nothing destructive, takes no write path, and is safe to re-run.
## Deliverables
1. `scripts/pgvector_spike.py` — disposable read-only probe (removed or left as a documented
one-off after M1.5 internalizes its findings).
2. `IMMICH_DB_URL` in `.env.example`; optional `immich_db_url` field in `config.py`. Spike
dependencies added: `psycopg` (psycopg3) and the `pgvector` Python package.
3. **`docs/superpowers/specs/2026-06-27-pgvector-embedding-findings.md`** — the one-page
schema that becomes M1.5's contract: table, embedding column, vector dimension, distance
operator, the embedding → `assetId` → SQLite `asset` join, coverage % (raw and image-only),
the model **and** Immich version observed, and the (optional) read-only-role recipe. The doc
must state that this is Immich's **unsupported internal schema**, valid only for the recorded
model + version, and that M1.5 re-probes / version-guards on every Immich upgrade.
4. Roadmap correction: fix **both** false claims named in "Why this exists" — the narrative
that M1 "runs a read-only feasibility spike" (→ "spike defined/pending per this spec") **and**
M1.5's status note "verified in M1" (→ "dependency spike pending/this spec").
## Running the spike
Prereqs: read access to Immich's Postgres and `IMMICH_DB_URL` set — put it in `.env` at the
repo root; the probe searches upward from the working directory to find it. DSN form:
`postgresql://USER:PASSWORD@HOST:PORT/DBNAME` (Immich defaults: user `postgres`, db `immich`;
URL-encode special chars in the password). Immich's stock compose does **not** publish Postgres
to the host, so either map its port or run where the DB is reachable on the LAN.
From the repo root (a fresh checkout / new worktree has no `.venv`):
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -r scripts/requirements-spike.txt
.venv/bin/python scripts/pgvector_spike.py # read-only, idempotent, safe to re-run
```
The probe prints a report and (re)writes the findings doc. Flags: `--no-write` (report only,
leave the doc untouched), `--sqlite PATH` (optional SQLite cross-check target; skips gracefully
when absent), `--findings PATH` (write the doc elsewhere). The DB session is opened read-only at
the session layer, so the probe cannot mutate Immich even via the write-capable `postgres` user.
## Definition of done
The spike is **done** once Requirements 04 are answered and recorded in the findings doc:
REST insufficiency documented (R0), embeddings readable (R1), join to `asset` confirmed (R2),
shape pinned — table / column / dimension / distance operator (R3), and coverage recorded both
raw and image-only (R4). **Coverage value does not gate done-ness** — per Risks, near-zero
coverage is a recorded fact, not a failure. By these criteria the spike already passed on
2026-06-27 (see the findings doc); `ROADMAP.md` reflects this.
**Picking it up when the CLIP re-run reaches ~100%:** the only remaining action is to refresh the
coverage snapshot in the contract doc. Once the re-run completes, run
`scripts/pgvector_spike.py` (no flags) so the committed findings doc records the final
full-library coverage, then commit the regenerated doc. Nothing else changes — readability, the
`assetId → asset.id` join, and the vector shape are already locked and are re-verified on every
run. (If a later Immich upgrade changes the schema, the probe's catalog discovery adapts; re-run
and re-commit the doc — that is M1.5's version-guard duty.)
## Out of scope (explicitly M1.5)
CLIP clustering; a tested `shared/photoflow/immich/db.py` reader; nearest-neighbor queries at
scale; any use of the embeddings beyond the feasibility checks above.
## Risks
- **Embeddings unreadable / not joinable** → M1.5 blocked; spike's whole point is to surface
this early and cheaply.
- **Schema differs from the known shape** → expected and handled by catalog discovery, not
hardcoded names.
- **Coverage near zero** (re-run unfinished) → not a spike failure; recorded as a fact so M1.5
can sequence around it.