# How to Build a Recommendations Engine with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a recommendations engine in Lovable using pgvector for item embeddings and collaborative filtering via Supabase. OpenAI generates vector embeddings for each item, stored in a pgvector column. A nightly pg_cron job regenerates user preference vectors from interaction history, and a For You feed queries the nearest neighbors using cosine similarity — no external ML infrastructure needed.

## Before you start

- Lovable Pro account for Edge Function and complex query generation
- Supabase project with pgvector extension enabled (Dashboard → Extensions → vector)
- Supabase Pro plan recommended for pg_cron (used for nightly regeneration)
- OpenAI API key saved to Cloud tab → Secrets as OPENAI_API_KEY
- Supabase service role key saved as SUPABASE_SERVICE_ROLE_KEY

## Step-by-step guide

### 1. Enable pgvector and set up the recommendations schema

Prompt Lovable to create the database schema with the pgvector extension, items table with embedding column, interaction tracking, and user preference vectors. The HNSW index is critical for fast similarity search at scale.

```
Set up a recommendations engine database in Supabase:

First, enable the vector extension: CREATE EXTENSION IF NOT EXISTS vector;

Tables:
- items: id, title, description, category (text), tags (text array), image_url, metadata (jsonb), embedding vector(1536), embedded_at (timestamptz), created_at
- user_interactions: id, user_id, item_id, interaction_type (view|like|save|purchase|skip), weight (float: view=0.1, like=0.5, save=0.7, purchase=1.0, skip=-0.2), created_at
- user_preferences: id (references auth.users), preference_vector vector(1536), vector_updated_at, interaction_count (int)
- recommendation_logs: id, user_id, item_id, algorithm (content_based|collaborative|hybrid), similarity_score (float), shown_at, clicked (bool default false)

RLS:
- items: public SELECT. Service role for INSERT/UPDATE.
- user_interactions: users can INSERT/SELECT their own rows (user_id = auth.uid())
- user_preferences: users can SELECT their own row. Service role for INSERT/UPDATE.
- recommendation_logs: users can INSERT their own logs. Service role for SELECT.

Indexes:
- CREATE INDEX idx_items_embedding ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
- CREATE INDEX idx_interactions_user ON user_interactions(user_id, interaction_type, created_at DESC);
- CREATE INDEX idx_user_preferences ON user_preferences USING hnsw (preference_vector vector_cosine_ops);

Create a Supabase RPC function get_similar_items(p_item_id uuid, p_limit int) that returns items ordered by embedding <=> (SELECT embedding FROM items WHERE id = p_item_id) LIMIT p_limit, excluding the input item itself.

Create a RPC function get_recommendations_for_user(p_user_id uuid, p_limit int) that returns items ordered by embedding <=> (SELECT preference_vector FROM user_preferences WHERE id = p_user_id) LIMIT p_limit.
```

> Pro tip: Start with the HNSW index (ef_construction=64, m=16) rather than IVFFlat. HNSW is faster for queries and doesn't require a pre-training VACUUM step — it builds incrementally as you insert vectors.

**Expected result:** pgvector extension is enabled. All tables are created with correct RLS. HNSW indexes are in place. The two RPC functions are ready. TypeScript types are generated.

### 2. Build the embedding generation Edge Function

Create a Supabase Edge Function that accepts an item ID (or a batch of IDs), generates OpenAI text embeddings from the item's title and description, and updates the embedding column. This is called when new items are inserted.

```
// supabase/functions/generate-embeddings/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  // Fetch items without embeddings (or specific IDs from request body)
  const body = await req.json().catch(() => ({}))
  let query = supabase
    .from('items')
    .select('id, title, description, tags')
    .limit(body.limit ?? 50)

  if (body.item_ids?.length) {
    query = query.in('id', body.item_ids)
  } else {
    query = query.is('embedding', null)
  }

  const { data: items, error } = await query
  if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders })

  const processed: string[] = []
  const failed: string[] = []

  for (const item of items ?? []) {
    try {
      const inputText = [
        item.title,
        item.description,
        (item.tags ?? []).join(' '),
      ].filter(Boolean).join('. ')

      const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: 'text-embedding-3-small',
          input: inputText,
        }),
      })

      const embData = await embeddingRes.json()
      const vector = embData.data?.[0]?.embedding

      if (!vector) throw new Error('No embedding returned')

      await supabase
        .from('items')
        .update({ embedding: vector, embedded_at: new Date().toISOString() })
        .eq('id', item.id)

      processed.push(item.id)
    } catch {
      failed.push(item.id)
    }
  }

  return new Response(
    JSON.stringify({ processed: processed.length, failed: failed.length }),
    { headers: corsHeaders }
  )
})
```

