Recently, I announced SolSistr, a platform that I have been building since October 2024 that organizes sorority recruitment data into a unified system, enabling chapters to manage and enhance their recruitment process through the power of AI.
While I shared the business motivation and launch story on LinkedIn, I wanted to write a technical reflection for those interested in the engineering side of building a SaaS from scratch.
In the following series of posts, I will cover:
Tech stack overview: Why I chose these tools and frameworks.
Main Features: Integral pieces of the puzzle.
Pitfalls I encountered: What broke, what was harder than expected, and what I would do differently.
Lessons learned: For anyone looking to build and ship their own SaaS.
I hope to document the major wins and setbacks I’ve encountered during this development journey and share advice for others on a similar path.
Tech Stack at a Glance
I chose to start with the ReactT3 Stack to build a strong, type-safe foundation for SolSistr while maintaining rapid development iteration (Shout out Theo-I became a fan of his youtube channel in this process). Having previously developed personal projects using React.js, I figured typescript would be an adequate next step to improve reliability, scalability, and package support as the codebase grew.
Frontend: [React, Next.js, Typescript, tRPC]
Backend: [Node.js, Flask]
Database: [PostgreSQL, PrismaORM, Neon Console]
Hosting: [Vercel, AWS S3, Fly.io]
Auth: [NextAuth.js]
Payment Processing: [Stripe]
Other Tools: [Tailwind CSS, Prisma, Python, Open AI API]
Motivation
As an aspiring React engineer and mathematics major, I saw an opportunity to build something that could simplify a chaotic process while deepening my skills in full-stack development, system design and data handling. At the time, I was also taking courses in Data Science, Scientific Computing, and Machine Learning, which made me especially interested in applying mathematical tools to real-world problems.
Cosine Similarity
One of the most exciting parts of building SolSistr was designing a system to quantify the semantic differences in user profiles and surface meaningful similarities between them. Something that had caught my interest recently with the boom in language model development is vectorization, or the process of transforming complex data like text, profiles, or preferences into numerical representations that models can understand and compare efficiently. With cosine similarity, comparing two profiles becomes a simple and efficient operation. Once the data is vectorized, calculating how similar two profiles are boils down to an O(1) dot-product computation that produces a quantitative similarity score between the two vectors:
cosine_similarity = np.dot(vector_1, vector_2) / (np.linalg.norm(vector_1) * np.linalg.norm(vector_2))
This formula measures the cosine of the angle between the two vectors in high-dimensional space. A smaller angle (closer to 0°) indicates that the vectors point in similar directions—meaning the profiles are more alike—while a larger angle (closer to 90°) suggests they are more dissimilar. Because cosine similarity focuses on direction rather than magnitude, it works especially well for comparing normalized profile data where the relative importance of features matters more than their absolute values.
While cosine similarity provides a quick way to compute the directional similarity between two vectors, interpreting the raw cosine value directly as a percentage match can be misleading. Even after normalizing the output of the similarity function to be between 0 and 1, the cosine function is non-linear, and equal differences in cosine values do not translate to equal differences in angles (or similarity). As detailed in this article, a better approximation is to convert the cosine similarity output to an angle using the arccosine function, treating the angle between vectors as a linearly spaced measure of similarity:
perc_dist = (math.pi - math.acos(cos_sim)) * 100 / math.pi
By doing this, we can normalize the angle to a percentage score (between 0 and 100), providing a more intuitive “confidence” score for similarity comparisons.
Vector Averaging
Now that we have established the method of profile comparison, we can dive into how we can use these similarity scores to assign PNMs to the most similar bump group automatically. The process of organizing members into bump groups is a method that is typically kept internal and done manually. Each chapter has its own strategies and preferences for how members are grouped, balancing factors like seniority and conversation styles to ensure each bump group is well rounded.
Traditionally, once bump groups are established, PNMs are manually assigned to groups based on broad similarities like shared hometowns, majors, or mutual interests. While manually assigning each PNM to a bump group can be thoughtful, it is extremely time consuming and subjective to the matcher. Thankfully, cosine similarity can come to the rescue! I experimented with a couple of different matching methods during development. My first idea was to calculate the average profile vector of the chapter members in each bump group and use this vector to compare against each PNM’s profile vector to determine similarity.
While this method provided a general sense of what the “average” bump group member might look like, it often smoothed over individual interest areas within the group, losing important nuance that could help with more meaningful matches. For example, if a bump group had members with strong but diverse interests—such as music, travel, and technology, the average vector would dilute these distinctions, resulting in less precise matching.
In contrast, I used a centroid vector approach that emphasized the shared high similarity interest areas among group members, rather than a simple unweighted mean across all dimensions. By focusing on the areas where members’ profiles overlapped, the centroid better represented the group’s collective identity, making it possible to match PNMs based on the specific interests and attributes that were most important/prevalent in that bump group.
Mathematically, this centroid calculation follows the standard definition:
centroid = (vector_1 + vector_2 + ... + vector_n) / N
where each vector is a user's complete profile vector and the result captures the geometric center of all group member profiles in a high-dimension vector space.
Greedy-Balanced Matching
At this point, I needed to find an algorithm that allowed for these centroid vectors to be compared to each PNM profile and determine which group would be the most appropriate match.
At first, I thought about trying every possible orientation of ways to match PNMs with bump groups and choosing the option where each group has the highest similarity score. This sounds great, but in reality running an algorithm like this (O(n^2) time complexity or worse) makes it infeasible for even medium-sized chapters.
With some research, I decided to go with a greedy balanced matching algorithm , matching each PNM with the most similar bump group with the added context of fair distribution across all bump groups. It also accounts for PNMs who were previously assigned manually to a bump group.
One of my goals was to allow members to partially fill bump groups manually and then auto complete the rest using the algorithm. To support this vision, I designed the system to accept the current assignment count for each bump group so the algorithm would respect existing assignments while maintaining balanced group sizes as it completed the matching process.
So in a step-by-step sense, my algorithm calculates the centroid vector of each bump group and compares the similarity of each PNM to each bump group. It then sorts those similarities from highest to lowest and proceeds to assign each PNM to the bump group with which they have the highest similarity, while respecting balanced distribution across groups. If a bump group has already reached its target count, or
target_count = (previously_assigned + to_assign) / bump_group_count
the algorithm will consider the PNM's next best matching group.
Profile Vector Generation Overview
With all of this being said, there is still a large piece of the puzzle that is missing; how do we actually transform a complex user profile into a vector in the first place? To achieve this, I used natural language processing techniques, specifically OpenAi's natural language embeddings model.
There are many things we understand without thinking about because of our context and experience in the real world. These hard to define relationships between thoughts like slang, different dialects, or misnomers make it extremely challenging for a computer to interpret language the way we do. Nature language embeddings helps bridge this gap in understanding by converting words/sentences into 1536-dimension vectors that capture their semantic meaning. These vectors don’t just identify obvious syntactic similarities but they also recognize subtle nuances in meaning and encode relationships between concepts, even those that are difficult for us to label or describe.
For example, using CU Boulder's sentence comparison tool, we can see that the sentence pair:
I really like cats.
I really like fortnite.
Has a cosine similarity score of 0.867 while the sentence pair
I really like cats.
I really like dogs.
Has a cosine similarity score of 0.941, showing that in a semantic sense, the word2vec model understands that cats are more similar to dogs than they are to fortnite.
While the sentences above are quite simple, generating profile vectors for actual users is quite a bit more complicated. Since the range of information a user can provide in their profile is very diverse, I decided to go with a multi-component approach that combines different aspects of the user's profile into one unified representation. I divided this unified vector into three parts:
Interests and Hobbies: Probably the vector's most important aspect - what does the user actually like doing? The user is given the opportunity to provide an interest, a description of their involvement and a weight (between 1 and 5) of how involved they are in that particular interest. Each interest gets processed through the OpenAi model to create a 1536-dimensional semantic vector. When someone has multiple interests, I calculate a weighted average where interests with higher weights (the things they're really passionate about) have more influence on the final vector. This means someone who rates "rock climbing" as a 5 and "casual reading" as a 2 will have a profile vector that leans more heavily toward the rock climbing semantic space.
Major: Similar to the user's interests and hobbies vector calculation, the user's major name is run through the OpenAi model creating another 1536-dimensional vector capturing the semantic meaning of the user's field of study. This allows the system to recognize the similarity between entries like “Information Technology” and “Computer Science,” ensuring that related fields are understood as closely aligned during matching, even if users phrase them differently.
Hometown: Geography is also a factor that often comes into play when matching PNMs to bump group. Users can provide their hometown, which they choose from a predetermined CSV with each hometown's latitude and longitude coordinates attached. I normalize these into a standardized range [-1, 1] so they contribute proportionally to the matching calculations. Without normalization, the large magnitude of geographic coordinates ( > 1) would distort the cosine similarity calculations, which operate within a typical range of -1 to 1, and could disproportionately influence the matching results.
When we combine all of these individual vectors, we are left with a 3074-dimension vector (1536 for interests, 1536 for major and 2 for coordinates).
Creating a system like this also means dealing with all sorts of edge cases that could otherwise break the matching pipeline. Performing cosine similarity is very stringent on the requirement that the two vectors being compared must have the exact same number of dimensions. If even a single component is missing or misaligned, the similarity calculation will fail or produce meaningless results.
I handled this issue by substituting a zero vector, effectively meaning “neither similar nor dissimilar,” ensuring that missing data does not falsely influence the match while preserving the required dimensional consistency for the calculation to proceed safely. I found that this approach allowed the system to remain robust and user friendly while ensuring incomplete profiles can still be fairly and accurately matched.
Thanks for reading! Stay tuned for my next post regarding the lessons I learned and pitfalls I ran into next week. -Evan


SOCIAL SHARE CARD GENERATOR