Shipping "For You" on Day One: A Three-Stage Cold-Start Playbook for Recommendation in Vibe Projects
No ML team? No problem: rule-based ranking ships on day one, collaborative filtering kicks in after a few hundred events, vector retrieval joins when content scales — a three-stage roadmap with copy-paste SQL and scripts, plus cold-start tactics and 8 pitfalls.

In your vibe project's first week, users open it and see nothing but a cold "Latest" list — that's the reality for 90% of solo projects. A "recommendation system" sounds like something only big tech can afford: ML teams, real-time feature engineering, massive data. But the truth is: recommendation systems are layered. You can ship a "For You" prototype on day one with a few dozen lines of SQL, add collaborative filtering once you have a few hundred behavioral events, and plug in vector retrieval when your content catalog grows — each stage has clear entry conditions, runnable code, and exit criteria. This guide gives you that three-stage roadmap: every SQL snippet and script below can be copied straight into your Postgres + cron project and run.
Four boundaries first: what this guide does NOT cover
VibeFix already has several guides that look similar, so let's draw the lines before you read on. The referral-program guide is about marketing-side growth — getting users to bring their friends in; this guide is about in-product personalization — what users see after they arrive. One handles "getting in the door," the other handles "after the door." Complementary, not overlapping. The launch-day guide is about product cold start — where your first users come from; this guide is about algorithmic cold start — what to do when data is sparse. One is an acquisition problem, the other a data problem. The search guide is about active retrieval — "I want to find"; this guide is about proactive distribution — "you might want to see." Search pulls, recommendation pushes. The four are orthogonal, each minding its own business.
1. Don't talk algorithms yet: three real shapes of recommendation and their ROI
The most common mistake solo developers make is asking "should I use collaborative filtering or deep learning?" first. Wrong question. Answer the business question first: what should your recommendation look like? In vibe projects, recommendation comes in exactly three real shapes:
- "For You" content feed: a personalized home feed. Typical: news sites, communities, inspiration libraries. Highest technical bar, strongest user perception.
- Related recommendations: "related content / you may also like" at the bottom of a detail page. Typical: blogs, tool directories, portfolios. Cheapest to build, highest ROI — the user already showed intent by clicking in, so three more picks convert naturally.
- Personalized home ranking: same home modules, reordered per user. Typical: template and asset libraries. Somewhere in between.
Decision table — pick your first shape by project type:
- Content / community (articles, posts, inspiration) → build the content feed first, add related recs later.
- Tool / portfolio (single tool pages, project showcases) → related recommendations alone are enough; a feed is over-engineering.
- Resource library (templates, assets, API directories) → related recs first, then personalized ranking once data arrives.
One line to remember: pick the shape before the technique. This guide's three-stage roadmap assumes the hardest shape — the content feed. If you only need related recs, stage one's tag matching or stage three's vector nearest-neighbors alone will do.
2. Data first: the minimal event set that makes recommendation possible
Without behavioral data, every algorithm is hot air. But don't let "big data" scare you: the minimal event set for recommendation is just four events, starting from a single table:
- impression: content appeared on the user's screen. This is the denominator — without impressions you can't compute CTR, and you can't tell "user dislikes it" from "user never saw it."
- click: the user clicked. The most basic positive signal.
- dwell: carries
duration_ms. Opening something and closing it in 3 seconds vs. reading for 5 minutes are two completely different signals. - favorite: a strong positive signal — one favorite outweighs ten clicks.
Every event must carry four fields: user_id, item_id, a server-side timestamp, and the impression position (you'll need it later for position-bias correction). Schema below — one Postgres table to start:
CREATE TABLE reco_events (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
event TEXT NOT NULL CHECK (event IN ('impression','click','dwell','favorite')),
duration_ms INT, -- only set for dwell events
position INT, -- list position at impression time (1-based)
surface TEXT NOT NULL, -- 'home_feed' | 'related' | 'search', ...
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_reco_events_user ON reco_events(user_id, created_at DESC);
CREATE INDEX idx_reco_events_item ON reco_events(item_id, event, created_at DESC);
CREATE INDEX idx_reco_events_time ON reco_events(created_at DESC);
Three instrumentation rules: ① freeze event names globally — casing and plurals decided once; ② user_id / item_id are never null — anonymous users get an anonymous ID (e.g. a UUID in localStorage), otherwise your "new users" can never enter collaborative filtering; ③ timestamps come from the server — client clocks drift and will scramble your ordering. Instrument first, algorithm later: this table is the single shared data source for all three stages, and later stages only add columns, never redesign it.
3. Stage one · rule-based recommendation: runs with zero users
Entry condition: none. Ships on day one, needs zero behavioral data. Rule-based recommendation = editor's picks + hot list + tag matching, blended with weights, pure SQL, recomputed by a daily cron into a materialized table that the online API just reads.
The weighting formula (memorize it — stages two and three only change how some terms are computed):
score = w1 × hotness + w2 × freshness + w3 × tag_match + w4 × editorial_boost
- Hotness: log-normalized clicks over the past 7 days,
ln(1 + clicks_7d) / ln(1 + max_clicks_7d). The log tames head blockbusters so the top three don't freeze forever. - Freshness:
exp(-age_hours / 72), decaying to ~37% after 72 hours. New content with no behavioral data survives on this term alone. - Tag match: Jaccard similarity between the user's historically clicked tags and the candidate's tags. For new users with no history this term is 0 and the formula gracefully degrades to "hotness + freshness."
- Editorial boost: a fixed bonus (e.g. +0.3) for hand-picked content — your "operator's hand," the most important lever during cold start.
Starting weights: w1=0.5, w2=0.3, w3=0.2, w4=0.3 (bonus term). During content cold start (lots of new content, little old content) raise w2 to 0.5; dial it back once the catalog stabilizes. Full SQL below, assuming a content table items(id, tags TEXT[], is_featured BOOL, published_at):
-- Runs daily via cron; results go into reco_rule_scores(item_id, score)
WITH heat AS (
SELECT item_id,
ln(1 + COUNT(*) FILTER (WHERE event = 'click')) AS h
FROM reco_events
WHERE created_at > now() - INTERVAL '7 days'
GROUP BY item_id
),
maxh AS (SELECT COALESCE(MAX(h), 1) AS m FROM heat),
user_tags AS (
-- tag set of the target user's clicks in the last 30 days (empty for new users)
SELECT array_agg(DISTINCT t) AS tags
FROM reco_events e, items i, unnest(i.tags) t
WHERE e.user_id = :uid AND e.event = 'click'
AND e.created_at > now() - INTERVAL '30 days'
AND e.item_id = i.id
)
INSERT INTO reco_rule_scores(item_id, score, computed_at)
SELECT i.id,
0.5 * COALESCE(h.h / maxh.m, 0)
+ 0.3 * exp(-EXTRACT(EPOCH FROM (now() - i.published_at)) / 3600 / 72)
+ 0.2 * COALESCE(
cardinality(i.tags & (SELECT tags FROM user_tags))::float
/ NULLIF(cardinality(i.tags | (SELECT tags FROM user_tags)), 0), 0)
+ CASE WHEN i.is_featured THEN 0.3 ELSE 0 END,
now()
FROM items i
LEFT JOIN heat h ON h.item_id = i.id
CROSS JOIN maxh
WHERE i.published_at > now() - INTERVAL '90 days' -- only content from last 90 days
ON CONFLICT (item_id) DO UPDATE SET score = EXCLUDED.score, computed_at = now();
The online API is one line: SELECT item_id FROM reco_rule_scores ORDER BY score DESC LIMIT 20, P95 well under 20ms. Exit criterion: when reco_events passes 100 distinct users and 500 event rows, move to stage two — but keep the rule list forever as the fallback for every failure mode (see section 9).
4. Stage two · collaborative filtering: once you have a few hundred behaviors
Entry condition: reco_events rows > 500 and distinct users > 100. The core idea in one line: "people who liked A also liked B, so show B to anyone who just viewed A." For a solo project, use item-based CF: it's more stable than user-based (item similarities change slowly) and it fits the "related recommendations" shape naturally.
Step one: turn implicit feedback into "ratings." No star ratings? Make your own: one click = 1 point, dwell over 30 seconds = 2 points, favorite = 5 points. Take the max per user-item pair and you have a user-item matrix. Step two: compute pairwise cosine similarity between items — the smaller the angle between two items' "who liked them" vectors, the more similar they are. Step three: compute offline and write back to the database — never compute this live per request; that's the fast lane to a melted primary DB (see pitfall ③ in section 10).
Below is an offline script you can run as-is (only dependencies: psycopg2 and numpy), scheduled daily via cron:
import numpy as np, psycopg2
conn = psycopg2.connect("dbname=yourdb")
cur = conn.cursor()
# 1. Pull 90 days of implicit feedback: click=1, dwell>30s=2, favorite=5; take max
cur.execute("""
SELECT user_id, item_id,
MAX(CASE WHEN event='favorite' THEN 5
WHEN event='dwell' AND duration_ms > 30000 THEN 2
WHEN event='click' THEN 1 ELSE 0 END) AS r
FROM reco_events
WHERE created_at > now() - INTERVAL '90 days'
GROUP BY user_id, item_id HAVING MAX(...) > 0
""")
rows = cur.fetchall()
users = {u: i for i, u in enumerate({r[0] for r in rows})}
items = {t: j for j, t in enumerate({r[1] for r in rows})}
M = np.zeros((len(users), len(items)))
for u, t, r in rows:
M[users[u], items[t]] = max(M[users[u], items[t]], r)
# 2. Item-item cosine similarity (normalize column vectors, then dot product)
col = M.T
norm = np.linalg.norm(col, axis=1, keepdims=True) + 1e-9
sim = (col / norm) @ (col / norm).T
# 3. Keep top-50 neighbors per item, write back to reco_item_sim(item_a, item_b, score)
cur.execute("TRUNCATE reco_item_sim")
inv = {j: t for t, j in items.items()}
batch = []
for j in range(len(items)):
top = np.argsort(-sim[j])[1:51] # skip self
for k in top:
if sim[j, k] > 0.05: # similarity threshold filters noise
batch.append((inv[j], inv[k], float(sim[j, k])))
cur.executemany(
"INSERT INTO reco_item_sim(item_a, item_b, score) VALUES (%s,%s,%s)", batch)
conn.commit()
print(f"users={len(users)} items={len(items)} pairs={len(batch)}")
Online recall logic in three lines of pseudocode: take the user's 10 most recent clicks → look up each item's top-20 neighbors in reco_item_sim → merge weighted by score × the user's rating of the source item, dedupe, take topK. Two details matter: ① use only 90 days of data — mixing ancient and fresh behavior at equal weight is pitfall ②; ② drop similarities below 0.05, or long-tail noise will drag recommendations off course. Exit criterion: item count passes 2,000, or your tag taxonomy starts collapsing (new tags appearing faster than you can curate, Jaccard going nowhere) — time for stage three.
5. Stage three · vector retrieval: when content piles up
Entry condition: item count > 2,000, or the CF item-similarity matrix has gone too sparse to be useful. Vector retrieval closes the semantic gap: a user reads "making podcast covers with AI," tag matching only finds items tagged "podcast," while vectors also find "AI-generated album art" — different tags, similar meaning.
The minimal pipeline is four steps, and all of it runs inside your existing Postgres — no separate vector database needed:
- ① Generate embeddings: concatenate title + summary into text, run an embedding model to get a vector. Trigger on publish for new items; backfill old ones weekly.
- ② Store with pgvector: install the
pgvectorextension; vectors live in a column on your content table. - ③ Build an ANN index: HNSW index for approximate nearest-neighbor search — millisecond responses at tens of thousands of items.
- ④ Retrieve online: one SQL query finds the 50 most similar items to a user-profile vector (the average of vectors the user consumed) or a single item vector.
-- 1. Install extension, add column (one-time)
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE items ADD COLUMN embedding vector(1024);
-- 2. HNSW index: m=16, ef_construction=64 is the sweet spot at ~10k items
CREATE INDEX idx_items_embedding
ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 3. Online retrieval: 50 items closest to the user-profile vector
-- (<=> is cosine distance)
SELECT id, title
FROM items
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1
LIMIT 50;
The cost breakdown (the part solo developers care about most): pick bge-m3 (1024 dims, open-source, runs locally) and marginal cost is zero — a 2-core cloud VM embeds hundreds of articles per minute. Prefer not to self-host? OpenAI text-embedding-3-small costs about $0.02 / 1M tokens — a 500-word article is ~700 tokens, so vectorizing 10,000 articles one-time costs ~$0.14, with pennies per day of increments after that. pgvector lives in the Postgres you already run: zero new infrastructure. Don't get greedy on dimensions: 1024 is plenty; 4096 quadruples storage and slows retrieval for under 1% quality gain (pitfall ①, section 10). The user-profile vector = weighted average of vectors the user clicked in the last 30 days (favorite ×5, long dwell ×2, click ×1), recomputed weekly offline into a reco_user_vec table — online serving is then a single vector lookup.
The three stages don't replace each other — they stack: final online strategy = 50 vector candidates + 30 CF candidates + 20 rule-list candidates → dedupe → rerank with the stage-one weighting formula (same formula, bigger candidate pool). Figure 1 shows the full pipeline:

Figure 1: Three-stage recommendation architecture. One reco_events table feeds three offline compute paths; cron jobs write results back to Redis / materialized tables; the online service only reads cache (P95 < 50ms), and the kill switch takes over if offline jobs fail.
6. The cold-start trilemma: a solution matrix
Cold start in recommendation isn't one problem — it's three, each with different solutions. New users (no behavioral history), new items (zero impressions, zero clicks), new systems (no data site-wide) — three tactics each, meant to be combined:
- New user × tactic 1 "hot-list fallback": put the site-wide hot list on top so the first screen is never empty. It's the least fancy and most effective move: nobody churns over "not personalized enough," but they do churn over "the home page is empty."
- New user × tactic 2 "interest quiz": ask first-time visitors to pick 3 tags — one click and you have a profile. Skip the 20-question survey; 3 tags + a skip button is what keeps conversion alive.
- New user × tactic 3 "seed trigger": the moment a user finishes their first article, follow up immediately with "similar to this one" (CF / vector). From the second impression on, recommendations are already personalized.
- New item × tactic 1 "content profile": title + tags → embedding at publish time, so zero-behavior items can still be retrieved by vector search. This is vector retrieval's biggest structural contribution to cold start.
- New item × tactic 2 "newcomer protection window": 2× impression boost for the first 24–72 hours, then decay. Implementation: in the rule formula, add
+0.2 × (1 - age_hours/72)for content younger than 72 hours. Without it, new content can never beat old blockbusters. - New item × tactic 3 "editorial slot": a hand-curated slot with forced exposure. During cold start, your operator's hand beats any algorithm.
- New system × tactic 1 "rules first": ship only rule-based recommendation on day one — it runs on zero data. This is the entire reason stage one exists.
- New system × tactic 2 "log backfill": if you have old access logs (Nginx logs, legacy analytics), parse them offline into
reco_events— free behavioral data. - New system × tactic 3 "operational fallback": during cold start, let a hand-edited featured stream stand in for the algorithm. Make the home page look good first, make it smart second.

Figure 2: Cold-start trilemma solution matrix. The three stack — a new user on a brand-new system looking at new items gets "rule list + interest quiz + newcomer protection window."
7. Evaluation: offline hit-rate@K plus online CTR/dwell
A recommendation system without evaluation is driving blind. You need two metric sets: offline (for tuning) and online (for business), and neither is optional.
Offline metric: hit-rate@K. The procedure is called "leave-one-out": for each user, hide their last click, recommend from their earlier history, and check whether the hidden item appears in the topK. Hit rate = users with a hit / total users. Set K to your first-screen count (e.g. 10). Run weekly — 20 lines of script:
-- hit-rate@10 via leave-one-out (Postgres, runs offline)
WITH last_click AS (
SELECT DISTINCT ON (user_id) user_id, item_id AS held_out
FROM reco_events WHERE event = 'click'
ORDER BY user_id, created_at DESC
),
hist AS ( -- history before the hidden last click
SELECT e.user_id, e.item_id
FROM reco_events e JOIN last_click l
ON e.user_id = l.user_id AND e.event = 'click'
AND e.created_at < (SELECT MAX(created_at) FROM reco_events
WHERE user_id = l.user_id AND item_id = l.held_out)
),
-- top-10 recommendations from "neighbors of historically clicked items"
-- (reuses reco_item_sim)
rec AS (
SELECT h.user_id, s.item_b AS item_id,
ROW_NUMBER() OVER (PARTITION BY h.user_id ORDER BY s.score DESC) AS rn
FROM hist h JOIN reco_item_sim s ON s.item_a = h.item_id
)
SELECT COUNT(*) FILTER (WHERE r.item_id = l.held_out)::float / COUNT(*)
AS hit_rate_at_10
FROM last_click l LEFT JOIN rec r
ON r.user_id = l.user_id AND r.rn <= 10;
Online metrics: CTR + average dwell time — both, always. CTR = clicks / impressions; optimizing CTR alone breeds clickbait (users click and bounce); optimizing dwell alone breeds bloated rambling. Both rising together means real improvement. CTR must use impression attribution: when a click happens, look back at that content's most recent impression to recover its position and surface — otherwise you can't separate "good recommendation" from "good placement."
Minimal A/B bucketing: hash user_id into buckets — one line of code; the experiment group gets the new strategy, control keeps the old. For bucketing theory and significance testing, VibeFix's A/B testing guide (ab-testing-experiment) covers it thoroughly, so no repeat here — just the minimal code:
import hashlib
def bucket(user_id: str, n: int = 100) -> int:
return int(hashlib.md5(user_id.encode()).hexdigest(), 16) % n
# bucket < 50 → control (rule-based); bucket >= 50 → experiment (new strategy)
# Compare CTR grouped by surface + bucket; wait a full 7 days before concluding
The rollout ritual is fixed: offline hit-rate@K improves first → 10% traffic for 7 days → online CTR/dwell both confirm → full rollout. Skipping any step is gambling.
8. Anti-gaming and diversity: don't let recommendation become an echo machine
In the first month after launch you'll meet two enemies: people gaming the rankings, and the algorithm narrowing itself into a rut.
Three anti-gaming moves: ① one user_id clicking the same author 5+ times in 10 minutes → halve that author's content weight (a penalty coefficient in the SQL is enough); ② behavior from accounts younger than 24 hours counts toward personal recommendation but not toward the hot list — gamers farm the hot list, not you; ③ clicks with an impression-to-click gap under 1 second count as mis-taps, not positive samples. These three stop 95% of amateur gaming. Facing professional fraud farms is the territory of VibeFix's fraud-prevention guide — device fingerprints and friends are out of scope here.
Three diversity moves: ① novelty penalty: content shown 3 times to the same user with no click disappears for 7 days — count impressions per user-item in reco_events. ② tag quota: no single tag takes more than 40% of the top 20, forcing room for the long tail. ③ author dedup: at most 3 items per author in the top 20. Remember: the goal of recommendation isn't "guess most accurately," it's "make the user want to open the app tomorrow." A home page of nothing but lookalike blockbusters may lift CTR 10% short-term while 30-day retention bleeds — diversity is retention insurance.
9. Kill switch: when recommendation dies, the home page must not
Recommendation is a "nice to have" feature — its failure severity must never exceed the home page's. Three lines of defense, tiny code, none optional:
Defense one: timeout circuit breaker. Recommendation API timeout at 300ms; on timeout, serve the rule list. Users would rather see a "slightly less personalized" home page than a spinner.
Defense two: empty-result fallback. Whenever any recall path returns empty (new user, vector table not built yet, cron died), degrade to the rule list automatically. The rule list is the site's last line of defense — it only needs the reco_rule_scores table, which exists from day one.
Defense three: a manual master switch. One env var / config flag flips the whole site back to pure rule-based ranking. When a midnight cron corrupts data, you flip the switch and sleep — no code deploy needed.
import os, time
RECO_TIMEOUT_MS = 300
# Defense three: master switch — RECO_MODE=rule forces rule-based site-wide
FORCE_RULE = os.getenv("RECO_MODE", "smart") == "rule"
def get_home_feed(user_id: str, limit: int = 20):
if not FORCE_RULE:
try:
start = time.time()
recs = smart_recall(user_id, limit) # vector + CF + rules merged
cost_ms = (time.time() - start) * 1000
# Defense one: timeout breaker; defense two: empty-result fallback
if recs and cost_ms < RECO_TIMEOUT_MS:
return recs
except Exception as e:
log_error("reco_failed", e) # log + alert, never throw to the user
# Fallback: the rule list always has content
return rule_rank(user_id, limit)
# After 5 consecutive smart_recall failures → auto-flip RECO_MODE to rule + alert.
# Done by a cron health check (count failures in a reco_health table); code omitted.
Remember the order: write the fallback before the recommendation. The kill switch is line zero of a recommendation system.
10. Eight pitfalls: tuition paid by those before you
- Pitfall ① wrong embedding dimensions: jumping straight to 4096-dim vectors quadruples pgvector storage and slows HNSW retrieval for under 1% quality gain. At ~10k items, 1024 dims (bge-m3) is the sweet spot; consider 1536 only past 100k.
- Pitfall ② mixing hot and cold data: weighting a click from 3 years ago equally with yesterday's — user interests moved on long ago. Put a 90-day window on every offline job; hot-list hotness looks at 7 days only.
- Pitfall ③ real-time updates melting the primary DB: recomputing similarities on every click pegs the primary's CPU. Offline compute + cron write-back is the iron law for online systems; "real-time" at most means "near-real-time" (one batch every 5 minutes).
- Pitfall ④ building an unexplainable black box: store a
reasonfield per recommendation ("because you read X" / "trending this week"). When users ask "why am I seeing this" and you can't answer, trust evaporates; without reasons, you can't debug either. - Pitfall ⑤ ignoring position bias: the #1 slot's CTR is naturally 3× the #10 slot's — not because it's better, but because of placement. Normalize by impression position (clicks / expected CTR at that position), or your "hot list" is just parroting the layout.
- Pitfall ⑥ recommending only the popular: a pure-hotness home page becomes a hot-list echo chamber within 3 days. Hotness weight above 0.7 is the danger signal — counter with freshness and diversity quotas.
- Pitfall ⑦ CF's cold items never getting a chance: zero-behavior items have all-zero similarities and never enter recall. Pair with section 6's newcomer protection window + content profiling (vectors) so new items get their first exposures.
- Pitfall ⑧ shipping without evaluation: launching an "optimized" version with no hit-rate@K baseline means every change is judged by gut feel. Run two weeks of baseline first, then optimize — that's what section 7's evaluation scripts are for.
One last action checklist, in order: ① today — create the reco_events table and ship the four-event instrumentation; ② this week — rule-based SQL + cron + kill switch, and "For You" appears on the home page; ③ after 500 behavioral events — CF script live, A/B for 7 days; ④ past 2,000 items — plug in pgvector vector retrieval. A recommendation system isn't a sprint, it's a three-leg relay — and on the day each leg ships, your users will feel the site get smarter.
Comments (0)
Related articles

Backups are the last line of defense; the trash bin is the first. This hands-on guide covers the three delete semantics, deleted_at data modeling, partial unique indexes, Prisma query-layer guards, tiered trash-bin retention, GDPR/PIPL erasure reconciliation, and an 8-item launch checklist.

Backups answer whether data is lost; disaster recovery answers how much comes back and how fast. This hands-on guide covers RPO/RTO targets, a five-scenario failure matrix, copy-pasteable PITR setups for Postgres, Supabase, Neon and SQLite, automated weekly restore drills, a one-page runbook template, and degradation switches.

User emails and login tokens sitting in your console.log? This guide gives solo teams a complete log-redaction playbook shippable in a day: a four-tier PII classification table, copy-paste TypeScript redaction trio, minimal console.log-to-pino migration, request-logging middleware templates, Sentry beforeSend config, tiered retention and cost math — plus 8 real-world pitfalls.