Perfumind
An AI fragrance discovery platform I built solo, end to end: a Next.js web app, a FastAPI/LangChain backend and Supabase, with LLM-orchestrated research and recommendations across a catalogue of 4,000+ perfumes.
- perfumes catalogued
- 4,000+
- steps in the AI discovery pipeline
- 9
- VPS running the whole stack
- 1
- design, build and operations
- Solo

If you love fragrance, your collection tends to live in your head, scattered across screenshots and forum bookmarks. Finding something similar to a bottle you adore means opening a dozen tabs. I built Perfumind to fix that for myself, and it grew into a full product. It is a fragrance database that researches and verifies itself, a recommendation engine that shows its reasoning, and a personal wardrobe and diary.
It is a solo project: I own the product, the architecture, the code, the infrastructure and the on-call. I use AI-assisted development (Claude Code) to move faster, while I review every change and decide the architecture myself.
The architecture
Everything runs on a single VPS behind Cloudflare, deployed as Docker containers through Coolify and Traefik. The browser only ever talks to the web app and to Supabase. The AI backend is never exposed directly.
- Browser / PWALanding site (Astro) and web app
- Next.js 16 web appPages, server actions, tag-based cache, agent proxy
- FastAPI AI backendLangChain pipelines, agent over SSE, background jobs
- SupabasePostgres + RLS, pgvector, Auth, Storage, Realtime
A few decisions shaped the rest of the system:
- Everything goes through the web app. Server actions attach the user’s Supabase JWT, an internal proxy secret and the real client IP. The backend rate-limits per user from the JWT, so many users behind one container IP don’t share a bucket.
- Two ways data gets fresh. Catalogue pages are cached by tag in Next.js. When the backend saves a perfume, it calls
/api/revalidateto expire those tags. Per-user updates, like a finished discovery, arrive over Supabase Realtime. - Row-level security by default. The web app queries Postgres as the signed-in user, so RLS applies everywhere. Only the backend writes catalogue data with the service role.
A database that researches itself
When someone asks for a perfume the catalogue doesn’t have, the backend researches it on the web. It turns the research into a structured record, checks every claim against its sources and saves it. The request returns immediately with a scan_id. The work runs in the background, and the user hears back through the notification bell.

The pipeline is a LangChain (LCEL) composition. Each step is a named RunnableLambda, so every run appears in LangSmith as a readable trace, one span per step:
_GENERATION_PIPELINE = (
RunnableLambda(_extract_metadata, name="Extract Metadata")
| RunnableLambda(_cache_branch, name="Cache Branch")
| RunnableParallel(
state=RunnableLambda(lambda s: s, name="Passthrough"),
db=RunnableLambda(_fetch_db_context, name="Fetch DB Context"),
web=RunnableLambda(_research, name="Web Research"),
)
| RunnableLambda(_merge_context_and_search, name="Merge Context & Search")
| RunnableLambda(_llm_analysis, name="LLM Analysis")
| RunnableLambda(_evidence_gate, name="Evidence Gate")
| RunnableLambda(_post_process_names, name="Post-Process Names")
| RunnableLambda(_persist_to_db, name="Persist to DB")
| RunnableLambda(_finalize_scan, name="Finalize Scan")
)
Cheap first, then escalate. A single cited web answer runs for every discovery. A heavier multi-step research agent runs only when the first analysis comes back thin: no notes for a layer, a flat pyramid, or a missing release year or price. It gets one retry, and a usable first result is kept if the retry fails.
Evidence beats confidence
LLMs are confident about everything, so confidence alone can’t be the bar. The analysis step must return, for each factual field, a confidence score and the source snippet it relied on. The evidence gate then resets anything that isn’t grounded to “unknown”. It also catches a quieter failure: a real snippet that comes from a different concentration’s listing, such as the EDT page used for the parfum.
def _is_supported(fe, min_confidence=_EVIDENCE_MIN_CONFIDENCE) -> bool:
"""True when a FieldEvidence entry grounds its claim: enough confidence AND a
supporting snippet. Missing evidence counts as supported: fail open rather
than destroy data we can't assess."""
if fe is None:
return True
return fe.confidence >= min_confidence and bool((fe.source_snippet or "").strip())
def _unsupported_reason(fe, search_results, requested_concentration, min_confidence=...):
if fe is None:
return None
source = find_source_result(fe.source_snippet, search_results)
if source is not None and concentration_mismatch(source, requested_concentration):
return "sourced from a different concentration's listing"
if not _is_supported(fe, min_confidence):
return "weak or unsupported evidence"
return None
One guessed release year never sinks an otherwise solid record. It just becomes unknown. A name check after the gate rejects research that came back describing a sibling perfume (for example “Matin” data for a “Soir” request) instead of storing the wrong notes.

