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.
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:
- 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.
- 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.
- Ritmo describe el ritmo y la estructura narrativa, desde uno lento hasta uno acelerado.
- Tono emocional describe la atmósfera general, incluyendo si una serie es demasiado seria como para considerarse entretenimiento ligero.
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
- Paso a paso: Diseñando un pipeline de recuperación RAG real para agentes de IA — Planifique un pipeline RAG con contratos SourceDocument y DocumentChunk, fragmentación, embeddings y ChromaDB, todo detrás de un RetrievalService que mantiene estable al agente.
- Chroma Context-1: Un subagente de búsqueda de 20B para un RAG agente más económico y preciso — Cómo Chroma’s Context-1 de 20B funciona como un subagente exclusivo para recuperación de información que elimina su propio contexto, cómo fue entrenado, qué falta sin su mecanismo de soporte y dónde presenta limitaciones.
- Construyendo un agente ReAct en Python puro: Pensamiento, Acción, Observación — Escribir un bucle de agente ReAct desde cero en Python: el contrato de Pensamiento, Acción, Observación, un bucle while controlado por PAUSE, retroalimentación de errores y dos modos de fallo en producción.
- Explicación de LangGraph: Grafos, estado, enrutamiento y patrones de producción — Cómo LangGraph modela a los agentes como grafos cíclicos con estado tipado, reductores, puntos de control, enrutamiento, herramientas y bucles ReAct, además de patrones para uso en producción.
- Notas prácticas: Creación de un agente de programación en nube privada: OpenCode + un LLM local — Guía paso a paso de las Notas prácticas: Creación de un agente de programación en nube privada: OpenCode + un LLM local: contratos, verificaciones y espacios de código listos para usar por parte de los equipos que implementan este patrón.
- Notas prácticas: ElastiCache para RAG: Cómo almacenar y consultar embeddings en — Guía paso a paso de las Notas prácticas: ElastiCache para RAG: Cómo almacenar y consultar embeddings en: contratos, verificaciones y espacios de código listos para usar para los equipos que implementan este patrón.