Многопроцессный рекомендатель корейских драм: BERTopic, RAG и метод слияния взаимных рангов
Рекомендатор K-dramа, учитывающий вкусы зрителей: метки тропов BERTopic, ChromaDB RAG, агент для интервью, расширение запросов и метод Reciprocal Rank Fusion перед повторной оценкой с помощью больших языковых моделей.
Источники: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**
Почему создавать рекомендатор корейских драм?
По мере увеличения списка просмотренных сериалов становится всё сложнее найти новую корейскую драму, стоящую просмотра. Темы на форумах помогают некоторое время, но затем превращаются в рутину. KDramaBoo — это система с несколькими агентами, предназначенная для автоматизации такого поиска. Она использует метод генерации с усилением на основе данных 1 500 детально описанных профилей драм и рекомендует сериалы в зависимости от предпочтений зрителя и его текущего эмоционального состояния.
Корейская волна, или Халлю, объясняет этот тренд, и он касается не только K-pop.
В этой диаграмме показано количество драм в год, взятое исключительно из TMDB и AsianWiki — двух источников, ориентированных в основном на западную аудиторию, — и эта цифра постоянно растет. Резкий скачок в годы пандемии, вероятно, обусловлен двумя причинами: зрители по всему миру, оставшиеся дома, нашли корейские драмы на Netflix и других стриминговых сервисах, а международный успех сериалов «Приземление на тебя» (2019) и «Игра кальмаров» (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 — это простой способ предоставления большой языковой модели релевантного контекста для улучшения качества её ответов; в данном случае речь идёт о рекомендациях корейских дорам. Разбиение на фрагменты, генерация векторов и их переранжирование — это технические решения, основанные на этой идее. Архитектура имеет два режима: загрузка данных и ответы.
В режиме загрузки документы разделяются на фрагменты, генерируются для них векторы, которые затем сохраняются в базе данных. При формировании ответа запрос пользователя также преобразуется с помощью того же кодера, после чего с помощью косинусного сходства находятся наиболее похожие фрагменты, которые добавляются в запрос для большой языковой модели. Модель получает только релевантные документы, а не всю базу данных, и отвечает на основе этого контекста, стараясь максимально сохранять его смысл.
Первым шагом было преобразование каждого 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 — комедию о современном поваре, который оказывается в теле королевы эпохи Чосон и вынужден разбираться с дворцовой политикой и борьбой за власть, помогая королю. Он занимает высокие позиции среди смешных корейских драм о членах королевской семьи, но не был найден при поиске именно по этому запросу. Система поиска сочла описание сюжета наиболее релевантным для запроса о стиле сюжета, однако в совмещённом описании от 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 и релевантности ответов показывают, остаются ли ответы основаны на полученном контексте, а анализ стабильности показывает, насколько последовательными остаются рекомендации при многократных запусках с одинаковым профилем предпочтений. Использование фиксированных значений параметров и нулевой температуры на этапе формирования запроса делает эту вторую проверку значимой, поскольку любые отклонения в таком случае происходят из-за процесса поиска информации и финальной обработки с помощью LLM, а не из-за формулировки запроса.