개요
이번에 "현재 자신의 상황에 맞는 식당 추천 시스템"을 개발하면서 식당 정보와 사용자들의 리뷰 정보를 수집하는 크롤링 코드를 작성했습니다.
이제 이 리뷰 정보를 기반으로 사용자들이 자연어로 요청하는 내용을 분석하여 가장 적절한 식당을 추천하는 기능을 개발해야 합니다.
예를 들어 다음과 같은 사용자 입력들이 있을 수 있습니다.
"여자친구와 100일 기념으로 갈 만한 식당을 추천해줘"
"친구들 모임에서 갈 만한 저렴한 술집 추천해줘"
"공덕역 주변 일식집 추천해줘"
이 입력을 기반으로 사용자가 원하는 가장 적절한 식당을 추천해주는 것이 이 프로젝트의 최종적인 목표입니다.
🛠️ 첫 번째 시도 - 분위기 정보 이진 분류
첫 번째 시도는 한 식당에 대한 여러 사용자들의 리뷰로부터 분위기 정보를 추출하는 방식이었습니다.
예를 들어 다음과 같은 리뷰가 있다고 합시다.
"조용하고 고급스러운 분위기였어요"
"직원들도 친절하시고 좋은 분위기예요"
여기서 분위기 정보를 추출하는 방식입니다. 위 리뷰를 기반으로 하면 아래와 같은 정보가 추출 되겠네요
"조용한", "고급스러운", "친절한"
그리고 이 정보를 카테고리화해서 RDB에 저장하는 방식입니다.
조금 더 자세하게 설명해보면 각 분위기 속성별로 독립적인 AI 분류기를 만들어서, 리뷰 텍스트만 넣으면 자동으로 분위기 정보를 추출하는 시스템입니다.
전체적인 구조는 다음과 같습니다.

하나의 리뷰가 들어오면 10개의 서로 다른 분류기가 동시에 작동합니다. 각각이 자신만의 기준으로 "이 속성이 언급되었나?"를 판단하는 거죠.
각 분류기는 이진 분류를 통해 해당 속성이 언급되었다면 1, 그렇지 않다면 0을 반환합니다.
제가 다중 분류가 아닌 이진 분류를 선택한 이유는 확장성 때문이었습니다.
만약 초기 mvp 기능을 구현할 때 4가지 분위기 정보를 기반으로 다중 분류기를 구현했다면 추후 분위기 정보를 확장할 때 문제가 될 수 있다고 생각했습니다.
그에 비해 이진 분류기의 경우 분위기가 추가되면 해당 분위기에 대한 이진 분류기를 만들어서 붙이면 되기 때문에 확장성이 훨씬 좋을 것이라고 생각했습니다.
이 구조를 기반으로 mvp를 만들기 시작했습니다.
우선 초반에는 각 이진 분류기를 따로 학습시키기 보다 zero shot classification을 사용하기로 결정했습니다.
zero shot classification이란 인공지능 모델이 학습하지 않은 클래스에 대한 분류를 수행하는 기술입니다.
이 zero shot classification을 통해 하나의 모델을 가지고 여러 분위기 이진 분류기를 만들어 mvp 기능을 테스트 해 볼 수 있었습니다.
이렇게 분위기 정보를 추출하여 이진 정보를 RDB에 저장했다면 식당마다 분위기 통계를 낼 수 있을 것이고 가장 적합한 분위기의 식당을 추천할 수 있을 것이라고 생각했습니다.
ex)