> Pro tip: Process items in batches of 50 and add a delay between batches to stay under OpenAI's rate limits. The text-embedding-3-small model supports up to 8,191 tokens of input — more than enough for a title + description.

**Expected result:** The Edge Function processes items without embeddings and updates their vector columns. Calling it with item_ids regenerates specific item embeddings. Processed and failed counts are returned.

### 3. Build the user preference vector computation

Create the Edge Function that computes user preference vectors from interaction history. It averages the embeddings of positively-interacted items with recency weighting and stores the result in user_preferences.

```
// supabase/functions/update-user-preferences/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = { 'Content-Type': 'application/json' }

serve(async (req: Request) => {
  const body = await req.json().catch(() => ({}))
  // Process a specific user or all users with recent interactions
  const userId = body.user_id as string | undefined

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  // Get users to update
  let userIds: string[] = []
  if (userId) {
    userIds = [userId]
  } else {
    const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
    const { data } = await supabase
      .from('user_interactions')
      .select('user_id')
      .gte('created_at', since)
      .not('interaction_type', 'eq', 'skip')
    userIds = [...new Set((data ?? []).map(r => r.user_id))]
  }

  const results = { updated: 0, skipped: 0 }

  for (const uid of userIds) {
    const { data: interactions } = await supabase
      .from('user_interactions')
      .select('weight, created_at, items(embedding)')
      .eq('user_id', uid)
      .gt('weight', 0)
      .not('items.embedding', 'is', null)
      .order('created_at', { ascending: false })
      .limit(100)

    if (!interactions?.length) { results.skipped++; continue }

    // Weighted average with recency decay
    const now = Date.now()
    const VECTOR_DIM = 1536
    const sumVector = new Array(VECTOR_DIM).fill(0)
    let totalWeight = 0

    for (const interaction of interactions) {
      const embedding = (interaction.items as any)?.embedding as number[] | null
      if (!embedding) continue

      const ageMs = now - new Date(interaction.created_at).getTime()
      const ageDays = ageMs / (1000 * 60 * 60 * 24)
      // Exponential decay: interactions from 30 days ago get half the weight
      const recencyWeight = Math.exp(-ageDays * Math.log(2) / 30)
      const totalW = interaction.weight * recencyWeight

      for (let i = 0; i < VECTOR_DIM; i++) {
        sumVector[i] += embedding[i] * totalW
      }
      totalWeight += totalW
    }

    if (totalWeight === 0) { results.skipped++; continue }

    const preferenceVector = sumVector.map(v => v / totalWeight)

    await supabase.from('user_preferences').upsert({
      id: uid,
      preference_vector: preferenceVector,
      vector_updated_at: new Date().toISOString(),
      interaction_count: interactions.length,
    }, { onConflict: 'id' })

    results.updated++
  }

  return new Response(JSON.stringify(results), { headers: corsHeaders })
})
```

**Expected result:** The Edge Function computes weighted preference vectors for users with recent interactions and upserts to user_preferences. Running it manually for a test user populates their preference vector.

### 4. Build the For You feed and related items UI

Create the main recommendations feed using the RPC functions built in step 1. Show a For You card grid with similarity scores, and add a related items section to each item detail page.

```
Build two pages:

1. For You feed at src/pages/ForYouFeed.tsx:
- Call the get_recommendations_for_user(user.id, 20) Supabase RPC function
- Render results as a responsive grid of Cards (2 cols mobile, 3 cols tablet, 4 cols desktop)
- Each Card shows: item image, title, category Badge, a 'Match' Badge showing the similarity score as a percentage (e.g. similarity score 0.89 = '89% Match')
- Below the match badge, show a reason label based on the item category and user's top interaction category (e.g. 'Because you liked: [category]')
- Add a Skeleton grid that shows while loading
- Add an 'Explore' Tab alongside 'For You' that shows random items from categories the user has not interacted with
- Log a recommendation_logs INSERT whenever an item Card is shown (shown_at = now, algorithm = 'collaborative')
- Log clicked = true when a Card is clicked

2. Related items section for item detail pages:
- After the main item content, add a 'Similar Items' section
- Call get_similar_items(item.id, 6) and render as a horizontal ScrollArea of Cards
- Each card shows image, title, and a 'Similarity: X%' muted text
- Show 'No similar items yet' if embeddings have not been generated for this item yet
```

