Add similar records

Initial stab by Claude
This commit is contained in:
Claudio Ortolina
2025-10-11 21:39:59 +02:00
parent 07218d16ca
commit 9cae972bc5
18 changed files with 1869 additions and 5 deletions
+10
View File
@@ -279,6 +279,15 @@ defmodule MusicLibrary.Records do
|> BackgroundRepo.insert()
end
def generate_embedding_async(record) do
meta = %{title: record.title, artists: Enum.map(record.artists, & &1.name)}
params = %{"record_id" => record.id}
params
|> Worker.GenerateRecordEmbedding.new(meta: meta)
|> BackgroundRepo.insert()
end
def resize_cover(record) do
with {:ok, thumb_data} <- Assets.Image.resize(record.cover_data),
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
@@ -340,6 +349,7 @@ defmodule MusicLibrary.Records do
def create_record(attrs \\ %{}) do
with {:ok, record} <- do_create_record(attrs) do
extract_colors_async(record, :fast)
generate_embedding_async(record)
record
|> Record.artist_ids()
@@ -0,0 +1,47 @@
defmodule MusicLibrary.Records.RecordEmbedding do
use Ecto.Schema
import Ecto.Changeset
alias MusicLibrary.Records.Record
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "record_embeddings" do
belongs_to :record, Record
field :embedding, MusicLibrary.Records.RecordEmbedding.EmbeddingType
field :text_representation, :string
timestamps(type: :utc_datetime)
end
def changeset(record_embedding, attrs) do
record_embedding
|> cast(attrs, [:record_id, :embedding, :text_representation])
|> validate_required([:record_id, :embedding, :text_representation])
|> validate_embedding_dimensions()
|> unique_constraint(:record_id)
end
defp validate_embedding_dimensions(changeset) do
case get_change(changeset, :embedding) do
nil ->
changeset
embedding when is_list(embedding) ->
if length(embedding) == 1536 do
changeset
else
add_error(
changeset,
:embedding,
"must have exactly 1536 dimensions, got #{length(embedding)}"
)
end
_ ->
add_error(changeset, :embedding, "must be a list of floats")
end
end
end
@@ -0,0 +1,50 @@
defmodule MusicLibrary.Records.RecordEmbedding.EmbeddingType do
@moduledoc """
Custom Ecto type for storing embedding vectors.
Embeddings are stored as JSON-encoded arrays of floats in the database,
but presented as Elixir lists in the application.
"""
use Ecto.Type
@impl true
def type, do: :string
@impl true
def cast(embedding) when is_list(embedding) do
if Enum.all?(embedding, &is_float/1) or Enum.all?(embedding, &is_number/1) do
# Convert all numbers to floats
{:ok, Enum.map(embedding, &to_float/1)}
else
:error
end
end
def cast(_), do: :error
@impl true
def load(json) when is_binary(json) do
case JSON.decode(json) do
{:ok, embedding} when is_list(embedding) ->
{:ok, Enum.map(embedding, &to_float/1)}
_ ->
:error
end
end
def load(_), do: :error
@impl true
def dump(embedding) when is_list(embedding) do
json = JSON.encode!(embedding)
{:ok, json}
rescue
_ -> :error
end
def dump(_), do: :error
defp to_float(n) when is_float(n), do: n
defp to_float(n) when is_integer(n), do: n * 1.0
end
+179
View File
@@ -0,0 +1,179 @@
defmodule MusicLibrary.Records.Similarity do
@moduledoc """
Functions for calculating and finding similar records based on embeddings.
"""
import Ecto.Query
alias MusicLibrary.Records.{Record, RecordEmbedding}
alias MusicLibrary.Repo
@doc """
Generates a text representation of a record for embedding generation.
The representation includes:
- Title
- Artist names
- Genres
- Release year
- Type (album, EP, etc.)
"""
def text_representation(%Record{} = record) do
artist_names = Record.artist_names(record)
genres = Enum.join(record.genres, ", ")
year = extract_year(record.release_date)
type = humanize_type(record.type)
"""
Album: #{record.title}
Artists: #{artist_names}
Genres: #{genres}
Released: #{year}
Type: #{type}
"""
|> String.trim()
end
@doc """
Finds similar records based on embedding similarity.
## Options
- `:limit` - Maximum number of similar records to return (default: 10)
- `:min_similarity` - Minimum similarity score (0.0 to 1.0, default: 0.0)
- `:scope` - Filter by :collection or :wishlist (default: no filter)
## Examples
iex> find_similar("record-id-123", limit: 5)
[%Record{}, ...]
iex> find_similar("record-id-123", min_similarity: 0.7, scope: :collection)
[%Record{}, ...]
"""
def find_similar(record_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
min_similarity = Keyword.get(opts, :min_similarity, 0.0)
scope = Keyword.get(opts, :scope)
with {:ok, source_embedding} <- get_embedding(record_id),
similar_records <- calculate_similarities(source_embedding, record_id, scope) do
similar_records
|> Enum.filter(fn {_record, similarity} -> similarity >= min_similarity end)
|> Enum.take(limit)
|> Enum.map(fn {record, similarity} -> {record, Float.round(similarity, 4)} end)
else
{:error, :not_found} -> []
end
end
@doc """
Calculates cosine similarity between two embedding vectors.
Returns a float between -1.0 and 1.0, where:
- 1.0 = identical vectors
- 0.0 = orthogonal vectors
- -1.0 = opposite vectors
"""
def cosine_similarity(vec_a, vec_b) when is_list(vec_a) and is_list(vec_b) do
if length(vec_a) != length(vec_b) do
raise ArgumentError, "Vectors must have the same length"
end
dot_product =
Enum.zip(vec_a, vec_b)
|> Enum.reduce(0.0, fn {a, b}, acc -> acc + a * b end)
magnitude_a = calculate_magnitude(vec_a)
magnitude_b = calculate_magnitude(vec_b)
if magnitude_a == 0.0 or magnitude_b == 0.0 do
0.0
else
dot_product / (magnitude_a * magnitude_b)
end
end
@doc """
Gets the embedding for a record.
"""
def get_embedding(record_id) do
case Repo.get_by(RecordEmbedding, record_id: record_id) do
nil -> {:error, :not_found}
embedding -> {:ok, embedding.embedding}
end
end
@doc """
Stores an embedding for a record.
"""
def store_embedding(record_id, embedding, text_representation) do
attrs = %{
record_id: record_id,
embedding: embedding,
text_representation: text_representation
}
%RecordEmbedding{}
|> RecordEmbedding.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace, [:embedding, :text_representation, :updated_at]},
conflict_target: :record_id
)
end
# Private functions
defp extract_year(nil), do: "Unknown"
defp extract_year(""), do: "Unknown"
defp extract_year(release_date) do
case String.split(release_date, "-", parts: 2) do
[year | _] -> year
_ -> "Unknown"
end
end
defp humanize_type(:album), do: "Album"
defp humanize_type(:ep), do: "EP"
defp humanize_type(:live), do: "Live"
defp humanize_type(:compilation), do: "Compilation"
defp humanize_type(:single), do: "Single"
defp humanize_type(:other), do: "Other"
defp humanize_type(_), do: "Unknown"
defp calculate_magnitude(vector) do
vector
|> Enum.reduce(0.0, fn x, acc -> acc + x * x end)
|> :math.sqrt()
end
defp calculate_similarities(source_embedding, source_record_id, scope) do
query =
from re in RecordEmbedding,
where: re.record_id != ^source_record_id,
join: r in Record,
on: r.id == re.record_id,
select: {r, re.embedding}
query = apply_scope_filter(query, scope)
query
|> Repo.all()
|> Enum.map(fn {record, embedding} ->
similarity = cosine_similarity(source_embedding, embedding)
{record, similarity}
end)
|> Enum.sort_by(fn {_record, similarity} -> similarity end, :desc)
end
defp apply_scope_filter(query, :collection) do
from [re, r] in query, where: not is_nil(r.purchased_at)
end
defp apply_scope_filter(query, :wishlist) do
from [re, r] in query, where: is_nil(r.purchased_at)
end
defp apply_scope_filter(query, _), do: query
end
@@ -0,0 +1,39 @@
defmodule MusicLibrary.Worker.GenerateRecordEmbedding do
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
require Logger
alias MusicLibrary.Records
alias MusicLibrary.Records.Similarity
@impl Oban.Worker
def perform(%Oban.Job{args: %{"record_id" => record_id}}) do
record = Records.get_record!(record_id)
with {:ok, embedding} <- generate_embedding(record),
{:ok, _} <- store_embedding(record, embedding) do
Logger.info("Generated embedding for record #{record_id}")
:ok
else
{:error, reason} = error ->
Logger.error("Failed to generate embedding for record #{record_id}: #{inspect(reason)}")
error
end
end
defp generate_embedding(record) do
text = Similarity.text_representation(record)
case OpenAI.embeddings(text) do
{:ok, embedding} ->
{:ok, {embedding, text}}
{:error, reason} ->
{:error, reason}
end
end
defp store_embedding(record, {embedding, text_representation}) do
Similarity.store_embedding(record.id, embedding, text_representation)
end
end