여러 개의 문서 또는 문장이 얼마나 비슷한 내용을 담고 있는지를 수치로 표현한 것
아래 네 문장이 있을 때, 누구나 1번과 2번이 "가장 유사"하다고 본다.
1. 먹고 싶은 사과
2. 먹고 싶은 바나나
3. 길고 노란 바나나 바나나
4. 저는 과일이 좋아요.
그런데 문장이 아주 길어지거나, 비교할 문서 양이 많아지거나, 정확히 어느 정도로 유사한지 정량화하려면 사람의 직관만으로는 한계가 있다. → 그래서 유사도를 계산하는 수학적 방법이 필요하다.
| 방법 | 핵심 아이디어 |
|---|---|
| 자카드 유사도 | 두 집합의 교집합 크기를 합집합 크기로 나눈 값. 단어의 존재 여부만 고려 |
| 유클리드 거리 | n차원 공간에서 두 점 사이의 직선 거리를 계산. 거리가 0에 가까울수록 유사 |
| 코사인 유사도 | 두 벡터가 이루는 각도의 코사인 값 이용. -1 ~ 1 값을 가지며 1에 가까울수록 유사 |
이 중 가장 많이 쓰이는 코사인 유사도를 자세히 본다.
벡터(Vector)
(0, 0)에서 좌표 (3, 4)로 향하는 벡터내적(Dot Product)
예) 벡터 A(3, 3, 3), B(4, -1, 0)
A · B = (3 × 4) + (3 × -1) + (3 × 0) = 12 + (-3) + 0 = 9
(3 × 4) => 둘 다 양수, "같은 방향" 으로 기여
(3 × -1) => 하나가 음수, "반대 방향" 으로 기여
(3 × 0) => 하나가 0, 방향 없음 "무관"
→ 내적은 두 벡터가 얼마나 같은 축 방향으로 뻗어 있는지를 알려준다.
→ 이 내적을 벡터의 크기로 나누면 크기 영향을 없애고 방향만 비교할 수 있다. 이것이 코사인 유사도다.
① 내적 계산
A · B = (Ax × Bx) + (Ay × By)
= (3 × 4) + (3 × 1)
= 15
② 크기(norm) 계산 — 피타고라스 정리(각 좌표 제곱의 합에 루트)
③ 최종 코사인 유사도
| 코사인 유사도 | 의미 |
|---|---|
| 1 | 두 벡터가 완전히 같은 방향 |
| 0 | 서로 직각 (관계 없음) |
| -1 | 완전히 반대 방향 |
[10, 5, 0], [2, 1, 0]이면 길이는 A가 더 길지만, 비율은 동일하게 2:1로 볼 수 있음from numpy import dot # 벡터 내적
from numpy.linalg import norm # 벡터 크기
def cosine_similarity(A, B):
return dot(A, B) / (norm(A) * norm(B))
벡터를 활용하므로 2차원이 아닌 n차원 데이터에서도 연산에 문제가 없다.
직관적 시각화는 어려워도A = (x1, x2, …, xn),B = (y1, y2, …, ym)형태로 수학적 정의가 가능하다.
코사인 유사도를 쓰려면 벡터 값이 필요하다. 그렇다면 텍스트 문서를 어떻게 벡터로 바꿀까?
문서 벡터화 = 컴퓨터가 연산할 수 있도록 문서(텍스트)를 수치 벡터로 바꾸는 과정
컴퓨터는 문자열 자체를 수학적 연산에 바로 쓸 수 없기 때문에, 내용/구조/의미를 수치로 옮겨야 검색/추천/분류 알고리즘을 적용할 수 있다.
예시 문서:
문서1. 먹고 싶은 사과
문서2. 먹고 싶은 바나나
문서3. 길고 노란 바나나 바나나
문서4. 저는 과일이 좋아요.
주어진 단어를 모두 나열하고, 문서별 등장 횟수를 기록한다.
| 문서 | 먹고 | 싶은 | 사과 | 바나나 | 길고 | 노란 | 저는 | 과일이 | 좋아요 | 벡터 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | [1,1,1,0,0,0,0,0,0] |
| 2 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | [1,1,0,1,0,0,0,0,0] |
| 3 | 0 | 0 | 0 | 2 | 1 | 1 | 0 | 0 | 0 | [0,0,0,2,1,1,0,0,0] |
| 4 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | [0,0,0,0,0,0,1,1,1] |
각 차원이 특정 의미를 갖도록 정의할 수도 있다. (예: v1 = 과일 단어 비율, v2 = 주관적 표현 비율)
| 문서 | 과일 단어 비율 | 주관적 표현 비율 | 벡터 |
|---|---|---|---|
| 1 | 1/3 ≈ 0.33 | 1/3 ≈ 0.33 | [0.33, 0.33] |
| 2 | 1/3 ≈ 0.33 | 1/3 ≈ 0.33 | [0.33, 0.33] |
| 3 | 2/4 = 0.5 | 0 | [0.5, 0] |
| 4 | 1/3 ≈ 0.33 | 2/3 ≈ 0.67 | [0.33, 0.67] |
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
# 문서 정의
docs = [
"먹고 싶은 사과",
"먹고 싶은 바나나",
"길고 노란 바나나 바나나",
"저는 과일이 좋아요",
]
# CountVectorizer로 단어 카운트 행렬 생성
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(docs)
# numpy 배열로 변환
doc_term_matrix = X.toarray()
vocab = vectorizer.get_feature_names_out()
print("단어 목록:", vocab)
print("\n문서-단어 행렬:\n", doc_term_matrix)
문서 간 코사인 유사도 계산:
from numpy import dot
from numpy.linalg import norm
def cosine_similarity(A, B):
return dot(A, B) / (norm(A) * norm(B))
for i in range(len(doc_term_matrix)):
for j in range(i + 1, len(doc_term_matrix)):
print(f'문서{i+1}과 문서{j+1}의 유사도:')
print(cosine_similarity(doc_term_matrix[i], doc_term_matrix[j]))
print('-' * 30)
1번 문서와 3번 문서의 유사도가 0으로 나옴워드 임베딩 : 텍스트를 의미를 반영한 숫자 벡터로 변환하는 기법
| 모델 | 설명 |
|---|---|
| Word2Vec | 단어 관계를 학습해 각 단어를 의미 벡터로 만드는 방법 |
| Doc2Vec | 문서와 단어 관계를 함께 학습해 문장/문단 전체를 하나의 벡터로 만드는 방법 |
| 생성형 AI 기반 임베딩 | 대규모 언어 모델(LLM)에서 제공하는 임베딩을 활용 |
분포 가설 : "서로 비슷한 맥락에서 등장하는 단어들은 의미도 비슷하다."
데이터 수집 → '대한민국 헌법.txt' 파일 활용
데이터 전처리 → 토큰화(띄어쓰기/형태소 기준), 한글 외 표현 제거, 불용어 제거
임베딩 모델 학습 → Word2Vec 모델 사용
from gensim.models.word2vec import Word2Vec
from kiwipiepy import Kiwi # 형태소 분석기
import re # 문자열 정리용 정규표현식
# 데이터 수집
with open("대한민국헌법.txt", encoding='cp949') as f:
content = f.read()
print(len(content)) # 19063글자
# 문장별로 분리 ('.', '?', '!' 뒤에 공백/개행이 오면 분리)
docs = re.split(r'(?<=[.?!])\s+', content.strip())
# 정규표현식으로 한글 외 문자 제거
docs = [re.sub("[^가-힣 ]", "", doc) for doc in docs]
# 불용어 목록 정의
stopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를',
'으로','자','에','와','한','하다']
kiwi = Kiwi()
tokenized_data = []
for sentence in docs:
tokenized_sentence = [token.form for token in kiwi.tokenize(sentence)]
sentence = [word for word in tokenized_sentence if word not in stopwords]
tokenized_data.append(sentence)
model = Word2Vec(
sentences = tokenized_data, # 학습에 사용할 토큰화된 문장들
vector_size = 100, # 단어 벡터 차원 크기
window = 5, # 주변 단어를 고려하는 윈도우 크기
min_count = 1, # 최소 등장 횟수(1회 미만이면 무시)
workers = 4, # 병렬 작업 스레드 수
sg = 0, # 0이면 CBOW, 1이면 Skip-gram
)
print(model.wv.index_to_key[:50]) # 빈도수 상위 50개 단어
품사 태그(NNG 일반명사, NNP 고유명사, NNB 의존명사, VV 동사, VA 형용사 등)로 필터링하면 더 의미 있는 단어만 남는다. 불용어 처리는 언어별 특성과 문서/도메인 특성에 따라 적절히 조정해야 한다. (예: 법률 문서에선 '제, 조, 항'이 불용어일 수 있지만 다른 문서에선 의미가 다를 수 있음)
# 유지할 품사 태그만 추출
keep_tags = {'NNG', 'NNP', 'NNB', 'VV', 'VA'}
tokenized_data = []
for sentence in docs:
tokenized_sentence = [token.form for token in kiwi.tokenize(sentence)
if token.tag in keep_tags]
sentence = [word for word in tokenized_sentence if word not in stopwords]
tokenized_data.append(sentence)
# 특정 단어의 임베딩 벡터 확인 (-1 ~ 1 사이 값, 길이 100)
print(model.wv.get_vector("법률"))
# 두 단어 간 코사인 유사도
similarity_value = cosine_similarity(
model.wv.get_vector('법률'), model.wv.get_vector('헌법')
)
print("'법률'과 '헌법'의 코사인 유사도:", similarity_value) # 0.632...
# 특정 단어와 가장 유사한 단어 Top 10
most_similar_words = model.wv.most_similar('헌법')
print("헌법과 유사한 단어 Top 10:", most_similar_words)
도큐먼트 임베딩 : 여러 단어로 이루어진 문장·문서를 통째로 임베딩하는 방법
from gensim.models import Doc2Vec
from gensim.models.doc2vec import TaggedDocument
from kiwipiepy import Kiwi
docs = ['자바스크립트는 웹사이트에 동적인 기능을 추가하는 핵심 프로그래밍 언어입니다', ...]
kiwi = Kiwi()
keep_tags = {'NNG', 'NNP', 'NNB', 'VV', 'VA'}
documents = []
for i, doc in enumerate(docs):
words = [token.form for token in kiwi.tokenize(doc) if token.tag in keep_tags]
# TaggedDocument: 문서에 태그를 붙임 (tags=[문서번호], words=[형태소 단어들])
documents.append(TaggedDocument(tags=[i], words=words))
# 모델 생성
model = Doc2Vec(
vector_size = 300, # 벡터의 차원
min_count = 1, # 단어 최소 빈도수
alpha = 0.025, # 학습률 (높을수록 가중치 업데이트가 크게 이루어짐)
min_alpha = 0.025, # 학습률 초기값
window = 8, # 문맥의 크기
)
# Vocabulary 빌드 & 학습
model.build_vocab(documents)
model.train(documents, total_examples=model.corpus_count, epochs=20)
# 첫 번째 문장과 가장 유사한 문장 출력
print(model.dv.most_similar(0))
| 구분 | Word2Vec | Doc2Vec |
|---|---|---|
| 학습 아이디어 | 주변/중심 단어 예측. 단어만 임베딩 벡터로 학습 | 문서에 태그(ID) 부여. 태그 벡터 + 단어 벡터 함께 학습 |
| 결과 | 각 단어를 의미적 벡터로 표현. 단어 간 유사도 계산이 용이 | 각 문서를 하나의 벡터로 표현. 문서 간 유사도를 직접 계산 가능 |
| 장점 | 학습·적용이 상대적으로 간단 | 문장/문서 전체의 맥락·주제를 반영하는 벡터를 얻을 수 있음 |
| 단점 | 문장 단위 유사도엔 별도 연산 필요. 문서 전체 맥락 벡터화엔 부족 | Word2Vec보다 학습 절차가 복잡. 데이터셋이 충분히 커야 품질 보장 |
| 활용 예시 | 단어 간 의미 유사도 판단, 단어 유추 | 문서 분류, 문서 군집화 |
GPT 계열 같은 대규모 언어 모델(LLM)에서 제공하는 임베딩 기능을 활용
from openai import OpenAI
# 블로그 글 제목 및 내용을 dictionary로 정의
post_manuals = {
"자바스크립트언어": "자바스크립트는 웹 개발에 필수적인 프로그래밍 언어입니다.",
"일본관광시기": "일본은 벚꽃이 피는 봄이 관광하기 가장 좋은 시기입니다.",
"파이썬언어": "파이썬 언어는 데이터분석과 기계학습에 효율적인 프로그래밍 언어입니다",
"기계학습기초": "기계학습은 데이터를 활용하여 컴퓨터가 학습하도록 하는 기술입니다.",
"스페인방문계절": "스페인은 날씨가 온화한 봄이나 가을에 방문하는 것이 이상적입니다.",
}
POST_LEN = len(post_manuals)
# OpenAI endpoint 설정
url = "https://gms.ssafy.io/gmsapi/api.openai.com/v1"
client = OpenAI(api_key=GMS_KEY, base_url=url)
# 각 value를 벡터로 변환 (text-embedding-3-small : 가장 저렴)
response = client.embeddings.create(
model="text-embedding-3-small",
input=post_manuals.values(),
encoding_format='float'
).data
# 임베딩 벡터 데이터만 리스트로 추출 (벡터 차원 == 1536)
embedding_vectors = [item.embedding for item in response]
print(len(embedding_vectors[0]))
from numpy import dot
from numpy.linalg import norm
def cosine_similarity(A, B):
return dot(A, B) / (norm(A) * norm(B))
# 유사도 행렬 저장
similarities = [[0] * POST_LEN for _ in range(POST_LEN)]
for i in range(POST_LEN):
for j in range(i + 1, POST_LEN):
similarity = cosine_similarity(embedding_vectors[i], embedding_vectors[j])
similarities[i][j] = similarity
similarities[j][i] = similarity
def recommendations(title):
posts_titles = list(post_manuals)
if title in posts_titles:
idx = posts_titles.index(title)
similar_doc = similarities[idx]
else:
print("도서 정보가 존재하지 않습니다.")
return []
# 유사도가 높은 글 순서로 정렬
sim_scores = list(enumerate(similar_doc))
sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)
similar_posts_titles = []
for index, post_info in sim_scores:
if title == posts_titles[index]: # 같은 글 제외
continue
if post_info > 0.3: # 특정 유사도를 넘는 글만 추천
similar_posts_titles.append(posts_titles[index])
return similar_posts_titles
results = recommendations('자바스크립트언어')
print(results) # ['파이썬언어']
recommendations 함수는 임베딩된 데이터를 토대로 코사인 유사도를 활용해 유사한 문서를 찾는 역할을 한다.pickle : 파이썬 객체 구조의 직렬화/역직렬화를 위한 바이너리 프로토콜
임베딩 결과를 매번 다시 계산하지 않도록 파일로 저장해두고 불러올 때 유용하다.
import pickle
# 저장 (write binary)
with open("embedding_vectors.pickle", "wb") as f:
pickle.dump(embedding_vectors, f)
# 불러오기 (read binary)
with open("embedding_vectors.pickle", "rb") as f:
embedding_vectors = pickle.load(f)
with open("similarities.pickle", "wb") as f:
pickle.dump(similarities, f)
with open("similarities.pickle", "rb") as f:
similarities = pickle.load(f)