Appearance
Plan — /scan-lab endpoint to validate pgvector without breaking anything
Battle plan to validate our own scan stack (pgvector + DINOv2) without touching the tcg-vision pipeline currently used by the RN app.
Date: 2026-05-06. Status: All 6 phases shipped (A-D on 2026-05-06, E-F on 2026-05-07). RN app now scans via /scan-lab for all three TCGs (Pokémon + OP + MTG).
Why this plan
tcg-vision (sidecar built from Tormknd's repo, hosted on our VPS) serves the production scan path via /scan-compat. It works well, but:
- The
faiss_index.binit uses is built offline by Tormknd; we don't know how to regenerate it ourselves - Adding Magic or any new TCG he hasn't covered means either asking him or building our own index
- We already have the code to do it ourselves (
embedCatalog.ts+ pgvectorcard_embeddingstable + embedding branch in/scan), but it's dormant and untested
See Card scan architecture for full context.
The risk
Activating the existing pgvector pipeline as-is means:
- Running
embedCatalog.tsfor ~80 minutes to populatecard_embeddings - Changing
BACKEND_SCAN_URLin the app to point at/scan(api) instead of/scan-compat(tcg-vision) - Crossing fingers that quality is equivalent
Several unknowns make that plan risky:
- The code has never run (
card_embeddingstable at 0 rows) - Index difference:
tcg-visionuses FAISSIndexFlatIP(100% recall), pgvector uses HNSW (~95-99% recall) - Image preprocessing potentially differs (rectification / crop / resize)
- Parallel-art variants:
card_embeddings.card_idis PK alone, soOP14-119_p1andOP14-119_p2collapse to one row
The plan — full isolation, zero risk
The idea is to add a parallel /scan-lab endpoint that queries a new card_embeddings_lab table, isolated from the prod path. We can A/B test without touching anything in production.
Before After
────── ─────
┌── tcg-vision :8000 ─────┐ ┌── tcg-vision :8000 ─────┐
│ /scan-compat (prod app) │ │ /scan-compat (prod app) │ ← unchanged
└─────────────────────────┘ └─────────────────────────┘
┌── api :8080 ─────┐ ┌── api :8080 ──────────────┐
│ /scan (dormant) │ │ /scan (dormant) │ ← unchanged
└──────────────────┘ │ /scan-lab (NEW) │
└─────┬─────────────────────┘
│ query
▼
┌── postgres ─────────────────┐
│ card_embeddings (empty) │ ← unchanged
│ card_embeddings_lab (NEW) │ ← new
└─────┬───────────────────────┘
▲
│ populated by
┌── ingest job ──────────────┐
│ embedCatalogLab.ts (NEW) │
│ → embeddings: sidecar │ ← shared, unchanged
└────────────────────────────┘No container modified. No existing endpoint touched. RN app unchanged.
Technical breakdown
Backend (tcg-collector-backend) — Phase A shipped (PR #103)
✅ Migration
0006_card_embeddings_lab.sql— schema-identical sibling ofcard_embeddings, distinct table name. Idempotent.✅
src/ingest/embedCatalogLab.ts— fork ofembedCatalog.ts. Differences:- Writes to
card_embeddings_lab - Picks any available image quality (prefers
low, falls back tofullthenhigh) to handle One Piece (singlefull) and Pokémon (low/high split) with the same code INGEST_LAB_TCG(pokemon/onepiece) andINGEST_LAB_SETS(comma-separated) for fast smoke loops
- Writes to
✅
src/api/src/scan/routeLab.ts—/scan-labendpoint:- Multipart photo → JPEG decode → bounds crop → DINOv2 sidecar → pgvector ANN against
card_embeddings_lab - Pure embedding signal (no pHash / OCR / known-cards fusion) so the A/B against
/scan-compatmeasures raw quality vs Tormknd's FAISS - Response shape mirrors
/scan-compat(samematch.id,match.name,cardDetails, etc. keys) for 1:1 diff auth.ts:/scan-labadded toPUBLIC_POST_PATHS(read-only despite POST)
- Multipart photo → JPEG decode → bounds crop → DINOv2 sidecar → pgvector ANN against
✅
docker/compose.yml— no new service. Theembeddings:sidecar is shared (stateless, model-agnostic).Tests — at minimum a unit test on the generated SQL query. No integration test (the embedding sidecar isn't available in CI). 40/40 ingest tests passing.
Variant-aware schema (tcg-collector-backend) — Phase B shipped (PR #105)
Pre-requisite for distinguishing variants at scan time: break the card_set_id → 1 row collapse.
- ✅ Migration
0007_card_variants.sql— purely additive, two new columns:cards.base_id— the logical card. Backfillbase_id = idon existing rows; future variant rows share abase_idbut have a distinctid(e.g.id="OP01-077_v2",base_id="OP01-077").cards.variant— the print tag.'base'for the principal print,'v2'/'v3'for additional prints.- Index on
base_id+UNIQUE (base_id, variant)constraint.cards.idremains PK and unique → every existing query keeps working.
- Naming choice
_v2/_v3vs Tormknd's_pN/_rN— Phase D builds our own embedding index from our own image rows, so we don't need to match the upstream FAISS suffix scheme. Sequential numbering (in OPTCG response order) is deterministic and avoids inventing a heuristic to map card names to print types. - Verified post-apply — 25 294 cards backfilled (23 159 pokemon + 2 135 onepiece), all
variant='base'andbase_id=id./cards/:id,/scan-lab,/scan-compat: no behaviour change.
Variant-aware OP ingest (tcg-collector-backend) — Phase C shipped (PR #107)
The OP ingest now leverages the new schema to store each print as its own row.
- ✅
assignVariantIds()insrc/ingest/onepiece.ts— groups OPTCG entries bycard_set_id, sorts each group bycard_imageURL in ASCII order (notlocaleCompare— see trap below), then assigns:- First entry →
cards.id = card_set_id,variant = 'base' - Subsequent →
cards.id = ${baseId}_v${N},variant = 'v2' / 'v3' / ...
- First entry →
- ICU/locale trap — under Alpine + slim ICU (our Docker image),
String#localeCompareranks_before., the opposite of glibc. During initial smoke this caused Box-Topper variants to steal the baseid. Fix: direct ASCII compare via</>. Regression test pinned in the suite (tests/optcgapi.test.mjs) with real-world OPTCG URLs (.jpgvs_p1.jpg). - Disk layout keyed on variant-suffixed id —
OP-01/077/full.jpg(base),OP-01/077_v2/full.jpg(variant). Existing base files don't move. - Verified post-ingest — 3 330 OPTCG entries → 2 135 base + 832 variants = 2 967 OP cards (vs 2 135 collapsed before). Distribution: 2 135 base / 674 v2 / 120 v3 / 34 v4 / 4 v5. Pokémon (23 159) untouched.
/cards/OP01-077returns "Perona" with the base URL;/cards/OP01-077_v2returns "Perona (Box Topper)". 46/46 ingest tests passing (6 new).
Catalog embed into card_embeddings_lab — Phase D shipped (with #109 preprocessing fix)
First real run of embedCatalogLab.ts against the prod DB.
- ✅ 24 539 rows embedded in 33 minutes, ~12 cards/s on the DINOv2-small sidecar (CPU only). 0 failures. Model
facebook/dinov2-small, dim 384, single model name throughout. - Coverage — every
cardsrow with acard_images.status='ok'entry was processed: Pokémon (~22k) + OP base + variants (~2.5k). TheembedCatalogLabselector picks the image in priority orderlow→full→high, so OP (singlefulljpg) and Pokémon (low/high split) work with the same code. - Fix #109 (post-Phase D) — a self-recall test (POST the indexed image of OP01-077 to
/scan-lab) returned the wrong card with sim ≈ 0.85. Diagnosis:/scan-labwas cropping the upload viadetectCardBounds, butembedCatalogLab.tssends the raw image. The two pipelines must do the same preprocessing or the vectors aren't comparable. Fix:/scan-labdefaults to no-crop,?crop=1is opt-in. - Smoke after fix — self-recall OP01-077: 0.85 → 0.9990. The Cardmarket portgas (ST13-010) scan still mismatches because ST decks aren't indexed (revert #101) — that's a coverage gap, not a pipeline bug.
Documentation
This page stays as the plan's reference. Once validated or abandoned, it'll be converted into:
- "Standalone pgvector stack (in prod)" — if successful
- "Standalone pgvector attempt (abandoned)" with lessons learned — if failed
Smoke validation procedure
"Empty table" smoke (verified in Phase A)
bash
curl -X POST -F "[email protected];type=image/jpeg" http://localhost:8080/scan-lab
# → { "ok": true, "match": null, "source": "no-match",
# "reason": "no-match-after-filter", "similarity": 0, "ms": 723 }DINOv2 inference (~250ms) and the pgvector query both run; no row comes back. Response shape mirrors /scan-compat's no-match for easy diffing.
Reason rename (PR #138, 2026-05-10)
The literal used to be lab-table-empty, which was misleading in production where the table holds 143k+ rows. The path is actually reached when HNSW's default 40 ANN candidates are all filtered out by the active WHERE c.tcg=$3 — typically when scanning a non-card image whose embedding lands in a region dominated by a different TCG. The new name describes what actually happens.
"Populated table" smoke (verified in Phase D)
bash
cd tcg-collector-backend
# 1. Embed a single OP-01 set (~154 images, ~7 minutes CPU vs ~80 min full)
sudo docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_LAB_TCG=onepiece -e INGEST_LAB_SETS=OP-01 \
--entrypoint "node dist/embedCatalogLab.js" ingest
# 2. Verify the table populates
sudo docker compose --env-file .env -f docker/compose.yml exec -T postgres \
psql -U tcg -d tcg -c "SELECT COUNT(*) FROM card_embeddings_lab;"
# Expected: ~150-154 (depending on collapsed variants)
# 3. Compare 5-10 cards: /scan-lab (our stack) vs /scan-compat (tcg-vision)
for img in /tmp/op-test-*.jpg; do
echo "=== $img ==="
curl -s -X POST -F "photo=@$img;type=image/jpeg" http://localhost:8080/scan-lab \
| python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'lab : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
curl -s -X POST -F "photo=@$img" http://localhost:8000/scan-compat \
| python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'compat : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
doneSuccess criteria:
- 100% of tested scans return the same
card_idas/scan-compat - Comparable latency (~250ms inference + a few ms ANN, total <500ms)
- No crash on edge inputs (blurry, partial, wrong rotation)
If KO: dig in to identify the gap (preprocessing? HNSW recall? image format?), fix, retry the smoke. The full embed of all 25k cards waits for validation.
PR workflow
Once implemented:
| Repo | Branch | Contents |
|---|---|---|
tcg-collector-backend | feat/scan-lab-pgvector | Migration 0006 + embedCatalogLab.ts + routeLab.ts + tests |
tcg-collector-backend | docs/scan-lab-changelog (separate) | CHANGELOG.md entry under [Unreleased] |
documentation | docs/scan-lab-results | Update this page with smoke test results |
See also
- Card scan architecture — pipeline overview
- Rollback — One Piece integration — prior rollback, same patterns
tcg-collector-backend/src/ingest/src/embedCatalog.ts— existing script to forktcg-collector-backend/src/api/src/scan/embeddings.ts— existing embedding branch to forktcg-collector-backend/src/ingest/migrations/0004_card_embeddings.sql— reference schema for the new table