How to Build an Article Recommendation System with TypeScript and Supabase

3 min read

We want to write a function with the following specification:

Given a post P, return all posts similar to P.

The way we’ll determine similarity between posts is by labeling posts with categories.

For this, we need to model a many-to-many relationship between posts and categories.

ER Diagram showing the relationship between Posts and Categories

We can then use cosine similarity to determine similarity between posts.

export function getCosineSimilarity(a: boolean[], b: boolean[]): number {
  let regex = /\s+/i;
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
  for (let i = 0; i < a.length; i++) {
    const a = Number(a[i]);
    const b = Number(b[i]);
    dotProduct += a * b;
    normA += a * a;
    normB += b * b;
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

The ordered vectors A and B we’re looking for map one-to-one with the categories we define.

For example, here’s how we might categorize posts about web development.

CategoryRendering PatternsBasics of Web Development
HTMLfalsetrue
CSSfalsetrue
JavaScripttruetrue
Reacttruefalse

So if Rendering Patterns is A, then A = [false, false, true, true].

We’ll use a Set to generate the category vectors in linear time.

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

We now have the components needed to build our recommendation algorithm.

Here’s how the algorithm will go:

  1. Fetch all posts and categories
  2. Get the category vectors for each post
  3. Find and remove P
  4. Get the cosine similarity between the remaining posts and P
  5. Filter out the remaining posts with no cosine similarity
  6. Return the remaining posts sorted by cosine similarity
export async function getRelatedPosts(postId: number): Promise<Post[]> {
  // 1. Fetch all posts and categories
  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(),
  ]);

  // No data was returned, return no related posts
  if (allPosts == null || allCategories == null) {
    return [];
  }

  // 2. Get the category vectors for each post
  const allCategoryIds = allCategories.map(({category_id}) => category_id);
  const posts = allPosts.map((post) => {
    return Object.assign(
      {
        categoryVector: getCategoryVector(
          new Set<number>(
            post.post_categories?.map(
              ({categories: {category_id}}) => category_id,
            ) ?? [],
          ),
          allCategoryIds,
        ),
      },
      post,
    );
  });

  // 3. Find P
  const p = posts.find((post) => post.post_id === postId);

  // The post wasn't found, return no related posts
  if (p == null) {
    return [];
  }

  return (
    posts
      // 3.5 Remove P
      .filter((post) => post.post_id !== p.post_id)

      // 4. Get the cosine similarity between remaining posts and P
      .map((post): DBPostCosineSimilarity => {
        return Object.assign(
          {
            cosineSimilarity: getCosineSimilarity(
              p.categoryVector,
              post.categoryVector,
            ),
          },
          post,
        );
      })

      // 5. Filter out the remaining posts with no cosine similarity
      .filter((post) => post.cosineSimilarity > 0)

      // 6. Return the remaining posts sorted by cosine similarity
      .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity)
  );
}

And with that, we have a post recommendation system. 🎉