LangChain을 이용한 영화 추천기 개발일지 - 1

이정진·2024년 1월 1일
1

Moviechat

목록 보기
1/2
post-thumbnail

Motivation

  • 지금까지 배웠던 내용들을 활용할 프로젝트로 영화 추천 Chat Agent를 만들어보기로 했습니다.
  • ChatGPT 영화 추천 대화 예시
  • 위 대화 내용을 보면 최신 한국 영화를 소개해 달라고 했을 때, 없는 영화제목을 가져오는 hallucination이 발생하는 것을 볼 수 있었습니다.
  • 사용자가 원하는 영화를 추천하기 위해 LangChain을 이용하여 Prompt Strategy, Fuction calling, RAG pipeline을 이용하여 개개인에 맞는 추천을 해주는 챗봇을 만들어보고자 했습니다.
  • Project github

Architecture

  • 볼 만한 영화 없나? 요새 날씨에 맞는 영화 추천해줘. 연말에 보기 좋은 영화 추천해줘. Mbti OOOO이 좋아할 만한 영화 추천해줘. 이러한 애매한 질문에도 답할 수 있도록 만들고 싶었습니다.
  • 이를 위해서 다음과 같은 구조를 생각했습니다.
  • LLM을 두개 띄워서 첫번째 Retriever에서 정보 수집을 담당, 두번째 Recommender에서는 받은 정보를 처리하는 구조를 생각했습니다.

Function Calling

  • 이를 구현하기 위해서 날씨, 검색 결과 등의 function calling을 구현하고자 했다.

날씨

  • OpenAPI를 이용하여 좌표를 입력 받으면 그 지역의 기온을 리턴해주는 함수
# temperature.py
import requests
import datetime
from pydantic.v1 import BaseModel, Field
from langchain.agents import tool

# Define the input schema
class OpenMeteoInput(BaseModel):
    latitude: float = Field(..., description="Latitude of the location to fetch weather data for")
    longitude: float = Field(..., description="Longitude of the location to fetch weather data for")

@tool(args_schema=OpenMeteoInput)
def get_current_temperature(latitude: float, longitude: float) -> dict:
    """Fetch current temperature for given coordinates."""
    
    BASE_URL = "https://api.open-meteo.com/v1/forecast"
    
    # Parameters for the request
    params = {
        'latitude': latitude,
        'longitude': longitude,
        'hourly': 'temperature_2m',
        'forecast_days': 1,
    }

    # Make the request
    response = requests.get(BASE_URL, params=params)
    
    if response.status_code == 200:
        results = response.json()
    else:
        raise Exception(f"API Request failed with status code: {response.status_code}")

    current_utc_time = datetime.datetime.utcnow()
    time_list = [datetime.datetime.fromisoformat(time_str.replace('Z', '+00:00')) for time_str in results['hourly']['time']]
    temperature_list = results['hourly']['temperature_2m']
    
    closest_time_index = min(range(len(time_list)), key=lambda i: abs(time_list[i] - current_utc_time))
    current_temperature = temperature_list[closest_time_index]
    
    return f'The current temperature is {current_temperature}°C'
# Chain 부분
from langchain.chat_models import ChatOpenAI
from langchain.tools.render import format_tool_to_openai_function
from langchain.prompts import ChatPromptTemplate
from utils import get_openai_api_key
from Tools.temperature import get_current_temperature
from langchain.agents.output_parsers import OpenAIFunctionsAgentOutputParser
from langchain.schema.agent import AgentFinish
import openai

openai.api_key = get_openai_api_key()
functions = [
        format_tool_to_openai_function(f) for f in [get_current_temperature]
]
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are helpful but sassy assistant. If user ask you about movie, you should answer sincerely. \
        if you don't have information of user, suppose that the user is in Seoul and don't ask user"),
    ("user", "{input}"),
])

def route(result):
	if isinstance(result, AgentFinish): # 함수를 쓰지 않기로 결정한다면 -> content 반환
		return result.return_values['output']
	else: # 함수를 쓰기로 결정한다면 -> 함수명에 따라 어떤 함수를 쓸지 결정해주고, argument를 넣은 값 반환
		tools = {
			"get_current_temperature": get_current_temperature,
		}
		return tools[result.tool].run(result.tool_input)
  
model = ChatOpenAI(temperature=0).bind(functions=functions)
chain = prompt | model | OpenAIFunctionsAgentOutputParser() | route
chain.invoke({"input":"오늘 부산 날씨는 어떤것 같아"})
  • 결과
