Eigene KI Empfehlungen in Solidus bauen
Kurz gesagt
Niveau: Profi
- Der Kern von Solidus hat kein Empfehlungssystem und zeichnet keine Produktaufrufe auf. Das Starter Frontend zeigt nur drei Produkte aus demselben Taxon.
- Fang mit gemeinsamem Auftreten an, korrigiert um die Beliebtheit, dann kollaboratives Filtern (ALS auf implizitem Feedback) und inhaltsbasiertes Filtern (TF IDF und Kosinus), gemischt, damit neue Produkte und dünne Daten abgedeckt sind.
- Trainier in einem wöchentlichen Batchjob, importier die Werte unter einer Modellversion und liefer sie aus einer gecachten Tabelle aus, mit Rückfall auf Taxonprodukte und Bestseller.
- Beweis es mit einem A/B Test. Erfass Verhalten für die Personalisierung erst nach einem Ja: Nach § 25 TDDDG ist nur das unbedingt Erforderliche einwilligungsfrei, und eine persönliche Empfehlungsreihe gehört nicht dazu.
Solidus gibt dir einen soliden eCommerce Kern und keine Empfehlungsmaschine. Diese Anleitung baut eine von den Signalen an auf: was zusammen gekauft wird, was ähnliche Kunden mochten, was der Katalog sagt, und eine Mischung aus allen dreien, mit Code, der läuft, einem Test, der es beweist, und einer Einwilligung, die vor dem ersten erfassten Aufruf steht.
Was Solidus dir gibt, und was es dir überlässt
Solidus ist das quelloffene eCommerce Framework auf Ruby on Rails, auf dem ich baue. Es liefert einen soliden Kern: Produkte, Varianten, Bestellungen, Zahlungen, Versand, Aktionen. Eine Empfehlungsmaschine liefert es nicht.
Was es liefert, liegt aber nah dran. Das Starter Frontend, die Storefront, die der Solidus Installer in deine eigene Anwendung kopiert, zeigt auf jeder Produktseite eine Reihe "You May Also Like".1 Dahinter steckt ein kleiner Override auf Spree::Product: Nimm die Kategorien des Produkts (Solidus nennt sie Taxons), sammle die anderen Produkte darin, behalte die ersten drei.2 Das ist ehrlich und nützlich, und es weiß nichts darüber, was Menschen tatsächlich kaufen.
Solidus zeichnet außerdem nicht auf, was sich Menschen ansehen. Sein Event Bus meldet Dinge wie eine abgeschlossene Bestellung, ein versandtes Paket oder eine stornierte Bestellung, aber nichts über einen Produktaufruf.3 Die Signale, aus denen ein Empfehlungssystem lernt, sammelst du also selbst, und die Intelligenz darüber baust du selbst.
Das gefällt mir. Ein Empfehlungssystem, das nur zählt, welche Produkte gemeinsam im Warenkorb liegen, ohne nach dem Warum zu fragen, reicht nicht. Baust du dein eigenes, weißt du, was es tut, die Daten gehören dir, und du stimmst es auf deinen Shop ab.
Durchgerechnetes Beispiel: der Shop in diesem Artikel
Das Lindenauer Teekontor ist ein erfundener Teehändler aus Leipzig, den Marlene mit einem Solidus Shop unter teekontor.example betreibt. Es verkauft 240 Produkte: lose Tees, Teekannen, Teesiebe, Becher und Geschenkdosen. In den letzten sechs Monaten kamen 9.000 Bestellungen herein, im Schnitt rund 31 € pro Bestellung.
Wer sich heute Earl Grey ansieht, bekommt drei andere Produkte aus dem Taxon "Schwarzer Tee" gezeigt. Marlenes Frage ist die, die dieser Artikel beantwortet: Kann der Shop das Teesieb zeigen, das Käufer von Earl Grey tatsächlich als Nächstes kaufen, und einer wiederkehrenden Kundin die Tees, die zu ihr passen, ohne das Gesetz oder die Website zu brechen?
Drei Wege zu empfehlen, und die Frage, die jeder beantwortet
Es gibt drei klassische Ansätze, und jeder beantwortet eine andere Frage.45
Gemeinsames Auftreten (Co Occurrence) fragt: Was wird meist zusammen mit diesem Produkt gekauft? Es braucht nur Bestellungen.
Kollaboratives Filtern (Collaborative Filtering) fragt: Was mochten Menschen mit ähnlichem Verhalten danach? Es lernt aus dem Verhalten aller zugleich und weiß nichts über die Produkte selbst.
Inhaltsbasiertes Filtern (Content Based Filtering) fragt: Was ist ähnlich beschrieben wie das, was diese Person mochte? Es liest den Katalog und weiß nichts über andere Kunden.
Ein Hybrid mischt das zweite und das dritte, weil jedes den blinden Fleck des anderen abdeckt. Kollaboratives Filtern kann kein Produkt empfehlen, das noch niemand angefasst hat. Inhaltsbasiertes Filtern entfernt sich nie weit von dem, was die Kundin schon kennt.67 Der Rest dieses Artikels baut alles davon, in dieser Reihenfolge.
Fang mit dem an, was zusammen gekauft wird, und korrigier die Beliebtheit
Beim gemeinsamen Auftreten beginnt auch der bekannte Item to Item Ansatz von Amazon: Für jedes Produkt die Produkte finden, die Kunden meist zusammen kaufen, eine Ähnlichkeit berechnen und vorab eine Tabelle ähnlicher Artikel bauen.4 Die naive Fassung zählt einfach. Das Problem: Zählen belohnt, was beliebt ist. Jedes Produkt wirkt mit dem Bestseller verwandt, weil der Bestseller in jedem Warenkorb liegt.
Die Lösung ist, den Anteil mit der Grundrate zu vergleichen.8 Die Konfidenz der Regel " gekauft, also auch gekauft" ist eine bedingte Wahrscheinlichkeit, und ihr Interesse ist, wie weit diese Wahrscheinlichkeit über dem Anteil aller Bestellungen liegt, die ohnehin enthalten:
Dabei ist die Zahl der Bestellungen mit Produkt , die Zahl der Bestellungen mit und , der Anteil der Bestellungen mit , die auch enthalten, und der Anteil aller Bestellungen mit . Ein Interesse nahe null heißt, dass mit nicht öfter auftaucht als überall sonst. Die Ideen darunter stehen in Grundlagen: Wahrscheinlichkeit und Grundlagen: Grundraten.
Durchgerechnetes Beispiel: das Teesieb und die Geschenktüte
Im Teekontor steckt Earl Grey in 1.200 der 9.000 Bestellungen. Der Glasbecher mit Teesieb steckt in 900 Bestellungen, und 300 davon enthalten auch Earl Grey. Die Geschenktüte aus Papier steckt in 2.700 Bestellungen, und 420 davon enthalten Earl Grey.
Konfidenz für das Teesieb: . Für die Geschenktüte: . Nach bloßem Zählen ist die Geschenktüte die bessere Empfehlung.
Jetzt die Grundraten. Das Teesieb steckt in aller Bestellungen, die Geschenktüte in .
Interesse für das Teesieb: . Für die Geschenktüte: . Die Geschenktüte ist einfach beliebt. Das Teesieb ist das, was Käufer von Earl Grey gezielt als Nächstes wollen. Diese Reihe lohnt sich.
Kollaboratives Filtern lernt für jede Kundin einen Geschmack
Gemeinsames Auftreten arbeitet Produkt für Produkt. Kollaboratives Filtern geht einen Schritt weiter und lernt für jede Kundin und jedes Produkt eine kurze Liste von Zahlen, sodass die Zahlen einer Kundin zu den Produkten passen, die sie mögen würde. Diese Listen heißen Embeddings oder latente Faktoren: Punkte in einem gemeinsamen Raum, in dem Dinge, die zusammen genutzt werden, nah beieinander liegen.5 Bekannt wurde die Methode durch den Netflix Prize, und Koren, Bell und Volinsky beschreiben zwei Wege, sie anzupassen: stochastischen Gradientenabstieg und alternierende kleinste Quadrate, kurz ALS.9
Shopdaten sind implizit. Menschen bewerten selten eine Teekanne, sie sehen sie an, legen sie in den Warenkorb, kaufen sie oder ignorieren sie. Hu, Koren und Volinsky haben die Methode genau dafür angepasst.10 Jedes Paar aus Kundin und Produkt bekommt eine Präferenz , 1, wenn die Kundin überhaupt mit dem Produkt interagiert hat, sonst 0, und eine Konfidenz, die mit den Belegen wächst:
Dabei ist eine Kundin und ein Produkt, die beobachtete Stärke der Interaktion, , wie schnell die Konfidenz mit ihr wächst, und die daraus folgende Konfidenz. ist der Faktorvektor der Kundin und der des Produkts, und ihr Skalarprodukt, die vom Modell vorhergesagte Präferenz. ist das Gewicht der Regularisierung, das die Vektoren klein hält, und die quadrierte Länge eines Vektors. ALS hält alle Produktvektoren fest und löst nach den Kundinnen, hält dann die Kundinnen fest und löst nach den Produkten, und wiederholt das.10 Die Grundlagen zu Vektoren und Matrizen stehen in Grundlagen: Matrizen.
Durchgerechnetes Beispiel: eine Kundin, drei Produkte
Marlenes Tracker gibt einem Kauf das Gewicht 5 und einem Produktaufruf das Gewicht 1 (die eigenen Gewichte des Beispiels, im nächsten Abschnitt erklärt). Eine Kundin aus Plagwitz hat Earl Grey einmal gekauft und zweimal angesehen, also . Mit , dem Standard der Bibliothek weiter unten, ist die Konfidenz . Für eine Teekanne, die sie nie angefasst hat, ist , und . Dem Modell ist es achtmal so wichtig, Earl Grey richtig zu treffen.
Nimm jetzt an, das Modell hätte nur zwei Faktoren gelernt. Ihr Vektor ist . Earl Grey ist , der Becher mit Teesieb , der Matchabesen .
Earl Grey: . Teesieb: . Besen: . Earl Grey hat sie schon, also wird es herausgefiltert, und das Teesieb führt. Niemand hat dem Modell gesagt, dass ein Teesieb zu losem Tee passt. Es hat es aus dem gelernt, was alle anderen getan haben.
Für die meisten Solidus Shops nutze ich ALS statt eines Deep Learning Modells. ALS kommt gut mit impliziten, dünn besetzten Daten zurecht, und jede einzelne Kundin hat nur einen winzigen Teil des Katalogs berührt. Neuronale Ansätze können besser sein, aber sie wollen weit mehr Daten und Rechenleistung, als ein Shop in Marlenes Größe hat.
Inhaltsbasiertes Filtern liest den Katalog
Inhaltsbasiertes Filtern macht aus dem Text jedes Produkts einen Vektor und empfiehlt die nächsten. Das Standardrezept ist TF IDF, kurz für Term Frequency, Inverse Document Frequency: Ein Wort bekommt für ein Produkt einen hohen Wert, wenn es im Text dieses Produkts häufig und im ganzen Katalog selten ist. Zwei Vektoren werden dann über den Winkel zwischen ihnen verglichen:
Dabei sind und die Wortvektoren der beiden Produkte, ihr Skalarprodukt und und ihre Längen. Das Ergebnis reicht von 0, nichts gemeinsam, bis 1, dieselbe Richtung.5 Der Vektorisierer von scikit-learn skaliert standardmäßig jede Zeile auf die Länge 1, der Kosinus ist also einfach das Skalarprodukt.11 Die Vektorrechnung steht in Grundlagen: Matrizen.
Es funktioniert ab dem ersten Tag, für eine neue Kundin, sobald sie ein Produkt ansieht, und für ein neues Produkt, sobald es im Katalog steht. Seine Schwäche: Es kann nur auf Interessen aufbauen, die die Kundin schon hat.6 Und es ist nur so gut wie der Produkttext. "Schwarzer Tee. 100 g." gibt ihm nichts zum Festhalten. Geschmacksnoten, Herkunft, Stärke und Anlass geben ihm viel. Dieselben reicheren Daten helfen KI Assistenten, deine Produkte zu finden, darum geht es in wie generative KI die Produktentdeckung verändert.
Durchgerechnetes Beispiel: drei Tees als Wortzählung
Nimm drei Wörter, "assam", "frühstück" und "grün", und zähl sie in drei Beschreibungen. English Breakfast ist , Assam Gold ist , und Sencha ist .
English Breakfast und Assam Gold: Das Skalarprodukt ist . Die Längen sind und . Der Kosinus ist , sehr ähnlich.
English Breakfast und Sencha: Das Skalarprodukt ist , also ist der Kosinus 0. Ein neuer Assam, der morgen dazukommt, wird schon am ersten Tag neben English Breakfast empfohlen, bevor ihn irgendwer gekauft hat.
Erst die Signale aufzeichnen, und nur mit einem Ja
Empfehlungen sind nur so gut wie die Signale, mit denen du sie fütterst, und Solidus zeichnet Bestellungen auf, keine Aufrufe.3 Die erste Aufgabe ist also eine kleine Eventtabelle. Ich halte sie bewusst schmal: wer, welches Produkt, welche Art von Ereignis, wie stark, wann. Kein Referrer, kein Freitext, nichts, was das Modell nicht nutzt.
# db/migrate/20260223090000_create_recommendation_tables.rb
class CreateRecommendationTables < ActiveRecord::Migration[7.1]
def change
create_table :recommendation_interaction_events do |t|
t.references :user, null: false, index: true
t.references :product, null: false, index: true
t.string :event_type, null: false
t.decimal :weight, precision: 4, scale: 2, null: false
t.timestamps
end
create_table :recommendation_item_scores do |t|
t.references :product, null: false, index: true
t.bigint :recommended_product_id, null: false
t.float :score, null: false
t.string :model_version, null: false, index: true
t.timestamps
end
create_table :recommendation_user_scores do |t|
t.references :user, null: false, index: true
t.references :product, null: false
t.float :score, null: false
t.string :model_version, null: false, index: true
t.timestamps
end
# spree_users with solidus_auth_devise; use your own user table otherwise
add_column Spree.user_class.table_name, :recommendations_consent, :boolean,
default: false, null: false
end
end
# app/models/recommendations.rb and friends
module Recommendations
class InteractionEvent < ApplicationRecord
self.table_name = "recommendation_interaction_events"
end
class ItemScore < ApplicationRecord
self.table_name = "recommendation_item_scores"
end
class UserScore < ApplicationRecord
self.table_name = "recommendation_user_scores"
end
class EventTracker
# Implicit feedback weights: a stronger signal of interest gets more.
WEIGHTS = { purchase: 5.0, add_to_cart: 3.0, product_view: 1.0 }.freeze
def track(event_type:, product:, user:)
return unless user&.recommendations_consent?
InteractionEvent.create!(
user_id: user.id,
product_id: product.id,
event_type: event_type.to_s,
weight: WEIGHTS.fetch(event_type)
)
end
# One row per customer and product, summed, for training.
def interaction_rows(since: 6.months.ago)
InteractionEvent
.where(created_at: since..)
.group(:user_id, :product_id)
.sum(:weight)
.map { |(user_id, product_id), weight| [user_id, product_id, weight.to_f] }
end
end
end
Zwei Dinge haben sich gegenüber der ersten Fassung dieses Artikels geändert. Die Prüfung des Nutzers lief über Spree::User, das es nur mit einem bestimmten Authentifizierungsgem gibt. Solidus lässt dich deine eigene Nutzerklasse mitbringen, also fragt der Code das Nutzerobjekt selbst.12 Und anonyme Besucher werden gar nicht mehr erfasst, darum geht es im Abschnitt zur Einwilligung weiter unten.
Durchgerechnetes Beispiel: eine Woche von Marlenes Signalen
Eine angemeldete Kundin, die Ja gesagt hat, ruft die Seite des Darjeeling dreimal auf, legt ihn einmal in den Warenkorb und kauft ihn. Die Tabelle bekommt fünf Zeilen. Fürs Training summiert: . Eine andere Kundin ruft die Seite einmal auf und geht: 1. Das Modell sieht die erste als starke Präferenz und die zweite als schwache. Diese Gewichte sind der Startpunkt des Beispiels, eine Ermessensfrage, und der A/B Test weiter unten zeigt, ob sie taugen.
Produktseite, Warenkorb und fertige Bestellung anbinden
Mit dem Starter Frontend liegen die Controller der Storefront in deiner eigenen Anwendung, es gibt also nichts zu dekorieren. Du änderst ProductsController direkt.1 Die Prüfung der Einwilligung sitzt im StoreController, von dem jeder Controller der Storefront erbt.
# app/controllers/store_controller.rb (add to the existing class)
helper_method :personalisation_consent?
# Your consent banner decides the cookie name and value. A signed in
# customer's choice is also stored on the account, for background jobs.
def personalisation_consent?
spree_current_user&.recommendations_consent? &&
cookies[:consent_personalisation] == "granted"
end
# app/controllers/products_controller.rb (add to the existing class)
after_action :track_product_view, only: :show
private
def track_product_view
return unless personalisation_consent?
Recommendations::EventTracker.new.track(
event_type: :product_view, product: @product, user: spree_current_user
)
end
# app/controllers/cart_line_items_controller.rb (add to the existing class)
after_action :track_cart_add, only: :create
private
def track_cart_add
return unless personalisation_consent? && @line_item && @order.errors.none?
Recommendations::EventTracker.new.track(
event_type: :add_to_cart, product: @line_item.product, user: spree_current_user
)
end
Käufe kommen über den Event Bus. Ein Subscriber in Solidus bindet Omnes::Subscriber ein, nennt das Ereignis, das er behandelt, und liest die Bestellung aus dem Payload. Außerdem muss er in einem Initializer an Spree::Bus angemeldet werden, innerhalb von to_prepare, damit er das Neuladen des Codes übersteht.13 Die erste Fassung dieses Artikels hat diese Anmeldung weggelassen, und ohne sie läuft der Subscriber nie.
# app/subscribers/recommendations/order_subscriber.rb
module Recommendations
class OrderSubscriber
include Omnes::Subscriber
handle :order_finalized,
with: :track_purchases,
id: :recommendations_track_purchases
def track_purchases(event)
order = event.payload[:order]
return unless order.user
tracker = EventTracker.new
order.line_items.each do |item|
tracker.track(event_type: :purchase, product: item.product, user: order.user)
end
end
end
end
# config/initializers/omnes.rb
Rails.application.config.to_prepare do
Recommendations::OrderSubscriber.new.subscribe_to(Spree::Bus)
end
Die Guides raten, den Event Bus und die eingebauten Hooks dem Überschreiben von Solidus Klassen vorzuziehen und einen Override, wenn du einen brauchst, klein und sprechend benannt zu halten.14 Dieses Setup braucht keinen.
Beide Modelle in einem Python Skript trainieren
Das Training läuft als Batchjob, nicht pro Seitenaufruf. Eine Rake Task schreibt zwei CSV Dateien, das Skript trainiert darauf und schreibt zwei weitere, und Rails importiert die Ergebnisse. Python bei jedem Seitenaufruf zu starten, kostet Ladezeit für nichts, und die erste Fassung dieses Artikels hat das bei den Fallstricken zugegeben, während ihr Code genau das tat.
Das Skript nutzt die Bibliothek implicit für ALS, die die Methode von Hu, Koren und Volinsky umsetzt,15 und scikit-learn für TF IDF. Eine Korrektur ist hier wichtig. Seit implicit 0.5 nimmt fit eine Matrix mit den Kunden als Zeilen und den Produkten als Spalten.16 Die erste Fassung hat die Matrix vor dem Fitten transponiert, was mit jeder aktuellen Version das Modell auf der falschen Achse trainiert.
"""Train the collaborative and the content model, write recommendation tables.
Usage: python3 lib/recommendations/train.py tmp/recs
Reads interactions.csv (user_id, product_id, weight) and products.csv
(product_id, text). Writes item_recs.csv and user_recs.csv.
Tested with implicit 0.7, scikit-learn 1.9 and pandas 3.1.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from implicit.als import AlternatingLeastSquares
from scipy.sparse import csr_matrix
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel
N = 12
def train_als(interactions, factors=50, iterations=30, regularization=0.01, alpha=1.0):
users = interactions["user_id"].unique()
items = interactions["product_id"].unique()
user_pos = pd.Series(np.arange(len(users)), index=users)
item_pos = pd.Series(np.arange(len(items)), index=items)
user_items = csr_matrix(
(
interactions["weight"].to_numpy(dtype=np.float32),
(
user_pos[interactions["user_id"]].to_numpy(),
item_pos[interactions["product_id"]].to_numpy(),
),
),
shape=(len(users), len(items)),
)
model = AlternatingLeastSquares(
factors=factors,
iterations=iterations,
regularization=regularization,
alpha=alpha,
use_gpu=False,
random_state=42,
)
# Since implicit 0.5, fit takes users as rows and products as columns.
# The old advice to transpose the matrix first now trains on the wrong axis.
model.fit(user_items, show_progress=False)
return model, users, items, user_items
def als_user_recs(model, users, items, user_items, n=N):
ids, scores = model.recommend(
np.arange(len(users)), user_items, N=n, filter_already_liked_items=True
)
rows = [
(users[u], items[i], float(s))
for u, (id_row, score_row) in enumerate(zip(ids, scores))
for i, s in zip(id_row, score_row)
if i >= 0 and np.isfinite(s)
]
return pd.DataFrame(rows, columns=["user_id", "product_id", "score"])
def als_similar(model, items, n=N):
ids, scores = model.similar_items(np.arange(len(items)), N=n + 1)
rows = [
(items[i], items[j], float(s))
for i, (id_row, score_row) in enumerate(zip(ids, scores))
for j, s in zip(id_row, score_row)
if j >= 0 and j != i
]
return pd.DataFrame(rows, columns=["product_id", "recommended_id", "score"])
def content_similar(products, n=N):
tfidf = TfidfVectorizer(
stop_words="english", ngram_range=(1, 2), min_df=2, max_df=0.95, sublinear_tf=True
)
matrix = tfidf.fit_transform(products["text"])
ids = products["product_id"].to_numpy()
rows = []
for start in range(0, matrix.shape[0], 1000):
# Rows are L2 normalised, so the dot product is the cosine similarity.
block = linear_kernel(matrix[start:start + 1000], matrix)
for offset, sims in enumerate(block):
i = start + offset
sims[i] = -1.0 # never recommend the product itself
for j in np.argsort(-sims)[:n]:
if sims[j] > 0:
rows.append((ids[i], ids[j], float(sims[j])))
return pd.DataFrame(rows, columns=["product_id", "recommended_id", "score"])
def normalise(df, key):
df = df[df["score"] > 0].copy()
df["score"] = df["score"] / df.groupby(key)["score"].transform("max")
return df
def blend(collab, content, key, w_collab=0.6, w_content=0.4, n=N):
pair = [key, "recommended_id"]
merged = normalise(collab, key).merge(
normalise(content, key), on=pair, how="outer", suffixes=("_cf", "_cb")
).fillna(0.0)
# Fewer than five collaborative candidates: lean on content instead.
thin = merged.groupby(key)["score_cf"].transform(lambda s: (s > 0).sum() < 5)
wc = np.where(thin, 0.3, w_collab)
wt = np.where(thin, 0.7, w_content)
merged["score"] = wc * merged["score_cf"] + wt * merged["score_cb"]
merged = merged.sort_values([key, "score"], ascending=[True, False])
return merged.groupby(key).head(n)[pair + ["score"]]
def main(folder):
folder = Path(folder)
interactions = pd.read_csv(folder / "interactions.csv")
products = pd.read_csv(folder / "products.csv").fillna("")
model, users, items, user_items = train_als(interactions)
content = content_similar(products)
item_recs = blend(als_similar(model, items), content, "product_id")
item_recs.to_csv(folder / "item_recs.csv", index=False)
# Content candidates for a person: products similar to their five strongest.
strongest = interactions.sort_values("weight", ascending=False).groupby("user_id").head(5)
by_content = (
strongest.merge(content, on="product_id")
.groupby(["user_id", "recommended_id"], as_index=False)["score"].sum()
)
seen = interactions.rename(columns={"product_id": "recommended_id"})[["user_id", "recommended_id"]]
by_content = by_content.merge(seen, how="left", indicator=True)
by_content = by_content[by_content["_merge"] == "left_only"].drop(columns="_merge")
collab = als_user_recs(model, users, items, user_items).rename(
columns={"product_id": "recommended_id"}
)
user_recs = blend(collab, by_content, "user_id").rename(
columns={"recommended_id": "product_id"}
)
user_recs.to_csv(folder / "user_recs.csv", index=False)
print(f"{len(users)} customers, {len(items)} products with interactions, "
f"{len(products)} products in the catalogue")
if __name__ == "__main__":
main(sys.argv[1])
Eine Anmerkung für einen deutschsprachigen Katalog: stop_words="english" filtert englische Füllwörter, und "english" ist derzeit der einzige Name, den scikit-learn dort kennt. Für deutsche Produkttexte übergibst du stattdessen eine eigene Liste deutscher Füllwörter.11
Probier es zuerst an einer kleinen erfundenen Datei aus, sagen wir 800 Kundinnen und 240 Produkte. Die Produkte, die noch niemand angefasst hat, bekommen trotzdem Empfehlungen, allein aus ihrem Text, und genau für diesen Kaltstart gibt es den Hybrid.
Beide mischen, damit jedes die Lücke des anderen abdeckt
Die Funktion blend macht den Hybrid. Sie skaliert die Werte jeder Methode so, dass der beste Kandidat für ein Produkt oder eine Kundin 1 ist, und addiert sie dann mit Gewichten: 0,6 für kollaborativ und 0,4 für inhaltsbasiert. Hat das kollaborative Filtern weniger als fünf Kandidaten, tauschen die Gewichte auf 0,3 und 0,7, weil seinen wenigen Werten noch nicht zu trauen ist. Diese Gewichte sind mein Startpunkt, kein Gesetz. Der A/B Test entscheidet.
Durchgerechnetes Beispiel: ein alter Liebling und ein Neuzugang
Auf der Seite von Earl Grey bekommt der Becher mit Teesieb 1,0 aus dem kollaborativen Filtern und 0,5 aus dem Inhalt: . Ein neuer Lady Grey, letzte Woche dazugekommen, hat gar keinen kollaborativen Wert, aber den besten Inhaltswert, 1,0: . Das Teesieb führt, und der Neuzugang schafft es trotzdem in die Reihe.
Jetzt ein Produkt mit nur drei kollaborativen Kandidaten. Die Gewichte werden 0,3 und 0,7. Das Teesieb: . Lady Grey: . Wenn die Kaufdaten dünn sind, bekommt der Katalog die lautere Stimme.
Aus Rails ausliefern, mit Version, Cache und Rückfallebenen
Rails liest die importierten Tabellen. Jeder Import bekommt eine neue Modellversion, der Cacheschlüssel enthält sie, und so braucht ein neues Training nie ein Leeren des Caches. Die erste Fassung dieses Artikels rief Rails.cache.delete_matched auf, was nicht jeder Cachespeicher unterstützt, und mehrere Hilfsmethoden, die sie nie definiert hat. Diese Fassung ist vollständig.
# app/models/recommendations/engine.rb
module Recommendations
Result = Struct.new(:product_ids, :strategy, :model_version)
class Engine
def initialize(limit: 8)
@limit = limit
@version = ItemScore.maximum(:model_version)
end
def for_product(product)
ids = cached(:product, product.id) do
ItemScore.where(product_id: product.id, model_version: @version)
.order(score: :desc).limit(@limit).pluck(:recommended_product_id)
end
return Result.new(ids, "similar_products", @version) if ids.size >= @limit / 2
# similar_products comes from the starter frontend's override
taxon_ids = product.similar_products(@limit).map(&:id)
Result.new((ids + taxon_ids).uniq.first(@limit), "taxon_fallback", @version)
end
def for_user(user, consent:)
return popular unless user && consent
ids = cached(:user, user.id) do
UserScore.where(user_id: user.id, model_version: @version)
.order(score: :desc).limit(@limit).pluck(:product_id)
end
ids.size >= @limit / 2 ? Result.new(ids, "personalised", @version) : popular
end
def for_cart(order)
return popular if order.nil? || order.line_items.empty?
in_cart = order.line_items.map { |item| item.variant.product_id }
ids = ItemScore.where(product_id: in_cart, model_version: @version)
.where.not(recommended_product_id: in_cart)
.group(:recommended_product_id)
.order(Arel.sql("MAX(score) DESC"))
.limit(@limit)
.pluck(:recommended_product_id)
ids.any? ? Result.new(ids, "cart_complementary", @version) : popular
end
# Bestsellers of the last 30 days. Product#orders runs through every
# variant including the master, so single variant products count too.
def popular
ids = cached(:popular, :all) do
Spree::Product.joins(:orders)
.where(spree_orders: { completed_at: 30.days.ago.. })
.group("spree_products.id")
.order(Arel.sql("COUNT(spree_line_items.id) DESC"))
.limit(@limit)
.pluck("spree_products.id")
end
Result.new(ids, "popularity", @version)
end
private
def cached(kind, id, &block)
Rails.cache.fetch(["recs", @version, kind, id, @limit], expires_in: 1.hour, &block)
end
end
end
Die Abfrage für die Beliebtheit behebt zwei stille Fehler der ersten Fassung. Sie hat variants gejoint, was in Solidus die Mastervariante ausschließt, ein Produkt ohne Optionen zählte also nie als Bestseller. Product#orders geht stattdessen über variants_including_master.17 Und sie hat auf orders gefiltert, was nicht der Name der gejointen Tabelle ist. Rails braucht dort spree_orders.
Durchgerechnetes Beispiel: drei Besucher an einem Nachmittag
Ein Besucher, der zum ersten Mal kommt, landet auf der Seite von Earl Grey. Es gibt acht Artikelwerte, also ist die Reihe "ähnliche Produkte", für alle gleich, gebaut aus dem, was alle Kunden gekauft haben.
Eine Minute später öffnet jemand Lady Grey, gestern importiert, mit zwei Artikelwerten. Zwei sind weniger als die Hälfte von acht, also füllen die Taxonprodukte des Starter Frontends die Reihe auf.
Eine angemeldete Kundin, die der Personalisierung zugestimmt hat, öffnet die Startseite. Sie hat zwölf Nutzerwerte und sieht ihre eigene Reihe. Ihre Nachbarin, angemeldet, aber ohne dieses Ja, sieht die Bestseller.
Das Diagramm liest sich von oben: Zuerst kommt die Einwilligung, dann das Sammeln, und die schwere Arbeit passiert einmal pro Woche, fern von der Kundschaft. Den Entscheidungskasten spüren die Käufer. Entweder hat das Modell genug zu sagen, oder die Seite fällt auf etwas Vernünftiges zurück, und nie auf eine leere Reihe.
Die Reihe rendern, ohne die Seite zu bremsen
Ein Helper macht aus dem Ergebnis der Engine Produkte, lädt sie mit ihren Bildern in einer Abfrage und lässt alles weg, was nicht mehr verfügbar ist. Die erste Fassung hat images am Produkt vorgeladen, aber in Solidus ist images an die Mastervariante delegiert und keine Assoziation, Rails kann es unter diesem Namen also nicht vorladen.17
# app/helpers/recommendations_helper.rb
module RecommendationsHelper
def recommended_products(context:, limit: 8)
engine = Recommendations::Engine.new(limit: limit)
result =
case context
when :product_page then engine.for_product(@product)
when :cart_page then engine.for_cart(current_order)
when :homepage then engine.for_user(spree_current_user, consent: personalisation_consent?)
end
return [] if result.nil? || result.product_ids.empty?
products = Spree::Product.where(id: result.product_ids)
.includes(master: :images).index_by(&:id)
result.product_ids.filter_map do |id|
product = products[id]
next unless product&.available?
{ product: product, strategy: result.strategy, model_version: result.model_version }
end
end
end
<%# app/views/products/_recommendations.html.erb %>
<% recommendations = recommended_products(context: context) %>
<% if recommendations.any? %>
<section class="recommendations"
data-strategy="<%= recommendations.first[:strategy] %>"
data-model-version="<%= recommendations.first[:model_version] %>">
<h2><%= title %></h2>
<ul>
<% recommendations.each do |rec| %>
<% product = rec[:product] %>
<li>
<%= link_to product_path(product, rec: rec[:strategy]) do %>
<% image = product.images.first %>
<%= image_tag(image.url(:small), alt: "") if image %>
<span><%= product.name %></span>
<span><%= product.display_price %></span>
<% end %>
</li>
<% end %>
</ul>
</section>
<% end %>
Die Routen des Starter Frontends liegen in deiner Anwendung, der Link ist also product_path, nicht spree.product_path. Das Bild hat absichtlich einen leeren Alternativtext: Der Produktname steht direkt daneben im selben Link, sonst würde ein Screenreader ihn zweimal vorlesen.
Beweis es mit einem A/B Test, bevor du ihm traust
Liefer Empfehlungen nie aus, ohne sie zu messen. Bau den Test vom ersten Tag an ein. Hier vergleicht der Test die Taxonreihe des Starter Frontends (Kontrolle) mit der Hybridreihe, für angemeldete Kundinnen und Kunden, die Ja gesagt haben. Die Zuteilung hasht die Kundennummer, dieselbe Person sieht also immer dieselbe Version.
# app/models/recommendations/experiment.rb
module Recommendations
class Experiment
NAME = "product_page_recs"
VARIANTS = { control: 0.5, hybrid: 0.5 }.freeze
def self.variant_for(user)
bucket = Digest::SHA256.hexdigest("#{NAME}:#{user.id}").to_i(16) % 1000 / 1000.0
cumulative = 0.0
VARIANTS.each do |variant, share|
cumulative += share
return variant if bucket < cumulative
end
:control
end
# Needs a table recommendation_ab_events (experiment, variant, event, user_id)
class Event < ApplicationRecord
self.table_name = "recommendation_ab_events"
end
def self.log(user:, event:)
Event.create!(experiment: NAME, variant: variant_for(user).to_s,
event: event.to_s, user_id: user.id)
end
def self.results(since: 30.days.ago)
counts = Event.where(experiment: NAME, created_at: since..)
.group(:variant, :event).distinct.count(:user_id)
VARIANTS.keys.map do |variant|
seen = counts.fetch([variant.to_s, "impression"], 0)
bought = counts.fetch([variant.to_s, "purchase"], 0)
{ variant: variant, customers: seen, purchasers: bought,
rate: seen.zero? ? 0.0 : bought.fdiv(seen) }
end
end
end
end
Ruf Experiment.log(user:, event: :impression) dort auf, wo die Reihe gerendert wird, und event: :purchase im Subscriber für Bestellungen. Ob ein Unterschied echt ist, entscheidest du, indem du die beiden Kaufraten mit einem Test für zwei Anteile vergleichst:
Dabei sind und die Kaufraten in der Kontrolle und im Hybrid, und die Zahl der Kunden in jeder Gruppe und die gepoolte Rate beider Gruppen zusammen. Ein jenseits von 1,96 in eine der beiden Richtungen ist auf dem Niveau von 5 % signifikant. Die Begründung, und warum du die Stichprobengröße festlegst, bevor der Test beginnt, stehen in Grundlagen: Hypothesentests und Grundlagen: Stichprobengröße.
Durchgerechnetes Beispiel: acht Wochen im Teekontor
1.600 von Marlenes Kundinnen und Kunden mit Einwilligung kommen in acht Wochen vorbei, 800 in jeder Version. 160 kaufen in der Kontrolle, 192 mit dem Hybrid. Die Raten sind und . Die gepoolte Rate ist .
Der Standardfehler: .
Also ist , ein zweiseitiger p Wert von etwa 5,3 %. Verlockend, und auf dem Niveau von 5 % noch nicht signifikant. Der Hybrid sieht besser aus, und ein Shop dieser Größe kann ihn in acht Wochen nicht vom Zufall unterscheiden. Marlene ruft das Ergebnis nicht vorzeitig aus. Sie lässt den Test bis zu der Stichprobengröße laufen, die sie vor dem Start festgelegt hat, denn beim ersten gut aussehenden Tag aufzuhören, ist der Weg, auf dem Tests lügen.
Nach Plan neu trainieren, nicht nach Laune
Eine Rake Task erledigt den ganzen Weg: exportieren, trainieren, importieren. Für die meisten Shops lasse ich sie wöchentlich laufen, für einen Shop mit hohem Volumen und schnell wechselndem Sortiment täglich.
# lib/tasks/recommendations.rake
require "csv"
namespace :recommendations do
desc "Export, train and import recommendation models"
task retrain: :environment do
dir = Rails.root.join("tmp/recs")
FileUtils.mkdir_p(dir)
CSV.open(dir.join("interactions.csv"), "w") do |csv|
csv << %w[user_id product_id weight]
Recommendations::EventTracker.new.interaction_rows.each { |row| csv << row }
end
CSV.open(dir.join("products.csv"), "w") do |csv|
csv << %w[product_id text]
Spree::Product.includes(:taxons).find_each do |product|
description = ActionController::Base.helpers.strip_tags(product.description.to_s)
csv << [product.id, [product.name, description, product.taxons.map(&:name).join(" ")].join(" ").squish]
end
end
system("python3", Rails.root.join("lib/recommendations/train.py").to_s, dir.to_s, exception: true)
Recommendations::Importer.new(dir).call
end
end
# app/models/recommendations/importer.rb
require "csv"
module Recommendations
class Importer
def initialize(dir)
@dir = Pathname(dir)
@version = Time.current.strftime("%Y%m%d%H%M")
end
def call
ActiveRecord::Base.transaction do
load_rows(ItemScore, "item_recs.csv") do |row|
{ product_id: row["product_id"].to_i,
recommended_product_id: row["recommended_id"].to_i,
score: row["score"].to_f }
end
load_rows(UserScore, "user_recs.csv") do |row|
{ user_id: row["user_id"].to_i, product_id: row["product_id"].to_i,
score: row["score"].to_f }
end
ItemScore.where.not(model_version: @version).delete_all
UserScore.where.not(model_version: @version).delete_all
end
end
private
def load_rows(model, file)
now = Time.current
CSV.foreach(@dir.join(file), headers: true).each_slice(5_000) do |batch|
model.insert_all(batch.map { |row| yield(row).merge(model_version: @version, created_at: now, updated_at: now) })
end
end
end
end
Der Import läuft in einer Transaktion, eine Seite sieht also nie ein halbes Modell. Der neue Versionsstring sortiert nach dem alten, die Engine nimmt ihn auf, und nach den alten Cacheeinträgen fragt einfach niemand mehr.
Das Kaltstartproblem, ehrlich
Das ist das schwierigste Problem bei Empfehlungssystemen, und der Code oben mildert es nur.
Eine neue Kundin ohne Verlauf bekommt Bestseller oder die Item to Item Reihe zu dem Produkt, das sie gerade ansieht. Kam sie über eine Kategorie oder eine Kampagne, ist dieser Kontext der beste Hinweis, den du hast. Persönlich ist nichts davon. Es ist nur besser als Zufall.
Ein neues Produkt hat keine Interaktionen, also kann kollaboratives Filtern es nicht einordnen: Es gibt kein Embedding zum Nachschlagen.7 Inhaltsbasiertes Filtern kann es, aus seinem Text, und die Mischung gibt ihm vom ersten Tag an einen Platz in der Reihe.
Ein kleiner Katalog, unter etwa 100 Produkten, gibt ALS zu wenig, um Muster zu finden. Bei einem sehr kleinen Sortiment schlägt eine von Hand gepflegte Liste "passt gut zu" vielleicht jedes Modell. Mit Kanonen auf Spatzen zu schießen, hilft keinem.
Saisonsortimente fangen jede Saison neu an, und die Produktnummern des letzten Winters sagen nichts über diesen Winter. Inhaltsbasiertes Filtern kommt damit besser klar, weil neue Produkte über ihre Eigenschaften eingeordnet werden.
Meine Faustregel für kollaboratives Filtern: mindestens 500 bis 1.000 Bestellungen über 50 oder mehr Produkte mit Tracking, bevor das erste Training läuft. Darunter fang mit gemeinsamem Auftreten und Inhalt an.
Durchgerechnetes Beispiel: neue Geschenkdosen vor Weihnachten
Im Oktober nimmt Marlene 30 Geschenkdosen für Weihnachten auf. Am ersten Tag hat keine davon eine einzige Bestellung. Jede bekommt ihre Reihe aus der Inhaltsähnlichkeit ("gewürzt", "geschenk", "schwarzer tee"), mit dem Gewicht 0,7, weil das kollaborative Filtern nichts hat. Mitte November, nach den wöchentlichen Trainings, haben die besten unter ihnen genug Bestellungen für kollaborative Werte, und die Gewichte gehen von selbst zurück auf 0,6 und 0,4.
Fünf Fallstricke, auf die ich achte
Beliebtheitsverzerrung. Kollaboratives Filtern empfiehlt beliebte Produkte häufiger, weil sie in mehr Mustern vorkommen. Die Forschung dazu findet Empfehlungen, die sich auf beliebte Artikel konzentrieren, selbst bei Kunden, deren eigener Geschmack zur langen Nische geht.18 Beobachte den Anteil deines Katalogs, der überhaupt je in einer Reihe auftaucht, und zeig ab und zu absichtlich einen weniger beliebten Artikel.
Die Filterblase. Inhaltsbasiertes Filtern hält einen Käufer von Laufschuhen bei Laufschuhen. Der Hybrid mildert das, er beseitigt es nicht.
Ladezeit. Deshalb läuft alles Schwere wöchentlich, und die Seite liest nur eine Tabelle über einen Cache.
Seltsame Korrelationen. Das Modell findet Muster, keine Gründe, und manchmal ist das Muster Unsinn. Korrelation ist keine Kausalität. Lass nach jedem Training einen Menschen eine Stichprobe von Reihen ansehen.
Offline Werte schmeicheln. Precision, Recall und ihre Verwandten, gemessen an vergangenen Daten, sind ein unvollkommener Ersatz für das, was live passiert,19 und in einer Simulationsstudie brachten bessere Offline Werte online abnehmenden Nutzen.20 Trau dem A/B Test, nicht dem Notebook.
Die Einwilligung kommt vor dem ersten erfassten Aufruf
Dieser Teil ist kurz, weil er an anderer Stelle gründlich behandelt ist. Für einen Shop in Deutschland treffen hier zwei Regelwerke aufeinander.
Das erste ist § 25 des Telekommunikation Digitale Dienste Datenschutz Gesetzes (TDDDG): Das Speichern von Informationen auf dem Endgerät und der Zugriff auf Informationen, die dort schon gespeichert sind, brauchen eine Einwilligung. Ausgenommen sind nur die Übertragung einer Nachricht und das, was unbedingt erforderlich ist, damit der Anbieter einen vom Nutzer ausdrücklich gewünschten digitalen Dienst bereitstellen kann.21 Die deutschen Datenschutzbehörden legen beides eng aus. Mit dem Aufruf eines Webshops ist der Basisdienst gewünscht, aber nicht automatisch jede Zusatzfunktion, und "unbedingt erforderlich" heißt technisch erforderlich für genau diesen Dienst. Dass etwas für das Geschäftsmodell wirtschaftlich nötig ist, begründet keine Ausnahme.22 Inhalte danach auszuwählen, was du aus dem Verhalten eines Menschen über seine Interessen schließt, ist eine solche Zusatzfunktion, keine technische Notwendigkeit des Shops.
Die Reihe Item to Item, für alle gleich, braucht also kein Profil. Die persönliche Reihe braucht ein Ja. Und das Cookie guest_token von Solidus, das den Warenkorb für einen Gast am Laufen hält,23 ist keine freie Tracking ID: Es dient dem Warenkorb, und auch dafür sehen die Behörden eine Speicherung erst dann als erforderlich an, wenn tatsächlich etwas in den Warenkorb gelegt wurde.22 Jemandem damit durch den Shop zu folgen, ist ein anderer Zweck. Deshalb erfasst der Code oben nur angemeldete Kundinnen und Kunden, die Ja gesagt haben, und sonst niemanden.
Das zweite ist die Datenschutz Grundverordnung (DSGVO). Ein Geschmacksvektor aus dem Verhalten eines Menschen ist Profiling, das die Verordnung als Verarbeitung personenbezogener Daten definiert, um etwa persönliche Vorlieben, Interessen oder das Verhalten zu analysieren oder vorherzusagen.24 Die erste Fassung dieses Artikels hat Empfehlungen unter Artikel 22 eingeordnet. Das war falsch. Artikel 22 betrifft Entscheidungen, die ausschließlich automatisiert fallen und rechtliche Wirkung entfalten oder einen Menschen in ähnlicher Weise erheblich beeinträchtigen.25 Ein Teekarussell ist das nicht. Was gilt: Fließen die Empfehlungen in Werbemails, kann eine Kundin dem Profiling für Direktwerbung jederzeit widersprechen, und dann endet es.26 Und die Daten müssen auf das beschränkt sein, was der Zweck braucht, und dürfen nicht länger als nötig aufbewahrt werden.27 Das Trainingsfenster von sechs Monaten und die schmale Eventtabelle sind die Art, wie der Code das erfüllt.
Die Löschung ist eine Methode:
module Recommendations
def self.erase(user)
InteractionEvent.where(user_id: user.id).delete_all
UserScore.where(user_id: user.id).delete_all
Experiment::Event.where(user_id: user.id).delete_all
user.update!(recommendations_consent: false)
end
end
Die gecachte Reihe läuft innerhalb einer Stunde ab. Für das ganze Bild lies Cookies, Consent und das ePrivacy Chaos im DACH Raum, Event Tracking und Consent Mode, personalisieren, ohne gruselig zu sein und DSGVO konforme KI Features.
Durchgerechnetes Beispiel: wer im Teekontor welche Reihe bekommt
Von Marlenes 3.000 angemeldeten Kundinnen und Kunden sagen, nehmen wir an, 1.800 im Banner Ja. Diese 1.800 werden erfasst, fließen ins Training und sehen ihre eigene Reihe. Die übrigen 1.200 bekommen weiterhin die Item to Item Reihe auf Produktseiten und die Bestseller auf der Startseite, und über sie wird nichts aufgezeichnet. Eine der 1.800 bittet später darum, vergessen zu werden: Recommendations.erase läuft, ihre Ereignisse und Werte verschwinden, und das nächste Training weiß nicht, dass es sie gab.
Wo alles läuft
Für einen typischen Solidus Shop sind es wenige Teile. Das Python Skript liegt im Rails Repository und läuft auf demselben Server wie die Rake Task. Bei einem großen Katalog oder einem Training, dessen Dauer ins Gewicht fällt, zieh es in einen eigenen kleinen Dienst um, aber vermeide eine Architektur, die niemand pflegen kann. Die Werte liegen in Postgres, die Reihen im Rails Cache, oft dem Redis, das du für Sidekiq ohnehin betreibst. Das Training ist ein geplanter Job. Vier Zahlen sagen dir, ob alles gesund ist: der Anteil der Produktaufrufe, die eine Reihe zeigen, die Klickrate auf Reihen, die Kaufrate aus Reihen und die Tage seit dem letzten Training.
Was ich zuerst bauen würde
Wenn du bei einem Solidus Shop bei null anfängst:
- Woche eins: die Verkabelung der Einwilligung und der Event Tracker. Auf Daten, die du nie gesammelt hast, kannst du nicht trainieren, und jeder Tag Warten sind Daten, die dir fehlen.
- Woche zwei: das Inhaltsmodell und die Item to Item Reihe auf Produktseiten. Es funktioniert mit dem Katalog, den du schon hast.
- Wochen vier bis sechs: Sobald ein paar Hundert Bestellungen mit Tracking da sind, das kollaborative Modell und der Hybrid auf Produktseiten und der Startseite.
- Woche acht: der A/B Test gegen die Taxonreihe, gemessen am Umsatz, nicht an Klicks.
- Danach: wöchentlich neu trainieren, die vier Zahlen beobachten, die Gewichte nach den Testergebnissen abstimmen, die Reihen für Warenkorb und Mail ergänzen.
Jeder Shop ist anders. Ein Modeshop mit 5.000 Artikelnummern und hohen Retouren braucht eine andere Abstimmung als ein Feinkostshop mit 200 Produkten und treuen Stammkunden. Das Gerüst kann beides. Du stimmst die Gewichte, das Trainingsfenster und die Rückfallebenen auf den Shop ab, der vor dir steht.
Footnotes
-
Solidus, solidus_starter_frontend README, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus_starter_frontend/main/README.md
-
Solidus, solidus_starter_frontend, templates/app/overrides/product_featured_similar_products.rb, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus_starter_frontend/main/templates/app/overrides/product_featured_similar_products.rb
-
Solidus, core/lib/spree/core/engine.rb (die auf Spree::Bus registrierten Ereignisse), v4.8.1, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus/v4.8.1/core/lib/spree/core/engine.rb
-
Greg Linden, Brent Smith und Jeremy York, Amazon.com Recommendations: Item-to-Item Collaborative Filtering, IEEE Internet Computing, Januar/Februar 2003, https://www.cs.umd.edu/~samir/498/Amazon-Recommendations.pdf
-
Google, Recommendation Systems: Candidate generation overview, Google for Developers, 2026, https://developers.google.com/machine-learning/recommendation/overview/candidate-generation
-
Google, Recommendation Systems: Content-based filtering advantages and disadvantages, Google for Developers, 2026, https://developers.google.com/machine-learning/recommendation/content-based/summary
-
Google, Recommendation Systems: Collaborative filtering advantages and disadvantages, Google for Developers, 2026, https://developers.google.com/machine-learning/recommendation/collaborative/summary
-
Jure Leskovec, Anand Rajaraman und Jeff Ullman, Mining of Massive Datasets, Frequent Itemset Mining and Association Rules (Vorlesungsfolien), Stanford University, http://www.mmds.org/mmds/v2.1/ch06-assocrules.pdf
-
Yehuda Koren, Robert Bell und Chris Volinsky, Matrix Factorization Techniques for Recommender Systems, IEEE Computer, 2009, https://datajobs.com/data-science-repo/Recommender-Systems-%5BNetflix%5D.pdf
-
Yifan Hu, Yehuda Koren und Chris Volinsky, Collaborative Filtering for Implicit Feedback Datasets, IEEE International Conference on Data Mining, 2008, http://yifanhu.net/PUB/cf.pdf
-
scikit-learn developers, TfidfVectorizer (Parameter norm und stop_words), scikit-learn documentation, 2026, https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html
-
Solidus, core/app/models/spree/user_class_handle.rb und Spree.user_class, v4.8.1, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus/v4.8.1/core/lib/spree/user_class_handle.rb
-
Solidus Guides, Subscribing to events, 2026, https://guides.solidus.io/customization/subscribing-to-events
-
Solidus Guides, Customizing the core, 2026, https://guides.solidus.io/customization/customizing-the-core
-
Ben Frederickson, implicit: AlternatingLeastSquares API documentation, 2026, https://benfred.github.io/implicit/api/models/cpu/als.html
-
Ben Frederickson, implicit CHANGELOG, v0.5.0 breaking API changes (model.fit nimmt eine user_items Matrix), GitHub, https://raw.githubusercontent.com/benfred/implicit/main/CHANGELOG.md
-
Solidus, core/app/models/spree/product.rb, v4.8.1, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus/v4.8.1/core/app/models/spree/product.rb
-
Himan Abdollahpouri, Masoud Mansoury, Robin Burke und Bamshad Mobasher, The Unfairness of Popularity Bias in Recommendation, arXiv, 2019, https://arxiv.org/abs/1907.13286
-
Balázs Hidasi und Ádám Tibor Czapp, Widespread Flaws in Offline Evaluation of Recommender Systems, arXiv, 2023, https://arxiv.org/abs/2307.14951
-
Karl Krauth und andere, Do Offline Metrics Predict Online Performance in Recommender Systems?, arXiv, 2020, https://arxiv.org/abs/2011.07931
-
Bundesministerium der Justiz und für Verbraucherschutz, Telekommunikation Digitale Dienste Datenschutz Gesetz (TDDDG), § 25 Schutz der Privatsphäre bei Endeinrichtungen, Absätze 1 und 2, Gesetze im Internet, https://www.gesetze-im-internet.de/ttdsg/__25.html
-
Konferenz der unabhängigen Datenschutzaufsichtsbehörden des Bundes und der Länder (DSK), Orientierungshilfe der Aufsichtsbehörden für Anbieter:innen von digitalen Diensten (OH Digitale Dienste), Version 1.2, Randnummern 76, 78 und 80, DSK, November 2024, https://www.datenschutzkonferenz-online.de/media/oh/OH_Digitale_Dienste.pdf
-
Solidus, core/app/helpers/spree/core/controller_helpers/auth.rb (set_guest_token), v4.8.1, GitHub, 2026, https://raw.githubusercontent.com/solidusio/solidus/v4.8.1/core/app/helpers/spree/core/controller_helpers/auth.rb
-
Europäisches Parlament und Rat, Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 4 Nummer 4 (Profiling), EUR Lex, 2016, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679
-
Europäisches Parlament und Rat, Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 22 Absatz 1, EUR Lex, 2016, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679
-
Europäisches Parlament und Rat, Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 21 Absätze 2 und 3, EUR Lex, 2016, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679
-
Europäisches Parlament und Rat, Verordnung (EU) 2016/679 (Datenschutz Grundverordnung), Artikel 5 Absatz 1 Buchstaben c und e, EUR Lex, 2016, https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:32016R0679