首页 / 文章

多智能体韩剧推荐系统:BERTopic、RAG与互斥排名融合技术

一款注重口味体验的韩剧推荐系统:采用BERTopic主题标签、ChromaDB RAG技术、面试代理功能、查询扩展以及互斥排名融合方法,最后再由大语言模型进行重新排序。

发布者:REACTAPP.TOP阅读约 10 分钟2366 词

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

为何要构建韩剧推荐系统

随着观看列表的不断增加,找到下一部值得观看的韩剧变得越来越困难。论坛帖子虽能提供一定帮助,但最终也会变成一项繁琐的任务。KDramaBoo是一个多智能体流程,旨在自动化这一搜索过程。它通过对1,500个经过丰富处理的剧集资料进行检索增强生成,再根据观众的喜好偏好和当前情绪状态来推荐剧集。

韩流现象解释了这一趋势的出现时机,而且其范畴远不止韩流音乐而已。

该图表仅统计来自TMDB和AsianWiki的年度剧集数量,这两个数据源的受众以西方人为主,且剧集数量一直在稳步上升。疫情期间的激增可能有两个原因:被困在家的全球观众通过Netflix及其他流媒体平台观看韩剧,而《请回答1988》(2019年)和《鱿鱼游戏》(2021年)在国际上的成功吸引了西方人的关注,也引发了更多英文相关的在线讨论。

构建语料库

这项工作的大部分内容都与数据库相关。TMDB API提供了2010年及以后的1,500部韩剧数据,每部剧目都附有评分平均值以及来自AsianWiki和r/KDRAMA的粉丝评论、帖子及讨论内容。通过逐步加大的延迟频率对这两个网站进行数据抓取,整个过程大约耗时一天。为避免将过时或冷门的剧目纳入结果,所有2017年之前发布的或评分低于6.5的剧目都被剔除,使得数据量从1,500部减少到874部。这一筛选机制是经过刻意设计的,因为如果推荐系统总是不断推荐被遗忘的剧目,很快就会失去用户的信任。