'The current temperature is 3.8°C'
  • 중간 과정을 살펴보면 다음과 같다.
chain = prompt | model
chain.invoke({"input":"오늘 부산 날씨는 어떤것 같아"})
# 결과
AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\n  "latitude": 35.1796,\n  "longitude": 129.0756\n}', 'name': 'get_current_temperature'}})
chain.invoke({"input":"오늘 서울 날씨는 어떤것 같아"})
# 결과
AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\n  "latitude": 37.5665,\n  "longitude": 126.9780\n}', 'name': 'get_current_temperature'}})

검색

  • 추가 정보를 얻기 위해서 검색 api 결과를 받아와 사용하고자 했다.
  • Naver search api는 api의 결과와 실제 검색 결과가 달라 SerpApi를 사용하기로 했다.
import re
from serpapi import GoogleSearch
from pydantic.v1 import BaseModel, Field
from langchain.agents import tool
from utils import get_serp_api_key

# Define the input schema
class SearchInput(BaseModel):
    query: str = Field(..., description="Search using Google when user ask for more information about the movie")

serpapi_key = get_serp_api_key()

# 결과를 예쁘게 받아오기 위해 패턴 매칭으로 필요없는 정보 제거
def filter_dict(dictionary, exclude_patterns):
    if not isinstance(dictionary, dict):
        return dictionary

    filtered = {}
    for key, value in dictionary.items():
        if isinstance(value, dict):
            filtered[key] = filter_dict(value, exclude_patterns)
        elif isinstance(value, list):
            filtered[key] = [filter_dict(item, exclude_patterns) for item in value]
        else:
            if not any(re.search(pattern, key) for pattern in exclude_patterns):
                filtered[key] = value
    return filtered

exclude_patterns = [r'links', r'image', r'link', r'source', r'extensions'] # 필요 없는 문자열 패턴

@tool(args_schema=SearchInput)
def get_serpapi_search(query: str) -> dict:
    """serpapi search API를 활용하여 관련 정보와 관련 뉴스를 반환하는 함수

    Args:
        query (str): 검색할 키워드

    Returns:
        dict: 검색 결과 dict로 반환
    """
    params = {
        "api_key": serpapi_key,
        "engine": "google",
        "q": query,
        "location": "Seoul, Seoul, South Korea",
        "google_domain": "google.co.kr",
        "gl": "kr",
        "hl": "ko"
    }
    search = GoogleSearch(params)
    results = search.get_dict()
    final_result = dict({"search_results": results['knowledge_graph']})
    if results.get('top_stories'):
        final_result['latest_news'] = results['top_stories']
    
    return filter_dict(final_result, exclude_patterns)
  • Chain 부분
openai.api_key = get_openai_api_key()
functions = [
        format_tool_to_openai_function(f) for f in [get_current_temperature, get_serpapi_search]
]

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are helpful but sassy assistant. If user ask you about movie, you should answer sincerely. \
    If you don't have information of user, suppose that the user is in Seoul and don't ask user anything like genre.\
    If needed, you can use latest movie informations {latest_movies} \
    If user wants MORE informations, use 'get_serpapi_search' with extracted movie, director or cast name.\
    Refuse all irrelevant questions."),
    ("user", "{input}"),
])

def route(result):
	if isinstance(result, AgentFinish):
		return result.return_values['output']
	else:
		tools = {
			"get_current_temperature": get_current_temperature,
   			"get_serpapi_search": get_serpapi_search,
		}
		return tools[result.tool].run(result.tool_input)
  
model = ChatOpenAI(temperature=0).bind(functions=functions)
information_retriever = prompt | model | OpenAIFunctionsAgentOutputParser() | route

prompt2 = ChatPromptTemplate.from_messages([
    ("system", "You are a movie recommender. Use your knowledge to recommend movies that fit\
    and rewrite with given informations {informations}. \
    ***ALWAYS USE EXACT FIGURES AND ONLY BASED ON GIVEN INFORMATIONS**\
    You must recommned more than one movie.\
    You shoud answer using only Korean."),
])
model2 = ChatOpenAI(temperature=0.4, max_tokens=1024)
rewrite = prompt2 | model2
  • 결과