**Expected result:** The For You feed shows personalized recommendations from the user's preference vector. The related items section shows content-based neighbors. Impression and click logs populate recommendation_logs.

### 5. Set up nightly regeneration and the recommendation dashboard

Configure the pg_cron schedule for nightly vector updates and build the recommendations analytics dashboard showing impression rates, click-through rates, and coverage metrics.

```
Build two things:

1. Prompt Lovable to create the pg_cron schedule:
Set up a pg_cron job named 'nightly-preference-update' that runs every night at 3am UTC. It should call the Supabase Edge Function update-user-preferences using the net.http_post function from pg_net extension. Pass no body (process all users with recent interactions). Also set up a second cron job 'embed-new-items' that runs every hour and calls the generate-embeddings Edge Function to process any items with a NULL embedding column.

2. Analytics dashboard at src/pages/RecommendationAnalytics.tsx (admin only):
- Total recommendations shown today (count from recommendation_logs where shown_at >= today)
- Click-through rate: (clicked = true) / total as a percentage, shown as a large stat Card
- Coverage: percentage of items table that has a non-null embedding
- User preference coverage: percentage of auth.users that have a row in user_preferences
- A Recharts LineChart showing daily CTR over the last 30 days
- A BarChart showing CTR by algorithm (content_based vs collaborative)
- Top 10 most recommended items (by count) as a DataTable with recommendation count and CTR columns
```

> Pro tip: Ask Lovable to add a 'Regenerate All Embeddings' admin Button that calls generate-embeddings with no filter. Use it after updating your item text or switching to a different OpenAI embedding model.

**Expected result:** pg_cron jobs are scheduled. The analytics dashboard shows live recommendation metrics. Coverage stats expose items and users that still need embeddings or preference vectors.

## Complete code example

File: `supabase/functions/generate-embeddings/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const body = await req.json().catch(() => ({}))

  let query = supabase
    .from('items')
    .select('id, title, description, tags')
    .limit(body.limit ?? 50)

  if (body.item_ids?.length) {
    query = query.in('id', body.item_ids)
  } else {
    query = query.is('embedding', null)
  }

  const { data: items, error } = await query
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500, headers: corsHeaders,
    })
  }

  const processed: string[] = []
  const failed: string[] = []

  for (const item of items ?? []) {
    try {
      const inputText = [
        item.title,
        item.description,
        (item.tags ?? []).join(' '),
      ].filter(Boolean).join('. ').slice(0, 8000)

      const embRes = await fetch('https://api.openai.com/v1/embeddings', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ model: 'text-embedding-3-small', input: inputText }),
      })

      const embData = await embRes.json()
      const vector: number[] = embData.data?.[0]?.embedding
      if (!vector) throw new Error(`No embedding for item ${item.id}`)

      const { error: uErr } = await supabase
        .from('items')
        .update({ embedding: vector, embedded_at: new Date().toISOString() })
        .eq('id', item.id)

      if (uErr) throw uErr
      processed.push(item.id)
    } catch {
      failed.push(item.id)
    }
  }

  return new Response(
    JSON.stringify({ processed: processed.length, failed: failed.length, failed_ids: failed }),
    { headers: corsHeaders }
  )
})
```

## Common mistakes

- **Using IVFFLAT index without running VACUUM ANALYZE first** — IVFFlat requires a training step over the existing data to partition the vector space into clusters. If you add the index before inserting data, it creates meaningless clusters and query performance suffers. Fix: Use HNSW index instead (as specified in step 1). HNSW builds incrementally and works well from the first insert. Switch to IVFFlat only if you have millions of vectors and need lower memory usage.
- **Storing embeddings as a JSONB array instead of a vector type** — JSONB arrays cannot use the <=> cosine distance operator and cannot be indexed with HNSW. Similarity queries would require loading all rows into memory and computing distances in application code. Fix: Use the vector(1536) type provided by the pgvector extension. Make sure the extension is enabled before creating the table. Verify with: SELECT * FROM pg_extension WHERE extname = 'vector'.
- **Including skip interactions in the preference vector** — Skip interactions (weight < 0) should pull the preference vector away from those items, not toward them. Including negative-weight items in a simple average contaminates the vector. Fix: Filter out skip interactions when computing the preference vector. Handle negative feedback separately by computing a 'dislike vector' and subtracting it from the preference vector: final_vector = preference_vector - 0.5 * dislike_vector.
- **Recomputing all user preference vectors on every page load** — Computing a weighted average over 100 interaction rows per user on every page view would be extremely slow and expensive, especially with many concurrent users. Fix: Use the nightly batch approach. The pg_cron job updates preference vectors for users who have new interactions. The frontend reads from the pre-computed user_preferences table — a single indexed lookup.

