skies.dev

Article Recommendation System with TypeScript and Supabase

3 min read

We want a function with a simple contract:

Given a post P, return the posts most similar to P.

A practical way to do that is to label posts with categories and compare their category overlap. That gives us a recommendation system that is easy to explain, easy to debug, and good enough for a small content site.

To support that, we need a many-to-many relationship between posts and categories.

ER Diagram showing the relationship between Posts and Categories
ER Diagram showing the relationship between Posts and Categories

Once we have that structure, we can turn each post into a category vector and compare those vectors with cosine similarity.

export function getCosineSimilarity(a: boolean[], b: boolean[]): number {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let i = 0; i < a.length; i++) {
    const aValue = Number(a[i]);
    const bValue = Number(b[i]);

    dotProduct += aValue * bValue;
    normA += aValue * aValue;
    normB += bValue * bValue;
  }

  const denominator = Math.sqrt(normA) * Math.sqrt(normB);

  return denominator === 0 ? 0 : dotProduct / denominator;
}

The vector order must match the category order exactly. For example, if we are describing posts about web development, the category list might look like this:

CategoryRendering PatternsBasics of Web Development
HTMLfalsetrue
CSSfalsetrue
JavaScripttruetrue
Reacttruefalse

In that example, the vector for Rendering Patterns would be [false, false, true, true].

We can generate those vectors in linear time with a Set lookup.

export function getCategoryVector(
  postCategoryIds: Set<number>,
  allCategoryIds: number[],
): boolean[] {
  return allCategoryIds.map((categoryId) => postCategoryIds.has(categoryId));
}

That gives us the pieces we need to rank related posts.

The algorithm is straightforward:

  1. Fetch all posts and categories.
  2. Build a category vector for each post.
  3. Find the source post P.
  4. Compare every other post to P with cosine similarity.
  5. Drop posts with a similarity score of 0.
  6. Return the remaining posts sorted from most similar to least similar.
export async function getRelatedPosts(postId: number): Promise<Post[]> {
  const [{ data: allPosts }, { data: allCategories }] = await Promise.all([
    supabase
      .from<Post>('posts')
      .select('post_id, post_categories (categories (category_id))')
      .throwOnError(),
    supabase
      .from<Category>('categories')
      .select('category_id')
      .order('category_id')
      .throwOnError(),
  ]);

  if (allPosts == null || allCategories == null) {
    return [];
  }

  const allCategoryIds = allCategories.map(({ category_id }) => category_id);

  const posts = allPosts.map((post) => ({
    ...post,
    categoryVector: getCategoryVector(
      new Set<number>(
        post.post_categories?.map(({ categories: { category_id } }) => category_id) ?? [],
      ),
      allCategoryIds,
    ),
  }));

  const sourcePost = posts.find((post) => post.post_id === postId);

  if (sourcePost == null) {
    return [];
  }

  return posts
    .filter((post) => post.post_id !== sourcePost.post_id)
    .map((post): DBPostCosineSimilarity => ({
      ...post,
      cosineSimilarity: getCosineSimilarity(sourcePost.categoryVector, post.categoryVector),
    }))
    .filter((post) => post.cosineSimilarity > 0)
    .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity);
}

This is a simple recommendation model, but it works well as a starting point. If you later need stronger relevance, you can layer on weights, tags, full-text search, or embeddings without changing the basic idea.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like