Article Recommendation System with TypeScript and Supabase
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.
We can then use cosine similarity to determine similarity between posts.
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 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.
Category | Rendering Patterns | Basics of Web Development |
---|---|---|
HTML | false | true |
CSS | false | true |
JavaScript | true | true |
React | true | false |
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:
- Fetch all posts and categories
- Get the category vectors for each post
- Find and remove P
- Get the cosine similarity between the remaining posts and P
- Filter out the remaining posts with no cosine similarity
- 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. 🎉