Back to roadmaps pgvector Course

Project: Related Articles Recommendation Engine

In this project, we will build a related articles recommendation engine. Given an article ID, the engine queries its embedding vector and retrieves the top 3 most similar articles in the database using Cosine distance, excluding the active article itself.


1. Table Schema Setup

Create an articles table containing article metadata and embedding vectors:

CREATE TABLE articles (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  summary TEXT NOT NULL,
  embedding VECTOR(1536)
);

2. Writing the Stored Procedure

Create a database function that retrieves recommendations based on an input article ID:

CREATE OR REPLACE FUNCTION get_related_articles (
  target_article_id INT,
  match_limit INT
)
RETURNS TABLE (
  id BIGINT,
  title TEXT,
  slug TEXT,
  similarity FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
  target_embedding VECTOR(1536);
BEGIN
  -- 1. Fetch the embedding vector for the target article
  SELECT embedding INTO target_embedding 
  FROM articles 
  WHERE articles.id = target_article_id;

  -- 2. Return the most similar articles
  RETURN QUERY
  SELECT
    a.id,
    a.title,
    a.slug,
    (1 - (a.embedding <=> target_embedding)) AS similarity
  FROM articles a
  -- Exclude the query article itself
  WHERE a.id != target_article_id
  ORDER BY a.embedding <=> target_embedding ASC
  LIMIT match_limit;
END;
$$;

3. Implementing the React Component

Here is the React UI component displaying recommendations for an active article:

// src/components/RelatedArticles.tsx
import React, { useState, useEffect } from "react";
import { supabase } from "../lib/supabase";

interface RecommendedArticle {
  id: number;
  title: string;
  slug: string;
  similarity: number;
}

export default function RelatedArticles({ articleId }: { articleId: number }) {
  const [recommendations, setRecommendations] = useState<RecommendedArticle[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchRecommendations();
  }, [articleId]);

  async function fetchRecommendations() {
    try {
      setLoading(true);
      const { data, error } = await supabase.rpc("get_related_articles", {
        target_article_id: articleId,
        match_limit: 3, // Fetch top 3 recommendations
      });

      if (error) throw error;
      if (data) setRecommendations(data);
    } catch (err) {
      console.error("Failed to load article recommendations:", err);
    } finally {
      setLoading(false);
    }
  }

  if (loading) return <p className="text-gray-400 text-sm">Loading recommendations...</p>;
  if (recommendations.length === 0) return null;

  return (
    <div className="mt-12 border-t pt-8">
      <h3 className="text-lg font-bold text-gray-950">Recommended Articles</h3>
      <p className="text-gray-500 text-xs mt-1">AI-powered recommendations based on content similarity</p>

      <div className="grid gap-4 sm:grid-cols-3 mt-6">
        {recommendations.map((item) => (
          <a
            key={item.id}
            href={`/blog/${item.slug}`}
            className="block p-4 border rounded-xl hover:border-blue-500 transition hover:shadow-sm bg-white"
          >
            <h4 className="font-semibold text-gray-900 line-clamp-2">{item.title}</h4>
            <p className="text-blue-600 text-xs font-medium mt-2">
              {Math.round(item.similarity * 100)}% match
            </p>
          </a>
        ))}
      </div>
    </div>
  );
}
Published on Last updated: