Home / Articles

Multi-Agent K-Drama Recommender: BERTopic, RAG and Reciprocal Rank Fusion

A taste-aware K-drama recommender: BERTopic trope tags, ChromaDB RAG, an interview agent, query expansion and Reciprocal Rank Fusion ahead of an LLM re-rank.

Publisher: REACTAPP.TOP10 min read2366 words

This article is published in English.

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

Why build a K-drama recommender

Finding the next Korean drama worth watching gets harder as a viewer's watched list grows. Forum threads help for a while, then turn into a chore. KDramaBoo is a multi-agent pipeline that tries to automate that search. It runs retrieval-augmented generation over 1,500 enriched drama profiles and recommends shows based on a viewer's taste profile and current emotional mood.

The Korean wave, or Hallyu, explains the timing, and it's about more than K-pop.

The chart counts dramas per year drawn only from TMDB and AsianWiki, two sources with a mostly western audience, and the count has climbed steadily. The jump around the pandemic years likely has two causes. Global audiences stuck at home found K-dramas on Netflix and other streamers, and the international success of Crash Landing on You (2019) and Squid Game (2021) brought western attention and more English-language discussion online.

Building the corpus

A large part of the work was the database. The TMDB API supplied 1,500 K-dramas from 2010 onward, and each entry was enriched with vote averages plus fan comments, posts and discussions from AsianWiki and r/KDRAMA. Both sites were scraped with exponential throttling, which took about a day. To keep old and unpopular shows out of the results, anything released before 2017 or rated below 6.5 was removed, shrinking the corpus from 1,500 to 874. That filter is a deliberate product choice, since a recommender that keeps surfacing forgotten titles quickly loses trust.

Synopses, comments and discussion threads are a pile of unstructured text, and from them the system builds a profile per drama that goes beyond bare metadata. The profile captures the things people actually look for when choosing a watch:

  1. Trope tags are narrower than genres. They name recurring plot devices, character dynamics or story elements that fans use as filters, like fake dating or contract marriage.
  2. Themes describe what the drama is emotionally and philosophically about. When Life Gives You Tangerines is more than a slice-of-life show, since it deals with resilience, lifelong devotion and empowerment across generations.
  3. Pacing describes the narrative rhythm and structure, from slow burn to fast moving.
  4. Emotional tone describes the overall atmosphere, including whether a show is too heavy to count as a light watch.
  5. Community reaction and sentiment goes past positive or negative to record what fans agreed on and what they disliked, such as an ending or a particular performance.

A Pydantic model defines this schema, with fields for the TMDB ID, name, trope tags and the other attributes.

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

Trope tags with topic modeling

Trope tags came from synopses, while the other four attributes were extracted from fan comments, posts and discussions. For the tags, the pipeline uses BERTopic with a GPT-4o-mini representation layer, and a threshold of 0.75 reduces the number of outliers. The configuration sets the dimensionality reduction and clustering models.

# 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}
                              )

Why not hand each synopsis to an LLM and ask for tags? Context windows are long enough that a few hundred tokens cause no trouble. The catch is that an LLM sees one drama at a time and has no awareness of the corpus. It might call "contract marriage" by that name in one show, "fake marriage" in another and "marriage of convenience" in a third, leaving the vocabulary inconsistent. BERTopic clusters every drama together, so similar synopses receive the same tags. Cost matters as well: around 900 LLM calls, one per drama, plus charges every time a new show arrives. The topic-modeling route was free, scalable and reproducible.

LLMs were kept for the abstract features, namely emotional tone and themes, where fan discussions need to be turned into prose, and for fixing any misassigned trope tags.

A quick look at RAG

RAG is a straightforward trick for handing an LLM relevant context so its answers improve, here the K-drama recommendations. Chunking, embedding and reranking are engineering built around that one idea. The architecture has two modes: ingestion and answering.

Ingestion splits documents into chunks, embeds them and saves the vectors in a database. During answering, the user's query is embedded with the same encoder, and the most similar chunks are pulled by cosine similarity and added to the LLM prompt. The model receives only relevant documents and not the whole database, and it answers from that context, ideally staying faithful to it.

The first step was to turn each DramaProfile into a document by joining all its values into one string. These strings were embedded with OpenAI's text-embedding-3-small model and stored in a ChromaDB instance, which completes 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

Why chunking hurt retrieval

Data scientists will ask about the missing step, chunking. The documents do have a natural structure that suggests five chunks, one per profile field. In practice, chunking lowered retrieval quality downstream.

