Inicio / Artículos

Recomendador de dramas coreanos multiagente: BERTopic, RAG y Fusión de Rango Recíproco

Un recomendador de dramas coreanos basado en el gusto: etiquetas de tropo BERTopic, ChromaDB RAG, un agente de entrevistas, expansión de consultas y Fusión de Rango Recíproco antes de una reclasificación con un LLM.

Editor: REACTAPP.TOP10 min de lectura2366 palabras

Fuentes: **K-DramaBoo: Building a Multi-Agent, Taste-Aware K-Drama Recommender with RAG and AgentsSDK**

¿Por qué crear un recomendador de dramas coreanos?

Encontrar el próximo drama coreano que vale la pena ver se vuelve más difícil a medida que crece la lista de obras ya vistas por un espectador. Los hilos de foro ayudan durante un tiempo, pero luego se convierten en una tarea tediosa. KDramaBoo es un sistema basado en múltiples agentes que intenta automatizar esa búsqueda. Aplica un método de generación reforzada con información recuperada sobre 1,500 perfiles detallados de dramas y recomienda series según el perfil de gustos del espectador y su estado emocional actual.

La ola coreana, o Hallyu, explica este momento histórico, y va más allá del K-pop.

El gráfico muestra la cantidad de dramas por año, extraídos únicamente de TMDB y AsianWiki, dos fuentes dirigidas principalmente al público occidental, y esa cifra ha ido en aumento constante. El aumento durante los años de la pandemia probablemente se deba a dos causas: el público mundial confinado en casa descubrió los K-dramas en Netflix y otras plataformas de streaming, y el éxito internacional de Crash Landing on You (2019) y Squid Game (2021) atrajo la atención de Occidente y generó más discusiones en inglés en línea.

Construcción del corpus

Una gran parte del trabajo consistió en la base de datos. La API de TMDB proporcionó 1,500 dramas coreanos a partir de 2010, y cada entrada se enriqueció con promedios de votos además de comentarios de fans, publicaciones y discusiones de AsianWiki y r/KDRAMA. Se extrajeron datos de ambos sitios mediante un control exponencial del ritmo de extracción, lo que llevó aproximadamente un día. Para evitar que aparecieran en los resultados series antiguas o poco populares, se eliminaron todas aquellas lanzadas antes de 2017 o con una calificación inferior a 6.5, reduciendo así el corpus de 1,500 a 874. Ese filtro es una decisión deliberada por parte del desarrollador, ya que un sistema de recomendación que muestra constantemente títulos olvidados pierde rápidamente la confianza del usuario.

