Mehr-Agenten-K-Drama-Recommendator: BERTopic, RAG und Reciprocal Rank Fusion
Ein auf Geschmack spezialisierter K-Drama-Recommendator: BERTopic-Tropentags, ChromaDB RAG, ein Interview-Agent, Abfragedehnung sowie Reciprocal Rank Fusion vor einer erneuten Rangfolgebestimmung durch ein LLM.
Quellen: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**
Warum einen K-Drama-Recommendator entwickeln?
Je länger die Liste der bereits angesehenen koreanischen Dramen wächst, desto schwieriger wird es, das nächste Drama zu finden, das es wert ist, angeschaut zu werden. Forumsthreads helfen zwar eine Weile, werden aber später zur lästigen Pflicht. KDramaBoo ist ein mehragentenbasierter Ansatz, der versucht, diese Suche zu automatisieren. Er nutzt retrieval-augmented generation mit 1.500 erweiterten Drama-Profilen, um Sendungen auf der Grundlage des Geschmacksprofils des Zuschauers sowie seiner aktuellen Stimmung zu empfehlen.
Die Korean Wave, auch Hallyu genannt, erklärt den Zeitpunkt dieses Phänomens – und es geht dabei um mehr als nur K-Pop.
Die Grafik zeigt die Anzahl der Dramas pro Jahr, die ausschließlich aus TMDB und AsianWiki stammen – zwei Quellen mit überwiegend westlichem Publikum – und diese Zahl steigt kontinuierlich an. Der Anstieg in den Jahren der Pandemie hat vermutlich zwei Ursachen. Weltweite Zuschauer, die zu Hause festsaßen, entdeckten K-Dramas auf Netflix und anderen Streaming-Diensten, und der internationale Erfolg von Crash Landing on You (2019) sowie Squid Game (2021) brachte westliche Aufmerksamkeit sowie mehr Diskussionen auf Englisch im Internet hervor.
Erstellung des Korpus
Ein großer Teil der Arbeit betraf die Datenbank. Die TMDB-API stellte 1.500 K-Dramas ab dem Jahr 2010 zur Verfügung, wobei jede Eintragung durch Durchschnittswerte der Abstimmungen sowie Fan-Kommentare, Beiträge und Diskussionen von AsianWiki und r/KDRAMA ergänzt wurde. Beide Websites wurden mit exponentiellem Throttling abgerufen, was etwa einen Tag in Anspruch nahm. Um alte und unpopuläre Serien aus den Ergebnissen zu entfernen, wurden alle Werke, die vor 2017 veröffentlicht wurden oder eine Bewertung unter 6,5 hatten, entfernt, wodurch sich die Anzahl der Einträge von 1.500 auf 874 verringerte. Dieser Filter ist eine bewusste Produktentscheidung, da ein Empfehlungssystem, das ständig vergessene Titel anzeigt, schnell das Vertrauen der Nutzer verliert.
Handlungsbeschreibungen, Kommentare und Diskussionsstränge bilden einen Haufen unstrukturierten Textes, aus dem das System für jedes Drama ein Profil erstellt, das über reine Metadaten hinausgeht. Dieses Profil fasst die Aspekte zusammen, nach denen die Menschen bei der Auswahl eines Films tatsächlich suchen:
- Tropentags sind enger gefasst als Genres. Sie bezeichnen wiederkehrende Handlungsmechanismen, Charakterdynamiken oder Erzählelemente, die Fans als Filter nutzen, wie zum Beispiel falsche Verabredungen oder Eheverträge.
- Themen beschreiben, worum es in einer Serie emotional und philosophisch geht. When Life Gives You Tangerines ist mehr als nur eine Seifenoper, da sie Themen wie Widerstandsfähigkeit, lebenslange Hingabe und Stärkung über Generationen hinweg behandelt.
- Rhythmus beschreibt den Erzählrhythmus und die Struktur, von langsamem Aufbau bis hin zu schnellem Verlauf.
- Emotionaler Ton beschreibt die allgemeine Atmosphäre, einschließlich der Frage, ob eine Serie zu ernst ist, um als entspannende Unterhaltung zu gelten.
Ein Pydantic-Modell definiert dieses Schema mit Feldern für die TMDB-ID, den Namen, die Trope-Tags sowie die anderen Attribute.
class DramaProfile(BaseModel):
tmdb_id: int = Field(description="TMDB id associated with the drama")
name: str = Field(description="Name of the Korean drama")
trope_tags: list[str] = Field(description="List of trope tags extracted from topic modeling on the drama synopsis. E.g. 'Contract Marriage', 'Enemies to Lovers', 'Revenge Plot'")
theme_labels: list[str] = Field(description="3-5 deep themes extracted from fan discussion. What viewers understood the drama to really be about emotionally and philosophically. E.g. 'grief processed through quiet solidarity', 'dignity in invisible ordinary lives', 'love as a force that exposes class anxiety'")
pacing: str = Field(description="A prose description of the drama's narrative pacing and structural arc.")
emotional_tone: str = Field(description="A prose description of the drama's overall emotional atmosphere and feeling.")
community_sentiment: str = Field(description="A prose description of fan community reception — what fans broadly agree on, what divides opinion, and any recurring specific reactions such as second lead syndrome, time skip backlash, or satisfying/unsatisfying ending")
Trope-Tags mit Thema-Modellierung
Die Trope-Tags stammen aus Zusammenfassungen, während die anderen vier Attribute aus Fan-Kommentaren, Beiträgen und Diskussionen extrahiert wurden. Für die Tags verwendet der Prozessfluss BERTopic mit einer Repräsentationsschicht von GPT-4o-mini, wobei ein Schwellenwert von 0,75 die Anzahl der Ausreißer verringert. Die Konfiguration legt die Dimensionalitätsreduzierung sowie die Clustering-Modelle fest.
# Initializing models for BERT Topic Modeling
# 1. Dimesionality Reduction
N_NEIGHBORS = 10
MIN_DIST = 0
N_COMPONENTS = 3
umap_model = umap.UMAP(
n_neighbors=N_NEIGHBORS,
min_dist=MIN_DIST,
n_components=N_COMPONENTS,
metric='cosine',
random_state=42)
# 2. Clustering Model
MIN_CLUSTER_SIZE = 7
MIN_SAMPLES = 2
clustering_model = hdbscan.HDBSCAN(
min_cluster_size=MIN_CLUSTER_SIZE,
min_samples=MIN_SAMPLES,
cluster_selection_method='eom',
metric='euclidean'
)
# 3. Vectorizer - to train including stop words
vectorizer = CountVectorizer(stop_words="english")
# 4. C-TF-IDF Model
ctfidf_model = ClassTfidfTransformer(bm25_weighting=True)
# 5. Representation Model
representation_model = OpenAI_BertRep(client=openai_client,
model="gpt-4o-mini",
delay_in_seconds=2,
prompt=custom_prompt,
nr_docs=5,
generator_kwargs = {"temperature":0}
)
Warum nicht jede Zusammenfassung an ein LLM übergeben und nach Tags fragen? Die Kontextfenster sind lang genug, sodass ein paar hundert Tokens kein Problem darstellen. Das Problem ist jedoch, dass ein LLM jeweils nur eine Serie betrachtet und kein Bewusstsein für das gesamte Korpus hat. Es könnte in einer Serie „Vertragsheirat“ nennen, in einer anderen „Falschehe“ und in einer dritten „Bequemheirat“, wodurch die Vokabularwahl inkonsistent bleibt. BERTopic gruppiert alle Serien zusammen, sodass ähnliche Zusammenfassungen die gleichen Tags erhalten. Auch die Kosten spielen eine Rolle: etwa 900 Aufrufe des LLMs, einer pro Serie, plus Gebühren jedes Mal, wenn eine neue Serie hinzukommt. Der Ansatz mit dem Thema-Modellieren war kostenlos, skalierbar und reproduzierbar.
LLMs wurden für die abstrakten Merkmale verwendet, nämlich den emotionalen Ton und die Themen, bei denen Fandiskussionen in Prosa umgewandelt werden müssen, sowie um fehlerhaft zugewiesene Trope-Tags zu korrigieren.
Ein kurzer Überblick über RAG
RAG ist ein einfacher Ansatz, um einem LLM relevanten Kontext zur Verfügung zu stellen, damit seine Antworten verbessert werden – in diesem Fall die Empfehlungen für K-Dramas. Chunking, Embedding und Reranking sind technische Verfahren, die auf dieser Idee basieren. Die Architektur verfügt über zwei Modi: Dokumenteneingang und Antwortgenerierung.
Beim Dokumenteneingang werden Dokumente in Blöcke aufgeteilt, diese eingebettet und die Vektoren in einer Datenbank gespeichert. Bei der Antwortgenerierung wird die Anfrage des Benutzers mit demselben Encoder eingebettet, und die am ähnlichstenen Blöcke werden mithilfe der Kosinusähnlichkeit ausgewählt und dem Prompt des LLM hinzugefügt. Das Modell erhält nur relevante Dokumente und nicht die gesamte Datenbank, und es antwortet auf dieser Grundlage, idealerweise in Übereinstimmung damit.
Der erste Schritt bestand darin, jedes DramaProfile in ein Dokument umzuwandeln, indem alle seine Werte zu einem einzigen String zusammengefügt wurden. Diese Strings wurden mit OpenAI’s Text-Embedding-3-Small-Modell verarbeitet und in einer ChromaDB-Instanz gespeichert, wodurch die Aufnahme abgeschlossen wurde.
def create_embeddings(chunks: list[Document], ids: list):
if os.path.exists(DB_NAME):
Chroma(persist_directory=DB_NAME, embedding_function=embeddings).delete_collection()
vectorstore = Chroma(persist_directory=DB_NAME, embedding_function=embeddings)
vectorstore.add_documents(documents=chunks, ids=ids)
collection = vectorstore._collection
count = collection.count()
sample_embedding = collection.get(limit=1, include=["embeddings"])["embeddings"][0]
dimensions = len(sample_embedding)
print(f"There are {count:,} vectors with {dimensions:,} dimensions in the vector store")
return vectorstore
Warum das Aufteilen die Suche beeinträchtigt
Datenwissenschaftler fragen nach dem fehlenden Schritt, dem Aufteilen der Daten. Die Dokumente weisen tatsächlich eine natürliche Struktur auf, die fünf Abschnitte nahelegt – einen pro Profilfeld. In der Praxis verringerte das Aufteilen die Qualität der Suche anschließend.
Nehmen wir Mr. Queen – eine Komödie über einen modernen Koch, der in den Körper einer Königin aus der Joseon-Zeit gerät und dabei sowohl dem König helfen als auch sich mit Palastpolitik und Machtspielen auseinandersetzen muss. Sie gilt als eine der lustigsten K-Dramas über die Königsfamilie, wurde aber bei genau dieser Suchanfrage nicht gefunden. Der Suchalgorithmus hielt den Zusammenfassungsabschnitt, der sich auf die Handlung bezieht, für am relevantesten, doch die kombinierten Zusammenfassungen von TMDB und AsianWiki erwähnen die Komödie überhaupt nicht. Die Beschreibung des Tempos hingegen schon, wie das folgende Beispiel zeigt.
Die Zusammenfassung behandelt den Koch, den Körper der Königin, den König sowie die Hofintrigen – ohne jeglichen Hauch von Humor. Der Zeitstrahl beschreibt eine komische Aufbautwicklung in den ersten Episoden, die später in schnelle, abwechslungsreiche Gags übergeht; einige Fans weisen auf wiederkehrende Witze in den Episoden 10 bis 12 hin. Durch die Trennung der Profilinformationen war jeder Teil zu klein, um den vollständigen Kontext zu bieten, weshalb das Abgleichen nur innerhalb eines einzigen Bereichs möglich war und nicht für alle Aspekte der Serie galt.
Ein Interviewagent für Geschmacksprofile
Kurze Suchanfragen wie „lustige K-Dramas über Royals“ erfassen weder den Geschmack noch die Stimmung des Zuschauers und sind zu kurz, um daraus viel herauszufinden. Deshalb spricht ein Interview-Agent anstelle einer detaillierten Anfrage über ein Tool mit dem Nutzer und sammelt aus einer festgelegten Reihe von Fragen Informationen zu Genres, Klischees, emotionalen Themen, Tempo sowie Ausschlusskriterien. Die Verwendung des Tools ist zwingend vorgeschrieben, weshalb der Agent bei jedem Schritt das Tool aufruft und nicht versucht, alleine zu antworten. Die Ausgabeform ist ein TasteProfile, das eine validierte Pydantic-Objektgarantiert, bei dem alle sieben Felder ausgefüllt und korrekt typisiert sind.
class TasteProfile(BaseModel):
genre_preference: str = Field(description="User's preference on drama genres")
trope_preference: str = Field(description="User's preference on storylines and tropes like fake dating etc.")
theme_preference: str = Field(description="Broader themes the user would like dramas to explore")
ending_preference: str = Field(description="User's preference on how the drama ends")
emotional_tolerance: str = Field(description="User's preference on how emotionally intense they'd like the drama to be")
pace_preference: str = Field(description="User's preference on drama's pacing")
dealbreakers: str = Field(description="Reasons/dealbreakers the user would not enjoy a drama")
@function_tool
def get_user_answers(question: str) -> str:
"""
Ask the user the given question and return their answer.
Args:
question: The question to ask the user
"""
answer = input(question)
return answer
interview_agent = Agent(name="Interview_Agent",
instructions=interview_system_prompt,
model="gpt-4o-mini",
tools=[get_user_answers],
model_settings=ModelSettings(tool_choice="required"),
output_type=TasteProfile)
Von dem Geschmacksprofil zu Abfragen
Jedes Feld in TasteProfile enthält freien Text, und die Verwendung dieses Objekts als einzige Abfrage ist keine gute Idee. Sieben Felder umfassen unterschiedliche semantische Dimensionen, und ihre Verknüpfung ergibt ein verwaschenes Embedding, das keiner einzigen Dimension besonders stark entspricht.
Ein Abfragenkonstruktor-Agent nimmt das TasteProfile und schreibt den freien Text in eine für die Suche geeignete Sprache um. Er teilt die sieben Felder in zwei oder drei gezielte Abfragen auf – eine für Themen, eine für Genre und Motive sowie eine für Tempo und Ton – wobei jede Abfrage in ihrer eigenen Dimension gesucht wird. Dies ähnelt der Abfragerweiterungstechnik, die häufig in RAG zur Steigerung der Rückrufrate eingesetzt wird. Die Temperatur wird auf 0 gesetzt, sodass das Modell bei jedem Schritt den wahrscheinlichsten Token wählt, und ein fester Seed sorgt dafür, dass die Stichprobenziehung möglichst deterministisch verläuft. Dasselbe Geschmacksprofil sollte bei jedem Ausführungsvorgang zu denselben Abfragen führen.
Suche und Rangfusion
Jede Abfrage wird mit demselben Text-Embedding-3-Small-Modell verarbeitet, das auch für die Dramendokumente verwendet wird, sodass beide in derselben Vektorraum existieren. ChromaDB führt eine Kosinusähnlichkeitssuche durch und gibt pro Abfrage die 20 am ähnlichstenen Dokumente zurück. Bei zwei oder drei Abfragen pro Profil ergeben sich somit zwei oder drei Ranglisten mit jeweils 20 Dramen, die möglicherweise überschneiden.
Reciprocal Rank Fusion fügt diese Ranglisten zusammen. Es handelt sich um einen gängigen Algorithmus zur Kombination mehrerer unabhängiger Ergebnislisten zu einer einzigen relevanten Liste, wobei Dramen belohnt werden, die in mehreren semantischen Dimensionen eine hohe Platzierung erreichen. Die Bewertung jedes Dramas ergibt sich aus seiner Rangposition in jeder Liste und wird als 1 / (k + Rang) berechnet. Da die Listen eine feste Länge haben, wird k auf 20 gesetzt, was einen Unterschied von 2 Stellen zwischen den höchsten und niedrigsten Rängen ermöglicht.
Die zusammengeführten Listen ignorieren weiterhin Popularität und Aktualität. Ein zusammengesetzter Score mischt daher RRF, einen normalisierten Durchschnitt der Stimmen sowie die Aktualität, wobei die Gewichte durch Experimente ermittelt werden. RRF erhält einen Wert von 0,7, sodass die Relevanz zu den angegebenen Präferenzen dominiert, während der Durchschnitt der Stimmen und die Aktualität jeweils 0,25 bzw. 0,05 erhalten, um Signale bezüglich Qualität und Frische hinzuzufügen.
Die endgültige Verarbeitung durch das LLM
Die sortierte Liste dient als Kontext für das LLM, das die vier Dramen auswählt, die am besten zu den Präferenzen des Benutzers passen, und für jedes eine personalisierte Bewertung erstellt. Diese Antwort ist die Empfehlung, die der Benutzer sieht.
Das zweistufige Design ist beabsichtigt. Die Suche ist auf die Wiedergabe relevanter Ergebnisse abgestimmt und ist sehr umfassend, sodass jede plausibel relevante Sendung in die Liste der Kandidaten gelangt. Der große Sprachmodell ist hingegen auf Präzision ausgelegt und kann Urteile fällen, die die Arithmetik nicht leisten kann. Keine Bewertungsfunktion wird erkennen, dass eine Serie über einen Koch, der durch Kochen ein Ziel findet, zu einem Zuschauer passt, der eine Geschichte über den Sinn im Alltagshandwerk sucht. Ein Modell, das das Geschmacksprofil neben dem vollständigen Dokument der Serie liest, kann diesen Zusammenhang herstellen, und die Trennung sorgt dafür, dass jede Stufe das Beste aus sich herausholt.
def refine_recommendations(taste_profile, ranked_list):
print('Refining Recommendations ---')
user_prompt = create_drama_user_prompt(taste_profile=taste_profile, ranked_drama_list=ranked_list[:20])
messages = [{"role": "system", "content": assessment_prompt},
{"role": "user", "content": user_prompt}]
client = OpenAI()
response = client.beta.chat.completions.parse(model='gpt-4o-mini',
messages=messages,
response_format=AssessmentOutput,
temperature=0,
seed=42)
result = response.choices[0].message.parsed
return result
Das Ergebnis
In dem Demo-Modus erhält ein auf Rache ausgerichtetes Profil The Glory als beste Empfehlung. Revenge of Others, High School Return of a Gangster und Weak Hero belegen die restlichen drei Plätze. Jede Empfehlung wird mit der Bewertung „Liebe es“, „Genieße es“ oder „Gemischt“ versehen, zusammen mit konkreten Gründen, warum sie zum Geschmack des Zuschauers passt. Aus Fandiskussionen gewonnene Hinweise weisen darauf hin, was dem Zuschauer möglicherweise nicht gefallen könnte, wie beispielsweise ein umstrittenes Ende oder eine wiederholte Handlungsebene. Da die Ratschläge sowohl auf dem Profil als auch auf echten Community-Einblicken beruhen, wirken sie persönlich und nicht allgemein. Die Benutzeroberfläche ist eine Gradio-App, und ein KI-Assistent half bei der HTML-Formatierung.
Als Nächstes folgt die Bewertung für ein solches System. Die Werte zur Treue von RAGAS sowie zur Relevanz der Antworten zeigen an, ob diese im extrahierten Kontext verankert bleiben, und eine Stabilitätsanalyse zeigt, wie konsistent die Empfehlungen bei wiederholten Ausführungen mit dem gleichen Geschmacksprofil sind. Feste Seeds sowie eine Temperatur von null in der Abfrageschicht sorgen dafür, dass diese zweite Überprüfung sinnvoll ist, denn alle verbleibenden Abweichungen stammen dann aus der Extraktion und der finalen Verarbeitung durch das LLM und nicht aus der Formulierung der Abfrage.