Багатоагентна рекомендувальна система K-Drama: BERTopic, RAG та метод Reciprocal Rank Fusion
Рекомендувач K-dramа, який враховує смаки: теги BERTopic trope, ChromaDB RAG, агент для інтерв’ю, розширення запитів та метод Reciprocal Rank Fusion перед повторним ранжуванням за допомогою LLM.
Джерела: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**
Чому створювати рекомендатор корейських драм
Зі збільшенням списку переглянутих драм стає все складніше знайти наступну корейську драму, яка варта уваги. Теми на форумах допомагають деякий час, але потім перетворюються на обтяжливу роботу. KDramaBoo — це система з кількома агентами, яка намагається автоматизувати цей пошук. Вона використовує метод генерації з підсиленням за допомогою даних для 1 500 детально описаних профілів драм та рекомендує серіали з урахуванням особистих уподобань користувача та його поточного емоційного стану.
Корейська хвиля, або Hallyu, пояснює цей часовий момент, і вона стосується не лише K-pop.
У цій діаграмі відображається кількість драм щороку, взятих виключно з TMDB та AsianWiki — двох джерел, які орієнтовані переважно на західну аудиторію, — і цей показник постійно зростає. Різкий стрибок у роки пандемії, ймовірно, має дві причини. Глядачі по всьому світу, які залишилися вдома, знайшли K-драми на Netflix та інших стрімінгових сервісах, а міжнародний успіх Crash Landing on You (2019) та Squid Game (2021) привернув увагу західних користувачів та спричинив більше обговорень англійською мовою в Інтернеті.
Створення корпусу
Велика частина роботи стосувалася бази даних. API TMDB надало 1 500 корейських драм з 2010 року, причому кожен запис був доповнений середніми оцінками голосувань, а також коментарями шанувальників, публікаціями та дискусіями з AsianWiki та r/KDRAMA. Обидві сторінки було оброблено з використанням експоненційного контролю швидкості, що зайняло приблизно день. Щоб уникнути включення старих та непопулярних серіалів до результатів, усе, що було випущено до 2017 року або мало оцінку нижче 6,5, було видалено, що зменшило обсяг даних з 1 500 до 874. Цей фільтр є свідомим вибором розробників, адже рекомендаційна система, яка постійно показує забуті титули, швидко втрачає довіру користувачів.
Анотації, коментарі та теми дискусій є неструктурованим текстом, і на основі нього система створює профіль кожної драми, який виходить за межі простих метаданих. Цей профіль відображає те, що люди насправді шукають під час вибору трансляції:
- Теги тропів є вужчими за жанри. Вони позначають постійно зустрічаючіся сюжетні механізми, динаміку персонажів чи елементи історії, які шанувальники використовують як фільтри, наприклад, фальшиві знайомства чи шлюб за контрактом.
- Теми описують те, про що емоційно та філософськи йдеться у драмі. When Life Gives You Tangerines — це більше, ніж звичайний серіал про повсякденне життя, адже він торкається тем стійкості, вірності протягом усього життя та надання сил різним поколінням.
- Темп описує ритм та структуру оповіді, від повільного розвитку подій до швидкого руху.
- Емоційний тон визначає загальну атмосферу, включаючи те, чи є серіал занадто серйозним, щоб вважатися легким для перегляду.
Модель Pydantic визначає цю схему, містячи поля для ID TMDB, назви, тегів та інших атрибутів.
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")
Теги тропів із моделюванням тем
Теги тропів були отримані з анотацій, тоді як інші чотири атрибути були вилучені з коментарів, публікацій та дискусій фанатів. Для тегів у процесі обробки використовується BERTopic із шаром представлення GPT-4o-mini, а поріг у 0.75 допомагає зменшити кількість відхилень. Конфігурація визначає моделі зменшення розмірності та кластеризації.
# 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}
)
Чому б не передати кожен опис серіалу ШІ та не попросити в нього теги? Вікна контексту достатньо великі, тож кілька сотень токенів не становлять проблеми. Проблема полягає у тому, що ШІ розглядає кожен серіал окремо та не має уявлення про весь корпус. У одному серіалі він може називати це явище „шлюбом за договором“, у іншому – „фальшивим шлюбом“, а у третьому – „шлюбом за вигодою“, що призводить до непослідовності термінології. BERTopic об’єднує всі серіали разом, тож схожі описи отримують однакові теги. Важливим є також фактор витрат: приблизно 900 викликів ШІ, по одному на кожен серіал, плюс збори щоразу, коли з’являється новий серіал. Підхід з використанням моделей тем був безкоштовним, масштабованим та відтворюваним.
ШІ використовувалися для аналізу абстрактних характеристик, а саме емоційного тону та тем, де обговорення шанувальників потрібно перетворити на прозу, а також для виправлення неправильно присвоєних тегів.
Короткий огляд RAG
RAG — це простий підхід для надання LLM відповідного контексту, щоб покращити його відповіді; у цьому випадку йдеться про рекомендації K-drama. Функції розділення на частини, генерації векторів та переурочення рангів є інженерними рішеннями, заснованими на цій ідеї. Архітектура має два режими: приймання даних та надання відповідей.
У режимі приймання даних документи розділяються на частини, ці частини преобразуються у вектори та зберігаються в базі даних. Під час надання відповідей запит користувача також преобразується за допомогою того самого кодера, а найбільш схожі частини за допомогою косинусової схожості додаються до запиту для LLM. Модель отримує лише відповідні документи, а не всю базу даних, і відповідає на основі цього контексту, ідеально залишаючись вірною йому.
Першим кроком було перетворення кожного DramaProfile на документ шляхом об’єднання всіх його значень у один рядок. Ці рядки були оброблені за допомогою моделі OpenAI text-embedding-3-small та збережені в інстанції ChromaDB, що завершує процес імпорту.
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
Чому розділення на частини погіршило пошук
Науковці з обробки даних запитуватимуть про відсутній крок — розділення на частини. У документах справді є природна структура, яка передбачає п’ять частин, по одній на кожне поле профілю. На практиці розділення на частини знизило якість пошуку.
Візьмемо Mr. Queen — комедію про сучасного кухаря, який опиняється в тілі королеви епохи Чосон та змушений боротися з дворовою політикою та боротьбою за владу, водночас допомагаючи королю. Вона посідає високі позиції серед кумедних K-dram про королівські родини, проте не була знайдена за саме цим запитом. Система пошуку вважала опис сюжету найбільш релевантним для запиту про сюжет, але об’єднаний опис TMDB та AsianWiki зовсім не згадує про комедію. Опис темпу подій, як показано у прикладі нижче, це робить.
Анотація охоплює шеф-кухаря, тіло королеви, короля та придворні інтриги, без жодних натяків на гумор. Опис темпу подій розповідає про комедійне наростання напруги у перших епізодах, яке згодом переходить у швидкі жарти; деякі шанувальники відзначають повторювані жарти у епізодах від 10 до 12. Розділення профілів означало, що кожен фрагмент був занадто малим, щоб містити повний контекст, тому порівняння відбувалося лише для однієї галузі, а не для всіх аспектів серіалу.
Інтерв’ю з агентом щодо профілів смаку
Короткі запити на кшталт „кумедні к-драми про королівські родини“ не відображають ані смаків, ані настрою глядача, крім того, вони занадто короткі, щоб з них можна було отримати багато інформації. Тож замість того, щоб просити детальний запит, агент для інтерв’ю спілкується з користувачем через інструмент, збираючи дані про уподобання щодо жанрів, тропів, емоційних тем, темпу та факторів, які роблять контент неприйнятним, за допомогою фіксованого набору запитань. Використання інструменту є обов’язковим, тому агент використовує його щоразу та не намагається відповісти самостійно. Тип результату — TasteProfile, який гарантує перевірений об’єкт Pydantic із заповненими та правильно типованими усіма сімома полями.
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)
Від профілю смаку до запитів
Кожне поле в TasteProfile є текстом вільного формату, і використання цього об’єкта як одного запиту — не найкраща ідея. Сім полів охоплюють різні семантичні виміри, і їх об’єднання призводить до створення розведеного ембеддингу, який не сильно відповідає жодному окремому виміру.
Агент-конструктор запитів бере TasteProfile та переписує текст вільного формату мовою, придатною для пошуку. Він розділяє сім полів на два або три цілеспрямовані запити: один для тем, один для жанру та тропів, і один для темпу та тону, причому кожен з них виконує пошук у відповідному вимірі. Це схоже на техніку розширення запитів, яка часто використовується в RAG для покращення точності пошуку. Значення параметра temperature встановлено на 0, тож модель обирає токен із найвищою ймовірністю на кожному кроці, а фіксоване значення seed забезпечує максимально детермінований процес вибору. Той самий профіль смаку має генерувати однакові запити під час кожного запуску.
Об’єднання пошуку та ранжування
Кожен запит обробляється за допомогою того самого моделі text-embedding-3-small, що використовується для документів про драми, тож обидва типи даних знаходяться в одному векторному просторі. ChromaDB проводить пошук за косинусовою схожістю та повертає 20 найбільш схожих документів для кожного запиту. Якщо у профілі є два чи три запити, це дає два чи три списки з 20 драмами, які можуть перетинатися.
Reciprocal Rank Fusion об’єднує ці списки. Це популярний алгоритм для об’єднання кількох незалежних списків результатів у один релевантний, який надає перевагу драмам, що посідають високі позиції у кількох семантичних вимірах. Оцінка кожної драми визначається за її позицією в кожному списку та розраховується як 1 / (k + ранг). Оскільки списки мають фіксовану довжину, k встановлюється на 20, що дозволяє мати різницю у 2 рази між найвищим та найнижчим рангами.
Об’єднані списки все ще ігнорують популярність та свіжість. Тому композитний бал поєднує RRF — нормалізовану середню кількість голосів та показник свіжості — з вагами, визначеними експериментально. RRF отримує 0,7, тож релевантність до зазначених уподобань має перевагу, тоді як середня кількість голосів та показник свіжості отримують 0,25 та 0,05 відповідно, щоб додати інформацію про якість та свіжість.
Остаточна обробка за допомогою LLM
Ранжований список стає контекстом, який надсилається до LLM, який вибирає чотири серіали, найкраще відповідні уподобанням користувача, та пише індивідуальну оцінку кожного з них. Ця відповідь і є рекомендацією, яку бачить користувач.
Двоступенева структура є навмисною. Механізм пошуку налаштований на виявлення якомога більшої кількості результатів, тож усі ймовірно релевантні серіали потрапляють до списку кандидатів. Штучний інтелект налаштований на точність та застосовує такий судження, якого не може здійснити арифметика. Жодна функція оцінювання не помітить, що драма про шеф-кухаря, який знаходить сенс у кулінарії, підходить глядачеві, який шукав історію про сенс у повсякденній роботі. Модель, яка аналізує профіль смаків поруч із повним документом серіалу, може встановити цей зв’язок, а розділення на два етапи дозволяє кожному з них виконувати свою роботу найкращим чином.
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
Результат
У демонстраційному варіанті профіль, орієнтований на помсту, отримує The Glory як найкращий варіант. Решту трьох місць займають Revenge of Others, High School Return of a Gangster та Weak Hero. Кожен вибір супроводжується оцінкою «Обожнюю», «Насолоджуюся» або «Змішано», а також конкретними причинами, чому він підходить смакам глядача. Увага, вказана у обговореннях фанатів, вказує на те, що глядач може не сподобатися, наприклад, суперечливий фінал чи повторювані сцени. Оскільки поради ґрунтуються як на профілі, так і на реальних відгуках спільноти, вони звучать особисто, а не універсально. Інтерфейс — це додаток Gradio, а форматування HTML допоміг здійснити штучний інтелект.
Далі йде оцінка такої системи. Оцінки вірності RAGAS та релевантності відповідей показують, чи залишаються відповіді пов’язаними з отриманим контекстом, а аналіз стабільності демонструє, наскільки послідовними є рекомендації під час багаторазових запусків з однаковим профілем смаку. Фіксовані значення параметрів та температура 0 на етапі формування запиту роблять цю другу перевірку значущою, адже будь-які відхилення тоді походять від процесу пошуку та остаточної обробки за допомогою LLM, а не від формулювання запиту.