Las sinopsis, comentarios y hilos de discusión son un montón de texto no estructurado, y a partir de ellos el sistema crea un perfil para cada drama que va más allá de los simples metadatos. El perfil captura los aspectos que las personas realmente buscan al elegir qué ver:

  1. Etiquetas de tropo son más específicas que los géneros. Denominan elementos narrativos recurrentes, dinámicas entre personajes o aspectos de la historia que los aficionados utilizan como filtros, como citas falsas o matrimonios por contrato.
  2. Temas describen de qué trata la serie desde el punto de vista emocional y filosófico. When Life Gives You Tangerines es algo más que una serie de vida cotidiana, ya que aborda la resiliencia, la dedicación de por vida y el empoderamiento a lo largo de las generaciones.
  3. Ritmo describe el ritmo y la estructura narrativa, desde uno lento hasta uno acelerado.
  4. Tono emocional describe la atmósfera general, incluyendo si una serie es demasiado seria como para considerarse entretenimiento ligero.
  • Reacción y sentimiento de la comunidad va más allá de lo positivo o negativo para registrar en qué estuvieron de acuerdo los fans y qué les disgustó, como un final o una actuación en particular.
  • Un modelo Pydantic define este esquema, con campos para el ID de TMDB, el nombre, las etiquetas de tropo y los demás atributos.

    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")
    

    Etiquetas de tropo con modelado de temas

    Las etiquetas de tropo provienen de las sinopsis, mientras que los otros cuatro atributos se extrajeron de los comentarios, publicaciones y discusiones de los fans. Para las etiquetas, el proceso utiliza BERTopic con una capa de representación GPT-4o-mini, y un umbral de 0.75 reduce la cantidad de valores atípicos. La configuración establece los modelos de reducción de dimensionalidad y 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}
                                  )
    

    ¿Por qué no entregar cada sinopsis a un LLM y pedirle las etiquetas? Las ventanas de contexto son lo suficientemente largas como para que unos pocos cientos de tokens no causen problemas. El problema es que un LLM solo procesa una serie a la vez y no tiene conciencia del corpus completo. Podría denominar “matrimonio por contrato” así en una serie, “matrimonio falso” en otra y “matrimonio de conveniencia” en una tercera, lo que genera inconsistencia en el vocabulario. BERTopic agrupa todas las series juntas, de modo que las sinopsis similares reciben las mismas etiquetas. El costo también es un factor importante: alrededor de 900 llamadas al LLM, una por cada serie, además de cargos cada vez que llega una nueva serie. El enfoque basado en modelado temático era gratuito, escalable y reproducible.

    Los LLM se mantuvieron para los aspectos abstractos, como el tono emocional y los temas, donde las discusiones de los fans deben transformarse en prosa, y también para corregir cualquier etiqueta de tropo asignada incorrectamente.

    Un vistazo rápido a RAG

    RAG es un método sencillo para proporcionar a un LLM un contexto relevante que mejore sus respuestas; en este caso, se trata de las recomendaciones de dramas coreanos. La división en fragmentos, el embedding y el reordenamiento son técnicas de ingeniería basadas en esa idea. La arquitectura cuenta con dos modos: la ingestión y la respuesta.

    En la fase de ingestión, los documentos se dividen en fragmentos, se les aplica embedding y los vectores resultantes se guardan en una base de datos. Durante la respuesta, la consulta del usuario también se procesa con el mismo codificador, y los fragmentos más similares se seleccionan mediante similitud coseno para incluirse en la prompt del LLM. El modelo recibe únicamente los documentos relevantes y no toda la base de datos, y responde a partir de ese contexto, procurando ser fiel a él.

    El primer paso fue convertir cada DramaProfile en un documento al unir todos sus valores en una sola cadena. Estas cadenas se procesaron con el modelo text-embedding-3-small de OpenAI y se almacenaron en una instancia de ChromaDB, lo que completa el proceso de ingestión.

    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
    

    Por qué el fragmentado perjudicó la recuperación

    Los científicos de datos preguntarán por el paso que falta: el fragmentado. Los documentos sí tienen una estructura natural que sugiere cinco fragmentos, uno por campo de perfil. En la práctica, el fragmentado redujo la calidad de la recuperación posteriormente.

    Tomemos Mr. Queen, una comedia sobre un chef moderno que termina atrapado en el cuerpo de una reina de la época Joseon y debe lidiar con la política palaciega y las luchas por el poder mientras ayuda al rey. Ocupa un lugar destacado entre las comedias coreanas sobre la realeza, pero no fue recuperada con esa consulta específica. El sistema de recuperación consideró que el fragmento con la sinopsis era el más relevante para una consulta de tipo trama, pero las sinopsis combinadas de TMDB y AsianWiki nunca mencionan que se trate de una comedia. En cambio, la descripción del ritmo sí lo hace, como muestra el ejemplo a continuación.

    El resumen trata sobre el chef, el cuerpo de la reina, el rey y las intrigas de la corte, sin ningún matiz de humor. El texto sobre el ritmo describe una construcción cómica en los primeros episodios que se intensifica con bromas rápidas más adelante, y algunos fans señalan bromas repetitivas entre los episodios 10 y 12. Al separar los perfiles, cada parte resultó demasiado pequeña para contener todo el contexto, por lo que la comparación solo fue posible dentro de un mismo campo y no en todos los aspectos de la serie.

    Un entrevistador para los perfiles de sabor

    Las consultas breves como “dramas k-coreanos graciosos sobre la realeza” no reflejan ni los gustos ni el estado de ánimo del espectador, y son demasiado cortas como para extraer mucha información de ellas. Por lo tanto, en lugar de solicitar una consulta detallada, un agente de entrevistas habla con el usuario a través de una herramienta, recopilando preferencias sobre géneros, tropos, temas emocionales, ritmo y aspectos que descartan ciertas opciones a partir de un conjunto fijo de preguntas. La elección de la herramienta está establecida como obligatoria, por lo que el agente utiliza la herramienta en cada turno y no intenta responder por sí solo. El tipo de salida es un TasteProfile, lo que garantiza un objeto Pydantic validado con todos los siete campos completados y con el tipo correcto.

    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)
    

    Del perfil de sabor a las consultas

    Cada campo en TasteProfile es texto libre, y utilizar el objeto como una sola consulta no es una buena idea. Siete campos abarcan diferentes dimensiones semánticas, y combinarlos genera un embedding diluido que no coincide de manera significativa con ninguna dimensión en particular.

    Un agente constructor de consultas toma el TasteProfile y reescribe el texto libre en un lenguaje adecuado para la recuperación de información. Divide los siete campos en dos o tres consultas específicas: una para los temas, otra para el género y los tropos, y otra para el ritmo y el tono; cada una busca dentro de su propia dimensión. Esto se asemeja a la técnica de expansión de consultas que se utiliza frecuentemente en RAG para mejorar la capacidad de recuperación. La temperatura se establece en 0 para que el modelo elija el token de mayor probabilidad en cada paso, y una semilla fija mantiene la muestreo lo más determinista posible. El mismo perfil de sabor debería generar las mismas consultas en cada ejecución.

    Fusión de recuperación y clasificación

    Cada consulta utiliza el mismo modelo text-embedding-3-small que se empleó para los documentos de dramas, por lo que ambos se encuentran en el mismo espacio vectorial. ChromaDB realiza una búsqueda de similitud coseno y devuelve los 20 documentos más similares por consulta. Con dos o tres consultas por perfil, se obtienen dos o tres listas ordenadas de 20 dramas, que pueden solaparse.

    Fusión de rangos recíprocos combina estas listas. Es un algoritmo popular para unir varias listas de resultados independientes en una sola lista relevante, y premia a los dramas que ocupan posiciones altas en varias dimensiones semánticas. La puntuación de cada drama proviene de su posición en cada lista, calculada como 1 / (k + rango). Dado que las listas tienen una longitud fija, k se establece en 20, lo que permite una diferencia de 2 veces entre los rangos más altos y los más bajos.

    Las listas fusionadas siguen ignorando la popularidad y la recencia. Por lo tanto, una puntuación compuesta combina el RRF, un promedio de votos normalizado y la recencia, con pesos determinados mediante experimentos. El RRF recibe un valor de 0.7, de modo que la relevancia con respecto a las preferencias indicadas predomina, mientras que el promedio de votos y la recencia reciben 0.25 y 0.05 respectivamente, para añadir indicadores sobre calidad y frescura.

    El proceso final con el LLM

    La lista ordenada se convierte en el contexto que se envía al LLM, el cual selecciona las cuatro series más alineadas con las preferencias del usuario y redacta una evaluación personalizada de cada una. Esa respuesta es la recomendación que ve el usuario.

    El diseño en dos etapas es intencional. La recuperación está ajustada para la recuperación de información y abarca una amplia gama, de modo que cada programa razonablemente relevante llegue al grupo de candidatos. El LLM está ajustado para la precisión y aplica el tipo de juicio que la aritmética no puede realizar. Ninguna función de puntuación se dará cuenta de que una serie sobre un chef que descubre su propósito a través de la cocina es adecuada para un espectador que buscaba una historia sobre el significado en el trabajo cotidiano. Un modelo que analice el perfil de preferencias junto con el documento completo de la serie puede establecer esa conexión, y esta división permite que cada etapa haga lo que mejor sabe hacer.

    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
    

    El resultado

    En la prueba, un perfil centrado en la venganza obtiene The Glory como su mejor opción. Revenge of Others, High School Return of a Gangster y Weak Hero ocupan los otros tres puestos. Cada selección incluye una evaluación de “Me gusta”, “Disfrútalo” o “Mixto”, junto con razones específicas por las cuales se ajusta al gusto del espectador. Las advertencias extraídas de las discusiones de los fans señalan elementos que el espectador podría no apreciar, como un final controvertido o una sección repetitiva. Dado que los consejos se basan tanto en el perfil como en los comentarios reales de la comunidad, parecen personales y no genéricos. La interfaz es una aplicación de Gradio, y un asistente de IA ayudó con su formato HTML.

    A continuación viene la evaluación para un sistema como este. Las puntuaciones de fidelidad de RAGAS y de relevancia de las respuestas indican si estas se basan en el contexto recuperado, y un análisis de estabilidad muestra cuán consistentes son las recomendaciones en ejecuciones repetidas con el mismo perfil de preferencias. Las semillas fijas y la temperatura cero en la fase de consulta son lo que hace significativa esa segunda verificación, ya que cualquier variación que quede proviene de la recuperación y del proceso final con el LLM, y no de la redacción de la consulta.

    Leer más