Add similar records
Initial stab by Claude
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user