System rekomendacji k-dram z wieloma agentami: BERTopic, RAG i Reciprocal Rank Fusion
Rekomendator k-dram z uwzględnieniem smaku: tagi tropów BERTopic, ChromaDB RAG, agent do przeprowadzania wywiadów, rozszerzanie zapytań oraz Reciprocal Rank Fusion przed ponownym rankowaniem przez LLM.
Źródła: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**
Dlaczego tworzyć system rekomendacji koreańskich dramatów?
Znalezienie kolejnego koreańskiego dramatu wartego obejrzenia staje się coraz trudniejsze w miarę rozrastania się listy obejrzanych filmów przez widza. Tematy na forach pomagają przez jakiś czas, ale potem stają się uciążliwe. KDramaBoo to system oparty na wielu agentach, który ma na celu zautomatyzowanie tego procesu poszukiwań. Wykorzystuje on metodę generacji wzbogaconej o dane z bazy, przetwarzając 1 500 szczegółowych profili dramatów, i rekomenduje seriale na podstawie profilu gustów widza oraz jego aktualnego nastroju.
Fala koreańska, czyli Hallyu, tłumaczy ten moment w historii i dotyczy czegoś więcej niż tylko K-popu.
Wykres przedstawia liczbę dramatów rocznie, pochodzących wyłącznie z TMDB i AsianWiki – dwóch źródeł skierowanych głównie do odbiorców zachodnich – a ta liczba stale rośnie. Gwałtowny wzrost w latach pandemii ma prawdopodobnie dwa powody. Odbiorcy na całym świecie, zamknięci w domach, odkryli k-dramy na Netflixie i innych platformach streamingowych, a międzynarodowy sukces Crash Landing on You (2019) oraz Squid Game (2021) przyciągnął uwagę Zachodu i spowodował więcej dyskusji w języku angielskim w internecie.
Budowanie korpusu
Znaczną część pracy stanowiła baza danych. API TMDB dostarczyło 1 500 k-dram z 2010 roku i późniejszych, a każda pozycja została wzbogacona o średnie oceny oraz komentarze fanów, posty i dyskusje z AsianWiki i r/KDRAMA. Obie strony zostały przeszukane przy użyciu eksponencjalnego ograniczania szybkości, co zajęło około jednego dnia. Aby uniknąć włączania do wyników starych i mało popularnych seriali, usunięto wszystko, co zostało wydane przed 2017 rokiem lub miało ocenę poniżej 6,5, co zmniejszyło zbiór z 1 500 do 874 pozycji. Ten filtr jest celowym wyborem produktowym, ponieważ system rekomendacji, który ciągle pokazuje zapomniane tytuły, szybko traci zaufanie użytkowników.
Streszczenia, komentarze i wątki dyskusyjne to zbiór nieustrukturyzowanego tekstu, a na ich podstawie system tworzy profil każdego dramy, który wykracza poza same metadane. Profil ten odzwierciedla to, czego ludzie faktycznie szukają przy wyborze serialu do obejrzenia:
- Tagi tropów są węższe niż gatunki. Określają one powtarzające się elementy fabularne, dynamikę postaci lub elementy historii, które fani wykorzystują jako kryteria oceny, takie jak fałszywe randkowanie czy małżeństwo kontraktowe.
- Tematy opisują to, o czym dramat mówi pod względem emocjonalnym i filozoficznym. When Life Gives You Tangerines to coś więcej niż zwykły serial o codziennym życiu, ponieważ porusza kwestie odporności, oddania na całe życie oraz wzmacniania pozycji kobiet na przestrzeni pokoleń.
- Rytm opisuje rytm i strukturę narracji, od powolnego rozwijania akcji po szybkie tempo.
- Ton emocjonalny opisuje ogólną atmosferę, w tym to, czy serial jest zbyt ciężki, by można go było uznać za lekki do oglądania.
Model Pydantic definiuje tę strukturę, zawierając pola dla ID TMDB, nazwy, tagów tropów oraz innych atrybutów.
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")
Tagi tropów z modelowaniem tematycznym
Tagi tropów pochodzą z streszczeń, natomiast pozostałe cztery atrybuty zostały wydobyte z komentarzy fanów, postów i dyskusji. Do przetwarzania tagów używany jest BERTopic z warstwą reprezentacji GPT-4o-mini, a progiem 0.75 ogranicza się liczbę wartości odstających. Konfiguracja określa metody redukcji wymiarowości oraz modele klastrzowania.
# 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}
)
Dlaczego nie przekazać każdej streszczenia modelowi LLM i nie poprosić o tagi? Okna kontekstowe są na tyle duże, że kilkaset tokenów nie stanowi problemu. Problem polega na tym, że model LLM przetwarza jedną serię po drugiej i nie ma świadomości całego korpusu. W jednej serii może nazywać to „małżeństwem kontraktowym”, w innej „fałszywym małżeństwem”, a w trzeciej „małżeństwem z wygody”, co powoduje niespójność słownictwa. BERTopic grupuje wszystkie seriale razem, dzięki czemu podobne streszczenia otrzymują te same tagi. Ważna jest również kwestia kosztów: około 900 wywołań modelu LLM, po jednym na każdą serię, plus opłaty za każdą nową serię. Metoda modelowania tematycznego była bezpłatna, skalowalna i powtarzalna.
Modele LLM zostały zachowane do wykrywania cech abstrakcyjnych, mianowicie tonu emocjonalnego i tematów, gdzie dyskusje fanów muszą zostać przekształcone w prozę, oraz do korygowania błędnie przypisanych tagów tropów.
Szybki przegląd RAG
RAG to prosta metoda dostarczania modelowi LLM odpowiedniego kontekstu, dzięki czemu jego odpowiedzi stają się lepsze – w tym przypadku chodzi o rekomendacje k-dram. Proces dzielenia na fragmenty, tworzenia wektorów i ponownego sortowania opiera się właśnie na tej idei. Architektura ma dwa tryby: pobieranie danych i odpowiadanie.
Pobieranie danych polega na dzieleniu dokumentów na fragmenty, tworzeniu dla nich wektorów i przechowywaniu tych wektorów w bazie danych. Podczas odpowiadania zapytanie użytkownika jest przekształcane za pomocą tego samego kodera, a najbardziej podobne fragmenty są wybierane na podstawie podobieństwa kosinowego i dodawane do promptu modelu LLM. Model otrzymuje tylko istotne dokumenty, a nie całą bazę danych, i odpowiada w oparciu o ten kontekst, starając się być mu wierny.
Pierwszym krokiem było przekształcenie każdego DramaProfile w dokument poprzez połączenie wszystkich jego wartości w jedną ciągłą sekwencję znaków. Te sekwencje zostały poddane przetwarzaniu za pomocą modelu OpenAI text-embedding-3-small i przechowane w instancji ChromaDB, co zakończyło proces przyjmowania danych.
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
Dlaczego dzielenie na fragmenty pogorszyło wyszukiwanie
Naukowcy danych pytają o brakujący krok, czyli dzielenie na fragmenty. Dokumenty rzeczywiście mają naturalną strukturę, która wskazuje na pięć fragmentów – po jednym na każde pole profilu. W praktyce dzielenie na fragmenty obniżyło jakość wyników wyszukiwania.
Weźmy Mr. Queen – komedię o współczesnym kucharzu, który trafia do ciała królowej z okresu dynastii Joseon i musi radzić sobie z polityką pałacową i walkami o władzę, jednocześnie pomagając królowi. Serial plasuje się wysoko w rankingu zabawnych k-dram o rodzinie królewskiej, a mimo to nie został wyświetlony przy dokładnie takim zapytaniu. System wyszukiwania uznał fragment z opisem fabuły za najbardziej istotny w przypadku zapytania dotyczącego fabuły, ale połączony opis z TMDB i AsianWiki w ogóle nie wspomina o tym serialu. Opis tempa akcji natomiast o nim wspomina, jak pokazuje poniższy przykład.
Streszczenie dotyczy szefa kuchni, ciała królowej, króla oraz intryg dworskich, bez żadnych nawiązań do humoru. Opis tempa akcji przedstawia komediowy rozwój w pierwszych odcinkach, który później przeradza się w szybkie serię żartów; niektórzy fani zauważają powtarzające się żarty w odcinkach od 10 do 12. Rozdzielenie profili sprawiło, że każdy z nich był zbyt mały, by zawierać pełny kontekst, więc dopasowanie odbywało się tylko w obrębie jednego obszaru, a nie we wszystkich aspektach serialu.
Agent ds. wywiadów na temat profili smakowych
Krótkie zapytania takie jak „zabawne k-dramy o królewskich rodzinach” nie odzwierciedlają ani gustu widza, ani jego nastroju, a są zbyt krótkie, by można było z nich wiele wydobyć. Dlatego zamiast prosić o szczegółowe zapytanie, agent odpowiedzialny za rozmowy rozmawia z użytkownikiem za pośrednictwem narzędzia, zbierając informacje o ulubionych gatunkach, motywach, tematach emocjonalnych, tempie akcji oraz kryteriach odrzucenia na podstawie ustalonej listy pytań. Wybór narzędzia jest obowiązkowy, więc agent korzysta z niego przy każdej rundzie i nie próbuje odpowiadać samodzielnie. Typ wyniku to TasteProfile, który gwarantuje zweryfikowany obiekt Pydantic z wypełnionymi i poprawnie skategoryzowanymi wszystkimi siedmioma polami.
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)
Od profilu smaku do zapytań
Każde pole w TasteProfile zawiera tekst swobodny, a używanie tego obiektu jako jednego zapytania nie jest dobrym pomysłem. Siedem pól obejmuje różne wymiary semantyczne, a ich połączenie daje rozcieńczony embedding, który silnie nie odpowiada żadnemu z tych wymiarów.
Agent konstruujący zapytania bierze TasteProfile i przepisuje tekst swobodny na język przyjazny do wyszukiwania. Dzieli siedem pól na dwa lub trzy skoncentrowane zapytania: jedno dotyczące tematów, drugie gatunku i tropów, a trzecie tempa i tonu, przy czym każde z nich wyszukuje w swoim własnym wymiarze. Jest to podobne do techniki rozszerzania zapytań często stosowanej w RAG w celu poprawy dokładności wyników. Temperatura jest ustawiona na 0, dzięki czemu model wybiera token o najwyższej prawdopodobieństwie na każdym kroku, a stałe seed zapewnia maksymalnie deterministyczne próbkowanie. Ten sam profil smaku powinien generować te same zapytania przy każdym uruchomieniu.
Fuzja wyszukiwania i rankingu
Każde zapytanie jest przetwarzane za pomocą tego samego modelu text-embedding-3-small, który jest używany do dokumentów z dramatami, dzięki czemu oba znajdują się w tym samym przestrzeni wektorowej. ChromaDB przeprowadza wyszukiwanie na podstawie podobieństwa kosinowego i zwraca 20 najbardziej podobnych dokumentów dla każdego zapytania. Przy dwóch lub trzech zapytaniach na profil otrzymujemy dwa lub trzy wykazy 20 dramatów, które mogą się pokrywać.
Reciprocal Rank Fusion łączy te wykazy. Jest to popularny algorytm służący do połączenia kilku niezależnych list wyników w jedną istotną listę, przy czym nagradza dramaty, które zajmują wysokie pozycje we wszystkich wymiarach semantycznych. Wynik dla każdego dramatu pochodzi z jego pozycji w każdej liście i jest obliczany jako 1 / (k + pozycja). Ponieważ listy mają stałą długość, wartość k ustalona jest na 20, co umożliwia różnicę 2x pomiędzy najwyższymi a najniższymi pozycjami.
Złączone listy nadal ignorują popularność i aktualność. Dlatego wspólna ocena łączy RRF – znormalizowaną średnią głosów oraz aktualność – z wagami ustalonymi poprzez eksperymenty. RRF otrzymuje wartość 0,7, dzięki czemu dominuje istotność dla określonych preferencji, natomiast średnia głosów i aktualność otrzymują odpowiednio 0,25 i 0,05, aby dodać informacje o jakości i świeżości.
Ostateczna faza z użyciem LLM
Urankowana lista staje się kontekstem przekazywanym do LLM, który wybiera cztery seriale najlepiej odpowiadające preferencjom użytkownika i pisze spersonalizowaną ocenę każdego z nich. Ta odpowiedź jest rekomendacją, którą widzi użytkownik.
Konstrukcja dwuetapowa jest celowa. Proces wyszukiwania jest dostosowany do maksymalizacji liczby wyników i obejmuje szeroki zakres, dzięki czemu każdy prawdopodobnie istotny program trafia do grupy kandydatów. Model językowy jest dostosowany do precyzji i stosuje rodzaj oceny, którego arytmetyka nie potrafi. Żadna funkcja oceny nie zauważy, że dramat o szefie kuchni, który odkrywa sens poprzez gotowanie, pasuje do widza szukającego historii o znaczeniu w codziennej pracy. Model, który analizuje profil gustów obok pełnego dokumentu z dramatem, może nawiązać tę związek, a podział na dwa etapy pozwala każdemu z nich robić to, w czym jest najlepszy.
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
Wynik
W demonstracyjnym wyświetleniu profil skupiony na zemście otrzymuje The Glory jako najlepszy pomysł na oglądanie. Pozostałe trzy miejsca zajmują Revenge of Others, High School Return of a Gangster oraz Weak Hero. Każda rekomendacja ma ocenę „Podoba mi się”, „Cieszę się z oglądania” lub „Mieszane”, wraz z konkretnymi powodami, dla których pasuje do gustu widza. Uwagi pochodzące z dyskusji fanów wskazują na elementy, które mogą nie spodobać się widzowi, takie jak kontrowersyjny finał lub powtarzające się fragmenty. Ponieważ rady opierają się zarówno na profilu, jak i na rzeczywistych opinii społeczności, brzmią one osobiście, a nie ogólnikowo. Interfejs stanowi aplikację Gradio, a w jej formatowaniu HTML pomógł asystent AI.
Następnie przyszedł czas na ocenę takiego systemu. Wyniki mierzące wierność RAGAS oraz trafność odpowiedzi pokazują, czy odpowiedzi opierają się na pobranych kontekstach, a analiza stabilności wykazuje, na jak zgodnych pozostają rekomendacje przy powtarzanych próbach z tym samym profilem preferencji. Ustalone wartości „seed” oraz temperatura równa zero na etapie wyszukiwania sprawiają, że ta druga weryfikacja ma sens, ponieważ wszelkie odchylenia wynikają wtedy z samego procesu pobierania danych i końcowej obróbki przez model językowy, a nie z sformułowania zapytania.