logo
Building Smart Recommendation Systems with Node.js and Google Gemini
by John Oba - Afrodev17 September, 2024 • 6 min read
Building Smart Recommendation Systems with Node.js and Google Gemini

In today’s world, personalization is key to creating engaging user experiences. A great way to achieve this is through recommendation systems, powered by embeddings. In this article, we'll walk through how you can create a recommendation system using Node.js, Google Generative AI (Gemini), and PostgreSQL with the PGVector extension.

With embeddings, we can capture semantic meaning in texts and compare them in ways that go beyond keyword matching. This guide will take you step-by-step through the process of building such a system, all while keeping things simple and easy to integrate with your existing backend.

What are Embeddings?

Before we get into the technical bits, let’s break down what embeddings are. In simple terms, embeddings transform pieces of text into numerical vectors (arrays of numbers). These vectors capture the semantic meaning of the text—so rather than just matching words, you’re matching the underlying concepts. This means when a user types "chocolate cake," your recommendation system can also suggest "brownie" or "chocolate mousse" because they’re conceptually similar.

Example of embeddings:

    [
        0.0097734295,
        0.0049043694,
        -0.032260485,
        0.031259637,
        0.030565087,
        0.012195867,
        0.015378907,
        0.007984511,
        0.046954036,
        0.01457735,
        0.010535651,
        -0.0008489747,
        0.06417112,
        0.104777284,
        0.010191115,
        -0.0032043846,
        0.020765187,
        0.018393643
    ]

Setting Up: Google Gemini and PGVector

In this guide, we'll use Google Gemini to generate text embeddings and store them in PostgreSQL using the PGVector extension. PGVector allows us to efficiently store and search for vectors within a database, which is crucial for handling recommendation tasks.

Here's a quick guide to setup PGVector link

You can use any other embedding model from openai, cohere, anthropic, gemini, etc. For this example, we'll use Gemini. To get started, generate an API key from the Google AI Studio. Here

Here’s a sneak peek of the tech stack for this article:

  • Node.js for building the API.
  • Google Gemini for generating embeddings.
  • PostgreSQL + PGVector for storing and searching vectors.
  • JWT authentication for securing API requests.
  • Multer for handling form data (in this case, text).

Getting Started

We’ll start with a simple Node.js application, using Express to handle routes and Google Gemini for embedding content. PostgreSQL with PGVector will handle storing and querying the embeddings.

Step 1: Setup Your Environment

First, you’ll need a .env file with your Google Gemini API key and database connection details:

API_KEY=your_google_generative_ai_api_key
DATABASE_URL=your_postgresql_database_url
ACCESS_TOKEN_SECRET=your_jwt_secret_key

Next, install the necessary packages:

npm install express dotenv pg uuid jsonwebtoken bcrypt multer @google/generative-ai

Step 2: Creating the API Structure

We’ll create a basic Express app that supports user authentication, embedding text, and recommending similar texts. Let’s start by setting up the server and connecting to the PostgreSQL database:

import { GoogleGenerativeAI } from "@google/generative-ai";
import * as dotenv from "dotenv";
import express from "express";
import { Client } from "pg";
import { v4 as uuidv4 } from "uuid";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import multer from "multer";

dotenv.config();

const apiKey = process.env.API_KEY as string;
const genAI = new GoogleGenerativeAI(apiKey);

const app = express();
app.use(express.json());

const client = new Client({
  connectionString: process.env.DATABASE_URL as string,
});
client.connect();

Step 3: User Authentication

We’ll use JWT for user authentication. Here’s a simple login and user creation system. First, users are authenticated through a login endpoint, which generates a token upon successful login:

// User login route
app.post("/login", async (req, res) => {
  const { username, password } = req.body;
  const user = await client.query("SELECT * FROM users WHERE name = $1", [
    username,
  ]);

  if (user.rows.length === 0)
    return res.status(400).json({ error: "User not found" });

  const validPassword = await bcrypt.compare(password, user.rows[0].password);
  if (!validPassword)
    return res.status(400).json({ error: "Invalid password" });

  const accessToken = jwt.sign(
    { userId: user.rows[0].user_id },
    process.env.ACCESS_TOKEN_SECRET as string,
    { expiresIn: "1h" }
  );
  res.json({ accessToken });
});

Step 4: Embedding Text

Now comes the exciting part—embedding text. We’ll use Google Gemini to generate embeddings and store them in PostgreSQL.

The embeddings are vectors, which capture the semantic meaning of the input text. Here's the /embed endpoint:

app.post("/embed", authenticateToken, async (req, res) => {
  try {
    const { text } = req.body;
    const userId = (req as any).user.userId;

    const model = genAI.getGenerativeModel({ model: "text-embedding-004" });
    const result = await model.embedContent(text);
    const embedding = result.embedding;

    await client.query(
      "INSERT INTO embeddings (user_id, text, vector) VALUES ($1, $2, $3)",
      [userId, text, JSON.stringify(embedding.values)]
    );
    res.json({ userId, text, embedding: embedding.values });
  } catch (error) {
    res.status(500).json({ error: "Internal Server Error" });
  }
});

Step 5: Making Recommendations

The real magic happens in the recommendation endpoint. After embedding the input text, we calculate its similarity to the stored embeddings using cosine similarity or PGVector's built-in vector distance functions. Here's how we implement the recommendation system:

app.post("/recommend", authenticateToken, async (req, res) => {
  try {
    const { text } = req.body;
    const model = genAI.getGenerativeModel({ model: "text-embedding-004" });
    const result = await model.embedContent(text);
    const targetEmbedding = result.embedding.values;

    const queryResult = await client.query(
      "SELECT text, vector, (vector <-> $1::vector) AS distance FROM embeddings ORDER BY distance LIMIT 5",
      [JSON.stringify(targetEmbedding)]
    );

    const recommendations = queryResult.rows.map((row) => ({
      text: row.text,
      similarity: 1 - row.distance,
    }));

    res.json(recommendations);
  } catch (error) {
    res.status(500).json({ error: "Internal Server Error" });
  }
});

Step 6: Calculating Cosine Similarity (If Needed)

If you need to calculate cosine similarity between vectors yourself, here’s a function to do that:

function cosineSimilarity(vecA: number[], vecB: number[]): number {
  const dotProduct = vecA.reduce((sum, a, idx) => sum + a * vecB[idx], 0);
  const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
  const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
  return dotProduct / (magnitudeA * magnitudeB);
}

Wrapping It Up

By now, you should have a good understanding of how to build a basic recommendation system using Node.js and embeddings with Google Gemini. Embeddings allow you to go beyond keyword matching, tapping into semantic meaning and providing your users with smarter, more relevant suggestions.

What’s great about this setup is its flexibility. You can easily plug it into your existing Node.js backend, and it scales well as your embedding database grows.

If you're ready to build intelligent recommendations for your app, now’s the time to start experimenting with embeddings. Here is the complete code for the example we just built.

Cheers!


More Stories from Afrodev

2023 AfroDev