Defines the read-only Postgres/pgvector feasibility spike that the roadmap claims ran in M1 but the M1 plan deferred. Pins the four pass/fail requirements, the disposable-probe approach, and the findings-doc contract that unblocks M1.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.0 KiB
pgvector embedding feasibility spike — design
Date: 2026-06-27
Milestone dependency: unblocks M1.5 (visual similarity). See docs/ROADMAP.md.
Status: design
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. 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. 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 four against the live database and records the answers in the findings doc. Phrased as plain questions:
- Can we read the CLIP embeddings at all? Connect read-only over the LAN and read the embedding vectors Immich stores. This is the real go/no-go: if the embeddings are not readable, M1.5 needs a different plan.
- Can we join each embedding back to a photo we already track? Our SQLite store keys assets by Immich asset ID. Confirm each embedding row carries that same ID so an embedding can be matched to a known asset. Without the join the vectors are unusable.
- What are the exact shapes? Record the table name, embedding column, vector dimension, and the correct pgvector distance operator for similarity. These have drifted across Immich versions, so they must be read from this DB, not assumed.
- What is the coverage? What fraction of library assets currently have an embedding (the user is re-running CLIP with a stronger model — has it finished?). Tells M1.5 how much it can lean on the signal.
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.
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.exampleand loaded byconfig.pyas an optional field (REST creds stay required; the DSN is only needed for the spike / M1.5). - Simplest credentials that work: Immich's existing Postgres user, kept read-only by the
probe issuing only
SELECT. A dedicated least-privilege read-only role is documented as recommended hardening for M1.5 but is not required to prove feasibility. - Driver:
psycopg(psycopg3). Add to the spike's dependencies; M1.5 will formalize it.
The probe
A single disposable script, scripts/pgvector_spike.py, that is idempotent and
read-only. It:
- connects using
IMMICH_DB_URL; - discovers candidate embedding tables/columns from the catalog (probe
smart_search/embeddingfirst, then fall back to scanninginformation_schemaforvector-typed columns) — so it survives version drift; - reads one embedding and reports its dimension and a sample of the distance operator
working (e.g. a
... ORDER BY embedding <=> embedding LIMIT 5self-similarity sanity check); - verifies the asset-ID join: the embedding row's key matches
assets.id, and that same ID is one we store in SQLite (shared/photoflow/core); - computes coverage:
count(embeddings) / count(assets)(and, if cheap, broken down by whether the asset is an image vs video); - 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
scripts/pgvector_spike.py— disposable read-only probe (removed or left as a documented one-off after M1.5 internalizes its findings).IMMICH_DB_URLin.env.example; optionalimmich_db_urlfield inconfig.py.docs/superpowers/specs/2026-06-27-pgvector-embedding-findings.md— the one-page pinned schema that becomes M1.5's contract: table, embedding column, vector dimension, distance operator, the embedding →assetId→ SQLiteassetjoin, coverage %, Immich version, and the (optional) read-only-role recipe.- Roadmap correction: M1.5's status note updated to "dependency spike pending/this spec" instead of "verified in M1."
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.