Recommandateur de dramas coréens multi-agents : BERTopic, RAG et fusion de rangs réciproques
Un recommandateur de dramas coréens basé sur la perception du goût : étiquettes de motifs BERTopic, ChromaDB RAG, un agent d’interview, expansion des requêtes et fusion de rangs réciproques avant un ré-rangement par un LLM.
Sources: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**
Pourquoi créer un recommandateur de dramas coréens
Il devient de plus en plus difficile de trouver le prochain drama coréen digne d’être regardé à mesure que la liste des œuvres déjà visionnées par un spectateur s’allonge. Les discussions sur les forums aident temporairement, mais deviennent rapidement une corvée. KDramaBoo est un système multi-agents conçu pour automatiser cette recherche. Il utilise une méthode de génération améliorée par la récupération d’informations à partir de 1 500 profils détaillés de dramas, et propose des recommandations en fonction du profil de goûts du spectateur ainsi que de son état émotionnel du moment.
La vague coréenne, ou Hallyu, explique ce phénomène, et elle concerne bien plus que le K-pop.
Le graphique indique le nombre de dramas par an, tiré uniquement de TMDB et d’AsianWiki, deux sources ciblant principalement un public occidental, et ce chiffre augmente constamment. L’augmentation remarquable durant les années de pandémie est probablement due à deux facteurs : les spectateurs du monde entier confinés chez eux ont découvert les K-dramas sur Netflix et d’autres plateformes de streaming, tandis que le succès international de Crash Landing on You (2019) et de Squid Game (2021) a attiré l’attention des Occidentaux et suscité davantage de discussions en langue anglaise en ligne.
Construction du corpus
Une grande partie du travail a porté sur la base de données. L’API TMDB a fourni 1 500 dramas coréens à partir de 2010, et chaque entrée a été enrichie de moyennes de votes ainsi que de commentaires des fans, de publications et de discussions provenant d’AsianWiki et de r/KDRAMA. Ces deux sites ont été explorés en utilisant une régulation exponentielle, ce qui a pris environ une journée. Afin d’éviter que des séries anciennes ou peu populaires n’apparaissent dans les résultats, tout ce qui a été diffusé avant 2017 ou dont la note était inférieure à 6,5 a été supprimé, réduisant ainsi le corpus de 1 500 à 874 éléments. Ce filtre est le résultat d’une décision délibérée, car un système de recommandation qui met constamment en avant des titres oubliés perd rapidement la confiance des utilisateurs.
Les synopsis, les commentaires et les threads de discussion constituent un ensemble de textes non structurés, à partir desquels le système crée un profil pour chaque drama allant au-delà des simples métadonnées. Ce profil prend en compte ce que les gens recherchent réellement lorsqu’ils choisissent une série à regarder :
- Les étiquettes de trope sont plus spécifiques que les genres. Elles désignent des mécanismes narratifs récurrents, des dynamiques de personnages ou des éléments de l’histoire que les fans utilisent comme critères de sélection, tels que les relations amoureuses fictives ou les mariages par contrat.
- Les thèmes décrivent ce dont traite une série sur le plan émotionnel et philosophique. When Life Gives You Tangerines est bien plus qu’une série de la vie quotidienne, car elle aborde la résilience, le dévouement tout au long de la vie et l’autonomisation entre générations.
- Le rythme décrit le rythme et la structure narrative, allant d’un développement lent à un déroulement rapide.
- Le ton émotionnel décrit l’atmosphère globale, y compris le fait qu’une série soit trop sombre pour être considérée comme une série légère.
Un modèle Pydantic définit ce schéma, avec des champs pour l’ID TMDB, le nom, les étiquettes de trope et les autres attributs.
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")
Étiquettes de trope avec le modélisation de sujets
Les étiquettes de trope proviennent des synopsis, tandis que les quatre autres attributs ont été extraits des commentaires, publications et discussions des fans. Pour ces étiquettes, le processus utilise BERTopic avec une couche de représentation GPT-4o-mini, et un seuil de 0,75 permet de réduire le nombre d’éléments atypiques. La configuration définit les modèles de réduction de dimensionnalité et de clustering.
# 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}
)
Pourquoi ne pas confier chaque synopsis à un LLM pour obtenir des tags ? Les fenêtres de contexte sont suffisamment larges pour que quelques centaines de tokens ne posent aucun problème. Le problème, c’est qu’un LLM ne traite qu’une série à la fois et n’a pas conscience du corpus dans son ensemble. Il pourrait qualifier un « mariage contractuel » de cette manière dans une série, un « mariage de façade » dans une autre et un « mariage de convenance » dans une troisième, ce qui rend le vocabulaire incohérent. BERTopic regroupe toutes les séries ensemble, de sorte que les synopsis similaires reçoivent les mêmes tags. Le coût est également un facteur important : environ 900 appels à l’LLM, un par série, auxquels s’ajoutent des frais chaque fois qu’une nouvelle série arrive. La méthode basée sur le modélage de thèmes était gratuite, scalable et reproductible.
Les LLM ont été conservés pour les caractéristiques abstraites, à savoir le ton émotionnel et les thèmes, où les discussions des fans doivent être transformées en prose, ainsi que pour corriger d’éventuels tags de stéréotypes mal attribués.
Un aperçu rapide de RAG
RAG est une méthode simple permettant de fournir à un LLM un contexte pertinent afin d’améliorer ses réponses, comme dans le cas des recommandations de dramas coréens. Le découpage en blocs, l’incorporation de vecteurs et le réclassement sont des techniques d’ingénierie basées sur cette idée. L’architecture dispose de deux modes : l’ingestion et la réponse.
L’ingestion divise les documents en blocs, les incorpore en vecteurs et enregistre ces derniers dans une base de données. Lors de la réponse, la requête de l’utilisateur est également convertie en vecteurs à l’aide du même encodeur, puis les blocs les plus similaires sont sélectionnés grâce à la similarité cosinus et ajoutés à la prompt du LLM. Le modèle ne reçoit que les documents pertinents et non toute la base de données, et il répond en se basant sur ce contexte, en restant idéalement fidèle à celui-ci.
La première étape consistait à transformer chaque DramaProfile en un document en regroupant toutes ses valeurs en une seule chaîne de caractères. Ces chaînes ont été traitées avec le modèle text-embedding-3-small d’OpenAI et stockées dans une instance ChromaDB, ce qui achève l’ingestion.
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
Pourquoi le découpage nuit à la récupération
Les data scientists se demanderont où se situe l’étape manquante : le découpage. Les documents présentent en effet une structure naturelle qui suggère cinq parties, une par champ de profil. En pratique, ce découpage a diminué la qualité de la récupération des informations ultérieures.
Prenons Mr. Queen, une comédie sur un chef moderne qui se retrouve prisonnier dans le corps d’une reine de l’époque Joseon et doit gérer les politiques de palais et les luttes de pouvoir tout en aidant le roi. Elle figure parmi les dramas coréens les plus drôles sur la famille royale, pourtant elle n’a pas été retrouvée avec cette recherche précise. Le système de récupération a jugé que le résumé relatif au scénario était le plus pertinent pour une recherche de ce type, mais les résumés combinés de TMDB et d’AsianWiki ne mentionnent jamais cette comédie. En revanche, la description du rythme en parle, comme l’exemple ci-dessous le montre.
Le résumé traite du chef, du corps de la reine, du roi et des intrigues de cour, sans la moindre touche d’humour. Le texte concernant le rythme décrit une montée en intensité comique au cours des premiers épisodes, qui se transforme ensuite en une série de blagues rapides ; certains fans ont remarqué des plaisanteries répétitives aux alentours des épisodes 10 à 12. En séparant les profils, chaque partie est devenue trop petite pour contenir le contexte complet, ce qui a rendu impossible une correspondance cohérente entre tous les aspects de la série.
Un agent d’interview pour les profils de goût
Les requêtes courtes comme « drames coréens amusants sur la famille royale » ne reflètent ni les goûts ni l’humeur du spectateur, et elles sont trop brèves pour en tirer beaucoup d’informations. Ainsi, au lieu de demander une requête détaillée, l’agent d’interview converse avec l’utilisateur à l’aide d’un outil, recueillant des préférences concernant les genres, les archétypes, les thèmes émotionnels, le rythme et les critères de rejet à partir d’un ensemble fixe de questions. Le choix de l’outil est obligatoire, de sorte que l’agent utilise cet outil à chaque tour et ne tente pas de répondre seul. Le type de résultat est un TasteProfile, qui garantit un objet Pydantic validé, avec tous les sept champs remplis et correctement typés.
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)
De la profile de goût aux requêtes
Chaque champ de TasteProfile contient du texte libre, et il n’est pas judicieux d’utiliser cet objet comme une seule requête. Sept champs couvrent différentes dimensions sémantiques, et leur combinaison produit un embedding dilué qui ne correspond fortement à aucune dimension en particulier.
Un agent constructeur de requêtes prend le TasteProfile et réécrit le texte libre en un langage adapté à la recherche. Il divise les sept champs en deux ou trois requêtes ciblées : une pour les thèmes, une pour le genre et les tropes, et une pour le rythme et le ton, chacune effectuant une recherche dans sa propre dimension. Cela ressemble à la technique d’expansion des requêtes souvent utilisée dans RAG pour améliorer le taux de rappel. La température est fixée à 0 afin que le modèle choisisse à chaque étape le token ayant la plus haute probabilité, et une graine fixe rend l’échantillonnage aussi déterministe que possible. Le même profile de goût doit produire les mêmes requêtes à chaque exécution.
Fusion de la récupération et du classement
Chaque requête utilise le même modèle text-embedding-3-small que celui employé pour les documents de dramas, de sorte que tous se trouvent dans le même espace vectoriel. ChromaDB effectue une recherche par similarité cosinus et retourne les 20 documents les plus similaires par requête. Avec deux ou trois requêtes par profil, on obtient donc deux ou trois listes classées de 20 dramas, susceptibles de se chevaucher.
Fusion par rang réciproque les fusionne. Il s’agit d’un algorithme populaire permettant de combiner plusieurs listes de résultats indépendantes en une seule liste pertinente, qui récompense les dramas qui occupent de hautes positions dans plusieurs dimensions sémantiques. Le score de chaque drama provient de sa position dans chaque liste, calculée comme 1 / (k + rang). Comme les listes ont une longueur fixe, k est fixé à 20, ce qui permet une différence de 2 fois entre les positions les plus élevées et les plus basses.
Les listes fusionnées ignorent toujours la popularité et la fraîcheur. Un score composite combine donc le RRF, une moyenne des votes normalisée ainsi que la fraîcheur, avec des pondérations déterminées par des essais. Le RRF reçoit une pondération de 0,7, de sorte que la pertinence par rapport aux préférences indiquées prévaut, tandis que la moyenne des votes et la fraîcheur reçoivent respectivement 0,25 et 0,05 pour ajouter des indications sur la qualité et la fraîcheur.
Le traitement final par le LLM
La liste classée devient le contexte transmis au LLM, qui sélectionne les quatre séries les mieux alignées avec les préférences de l’utilisateur et rédige une évaluation personnalisée pour chacune d’elles. Cette réponse constitue la recommandation que voit l’utilisateur.
La conception en deux étapes est intentionnelle. La récupération des données est optimisée pour le rappel et élargit le champ des recherches afin que chaque série potentiellement pertinente soit incluse dans la liste des candidats. Le LLM, quant à lui, est optimisé pour la précision et applique un jugement que l’arithmétique ne peut pas assurer. Aucune fonction de notation ne remarquera qu’une série sur un chef qui découvre un but à travers la cuisine convient à un spectateur cherchant une histoire sur le sens du travail quotidien. Un modèle capable de lire le profil des préférences associé au document complet de la série peut établir ce lien, et cette séparation permet à chaque étape de faire ce pour quoi elle est la plus adaptée.
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
Le résultat
Lors de la démonstration, un profil axé sur la vengeance obtient The Glory comme meilleure correspondance. Revenge of Others, High School Return of a Gangster et Weak Hero occupent les trois autres places. Chaque choix s’accompagne d’un jugement : « J’aime », « J’apprécie » ou « Mélange », ainsi que de raisons spécifiques expliquant pourquoi il correspond aux goûts du spectateur. Les points d’attention tirés des discussions des fans indiquent ce que le spectateur pourrait ne pas apprécier, comme une fin controversée ou une période répétitive. Comme les conseils s’appuient à la fois sur le profil et sur les retours de la communauté réelle, ils semblent personnels et non génériques. L’interface est une application Gradio, et un assistant IA a aidé à sa mise en forme HTML.
Viennent ensuite les évaluations pour un système de ce type. Les scores de fidélité RAGAS et de pertinence des réponses indiquent si celles-ci restent ancrées dans le contexte récupéré, tandis qu’une analyse de stabilité montre dans quelle mesure les recommandations restent cohérentes lors de plusieurs exécutions avec le même profil de préférences. L’utilisation de seeds fixes et d’une température nulle à l’étape de la requête rend cette deuxième vérification significative, car toute variation résiduelle provient alors de la récupération des données et du traitement final par le LLM, et non de la formulation de la requête.