## Best practices

- Use text-embedding-3-small (1536 dimensions) rather than text-embedding-ada-002. It is cheaper per token, equally accurate for recommendation tasks, and the vectors are smaller which reduces storage and query time.
- Cap the interaction history used for preference vector computation at 100–200 items. Very old interactions are less relevant and including them dilutes the signal from recent behavior.
- Log every recommendation impression in recommendation_logs. This lets you measure CTR, detect distribution shift, and identify items that are over-recommended (always appear but never get clicked).
- Add an embedding freshness check: if an item's description or tags change significantly, clear its embedding column so the hourly cron job regenerates it. Add a trigger that sets embedding = NULL on UPDATE of title, description, or tags.
- Test recommendation quality manually by creating test users with known preferences and verifying the top recommendations make semantic sense. Automated metrics like CTR can miss qualitative failures.
- Set a minimum similarity threshold (e.g. 0.65) on recommendations. If the user's preference vector has no good matches above the threshold, fall back to popular items in their most-interacted category rather than showing poor matches.
- Add a new_user cold start path: for users with fewer than 5 interactions, show popular items in each category and ask them to rate 3–5 items to bootstrap their preference vector.

## Frequently asked questions

### Do I need to know anything about machine learning to build this?

No. The Lovable prompts and Edge Function code provided handle all the vector math. pgvector's <=> operator does the cosine similarity computation inside PostgreSQL — you write SQL, not ML code. OpenAI generates the embeddings via a single API call per item. The concepts (averaging vectors, cosine distance) can be treated as black boxes for building purposes.

### How much does it cost to generate embeddings for all my items?

OpenAI text-embedding-3-small costs $0.02 per million tokens. A typical item title + description is about 100 tokens. Generating embeddings for 10,000 items costs approximately $0.02 — essentially free. Regenerating them monthly for 10,000 items adds $0.24/year. Monitor your OpenAI usage dashboard to confirm actual costs for your content.

### What happens when a new user has no interaction history?

New users have no preference vector. The get_recommendations_for_user function returns empty results. Handle this cold-start problem by showing an onboarding screen where users select 3–5 categories or rate 5 sample items. Insert those ratings as 'onboarding' user_interactions with weight 0.5, then call the update-user-preferences Edge Function immediately to bootstrap the preference vector.

### Can I use a different embedding model instead of OpenAI?

Yes. Any model that returns a fixed-length float array works. If you switch models, change the vector(1536) column to match the new dimension (e.g. vector(768) for smaller models). You must regenerate all item embeddings with the new model — mixing vectors from different models produces meaningless similarity scores. Set all embedding columns to NULL and run the embedding Edge Function again.

### How many items can pgvector handle efficiently?

The HNSW index performs well up to millions of vectors on a reasonably sized Postgres instance. Supabase Pro (8GB RAM) handles approximately 500,000–1,000,000 1536-dimension vectors with sub-100ms query times. For larger catalogs, consider reducing vector dimensions using PCA, or switching to a dedicated vector database like Qdrant.

### Can RapidDev help build a more sophisticated recommendation system?

Yes. RapidDev builds production recommendation engines including real-time preference updates, multi-objective ranking (balancing relevance, diversity, and freshness), and A/B testing frameworks for ranking algorithms. Reach out if you need recommendation quality that goes beyond the cosine similarity baseline.

### How do I prevent the same items from appearing every time in the For You feed?

Exclude items the user has already interacted with in the get_recommendations_for_user query using a subquery: AND items.id NOT IN (SELECT item_id FROM user_interactions WHERE user_id = p_user_id). For the feed UI, track which items have been shown in this session using React state and filter them out before rendering. Add a 'Load more' button that fetches the next page of recommendations.

### How long does it take for new user interactions to affect recommendations?

With the nightly batch approach, new interactions take up to 24 hours to affect recommendations. For a more responsive system, call the update-user-preferences Edge Function via a Supabase Database Trigger whenever a new interaction is inserted. This updates the preference vector in near real-time at the cost of more Edge Function invocations. Use a queue pattern to batch rapid interactions.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/recommendations-engine
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/recommendations-engine