Recommendations that explain themselves
I didn’t want a vague “you might also like”. Every match is scored on shared accords and notes, with embedding similarity as a smaller signal. Users see an honest match percentage, and the AI then explains the tradeoffs: what’s closer, what’s cheaper, and what you’d give up.
def _dice(a: set[str], b: set[str]) -> float:
if not a or not b:
return 0.0
return (2 * len(a & b)) / (len(a) + len(b))
def _combined(accord_sim: float, note_sim: float, emb_centered: float) -> float:
emb_norm = _clamp01(emb_centered / 0.4) # centered cosine tops out ~0.4
return 0.4 * accord_sim + 0.45 * note_sim + 0.15 * emb_norm
Weighting the explainable signals (shared notes and accords) above the opaque one (embeddings) keeps the score something a user can check against the note pyramid.

Mapping the whole catalogue
Each perfume version is embedded with text-embedding-3-large and stored in pgvector. To power the visual Explore map, a background job projects the whole catalogue down to 2D with UMAP, so similar scents cluster together on screen:
embeddings = np.array(processed_embeddings)
reducer = umap.UMAP(n_neighbors=min(len(versions) - 1, 15), min_dist=0.1, n_components=2)
coords = reducer.fit_transform(embeddings)
for i, v in enumerate(versions):
await supabase.table("perfume_versions").update({
"x_coords": float(coords[i, 0]),
"y_coords": float(coords[i, 1]),
}).eq("id", v["id"]).execute()
One lesson came from this job: an unpaginated query silently stopped at PostgREST’s 1,000-row cap, so UMAP was fitted on only part of the catalogue. All bulk reads now go through a paginating helper. The embedding text format is also versioned per row, because mixing two formats in one vector space quietly degrades similarity search.
A scent isn’t a snapshot
Most fragrance sites show a static note list. Perfumind plays each perfume as a timeline: top, heart and base notes rise, peak and fade on their own curves, shaped by the fragrance’s real longevity and sillage.

The curves are plain math on the client. Smoothsteps fade each phase in and out, with tails stretched by sillage and base decay slowed by longevity:
export function getPhaseWeights(time: number, settings: TimelineSettings, evolution: EvolutionModel) {
const { topEnd, middleEnd, baseEnd } = settings;
const heartLen = middleEnd - topEnd;
const baseLen = baseEnd - middleEnd;
// Top: present immediately, decays from mid-top phase into the heart.
const top = 1 - smoothstep(topEnd * 0.4, topEnd + heartLen * evolution.topTailFrac, time);
// Heart: eases in across the top phase, lingers a while into the base.
const middle =
smoothstep(0, topEnd, time) *
(1 - smoothstep(topEnd + heartLen * 0.6, middleEnd + baseLen * evolution.heartTailFrac, time));
// Base: eases in across the heart phase, then gradually wears off.
const base = smoothstep(topEnd, middleEnd, time) * (1 - evolution.baseDecay * smoothstep(middleEnd, baseEnd, time));
return { top, middle, base };
}
The Perfumind Assistant
The assistant is an AI agent that can query the catalogue and the user’s own data to answer questions. It is a tool-calling chat (up to 5 steps) with tools for the catalogue, the user’s wardrobe, wishlist and diary, and web search, streamed over SSE. Ask “what should I wear to a summer wedding from my collection?” and it looks at what you own before it answers.
Answers aren’t just text. Tool results are rendered as interactive UI widgets, such as perfume cards, comparisons and wardrobe picks, so you can open a perfume or act on a suggestion straight from the chat.

Camera search, authenticity checks and a diary
The same backend powers a set of smaller AI features. Each is a single structured LLM call, billed per call:
- Camera search. Snap a bottle: if it’s catalogued you land on its page. If not, the label is read and handed to AI discovery, pre-filled.
- Authenticity scanner. Upload photos and the model scrutinises glass, fonts, atomiser and batch codes, then flags anything that looks off.

Running it as a business
- Billing follows real cost. Token and tool-call usage from every step is summed into a cost log. Only completed runs send a usage event to Polar, which meters credits against Free, Plus and Pro subscriptions. Failed or rejected discoveries are never billed.
- Observability. PostHog handles consent-gated product analytics and error tracking on both the web app and the backend. Grafana Cloud (via Alloy) collects host and container metrics and logs. LangSmith traces every pipeline run, with API keys stripped from the traced state.
- Shipping. Supabase migrations, GitHub Actions workflows and Docker images deploy through Coolify to the VPS. The web app is bilingual (English and Turkish) and installable as a PWA.
What I took away
Building Perfumind alone forced me to own every layer, from prompt design and evidence scoring to Postgres row-level security, cache invalidation, billing webhooks and production monitoring. The biggest lesson: treat LLM output as untrusted input. Make the model show its sources, gate on evidence, and design the pipeline so a partial answer degrades gracefully instead of corrupting the database.