Skip to content
Work

Vectro CRM

2026-09-17

Next.jsNestJSFastAPIChromaDBPostgreSQL

Live demo: vectro.luisv.dev (one click, no signup).

The problem

CRM records are written in prose and searched by keyword. An account manager types a call note saying a client "isn't sure the numbers justify another year", and three months later nobody can find it, because the word nobody wrote down is "churn".

The context that matters in a CRM lives in free text (call notes, emails, meeting summaries) and keyword search only finds it if you remember the exact phrasing someone used. That's backwards. You remember what a conversation was about long after you've forgotten how it was worded.

Semantic search inverts that: you describe the meaning, and the system finds the records that match it. I chose this domain deliberately, because I wanted to understand embeddings and vector search properly and a CRM is a place where they earn their keep rather than decorate a demo.

What it does

Search "customer worried about renewal" and it surfaces the call notes where someone hedged about their contract; no shared vocabulary required. A ⌘K command palette queries deals, contacts and activities at once and ranks results across all three.

Around that sits a real product rather than a search harness: a Kanban deal pipeline with drag-and-drop, soft deletes, automatic activity logging when a deal changes stage, and role-based auth.

Architecture

A pnpm monorepo with three services and a database:

  • Next.js 16 frontend (App Router, server components, Clerk middleware)
  • NestJS 11 REST API: the only thing that talks to the database
  • Python FastAPI vector service: owns embedding and similarity search
  • PostgreSQL via Prisma

The vector service is separate for a reason: embedding is CPU-bound Python work with a completely different dependency tree and failure mode from the API. Keeping it behind an HTTP boundary means the API stays a thin, fast Node service, and the model can be swapped without touching business logic.

Indexing is deliberately simple. Each entity is flattened into a short string (a contact becomes name title email, an activity becomes type title description), embedded with all-MiniLM-L6-v2 into a 384-dimensional vector, and stored in ChromaDB with one collection per entity type. Writes trigger a fire-and-forget re-embed, so search never blocks a user's request and an embedding failure never fails their save.

Constraints

  • Solo build, part-time. Every piece of operational complexity is complexity I maintain alone.
  • No GPU, no inference budget. Embeddings run on CPU or not at all.
  • €4.88/month of hardware. A 4GB, 2-core VPS runs all four services.
  • Public and unattended. Anonymous visitors get write access, so the demo has to survive strangers and repair itself without me watching.

Tradeoffs

A small model on CPU, not a hosted embedding API. all-MiniLM-L6-v2 is six layers and 384 dimensions, far from the best model available. But it runs locally in ~35ms, costs nothing per query, and adds no external dependency that can rate-limit or disappear. For search over hundreds of records, retrieval quality was never the bottleneck; cost and operational independence mattered more.

Return nothing rather than noise. Results scoring below 0.3 cosine similarity are dropped. That means a query with no genuine match shows an empty state instead of the three least-irrelevant records. Cosine similarity always returns a ranking, so without a floor a search engine is never allowed to say "I don't know", and confidently wrong results erode trust faster than an honest blank.

Postgres on the same box, not managed. With 4GB there was room, and it removes an external dependency, a free-tier's terms, and the cold-start latency a managed instance adds to the first query a visitor makes. The cost is that backups are my problem, mitigated because all content is generated from a deterministic seed, so recovery is re-running it.

Keeping PyTorch when I no longer had to. On a smaller box I'd have swapped torch and sentence-transformers for ONNX runtime: same model, roughly a quarter of the image and half the memory. The 4GB tier made that unnecessary, so I didn't do it. The optimisation is real and stays available if the constraint ever returns; doing it anyway would have been work spent on a problem I don't have.

Shared demo data with an hourly reset, not per-visitor isolation. Every visitor edits the same records. Isolation would mean tenant scoping through the schema and every query, a multi-tenancy project wearing a demo's clothes. Instead the database is regenerated hourly from a fixed seed, so vandalism has a one-hour half-life and the demo repairs itself.

Sign-in tokens instead of published credentials. The demo has no password anywhere in the client. A server action mints a single-use, 60-second Clerk sign-in token; the browser redeems it. Nothing to scrape, nothing to rotate, and it sidesteps the factor chain entirely.

Auth enforced twice. Clerk middleware gates pages at the Next.js edge; a global NestJS guard verifies the JWT on every API request independently. The first is user experience, the second is security. Conflating them is how APIs end up trusting a frontend that anyone can bypass. Destructive routes additionally require an ADMIN role, and a test walks the routing table on every run to assert that the only publicly reachable endpoint in the application is the health check.

What it measurably changed

These are engineering measurements from a personal project, not business outcomes; it has no production users to report on.

MeasureResult
Query latency35ms p50, 44ms p95 (50 measured requests after 5 warmup, on 2 CPU cores)
Corpus170 entities across 3 collections (50 contacts, 20 deals, 100 activities)
Embeddings384 dimensions, CPU-only, no GPU anywhere in the stack
MemoryWhole stack (4 services plus Postgres) inside ~1.1GB
Hosting€4.88/month, single VPS, TLS via Caddy
RecoveryFully self-restoring: hourly reseed, re-embed and demo-account reset

The latency figure is worth being precise about. With 170 documents the vector lookup is microseconds: cosine similarity over 170 × 384 floats is nothing. Essentially all 35ms is one forward pass through MiniLM to embed the query. It measures embedding cost, not vector database performance, and corpus size barely moves it. Making it faster means a smaller model or quantisation, not a different store.

Write-up

Getting it onto a public URL surfaced three things that had been true when I wrote them and had quietly stopped being true since, including a semantic search that returned zero results for every query, for a reason I think is worth reading: Three assumptions that expired before I shipped.