剧情简介、评论及讨论帖都属于非结构化文本,系统正是基于这些内容为每部剧目构建超出单纯元数据范围的详细档案。该档案涵盖了人们在选择观看剧目时实际关注的信息:

  1. 套路标签的范围比类型更窄。它们指的是那些反复出现的情节设计、人物关系或故事元素,粉丝们会用这些作为筛选标准,比如假恋爱或契约婚姻。
  2. 主题描述了剧集在情感和哲学层面所探讨的内容。《当生活给予你橘子》不仅仅是一部生活剧,因为它还探讨了韧性、一生的忠诚以及代际间的赋能。
  3. 节奏指的是叙事的节奏与结构,从缓慢推进到快速发展不等。
  4. 情感基调描述的是整体氛围,包括一部剧是否过于沉重而无法被视为轻松的娱乐作品。
  • 社区反应与情绪不仅涵盖正面或负面的评价,还会记录粉丝们认同或不满的内容,比如结局或某次表演等。
  • Pydantic 模型用于定义这一结构,其中包含 TMDB ID、名称、类型标签以及其他属性等字段。

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

    基于主题建模的套路标签

    套路标签来自剧情简介,而其他四个属性则从粉丝评论、帖子及讨论中提取。对于这些标签,处理流程采用了带有GPT-4o-mini表示层的BERTopic模型,并通过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
    

    为何分块会损害检索效果

    数据科学家会询问缺失的步骤——分块。这些文档确实存在自然结构,可划分为五个部分,每个资料字段对应一个部分。但实际上,分块降低了后续的检索质量。

    以《女王先生》为例,这是一部关于现代厨师意外陷入朝鲜时代女王体内,必须在协助国王的同时应对宫廷政治与权力斗争的喜剧。它在“关于王室的搞笑韩剧”类别中排名很高,但却没有因该查询被检索出来。检索系统认为剧情概要部分最符合这类查询的需求,但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中常用于提升召回率的查询扩展技术。温度参数被设置为0,以便模型在每一步都选择概率最高的词元,而固定的种子值则使采样过程尽可能确定。相同的风味特征在每次运行时都应该生成相同的查询语句。

    检索与排序融合

    每个查询都使用与戏剧文档相同的 text-embedding-3-small 模型进行文本嵌入,因此二者处于同一个向量空间中。ChromaDB 会执行余弦相似度搜索,为每个查询返回最相似的 20 份文档。由于每个用户档案有两到三个查询,因此会生成两到三个各含 20 部戏剧的排名列表,这些列表可能存在重叠。

    互反排名融合算法用于合并这些列表。这是一种常用的算法,可将多个独立的结果列表整合为单一的相关列表,同时会优先考虑在多个语义维度上排名较高的戏剧。每部戏剧的得分取决于它在各个列表中的排名位置,计算公式为 1 / (k + 排名)。由于列表长度固定,k 被设定为 20,这样排名靠前与靠后的戏剧之间就能有 2 倍的分数差异。

    合并后的列表仍然不考虑受欢迎程度和新鲜度。因此,综合评分通过实验确定的权重,将RRF、标准化投票平均值以及新鲜度相结合。RRF被赋予0.7的权重,以确保与用户明确表达的偏好高度相关;投票平均值和新鲜度则分别被赋予0.25和0.05的权重,用以体现内容的质量和新鲜度。

    最终传递给LLM的列表

    经过排序后的列表会成为输入给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
    

    结果

    在演示中,以复仇为主题的观众偏好将《荣耀》列为最匹配的作品,《他人的复仇》、《黑帮高中生归来》和《弱小英雄》则占据其余三个位置。每部作品的推荐结果都会标注为“喜欢”、“欣赏”或“褒贬不一”,并附上符合该观众口味的具体理由。根据粉丝讨论总结出的注意事项会指出观众可能不喜欢的部分,比如有争议的结局或重复过多的情节。由于这些建议既基于观众偏好分析,也参考了真实社区的反馈,因此显得非常个性化而非千篇一律。该界面为Gradio应用,其HTML格式编排由人工智能助手协助完成。

    对于这样的系统,接下来要进行评估。RAGAS的忠实度得分与答案相关性得分能够表明答案是否基于检索到的上下文,而稳定性分析则可以显示在具有相同偏好设置的情况下,重复运行时推荐结果的一致性如何。在查询阶段使用固定种子值和零温度参数,正是为了让第二项检查具有实际意义,因为此时出现的任何差异都源自于信息检索和最终的LLM处理过程,而非查询语句本身。

    相关阅读

  • Chroma Context-1:一种用于实现更廉价、更高效代理式RAG的20B规模搜索子代理 —— Chroma的20B Context-1如何作为仅负责检索的子代理来筛选自身上下文,它的训练方式,没有该子代理时的不足,以及它的局限性。
  • 用纯Python构建ReAct智能体:思考、行动与观察 —— 从零开始用Python编写ReAct智能体循环:思考、行动、观察的流程规范,基于PAUSE机制的while循环,错误反馈机制以及两种实际应用中的故障模式。
  • LangGraph详解:图结构、状态管理、路由机制及生产环境应用模式 —— 阐述LangGraph如何将智能体建模为具有类型化状态、归约器、检查点、路由功能、工具以及ReAct循环的循环图结构,同时介绍其生产环境应用模式。
  • 实用指南:构建私有云编码智能体:OpenCode与本地大语言模型的结合 —— 详细指导如何构建私有云编码智能体:OpenCode与本地大语言模型的整合,包括相关契约、校验规则以及供团队直接使用的代码模板。
  • 实用笔记:ElastiCache在RAG中的应用——如何存储和查询嵌入向量 — 该实用笔记的详细操作指南,包括适用于采用此模式的团队的契约、检查项以及可直接插入的代码片段。