如何用 Elixir 更輕鬆地建立推薦系統
How to Create an Easier Recommendation System with Elixir
Elixir 教學示範如何從零建立內容式推薦系統,並以餘弦相似度根據用戶偏好為電影排序。實作把偏好轉成 5 元 tuple,排序時比較平方餘弦值,僅在顯示結果時計算平方根,並處理零向量以避免除以零。文章亦指出 Elixir 的並行處理和容錯特性,並提及 Nx、Axon、Scholar、MeliGraph 等 ML 函式庫可用於擴展系統。
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 emerging. The vector-db-elixir project enables fast exact k-NN search over stored vectors with configurable distance metrics (cosine, L2, dot product), eliminating manual matching and enabling real-time recommendations. Similarly, hnswlib provides an Elixir binding for the HNSW library with cosine similarity support.
Practical Considerations
Performance at Scale
The Elixir community has learned hard lessons about scaling similarity calculations. At around 5 million records, loading all data into memory becomes impractical—pushing computation to the database is recommended when possible. For intermediate scales, Flow or GenStage can parallelize the work across cores. For very large lists (over 10k items), consider using Matrex for dot products or leveraging pgvector in PostgreSQL for vector similarity search.
Data Quality Observations
Two things stand out in practice:
1. Genre dominance. If one genre axis (like sci_fi) has high values across many items, any user with a high preference for that genre will see almost exclusively those items at the top. To encourage diversity, consider normalizing each dimension (dividing each feature by its dataset mean) before computing cosine similarity.
2. Near-identical vectors. Films like The Godfather and The Godfather Part II may have identical weights, making them indistinguishable. Adding a secondary feature (release year, director, popularity) as a tie-breaker resolves this.
Adding Diversity to Results
If you want to avoid monothematic lists, apply a simple diversity filter—for example, at most 2 films per dominant genre:
def recommend(user, movies \\ @movies, top_n \\ 10) do
user_vector = user |> prefs() |> to_tuple()
movies
|> Enum.map(fn movie ->
{name(movie), :math.sqrt(cosine(user_vector, movie |> prefs() |> to_tuple()))}
end)
|> Enum.sort_by(fn {_, s} -> -s end)
|> Enum.reduce({[], %{}}, fn {title, score}, {acc, counts} ->
genre = dominant_genre(title)
if Map.get(counts, genre, 0) < 2 do
{[{title, score} | acc], Map.update(counts, genre, 1, &(&1 + 1))}
else
{acc, counts}
end
end)
|> elem(0)
|> Enum.reverse()
|> Enum.take(top_n)
end
This keeps the list varied without sacrificing much relevance.
A Practical Roadmap
Here's how to evolve your simple recommender into a production system:
| Stage | Approach | Tools |
|---|---|---|
| Prototype | Content-based filtering with cosine similarity | Your Classification module |
| Small scale | Add collaborative filtering via co-occurrence | MeliGraph SimilarItems
|
| Medium scale | Matrix factorization for latent features | Matrex SVD, Nx tensors |
| Large scale | Neural collaborative filtering | Nx + Axon, Scholar metrics |
| Production | Real-time serving, A/B testing | Phoenix, Oban, FLAME |
Conclusion
Elixir offers a uniquely pragmatic path to building recommendation systems. You can start with a 60-line module that runs in any IEx session, then scale up to graph algorithms, tensor operations, and GPU-accelerated neural networks—all without leaving the language.
The key insight is that recommendation systems are fundamentally about data transformation: user preferences → similarity scores → ranked items. That's a pipeline, and pipelines are what Elixir does best.
Whether you're building a movie recommender like the one above, a product suggestion engine for e-commerce, or a content feed for a social platform, the functional, concurrent, and fault-tolerant nature of Elixir gives you a solid foundation to start simple—and the ecosystem to grow sophisticated when you need to.
來源:DEV Community · dev.to