Qdrant로 협업 필터링 기반 추천 시스템 구축하기

Qdrant로 협업 필터링 기반 추천 시스템 구축하기 (collaborative-filtering)

시간: 45분 난이도: 중급 Colab에서 열기

스포티파이(Spotify)가 당신이 한 번도 들어본 적 없는 밴드의 다음 곡을 추천해 줄 때마다, 그 알고리즘은 그 곡에 대한 다른 사용자들의 상호작용을 기반으로 해요. 이런 유형의 알고리즘을 협업 필터링(collaborative filtering) 이라고 부릅니다.

> 출처: [Qdrant 공식 문서 — collaborative-filtering](https://qdrant.tech/documentation/tutorials-search-engineering/collaborative-filtering)

콘텐츠 기반 추천과 달리 협업 필터링은 객체의 의미가 사용자 선호와 느슨하거나 무관할 때 탁월해요. 이 적응력이 바로 매력적인 이유죠. 영화, 음악, 책 추천이 그런 사용 사례의 좋은 예시예요. 결국 우리가 책을 고를 때 줄거리 반전만으로 고르는 경우는 드물잖아요.

협업 필터링 엔진을 만드는 전통적인 방법은 사용자-아이템 관계의 희소 행렬을 사용자·아이템 벡터의 압축된 밀집 표현으로 변환하는 모델을 훈련하는 것을 포함해요. 이 목적에 가장 흔히 언급되는 알고리즘으로는 SVD(Singular Value Decomposition)Factorization Machines가 있어요. 하지만 모델 훈련 접근법은 상당한 리소스 투자가 필요해요. 모델 훈련은 데이터, 정기적인 재훈련, 성숙한 인프라를 요구하죠.

방법론 (Methodology)

다행히도 모델 훈련 없이 협업 필터링 시스템을 구축하는 방법이 있어요. 유사도 검색에 기반한 기법을 사용하면 해석 가능한 추천과 확장 가능한 시스템을 얻을 수 있습니다. 영화 추천 시스템을 만드는 예시로 어떻게 동작하는지 살펴볼게요.

구현 (Implementation)

이를 구현하려면 Qdrant with Sparse Vectors라는 단순하면서도 강력한 리소스를 사용할 거예요.

노트북: 여기서 코드를 직접 실행해 볼 수 있어요

설정 (Setup)

먼저 필요한 라이브러리를 가져오고 환경을 정의해야 해요.

import os
import pandas as pd
import requests
from qdrant_client import QdrantClient, models
from qdrant_client.models import PointStruct, SparseVector, NamedSparseVector
from collections import defaultdict

# OMDB API Key - 영화 포스터용
omdb_api_key = os.getenv("OMDB_API_KEY")

# 컬렉션 이름
collection_name = "movies"

# Qdrant 클라이언트 설정
qdrant_client = QdrantClient(
    os.getenv("QDRANT_HOST"),
    api_key=os.getenv("QDRANT_API_KEY")
)

출력 정의 (Define output)

여기서는 추천 엔진이 출력으로 영화 포스터를 검색하도록 구성할 거예요.

# OMDB API를 사용해 영화 포스터를 가져오는 함수
def get_movie_poster(imdb_id, api_key):
    url = f"https://www.omdbapi.com/?i={imdb_id}&apikey={api_key}"
    data = requests.get(url).json()
    return data.get('Poster'), data

데이터 준비 (Prepare the data)

영화 데이터셋을 로드해요. 여기에는 사용자 평점, 영화 제목, OMDB ID라는 세 가지 주요 CSV 파일이 포함됩니다.

# CSV 파일 로드
ratings_df = pd.read_csv('data/ratings.csv', low_memory=False)
movies_df = pd.read_csv('data/movies.csv', low_memory=False)

# ratings_df와 movies_df의 movieId를 문자열로 변환
ratings_df['movieId'] = ratings_df['movieId'].astype(str)
movies_df['movieId'] = movies_df['movieId'].astype(str)

rating = ratings_df['rating']

# 평점 정규화
ratings_df['rating'] = (rating - rating.mean()) / rating.std()

# 영화 메타데이터와 병합해 영화 제목 얻기
merged_df = ratings_df.merge(
    movies_df[['movieId', 'title']],
    left_on='movieId', right_on='movieId', how='inner'
)

# 중복 (userId, title) 쌍을 처리하기 위해 평점 집계
ratings_agg_df = merged_df.groupby(['userId', 'movieId']).rating.mean().reset_index()

ratings_agg_df.head()
userId movieId rating
0 1 1 0.429960
1 1 1036 1.369846
2 1 1049 -0.509926
3 1 1066 0.429960
4 1 110 0.429960

희소로 변환 (Convert to sparse)

여러 사용자의 수많은 리뷰를 검색하고 싶다면, 이 리뷰들을 희소 행렬로 표현할 수 있어요.

# 평점을 희소 벡터로 변환
user_sparse_vectors = defaultdict(lambda: {"values": [], "indices": []})
for row in ratings_agg_df.itertuples():
    user_sparse_vectors[row.userId]["values"].append(row.rating)
    user_sparse_vectors[row.userId]["indices"].append(int(row.movieId))

데이터 업로드 (Upload the data)

여기서 Qdrant 클라이언트를 초기화하고 데이터를 저장할 새 컬렉션을 만들어요. 사용자 평점을 희소 벡터로 변환하고 payload에 movieId를 포함시킵니다.

# 데이터 생성기 정의
def data_generator():
    for user_id, sparse_vector in user_sparse_vectors.items():
        yield PointStruct(
            id=user_id,
            vector={"ratings": SparseVector(
                indices=sparse_vector["indices"],
                values=sparse_vector["values"]
            )},
            payload={"user_id": user_id, "movie_id": sparse_vector["indices"]}
        )

# 데이터 생성기를 사용해 포인트 업로드
qdrant_client.upload_points(
    collection_name=collection_name,
    points=data_generator()
)

질의 정의 (Define query)

추천을 받으려면 우리와 취향이 비슷한 사용자를 찾아야 해요. 좋아하는 영화 몇 편에 평점을 매겨 우리의 선호를 표현해 봅시다.

1은 영화를 좋아한다는 뜻, -1은 싫어한다는 뜻이에요.

my_ratings = {
    603: 1,     # Matrix
    13475: 1,   # Star Trek
    11: 1,      # Star Wars
    1091: -1,   # The Thing
    862: 1,     # Toy Story
    597: -1,    # Titanic
    680: -1,    # Pulp Fiction
    13: 1,      # Forrest Gump
    120: 1,     # Lord of the Rings
    87: -1,     # Indiana Jones
    562: -1     # Die Hard
}

to_vector 코드를 보려면 클릭

# my_ratings에서 희소 벡터 만들기
def to_vector(ratings):
    vector = SparseVector(
        values=[],
        indices=[]
    )
    for movie_id, rating in ratings.items():
        vector.values.append(rating)
        vector.indices.append(movie_id)
    return vector

질의 실행 (Run the query)

평점이 포함된 영화 목록을 업로드한 뒤, Qdrant에서 검색을 수행해 우리와 가장 비슷한 사용자들을 얻을 수 있어요.

# 검색 수행
results = qdrant_client.query_points(
    collection_name=collection_name,
    query=to_vector(my_ratings),
    using="ratings",
    limit=20
).points

이제 다른 유사 사용자들이 좋아하지만 우리가 아직 보지 못한 영화를 찾을 수 있어요. 발견된 사용자들의 결과를 합치고, 이미 본 영화를 걸러낸 다음, 점수로 정렬해 봅시다.

# 결과를 점수로 변환하고 점수로 정렬
def results_to_scores(results):
    movie_scores = defaultdict(lambda: 0)
    for result in results:
        for movie_id in result.payload["movie_id"]:
            movie_scores[movie_id] += result.score
    return movie_scores

# 결과를 점수로 변환하고 점수로 정렬
movie_scores = results_to_scores(results)
top_movies = sorted(movie_scores.items(), key=lambda x: x[1], reverse=True)

Jupyter Notebook에서 결과 시각화하기 마지막으로 추천된 상위 5개 영화를 포스터와 제목과 함께 표시해요.

# 상위 5개 결과를 표시하는 HTML 만들기
html_content = "<div class='movies-container'>"

for movie_id, score in top_movies[:5]:
    imdb_id_row = links.loc[links['movieId'] == int(movie_id), 'imdbId']
    if not imdb_id_row.empty:
        imdb_id = imdb_id_row.values[0]
        poster_url, movie_info = get_movie_poster(imdb_id, omdb_api_key)
        movie_title = movie_info.get('Title', 'Unknown Title')

        html_content += f"""
        <div class='movie-card'>
            <img src="{poster_url}" alt="Poster" class="movie-poster">
            <div class="movie-title">{movie_title}</div>
            <div class="movie-score">Score: {score}</div>
        </div>
        """
    else:
        continue  # imdb_id를 찾지 못하면 건너뛰기

html_content += "</div>"

display(HTML(html_content))

추천 결과 (Recommendations)

영화 포스터 전체 표시를 보려면 노트북 출력을 확인하세요. 다음은 html 콘텐츠를 제외한 결과예요.

Toy Story, Score: 131.2033799
Monty Python and the Holy Grail, Score: 131.2033799
Star Wars: Episode V - The Empire Strikes Back, Score: 131.2033799
Star Wars: Episode VI - Return of the Jedi, Score: 131.2033799
Men in Black, Score: 131.2033799

협업 필터링 위에 사용자 인구통계, 영화 장르, 영화 태그 같은 다른 특징을 통합하면 추천 시스템을 더욱 향상시킬 수 있어요.

예를 들어 시간 기반 필터를 통해 최근 평점만 고려할 수도 있어요. 이렇게 하면 사용자들 사이에 현재 인기 있는 영화를 추천할 수 있습니다.

결론 (Conclusion)

살펴봤듯이 Qdrant와 Sparse Vectors를 사용하면 집중적인 모델 훈련 없이도 흥미로운 영화 추천 시스템을 구축할 수 있어요. 이 접근법은 추천 과정을 단순화할 뿐 아니라 확장 가능하고 해석 가능하게 만들어 줍니다. 향후 튜토리얼에서는 이 조합을 더 실험해 추천 시스템을 더 향상시킬 수 있을 거예요.

더 알아보기 (Learn more)