informations = information_retriever.invoke({"input":"크리스토퍼 놀란")
result = rewrite.invoke({"informations": informations})
print(result.content)
크리스토퍼 놀란 감독의 작품 중 추천할 만한 영화는 다음과 같습니다:

1. 오펜하이머 (2023년) - 2023년에 개봉 예정인 크리스토퍼 놀란 감독의 신작입니다.
2. 다크 나이트 (2008년) - 크리스토퍼 놀란 감독의 대표작 중 하나로, 배트맨 시리즈의 세 번째 작품입니다.
3. 인터스텔라 (2014년) - 과학적인 요소를 담은 SF 영화로, 인류의 생존을 위한 우주 여행을 그린 작품입니다.
4. 인셉션: 코볼사의 일 (2010년) - 꿈 속에서 꿈을 꾸는 특이한 개념을 다룬 작품으로, 꿈 속에서의 현실과 환상을 구분하는 이야기입니다.

이 외에도 크리스토퍼 놀란 감독의 다른 작품들도 많이 있으니, 관심이 있다면 더 찾아보시기 바랍니다.

Crawling

  • 최신영화 데이터를 받아오기 위해서 Daum영화를 selenium으로 크롤링하여 사용했다.

  • 마우스를 올리면 줄거리까지 나오기 때문에 동적 크롤링으로 위의 경로를 넣어줬다.
  • 크롤링 결과
  • 일단은 함수형태 이전에 위 최신영화 정보 데이터프레임을 프롬프트에 직접 Context로 추가해줬다.
import pandas as pd
prompt3 = ChatPromptTemplate.from_messages([
    ("system", "You are a movie recommender. Use your knowledge to recommend movies that fit\
    and rewrite with given informations {informations}. \
    If needed, you can use latest movie informations {latest_movies}    \
    You shoud answer using only Korean and markdown structure"),
])
model3 = ChatOpenAI(temperature=0, max_tokens=1024)
chain3 = prompt3 | model3
result1 = chain.invoke({"input":"오늘 날씨에 맞는 영화 추천좀. 최신 영화중에 어떤게 좋을까?"})
result3 = chain3.invoke({"informations": result1, "latest_movies":pd.read_csv("./data/daum_movie/movie_list.csv")})
  • 결과
현재 온도가 1.4°C로 매우 추운 날씨입니다. 추위를 느끼기 쉬운 날씨이므로, 따뜻하고 편안한 영화를 추천해 드릴게요.

추천 영화: 서울의 봄
- 관람가: 12세 이상 관람가
- 평점: 9.5
- 예매율: 14.9%
- 개봉일: 23년 11월 22일
- 줄거리: 1979년 12월 12일, 수도 서울 군사반란 발생 그날, 대한민국의 운명이 바뀌었다. 이날, 군사반란에 맞서 싸웠던 사람들의 이야기를 그린 영화입니다. 추운 날씨에는 따뜻한 향수를 불러일으키는 이 영화를 추천합니다.

추천 영화: 말하고 싶은 비밀
- 관람가: 전체 관람가
- 평점: 9.4
- 예매율: 0.5%
- 개봉일: 23년 12월 13일
- 줄거리: 어느 날 갑자기 고백 사고!? 마음이 잘못 배달되었다! 주인공인 노조미는 자신의 책상 서랍에 갑작스럽게 나타난 비밀 고백장치를 통해 다른 사람들의 비밀 고백을 듣게 되는데요. 따뜻한 이야기와 함께 마음을 따뜻하게 녹일 수 있는 영화입니다.

이 두 영화는 추운 날씨에 따뜻한 감정을 불러일으킬 수 있는 영화입니다. 온 가족이 함께 즐길 수 있는 영화이니 추천해 드립니다.
  • 외에도 mbti는 Retriever가 user query에서 얻은 mbti에 대한 설명을 return하여 recommender로 넘겨주는 형태의 함수로 만들었다.

문제점

  • 할루시네이션 발생
  • 연관된 영화를 알려줄 때 예전에 개봉한 영화기 때문에 데이터가 없음에도 최신영화 context에 기반해서 숫자를 가져와 예매율과 평점을 말하는 현상 발생
  • 모델마다 최대 context length가 정해져 있는데 prompt가 길어지면서 이를 초과하게되어 오류 발생. 이를 해결하기 위해 prompt에 넣어주기 보다는 함수로 만들어야 했다.
  • 두개의 LLM을 띄워서 하는 구조가 비효율적이라는 생각이 들어 하나의 Agent로 구현해보기로 했다.
profile
라이브데이터 Developer

0개의 댓글