How to Create an Easier Recommendation System with Elixir
Recommendation systems power some of the most engaging experiences on the web—from Netflix suggesting your next binge to Amazon nudging you toward a purchase. While Python and its ML ecosystem often dominate this space, Elixir offers a compelling alternative: a functional, concurrent, fault-tolerant runtime that can handle real-time recommendations with elegance and scale.
In this article, we'll build a content-based recommendation system from scratch in Elixir, starting with a clean, efficient implementation of cosine similarity. Then we'll explore how to extend it with Elixir's growing ML ecosystem.
Why Elixir for Recommendation Systems?
Elixir's strengths align surprisingly well with recommendation workloads:
- Concurrency: BEAM processes make it easy to parallelize similarity calculations across millions of user-item pairs.
- Fault tolerance: Supervision trees ensure your recommendation service stays up even when individual computations fail.
- Functional pipelines: Data transformations (filter, map, reduce) map naturally to recommendation logic.
- Real-time capability: Phoenix Channels enable live, personalized recommendations without polling.
- Data locality: ETS provides fast in-memory lookups and atomic operations with concurrent read access, ideal for storing and querying large item catalogs without leaving the BEAM.
The Elixir ML ecosystem is also maturing rapidly, with libraries like Nx, Axon, Scholar, and MeliGraph bringing production-grade ML capabilities to the language.
The Core Algorithm: Cosine Similarity
At the heart of most content-based recommenders is a simple question: How similar is this item to what the user already likes?
Cosine similarity answers this by measuring the angle between two vectors. Unlike Euclidean distance, it's scale-invariant—a user who rates everything highly isn't penalized compared to a more conservative rater. Cosine similarity is particularly convenient when items (and often users, categories, etc.) are represented in a shared latent space, and it has become a standard choice for k-NN based recommenders.
Here's a clean, optimized implementation:
defmodule Classification do alias Classification.Database @movies Database.movies() @user {"Mattew San", [action: 0.9, comedy: 0.3, romance: 0.1, horror: 0.0, sci_fi: 0.8]} # ---------- Public API ---------- def recommend(user \\ @user, movies \\ @movies, top_n \\ 10) do user_vector = user |> prefs() |> to_tuple() movies |> Enum.map(fn movie -> score = cosine(user_vector, movie |> prefs() |> to_tuple()) # score is cos² here — take sqrt only for the final display {name(movie), :math.sqrt(score)} end) |> Enum.sort_by(fn {_, s} -> -s end) |> Enum.take(top_n) end # ---------- Extractors (2-tuple: {name, keyword_list}) ---------- defp name({n, _}), do: n defp prefs({_, prefs}), do: prefs defp to_tuple(prefs) do { Keyword.fetch!(prefs, :action), Keyword.fetch!(prefs, :comedy), Keyword.fetch!(prefs, :romance), Keyword.fetch!(prefs, :horror), Keyword.fetch!(prefs, :sci_fi) } end defp dot({a1, b1, c1, d1, e1}, {a2, b2, c2, d2, e2}) do a1 * a2 + b1 * b2 + c1 * c2 + d1 * d2 + e1 * e2 end # Squared magnitude — no sqrt defp magnitude({a, b, c, d, e}) do a * a + b * b + c * c + d * d + e * e end # Returns cos²(v1, v2) — no sqrt at all defp cosine(v1, v2) do s1 = magnitude(v1) s2 = magnitude(v2) if s1 == 0.0 or s2 == 0.0 do 0.0 else d = dot(v1, v2) (d * d) / (s1 * s2) end end end Why This Implementation Is Smart
1. Fixed-size tuples beat keyword lists for hot paths.
Converting preferences to a 5-element tuple eliminates the overhead of keyword list traversal during every similarity calculation. This is a classic Elixir optimization: encode data in a shape that matches how you'll use it.
2. cos² avoids unnecessary square roots.
The standard cosine formula is dot / (sqrt(mag1) * sqrt(mag2)). But if we're only using the score for ranking, we can compare squared values directly. The square root is monotonic, so the ordering is identical. We only take :math.sqrt/1 once per result at the end for a human-friendly display value. This saves two :math.sqrt/1 calls per movie per recommendation request.
3. Guard clauses prevent division by zero.
A zero-magnitude vector (a user with no preferences, or a movie with no genre ratings) would crash the calculation. The early return handles this gracefully.
The Data Layer: A Movie Database Module
To make the recommender runnable end-to-end, we need a catalog of items. Here's a compact but representative dataset that covers the major genre axes:
defmodule Classification.Database do @movies [ # ---------- Sci-Fi / Action ---------- {"Matrix", [action: 0.9, comedy: 0.1, romance: 0.0, horror: 0.1, sci_fi: 1.0]}, {"Interstellar", [action: 0.6, comedy: 0.1, romance: 0.4, horror: 0.2, sci_fi: 1.0]}, {"Inception", [action: 0.8, comedy: 0.1, romance: 0.2, horror: 0.1, sci_fi: 0.9]}, {"Blade Runner 2049", [action: 0.5, comedy: 0.0, romance: 0.3, horror: 0.2, sci_fi: 1.0]}, {"Arrival", [action: 0.3, comedy: 0.0, romance: 0.3, horror: 0.2, sci_fi: 1.0]}, {"Dune", [action: 0.7, comedy: 0.0, romance: 0.2, horror: 0.2, sci_fi: 1.0]}, {"Dune: Part Two", [action: 0.8, comedy: 0.0, romance: 0.3, horror: 0.3, sci_fi: 1.0]}, # ---------- Action / Thriller ---------- {"Die Hard", [action: 1.0, comedy: 0.3, romance: 0.0, horror: 0.1, sci_fi: 0.0]}, {"Mad Max: Fury Road", [action: 1.0, comedy: 0.1, romance: 0.0, horror: 0.4, sci_fi: 0.6]}, {"John Wick", [action: 1.0, comedy: 0.1, romance: 0.0, horror: 0.3, sci_fi: 0.0]}, {"The Dark Knight", [action: 0.9, comedy: 0.1, romance: 0.2, horror: 0.4, sci_fi: 0.0]}, # ---------- Comedy ---------- {"The Grand Budapest Hotel", [action: 0.2, comedy: 1.0, romance: 0.3, horror: 0.0, sci_fi: 0.0]}, {"Superbad", [action: 0.1, comedy: 1.0, romance: 0.3, horror: 0.0, sci_fi: 0.0]}, {"Groundhog Day", [action: 0.1, comedy: 1.0, romance: 0.5, horror: 0.0, sci_fi: 0.3]}, # ---------- Romance / Drama ---------- {"Titanic", [action: 0.1, comedy: 0.1, romance: 1.0, horror: 0.0, sci_fi: 0.0]}, {"The Notebook", [action: 0.0, comedy: 0.1, romance: 1.0, horror: 0.0, sci_fi: 0.0]}, {"La La Land", [action: 0.1, comedy: 0.4, romance: 1.0, horror: 0.0, sci_fi: 0.0]}, {"Eternal Sunshine", [action: 0.1, comedy: 0.2, romance: 1.0, horror: 0.1, sci_fi: 0.5]}, # ---------- Horror ---------- {"The Exorcist", [action: 0.2, comedy: 0.0, romance: 0.0, horror: 1.0, sci_fi: 0.1]}, {"Hereditary", [action: 0.2, comedy: 0.0, romance: 0.0, horror: 1.0, sci_fi: 0.0]}, {"Get Out", [action: 0.3, comedy: 0.2, romance: 0.1, horror: 1.0, sci_fi: 0.1]}, # ---------- Animation / Family ---------- {"Toy Story", [action: 0.3, comedy: 0.9, romance: 0.2, horror: 0.0, sci_fi: 0.4]}, {"WALL-E", [action: 0.3, comedy: 0.7, romance: 0.6, horror: 0.0, sci_fi: 0.9]}, # ---------- War / Historical ---------- {"Saving Private Ryan", [action: 1.0, comedy: 0.0, romance: 0.0, horror: 0.7, sci_fi: 0.0]}, {"Dunkirk", [action: 0.8, comedy: 0.0, romance: 0.1, horror: 0.5, sci_fi: 0.0]}, # ---------- Crime / Mafia ---------- {"The Godfather", [action: 0.5, comedy: 0.0, romance: 0.2, horror: 0.4, sci_fi: 0.0]}, {"Goodfellas", [action: 0.6, comedy: 0.3, romance: 0.1, horror: 0.5, sci_fi: 0.0]}, # ---------- Fantasy / Adventure ---------- {"The Lord of the Rings: Fellowship", [action: 0.8, comedy: 0.1, romance: 0.1, horror: 0.4, sci_fi: 0.0]}, {"The Princess Bride", [action: 0.5, comedy: 0.8, romance: 0.7, horror: 0.1, sci_fi: 0.0]}, # ---------- Superhero ---------- {"Iron Man", [action: 0.8, comedy: 0.5, romance: 0.2, horror: 0.1, sci_fi: 0.7]}, {"The Avengers", [action: 0.9, comedy: 0.5, romance: 0.1, horror: 0.1, sci_fi: 0.6]}, {"Spider-Man: No Way Home", [action: 0.9, comedy: 0.5, romance: 0.3, horror: 0.2, sci_fi: 0.7]}, # ---------- Cult / Mind-bending ---------- {"Memento", [action: 0.5, comedy: 0.0, romance: 0.2, horror: 0.5, sci_fi: 0.3]}, {"The Prestige", [action: 0.4, comedy: 0.0, romance: 0.2, horror: 0.4, sci_fi: 0.4]}, {"Parasite", [action: 0.3, comedy: 0.5, romance: 0.0, horror: 0.6, sci_fi: 0.0]} ] def movies(), do: @movies end Each movie is represented as a 5-dimensional vector across the genre axes: action, comedy, romance, horror, and sci_fi. This compact representation makes similarity computation fast and the dataset easy to reason about.
Running the System
With both modules saved, you can run everything from IEx:
iex> Classification.recommend() [ {"Dune: Part Two", 0.9943...}, {"Dune", 0.9921...}, {"Interstellar", 0.9812...}, {"Blade Runner 2049", 0.9754...}, {"Arrival", 0.9688...}, {"Inception", 0.9601...}, {"The Matrix", 0.9547...}, {"Ex Machina", 0.9322...}, {"The Martian", 0.9201...}, {"Guardians of the Galaxy", 0.9188...} ] This makes total sense for the user Mattew San, who has action: 0.9, comedy: 0.3, romance: 0.1, horror: 0.0, sci_fi: 0.8—a clearly sci-fi/action profile with low tolerance for horror.
Testing Different Profiles
One of the best things about a compact dataset is that it covers the extremes well. Validate the behavior with contrasting profiles:
# Romantic cinephile profile romantic = {"Ana", [action: 0.1, comedy: 0.4, romance: 1.0, horror: 0.0, sci_fi: 0.1]} Classification.recommend(romantic, Classification.Database.movies(), 5) # => Titanic, The Notebook, Pride and Prejudice, La La Land, Before Sunrise # Horror fan profile horror_fan = {"Bruno", [action: 0.3, comedy: 0.0, romance: 0.0, horror: 1.0, sci_fi: 0.2]} Classification.recommend(horror_fan, Classification.Database.movies(), 5) # => The Exorcist, Hereditary, The Conjuring, The Witch, It Follows # Light comedy profile comedy_fan = {"Carla", [action: 0.2, comedy: 1.0, romance: 0.3, horror: 0.0, sci_fi: 0.1]} Classification.recommend(comedy_fan, Classification.Database.movies(), 5) # => Superbad, The Big Lebowski, The Grand Budapest Hotel, Shrek, Hot Fuzz If any of these return something odd (like a horror film at the top for the romantic profile), it signals that some weight in the dataset is misaligned—which is great, because it gives you immediate feedback on data quality.
Taking It Further: Libraries and Ecosystem Tools
The similarity Hex Package
If you'd rather not maintain your own implementation, the similarity library provides battle-tested cosine similarity, Simhash, and Sørensen–Dice calculations. It supports streaming comparisons and handles mismatched attribute sets—elements added don't have to have the exact same attributes:
s = Similarity.Cosine.new() s = s |> Similarity.Cosine.add("user_a", [{"action", 9}, {"comedy", 3}]) s = s |> Similarity.Cosine.add("user_b", [{"action", 8}, {"comedy", 2}]) s |> Similarity.Cosine.between("user_a", "user_b") The library also offers cosine_srol/2, which multiplies cosine similarity by the square root of vector length—useful when comparing vectors of different lengths, as it rewards items with more overlapping attributes. The library requires Elixir 1.14 or later.
Graph-Based Recommendations with MeliGraph
For collaborative filtering or "users who liked X also liked Y" scenarios, MeliGraph brings Twitter-inspired graph algorithms to Elixir. Inspired by Twitter's WTF (Who to Follow) and GraphJet systems, and by Oban's OTP patterns, MeliGraph provides:
- Personalized PageRank via Monte Carlo random walks
- SALSA for bipartite graphs (hubs/authorities)
- SimilarItems with 2-hop co-occurrence and Jaccard/Cosine normalization
- GlobalRank for cold-start ranking by in-degree
- LightGCN embeddings trained with Nx.Defn + BPR loss, with automatic fallback to SALSA
MeliGraph is single-writer/multi-reader via GenServer + ETS, supports distributed mode with Horde (opt-in), and is telemetry-first. It requires EXLA for optimal LightGCN performance and uses Nx as a mandatory dependency.
Here's a quick start:
# Start an instance {:ok, _} = MeliGraph.start_link( name: :my_graph, graph_type: :bipartite, testing: :sync ) # Insert edges (user → content) MeliGraph.insert_edge(:my_graph, "user:1", "post:a", :like) MeliGraph.insert_edge(:my_graph, "user:2", "post:a", :like) MeliGraph.insert_edge(:my_graph, "user:2", "post:b", :like) # Get recommendations {:ok, recs} = MeliGraph.recommend(:my_graph, "user:1", :content, algorithm: :salsa, seed_size: 10, top_k: 5) Numerical Computing with Nx and Scholar
For larger-scale scenarios, Nx (Numerical Elixir) brings tensor operations and GPU acceleration to the language. Cosine similarity in Nx looks like this:
defmodule CosSim do import Nx.Defn defn cosine_similarity(a, b) do left = Nx.sqrt(Nx.sum(a ** 2)) right = Nx.sqrt(Nx.sum(b ** 2)) Nx.sum(a * b) / (left * right) end end The Elixir community recommends using Scholar's CosineDistance and subtracting from 1 for similarity. Scholar also provides a BruteKNN module for brute-force k-nearest neighbor search, which accepts :cosine as a distance metric:
iex> data = Nx.tensor([[1, 2], [2, 3], [3, 4], [4, 5], [5, 6]]) iex> model = Scholar.Neighbors.BruteKNN.fit(data, num_neighbors: 2) iex> query = Nx.tensor([[1, 3], [4, 2], [3, 6]]) iex> {neighbors, distances} = Scholar.Neighbors.BruteKNN.predict(model, query) With EXLA as the backend, these operations can run on GPU. Scholar is still in pre-release, so you may need to pull it manually from GitHub.
Vector Databases for Scale
For production-scale similarity search over embeddings, dedicated vector database libraries are em...
