이번 편에서 다루는 것
추천 시스템의 핵심은 "이 강좌와 이 학생이 얼마나 잘 맞는가?"를 계산하는 것이다.
이를 위해 텍스트(강좌 설명, 학과 정보, 관심분야)를 수치로 변환해야 한다. 이것이 임베딩(Embedding)이다.
임베딩이란?
임베딩은 텍스트를 고정 길이의 숫자 배열(벡터)로 변환하는 기술이다.
"파이썬 프로그래밍 기초" → [0.23, -0.45, 0.12, ..., 0.67] (768차원)
"웹 개발 입문" → [0.21, -0.42, 0.15, ..., 0.63] (768차원)
의미가 비슷한 텍스트는 비슷한 벡터를 가진다. 이 성질을 이용해 코사인 유사도로 두 텍스트의 유사성을 측정한다.
모델 선택: 왜 XLM-R인가?
고려한 옵션들
| 모델 | 특징 | 검토 결과 |
| OpenAI text-embedding | 성능 우수, 사용 간편 | API 비용, 데이터 외부 전송 필요 |
| KoBERT | 한국어 특화 | 영어 섞인 텍스트 처리 한계 |
| multilingual-BERT | 다국어 지원 | 한국어 성능이 상대적으로 부족 |
| XLM-R | 100개 언어, 한국어 성능 우수 | 로컬 실행 가능, 적절한 모델 크기 |
XLM-R을 선택한 이유
1. 한국어 + 영어 혼합 텍스트 처리
강좌 설명을 보면 한국어와 영어가 섞여 있다:
"본 강좌에서는 Python과 Django 프레임워크를 활용한 웹 개발을 학습합니다."
"데이터베이스 설계 및 SQL 쿼리 최적화 기법을 다룹니다."
XLM-R은 이런 혼합 텍스트를 자연스럽게 처리한다.
2. 외부 의존성 없음
- API 호출 없이 로컬에서 실행
- 데이터가 외부로 나가지 않음
- 비용 발생 없음
3. Sentence Transformers 지원
sentence-transformers 라이브러리에서 쉽게 사용 가능한 사전학습 모델이 있다.
기본 임베딩 구현
모델 로드
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import List, Union
class EmbeddingEngine:
def __init__(self, model_path: str = "xlm-r-100langs-bert-base-nli-stsb-mean-tokens"):
"""임베딩 엔진 초기화"""
print(f"모델 로드 중: {model_path}")
self.model = SentenceTransformer(model_path)
self.embedding_dim = 768 # XLM-R base의 차원
print("모델 로드 완료")
def encode(self, texts: Union[str, List[str]]) -> np.ndarray:
"""텍스트를 임베딩 벡터로 변환"""
if isinstance(texts, str):
texts = [texts]
embeddings = self.model.encode(
texts,
convert_to_numpy=True,
show_progress_bar=len(texts) > 100
)
return embeddings
간단한 사용 예시
# 엔진 초기화
engine = EmbeddingEngine()
# 단일 텍스트 임베딩
text = "파이썬 프로그래밍 기초"
vector = engine.encode(text)
print(f"임베딩 차원: {vector.shape}") # (1, 768)
# 여러 텍스트 임베딩
texts = [
"파이썬 프로그래밍 기초",
"웹 개발 입문",
"데이터베이스 설계"
]
vectors = engine.encode(texts)
print(f"임베딩 shape: {vectors.shape}") # (3, 768)
유사도 계산
두 벡터가 얼마나 비슷한지 측정하는 방법으로 코사인 유사도를 사용했다.
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
class EmbeddingEngine:
# ... (이전 코드)
def similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
"""두 벡터 간 코사인 유사도 계산"""
# reshape for sklearn
v1 = vec1.reshape(1, -1) if vec1.ndim == 1 else vec1
v2 = vec2.reshape(1, -1) if vec2.ndim == 1 else vec2
return cosine_similarity(v1, v2)[0][0]
def find_similar(self, query_vec: np.ndarray, candidate_vecs: np.ndarray, top_k: int = 5) -> List[tuple]:
"""쿼리 벡터와 가장 유사한 후보 찾기"""
# 모든 후보와의 유사도 계산
query = query_vec.reshape(1, -1)
similarities = cosine_similarity(query, candidate_vecs)[0]
# 상위 K개 인덱스
top_indices = np.argsort(similarities)[::-1][:top_k]
return [(idx, similarities[idx]) for idx in top_indices]
유사도 테스트
engine = EmbeddingEngine()
# 강좌 설명들
courses = [
"파이썬을 활용한 데이터 분석 기초",
"자바 프로그래밍 입문",
"마케팅 원론",
"머신러닝 기초",
"회계 원리"
]
# 학생 관심분야
interest = "인공지능과 데이터 과학"
# 임베딩
course_vecs = engine.encode(courses)
interest_vec = engine.encode(interest)
# 유사도 계산
results = engine.find_similar(interest_vec, course_vecs, top_k=3)
for idx, score in results:
print(f"{score:.3f} - {courses[idx]}")
결과:
0.847 - 머신러닝 기초
0.812 - 파이썬을 활용한 데이터 분석 기초
0.534 - 자바 프로그래밍 입문
"AI와 데이터 과학"에 관심 있는 학생에게 "머신러닝"과 "데이터 분석" 강좌가 높은 점수로 추천된다.
배치 임베딩 처리
강좌 2,000개를 하나씩 임베딩하면 너무 느리다. 배치로 처리했다.
from typing import List, Dict, Any
import numpy as np
class CourseEmbedder:
def __init__(self, engine: EmbeddingEngine, batch_size: int = 64):
self.engine = engine
self.batch_size = batch_size
def embed_courses(self, courses: List[Dict[str, Any]]) -> Dict[str, np.ndarray]:
"""전체 강좌 임베딩 생성"""
embeddings = {}
# 강좌 설명 텍스트 준비
course_ids = [c['id'] for c in courses]
course_texts = [self._make_course_text(c) for c in courses]
# 배치 처리
for i in range(0, len(course_texts), self.batch_size):
batch_texts = course_texts[i:i + self.batch_size]
batch_ids = course_ids[i:i + self.batch_size]
# 배치 임베딩
batch_vecs = self.engine.encode(batch_texts)
# 결과 저장
for j, course_id in enumerate(batch_ids):
embeddings[course_id] = batch_vecs[j]
print(f"임베딩 진행: {min(i + self.batch_size, len(courses))}/{len(courses)}")
return embeddings
def _make_course_text(self, course: Dict[str, Any]) -> str:
"""강좌 정보를 하나의 텍스트로 결합"""
parts = [
course.get('name', ''),
course.get('description', ''),
course.get('department', '')
]
return ' '.join(filter(None, parts))
성능 비교
| 방식 | 2,000개 강좌 처리 시간 |
| 개별 처리 | 약 5분 |
| 배치 64 | 약 45초 |
| 배치 128 | 약 35초 |
배치 크기를 늘리면 빨라지지만, 메모리 사용량도 증가한다. 64~128 사이가 적절했다.
임베딩 품질 문제: 부족한 설명
발견한 문제
임베딩을 만들고 테스트해보니 문제가 있었다.
# 문제의 강좌 설명 예시
course_1 = {
"name": "컴퓨터공학개론",
"description": None # 설명이 없음!
}
course_2 = {
"name": "데이터베이스",
"description": "데이터베이스" # 이름과 똑같음
}
설명이 없거나 너무 짧으면 임베딩 품질이 떨어진다.
해결 방향
이 문제는 5편에서 다룰 LLM을 활용한 설명 보강으로 해결했다.
우선 현재 단계에서는 학과 정보를 활용해 보완했다:
def _make_course_text(self, course: Dict[str, Any]) -> str:
"""강좌 정보를 하나의 텍스트로 결합 (개선 버전)"""
parts = []
# 강좌명은 항상 포함
if course.get('name'):
parts.append(course['name'])
# 설명이 있고 충분히 길면 포함
desc = course.get('description', '')
if desc and len(desc) > len(course.get('name', '')):
parts.append(desc)
# 학과 정보로 문맥 보강
if course.get('department'):
parts.append(f"{course['department']} 관련 강좌")
return ' '.join(parts)
임베딩 캐싱 전략
매번 임베딩을 생성하면 시간이 낭비된다. 캐싱을 구현했다.
방법 1: 파일 캐시
import pickle
import os
from typing import Dict
import numpy as np
class EmbeddingCache:
def __init__(self, cache_dir: str = "./cache"):
self.cache_dir = cache_dir
os.makedirs(cache_dir, exist_ok=True)
def save(self, key: str, embeddings: Dict[str, np.ndarray]):
"""임베딩 캐시 저장"""
path = os.path.join(self.cache_dir, f"{key}.pkl")
with open(path, 'wb') as f:
pickle.dump(embeddings, f)
print(f"캐시 저장: {path}")
def load(self, key: str) -> Dict[str, np.ndarray]:
"""캐시된 임베딩 로드"""
path = os.path.join(self.cache_dir, f"{key}.pkl")
if os.path.exists(path):
with open(path, 'rb') as f:
return pickle.load(f)
return None
def exists(self, key: str) -> bool:
"""캐시 존재 여부"""
path = os.path.join(self.cache_dir, f"{key}.pkl")
return os.path.exists(path)
방법 2: DB 저장 (선택)
대용량 운영에서는 DB에 임베딩을 저장할 수도 있다:
# PostgreSQL에 벡터 저장 (pgvector 확장 사용)
from sqlalchemy import Column, Integer, String
from pgvector.sqlalchemy import Vector
class CourseEmbedding(Base):
__tablename__ = 'course_embeddings'
id = Column(Integer, primary_key=True)
course_id = Column(String, unique=True, index=True)
embedding = Column(Vector(768)) # XLM-R의 768차원
이 프로젝트에서는 데이터 규모가 크지 않아 파일 캐시를 사용했다.
전체 임베딩 파이프라인
class RecommendationPipeline:
def __init__(self):
self.engine = EmbeddingEngine()
self.cache = EmbeddingCache()
self.course_embedder = CourseEmbedder(self.engine)
def prepare_embeddings(self, courses: List[Dict], cache_key: str = "courses"):
"""추천을 위한 임베딩 준비"""
# 캐시 확인
cached = self.cache.load(cache_key)
if cached:
print(f"캐시된 임베딩 사용: {len(cached)}건")
return cached
# 새로 생성
print(f"임베딩 생성 시작: {len(courses)}건")
embeddings = self.course_embedder.embed_courses(courses)
# 캐시 저장
self.cache.save(cache_key, embeddings)
return embeddings
성과 및 교훈
성과
| 지표 | 수치 |
| 모델 로드 시간 | 약 3초 |
| 2,000 강좌 임베딩 | 약 40초 |
| 캐시 로드 시간 | 약 0.5초 |
| 임베딩 차원 | 768 |
배운 점
- 모델 선택이 중요: 용도에 맞는 모델을 신중하게 선택해야 함
- 입력 품질 = 출력 품질: 설명이 부족하면 임베딩도 부정확
- 배치 처리 필수: 대용량 임베딩은 배치로 처리
- 캐싱으로 속도 개선: 변하지 않는 데이터는 캐싱
다음 편 예고
임베딩 준비가 됐으니, 다음 편에서는 추천 알고리즘 설계를 다룬다.
- 학과 기반 추천과 관심분야 기반 추천의 결합
- 70:30 가중치 설계 근거
- 상위 K개 선별 로직
- 실제 추천 결과 예시
시리즈 목차
- 프로젝트 소개 - 왜 AI 추천이 필요했나
- 추천을 위한 데이터 파이프라인 설계
- XLM-R로 강좌 임베딩 구축하기 ← 현재 글
- 추천 알고리즘 설계 - 가중치 기반 개인화
- LLM으로 강좌 설명 보강하기
- 성능 최적화 - CPU에서 27배 빠르게
- API 서버 구축과 배포
'프로그래밍 > AI 교과 추천 시스템 개발기' 카테고리의 다른 글
| 6편. 성능 최적화 - CPU에서 27배 빠르게 (0) | 2026.02.20 |
|---|---|
| 5편. LLM으로 강좌 설명 보강하기 (0) | 2026.02.19 |
| 4편. 추천 알고리즘 설계 - 가중치 기반 개인화 (0) | 2026.02.18 |
| 2편. 추천을 위한 데이터 파이프라인 설계 (0) | 2026.02.16 |
| 1편. 프로젝트 소개 - 왜 AI 추천이 필요했나 (0) | 2026.02.15 |