Back to blog
elixir phoenix agent nx nlp wine ML

Semantic Wine Search with Elixir, Bumblebee and Apple Silicon

Alembic Labs ·

Semantic Wine Search with Elixir, Bumblebee and Apple Silicon

See It in Action

Before diving into the code, try the live demo: garcon.fly.dev

Type something like:

  • "crisp citrus mineral" - finds fresh, zesty champagnes
  • "rich toasty brioche" - returns aged, complex sparkling wines
  • "cheap party wine" - budget-friendly proseccos and cavas

The app returns wines that match semantically, even when those exact words aren’t in the description. That’s the magic of embeddings.

What’s Under the Hood

Here’s the entire search logic:

defmodule Garcon.Search.Similarity do
  alias Garcon.Search.Index
  alias Garcon.ML.Embeddings

  def search(query_text, opts \\ []) do
    top_k = Keyword.get(opts, :top_k, 10)
    query_embedding = Embeddings.generate_query_embedding(query_text)
    Index.search(query_embedding, top_k)
  end
end

That’s it. Three lines of actual logic:

  1. Generate an embedding from the user’s query
  2. Search the index for similar wines
  3. Return the top K results

The complexity is hidden in two places: the ML model that generates embeddings, and the index that makes similarity search fast. We’ll build both from scratch.

The Tech Stack

  • Phoenix LiveView: Real-time interface with instant search results
  • Bumblebee: Elixir library for ML models (Hugging Face)
  • Nx: Tensors and numerical computing (Elixir’s “NumPy”)
  • EMLX/EXLA: Nx backends for hardware acceleration (EMLX for Apple Silicon’s Metal GPU in dev, EXLA for production)
  • Explorer: DataFrames for loading the CSV
  • Agent: In-memory wine storage (simple and fast)
  • DETS: Persistent embedding cache

We’ll start with a simple brute-force approach using Nx.dot for similarity search. This works great for learning the concepts, but we’ll quickly hit performance limits. Later in the series, we’ll explore how to scale this properly…

The Dataset

~130k wines extracted from Kaggle’s Wine Reviews dataset. Each wine has:

  • title: wine name
  • description: tasting notes (our text for NLP)
  • variety: grape variety (Champagne Blend, Prosecco, etc.)
  • points: score out of 100
  • price: price in dollars

How It Works (Overview)

┌─────────────────────────────────────────────────────────────────┐
│                        PREPARATION (once)                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  CSV ──► Explorer DataFrame ──► Agent (memory)                  │
│                                                                 │
│  For each wine:                                                 │
│    description ──► e5 Model ──► embedding [384 floats]          │
│                                      │                          │
│                                      ▼                          │
│                               DETS (disk)                       │
│                                      │                          │
│                                      ▼                          │
│                          Normalized Nx matrix                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        SEARCH (real-time)                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  "crisp citrus" ──► e5 Model ──► query embedding [384]          │
│                                          │                      │
│                                          ▼                      │
│                          Nx.dot(matrix, query)                  │
│                                          │                      │
│                                          ▼                      │
│                          Similarity scores                      │
│                                          │                      │
│                                          ▼                      │
│                          Top 10 wines + scores                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Let’s Build It

Create a new Phoenix app:

mix phx.new garcon --no-ecto --no-mailer
cd garcon

We skip Ecto because we’ll store wines in memory with an Agent. Simpler for this use case.

Add the ML dependencies to mix.exs:

defp deps do
  [
    # ... existing deps ...

    # ML stack
    {:nx, "~> 0.9"},
    {:exla, "~> 0.9"},
    {:bumblebee, "~> 0.6"},
    {:explorer, "~> 0.9"},

    # Apple Silicon GPU (optional, dev only)
    {:emlx, github: "elixir-nx/emlx", only: :dev}
  ]
end

Fetch dependencies:

mix deps.get

The first time you run the app, Bumblebee will download the ML model (~120MB). This happens once and gets cached in ~/.cache/bumblebee.

What We’ll Cover

  1. Embeddings explained - What are vectors and why they capture meaning
  2. Nx explained - Tensors, operations, and why it’s fast
  3. Bumblebee and models - Loading and running the e5 model
  4. Storage with ETS/DETS - Caching embeddings for performance
  5. Vectorized search - Brute-force cosine similarity with Nx
  6. LiveView interface - Real-time search with debouncing
  7. Recap - Putting it all together
  8. Scaling up - When brute-force isn’t enough anymore…

Prerequisites

  • Elixir 1.15+
  • A Mac with Apple Silicon for EMLX, otherwise EXLA works too

Need help using ML/AI in your systems? We specialize in Elixir systems that gets clever everyday. Get in touch.