Migration Complete: Qdrant Is Dead, Long Live pgvector

Mar 5, 2026 12 min read

Today I finished the Supabase migration. All twenty steps, done. Local Qdrant is gone. Every piece of the stack (API, bridge computation, enrichment scripts, embedding pipeline, tests) now runs against one hosted database. It feels like taking off a backpack after a long hike.


What Actually Happened Today

Steps 12–15: The Code Surgery

The hardest part of the migration was compute_bridges.py. This file had Qdrant woven through it like thread through fabric (six separate touchpoints). Qdrant filter objects became SQL WHERE fragments. Qdrant scroll() calls became SELECT id FROM products WHERE text_embedding IS NOT NULL. Vector retrieval that used to cross-reference two Qdrant collections became a single-row query pulling both embeddings at once.

The enrichment scripts were simpler but tedious. Six files, all following the same pattern: remove the VectorDB import, delete the giant payload dict, replace the Qdrant upsert with two lines:

product.text_embedding = text_emb.tolist()
product.image_embedding = img_emb.tolist()

No more building a 28-field metadata dictionary to keep Qdrant payloads in sync with Postgres. The data just lives where it lives.

Steps 16–17: Cleanup and Frontend

Moved the obsolete Qdrant backfill scripts into scripts/ rather than deleting them. Old code has a way of being useful when you’re debugging “wait, how did this work before?” Added the Supabase hostname to next.config.ts for Next.js image optimization.

Step 18: Rewriting the Tests

This was more work than I expected. Every test file that touched the database or vector search needed updating:

The satisfying part: deleting test classes that tested Qdrant-Postgres sync. Those tests existed because of architectural complexity. Removing the complexity removed the tests. That’s a good sign.

Step 19: Verification

All tests pass:

Step 20: Documentation and Cleanup

Updated five docs. The interesting one was ARCHITECTURE.md. The temporal classification section was wrong: it described a year-distance algorithm (same_era: |year_a - year_b| <= 25) but the actual code uses era name comparison plus decade distance. cross_era means different named eras (Victorian vs. Art Deco), cross_time means same era but decades more than 30 years apart. The old same_era and near_era types were stale data from earlier code versions.

Deleted 3,957 same_era bridges. They were produced by code that no longer exists and don’t match the current bridge classification logic. Kept 425 near_era bridges for now: they’re questionable but not clearly wrong.

Created storage/image_storage.py, a Supabase Storage upload helper for future data loaders. Three functions, 35 lines. Now any new loader can call upload_product_image(product_id, raw_bytes) instead of base64-encoding into the database column.


The Before and After

Before (Feb 27):

After (Mar 5):

Eliminated: one entire database service, one Python client library, one connection pool, hundreds of lines of payload synchronization code, two test classes that only existed to verify cross-database consistency.


Decisions Made Today

Delete same_era bridges, keep near_era

The current classify_temporal_type function in compute_bridges.py produces three outcomes: cross_era (different named eras), cross_time (same era, decades 30+ years apart), or None (skip, not interesting). It never produces same_era or near_era. Those types came from an older version of the code.

3,957 same_era bridges were noise: products from the same era with no interesting temporal tension. Deleted. The 425 near_era bridges might be worth keeping or reclassifying. Left them for now.

Documentation should match the code, not the plan

Found three places where docs described planned behavior instead of actual behavior (temporal classification, bridge type lists, Qdrant references). Fixed all of them. A lesson I keep relearning: documentation drifts the moment you stop treating it as source of truth.


What’s Next

The migration plan had an “Optional” section at the bottom: an image upload helper for future data loaders. Built that today as storage/image_storage.py. The next plan to implement is the next phase of the project, and I’ll be picking that up tomorrow.

Remaining cleanup: uninstall qdrant-client from the virtualenv. It’s not in requirements.txt but it’s still installed locally. One command, but I want to run the full test suite one more time first just to be sure nothing is importing it transitively.

The Supabase migration is the kind of work that doesn’t feel exciting but makes everything after it easier. Every future feature (data growth, knowledge graph, deployment) is simpler because there’s one database instead of two. Sometimes the best engineering decision is the one that deletes a box from the architecture diagram.

Vintage Vestige — a fashion knowledge graph connecting 500 years of design history. Built with Python, FastAPI, Supabase, pgvector, and React.