Take Mr. Queen, a comedy about a modern chef who ends up trapped in the body of a Joseon-era queen and has to handle palace politics and power struggles while helping the King. It ranks high for funny K-dramas about royals, yet it wasn't retrieved for exactly that query. The retriever judged the synopsis chunk most relevant to a plot-style query, but the combined TMDB and AsianWiki synopsis never mentions the comedy. The pacing description does, as the example below shows.

The synopsis covers the chef, the queen's body, the King and the court intrigue, with no hint of humor. The pacing text describes a comedic build-up over the first episodes that escalates into rapid-fire gags later, with some fans noting repetitive jokes around episodes 10 to 12. Splitting profiles apart meant each chunk was too small to carry the full context, so matching worked against a single field and not across all aspects of the show.

An interview agent for taste profiles

Short queries like "funny k-dramas about royals" capture neither a viewer's taste nor their mood, and they're too brief to extract much from. So instead of asking for a detailed query, an interview agent talks to the user through a tool, collecting preferences about genres, tropes, emotional themes, pacing and dealbreakers from a fixed set of questions. Tool choice is set to required, so the agent calls the tool every turn and doesn't try to answer alone. The output type is a TasteProfile, which guarantees a validated Pydantic object with all seven fields filled and correctly typed.

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)

From taste profile to queries

Every field in TasteProfile is free text, and using the object as one query isn't a good idea. Seven fields span different semantic dimensions, and joining them produces a diluted embedding that matches no single dimension strongly.

A query constructor agent takes the TasteProfile and rewrites the free text in retrieval-friendly language. It splits the seven fields into two or three focused queries, one for themes, one for genre and tropes, and one for pacing and tone, each searching along its own dimension. This resembles the query expansion technique often used in RAG to boost recall. Temperature is set to 0 so the model takes the highest-probability token at each step, and a fixed seed keeps sampling as deterministic as possible. The same taste profile should yield the same queries on every run.

Retrieval and rank fusion

Each query is embedded with the same text-embedding-3-small model used for the drama documents, so both live in one vector space. ChromaDB runs a cosine similarity search and returns the top 20 most similar documents per query. With two or three queries per profile, that gives two or three ranked lists of 20 dramas, possibly overlapping.

Reciprocal Rank Fusion merges them. It's a popular algorithm for combining several independent result lists into a single relevant one, and it rewards dramas that rank high across several semantic dimensions. Each drama's score comes from its rank position in every list, calculated as 1 / (k + rank). Because the lists have a fixed length, k is set to 20, which allows a 2x difference between top and bottom ranks.

The fused lists still ignore popularity and recency. A composite score therefore blends RRF, a normalized vote average and recency, with weights found by experiment. RRF gets 0.7 so relevance to stated preferences dominates, and vote average and recency get 0.25 and 0.05 to add signals about quality and freshness.

The final LLM pass

The ranked list becomes the context that goes to the LLM, which picks the four dramas best aligned with the user's preferences and writes a personalized assessment of each. That response is the recommendation the user sees.

The two-stage design is intentional. Retrieval is tuned for recall and casts a wide net so every plausibly relevant show lands in the candidate pool. The LLM is tuned for precision and applies the kind of judgment that arithmetic can't. No scoring function will notice that a drama about a chef discovering purpose through cooking suits a viewer who wanted a story about meaning in everyday work. A model that reads the taste profile next to the complete drama document can draw that link, and the split keeps each stage doing what it does best.

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 result

In the demo run, a revenge-focused profile gets The Glory as its top match. Revenge of Others, High School Return of a Gangster and Weak Hero fill out the remaining three places. Every pick carries a verdict of Love it, Enjoy it or Mixed, along with specific reasons it fits the viewer's taste. Watch-outs taken from fan discussions point out what the viewer might not enjoy, such as a divisive ending or a repetitive stretch. Because the advice is grounded in both the profile and real community feedback, it reads as personal and not generic. The interface is a Gradio app, and an AI assistant helped with its HTML formatting.

Evaluation comes next for a system like this. RAGAS faithfulness and answer relevancy scores show whether answers stay grounded in the retrieved context, and a stability analysis shows how consistent recommendations remain across repeated runs with the same taste profile. Fixed seeds and zero temperature in the query stage are what make that second check meaningful, because any variation left over then comes from retrieval and the final LLM pass and not from the query wording.