🚧첫 번째 어려움 - 분위기 분류 부정확성 및 편향 문제
첫 번째로 겪은 어려움은 분위기 부정확성과 편향 문제였습니다.
아무래도 zero shot classificataion을 사용하다보니 분위기 분류가 제대로 되지 않는 경향이 있었습니다.
또한 여러 개의 리뷰를 기반으로 분위기를 추출하다보면 식당마다 고유한 특성이 드러나지 않고 거의 유사한 분위기들이 많이 나오는 현상이 발생했습니다.
특히 분위기 중 "조용한", "고급스러운"과 같은 명확한 분위기는 분류가 잘 되었지만 "모던한", "아늑한"과 같은 애매한 분위기는 분류가 잘 되지 않아 항상 0이 나오는 현상이 발생했습니다.
정확도를 높이기 위해 직접 학습을 시키자니 분류기가 여러 개라 시간이 부족할 것 같았고 편향 문제를 해결하기 위해 명확한 분위기만 분류한다면 식당의 특색을 나타내기 어려울 것이라고 생각했습니다.
🚧두 번째 어려움 - 제한적인 답변
또한 단순 분위기만으로는 사용자의 요청을 처리하기에 제한적이었습니다.
먼저 사용자 리뷰에는 여러가지 정보가 담길 수 있습니다.
음식 정보가 담길 수도 있고 서비스에 대한 정보가 담길 수도 있고 동행자 정보나 모임 목적이 담길 수도 있습니다.
그런데 이러한 정보들을 사용하지 않고 오직 분위기 정보만 사용한다면 자연어 검색의 자유로운 특성을 제대로 살릴 수 없었습니다.
🛠️ 두 번째 시도 - 임베딩 활용
두 번째로 시도한 방식은 리뷰 텍스트 자체를 임베딩하는 방식입니다.
벡터로 만들어서 유사도 검색을 하게 되면 분위기 정보 뿐만 아니라 여러 정보를 함께 사용할 수 있을 것이라고 생각했습니다.
🚧첫 번째 어려움 - 의미적 반대 관계의 높은 유사도 문제
처음 텍스트를 임베딩으로 만들 때 반대 관계를 가지고 있는 텍스트도 높은 유사도를 보이는 문제가 있었습니다.
예를 들어 "조용한"과 "시끄러운"은 반대 관계를 가지고 있고 "조용한 분위기"로 검색했을 때 극명한 유사도 차이가 나타나야 합니다.
하지만 text-embedding-ada-002 모델을 사용했을 때 기준으로 극명한 유사도 차이가 나타나지 않았습니다.
아래 코드는 text-embedding-ada-002 모델을 사용하여 "조용한 분위기"로 유사도 검색을 했을 때 "조용한" 벡터와 "시끄러운" 벡터의 유사도를 측정하는 코드입니다.
from openai import OpenAI
import numpy as np
client = OpenAI()
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def get_embedding(text):
response = client.embeddings.create(input=text, model="text-embedding-ada-002")
return response.data[0].embedding
# 테스트 리뷰들
reviews = {
"silent_review": "조용한",
"noisy_review": "시끄러운"
}
# 임베딩 생성
embeddings = {}
for name, review in reviews.items():
embeddings[name] = get_embedding(review)
# 쿼리 테스트
query = "조용한 분위기"
query_emb = get_embedding(query)
# 유사도 계산
results = []
for name, emb in embeddings.items():
similarity = cosine_similarity(query_emb, emb)
results.append((name, similarity))
# 결과 출력
results.sort(key=lambda x: x[1], reverse=True)
for name, score in results:
print(f"{name}: {score:.4f}")
결과는 다음과 같습니다.
silent_review: 0.8936
noisy_review: 0.8598
시끄러운 벡터와 조용한 벡터의 유사도 차이가 거의 나지 않습니다.
그래서 임베딩 모델을 text-embedding-3-small로 변경을 해보았습니다.
silent_review: 0.7194
noisy_review: 0.3405
조금 더 극명한 차이를 보여주고 있고 text-embedding-ada-002 모델보다 5배 저렴하기 때문에 text-embedding-3-small 모델을 사용하기로 결정했습니다.
🚧두 번째 어려움 - 리뷰가 길 때 부정확성
예를 들어 사용자가 "조용한 분위기"의 식당을 원한다고 합시다.
그런데 사용자 리뷰의 경우 분위기 정보만 포함하고 있지 않습니다.
음식에 대한 정보가 담길 수도 있고 서비스에 대한 내용이 담길 수도 있습니다.
그런데 전체 리뷰를 임베딩하게 되었을 경우 제대로 된 유사도 검색이 되지 않았습니다.
예를 들어 다음과 같은 리뷰가 있다고 합시다
길고 조용한 리뷰
"음식도 맛있고 서비스도 좋고 가격도 적당하고 위치도 좋고 주차도 편하고 화장실도 깨끗하고 직원들도 친절하고 메뉴도 다양하고 분위기가 조용하고 아늑해요 인테리어도 예쁘고 와인도 맛있고",
짧고 조용한 리뷰:
"조용하고 아늑한 분위기",
길고 시끄러운리뷰
"파스타 맛있고 스테이크 완벽하고 디저트 달콤하고 커피 진하고 직원 친절하고 서비스 빠르고 가격 합리적이고 시끄럽고 활기찬 분위기예요"
조용한 분위기를 검색했을 때 조용한 리뷰가 포함된 리뷰는 높은 유사도와 길고 시끄러운 리뷰는 낮은 유사도가 나와야 합니다.
아래 코드는 "조용한 분위기"와 위 리뷰들의 유사도 결과를 비교하는 코드입니다.
from openai import OpenAI
import numpy as np
client = OpenAI()
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def get_embedding(text):
response = client.embeddings.create(input=text, model="text-embedding-3-small")
return response.data[0].embedding
# 테스트 리뷰들
reviews = {
"long_silent_review": "음식도 맛있고 서비스도 좋고 가격도 적당하고 위치도 좋고 주차도 편하고 화장실도 깨끗하고 직원들도 친절하고 메뉴도 다양하고 분위기가 조용하고 아늑해요 인테리어도 예쁘고 와인도 맛있고",
"short_silent_review": "조용하고 아늑한 분위기",
"long_noisy_review": "파스타 맛있고 스테이크 완벽하고 디저트 달콤하고 커피 진하고 직원 친절하고 서비스 빠르고 가격 합리적이고 시끄럽고 활기찬 분위기예요"
}
# 임베딩 생성
embeddings = {}
for name, review in reviews.items():
embeddings[name] = get_embedding(review)
# 쿼리 테스트
query = "조용한 분위기"
query_emb = get_embedding(query)
# 유사도 계산
results = []
for name, emb in embeddings.items():
similarity = cosine_similarity(query_emb, emb)
results.append((name, similarity))
# 결과 출력
results.sort(key=lambda x: x[1], reverse=True)
for name, score in results:
print(f"{name}: {score:.4f}")
결과는 다음과 같습니다.
short_silent_review: 0.8386
long_silent_review: 0.3375
long_noisy_review: 0.3217
저희가 원하는 결과와 달리 짧고 조용한 리뷰는 높은 유사도를 보이지만 길고 조용한 리뷰는 매우 낮은 유사도를 보이고 있습니다.
이 문제를 해결하기 위해 중간에 리뷰를 한 번 가공하기로 결정했습니다.
LLM API를 활용하여 긴 리뷰에서 분위기 정보만 추출하는 방식입니다.
긴 리뷰에서 분위기 정보만 추출하여 더 높은 정확도를 얻을 수 있었습니다.
물론 리뷰에서 분위기 정보만 나오는 것은 아닙니다.
그래서 총 4가지 카테고리를 분류하여 이 카테고리에 대한 정보를 추출한 뒤 각 카테고리 별로 임베딩을 수행하여 벡터로 만들기로 결정했습니다.
✅ 해결책: 리뷰를 카테고리 별로 추출한 뒤 독립적인 임베딩 적용
다음과 같은 긴 리뷰가 있다고 합시다.
"가족들과 생일파티로 왔는데 파스타가 정말 맛있었어요. 분위기도 조용해서 좋았습니다."
이 리뷰를 그대로 벡터로 만들게 되면 유사도 검색이 제대로 되지 않기 때문에 다음과 같이 4가지 카테고리로 정보를 추출하기로 결정했습니다.
- 동행자 정보
- 음식 정보
- 분위기 정보
- 모임 목적 정보
위 리뷰 예시에서는 아래와 같은 JSON 정보가 추출되게 됩니다.
{
"동행자_정보": "가족",
"음식_정보": "파스타",
"분위기_정보": "조용한",
"모임_목적_정보": "생일파티"
}
JSON의 각 카테고리 별로 임베딩 벡터를 만들어 vector DB에 저장하게 됩니다.
그리고 사용자가 다음과 같은 요청을 보냅니다.
"조용한 분위기 식당 추천해줘"
여기서도 같은 방식으로 카테고리 별로 정보를 추출합니다.
{
"동행자_정보": null,
"음식_정보": null,
"분위기_정보": "조용한",
"모임_목적_정보": null
}
존재하지 않는 정보는 무시하고 "조용한" 벡터와 유사한 리뷰를 코사인 유사도 검색을 바탕으로 가져오게 됩니다.
이렇게 keyword를 추출하는 방식으로 긴 리뷰의 유사도 검색 정확도를 높일 수 있었습니다.
그리고 결국 리뷰를 추천해주는 게 아닌 식당을 추천해주기 때문에 식당에 존재하는 각 리뷰들의 카테고리 별로 평균을 내서 유사도 검색 결과가 가장 좋은 식당을 추천해주는 방식을 적용했습니다.

이렇게 카테고리 별 벡터 유사도 검색으로 인해 추천 성능을 개선해보는 작업을 해보았습니다.
마무리
사용자에 적합한 식당을 추천하기 위한 로직에 대해 정리를 해보았습니다.
아직 완벽하지 않은 로직이지만 우선 이 방식을 기반으로 MVP를 만들어보고 추후 계속 개선을 해나갈 예정입니다.
추후 유사도 개선이라던가 벡터 검색 성능을 개선하기 위한 방법을 고민해보고 개선할 예정입니다.
긴 글 읽어주셔서 감사합니다.
