TIL 05-29 - 유사도

김덕협·2026년 5월 29일

TIL

목록 보기
16/41

문서 유사도 (Document Similarity)

여러 개의 문서 또는 문장이 얼마나 비슷한 내용을 담고 있는지를 수치로 표현한 것


1. 문서 유사도란?

어디에 쓰이나?

  • 추천 시스템 : 도서·영화·금융 상품 등에서 사용자 관심사와 유사한 콘텐츠를 찾아 추천
  • 검색 및 정보 탐색 : 입력 쿼리(질문)와 유사한 문서를 빠르게 찾아 검색 품질을 높임
  • 분류·군집 및 중복 검출 : 뉴스 기사·논문 등이 서로 얼마나 유사한지 계산 → 중복 기사 묶음 처리, 토픽별 문서 분류
  • 의사결정 지원 : 방대한 문서(보고서, 계약서 등) 중 유사 문서나 핵심 정보를 신속히 파악

사람은 직관적으로 판단할 수 있지만…

아래 네 문장이 있을 때, 누구나 1번과 2번이 "가장 유사"하다고 본다.

1. 먹고 싶은 사과
2. 먹고 싶은 바나나
3. 길고 노란 바나나 바나나
4. 저는 과일이 좋아요.

그런데 문장이 아주 길어지거나, 비교할 문서 양이 많아지거나, 정확히 어느 정도로 유사한지 정량화하려면 사람의 직관만으로는 한계가 있다. → 그래서 유사도를 계산하는 수학적 방법이 필요하다.


2. 문서 유사도 계산 방법

방법핵심 아이디어
자카드 유사도두 집합의 교집합 크기를 합집합 크기로 나눈 값. 단어의 존재 여부만 고려
유클리드 거리n차원 공간에서 두 점 사이의 직선 거리를 계산. 거리가 0에 가까울수록 유사
코사인 유사도두 벡터가 이루는 각도의 코사인 값 이용. -1 ~ 1 값을 가지며 1에 가까울수록 유사

이 중 가장 많이 쓰이는 코사인 유사도를 자세히 본다.


3. 코사인 유사도 (Cosine Similarity)

먼저 알아야 할 개념

벡터(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,  방향 없음 "무관"

→ 내적은 두 벡터가 얼마나 같은 축 방향으로 뻗어 있는지를 알려준다.
→ 이 내적을 벡터의 크기로 나누면 크기 영향을 없애고 방향만 비교할 수 있다. 이것이 코사인 유사도다.

공식

cosine similarity=ABAB\text{cosine similarity} = \frac{A \cdot B}{\|A\|\,\|B\|}

계산 예제 : A(3, 3), B(4, 1)

① 내적 계산

A · B = (Ax × Bx) + (Ay × By)
      = (3 × 4) + (3 × 1)
      = 15

② 크기(norm) 계산 — 피타고라스 정리(각 좌표 제곱의 합에 루트)

AB=Ax2+Ay2×Bx2+By2=18×17=33417.492\|A\|\,\|B\| = \sqrt{A_x^2 + A_y^2} \times \sqrt{B_x^2 + B_y^2} = \sqrt{18} \times \sqrt{17} = 3\sqrt{34} \approx 17.492

③ 최종 코사인 유사도

ABAB=153340.857\frac{A \cdot B}{\|A\|\,\|B\|} = \frac{15}{3\sqrt{34}} \approx 0.857

값의 의미

코사인 유사도의미
1두 벡터가 완전히 같은 방향
0서로 직각 (관계 없음)
-1완전히 반대 방향

왜 "크기"가 아닌 "방향"만 볼까?

  1. 검색/추천에서 중요한 건 "어떤 단어/특징이 얼마나 중요한가?" 이지 "몇 번 출현했는가?" 가 아니다.
    • 예) 두 문서가 [10, 5, 0], [2, 1, 0]이면 길이는 A가 더 길지만, 비율은 동일하게 2:1로 볼 수 있음
  2. 문서가 불필요하게 길어지거나 한쪽 출현 횟수만 늘어도, 패턴(순서와 비율)을 정확히 비교할 수 있음
  3. -1 ~ 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) 형태로 수학적 정의가 가능하다.


4. 문서 벡터화 (Vectorization)

코사인 유사도를 쓰려면 벡터 값이 필요하다. 그렇다면 텍스트 문서를 어떻게 벡터로 바꿀까?

문서 벡터화 = 컴퓨터가 연산할 수 있도록 문서(텍스트)를 수치 벡터로 바꾸는 과정
컴퓨터는 문자열 자체를 수학적 연산에 바로 쓸 수 없기 때문에, 내용/구조/의미를 수치로 옮겨야 검색/추천/분류 알고리즘을 적용할 수 있다.

예시 문서:

문서1. 먹고 싶은 사과
문서2. 먹고 싶은 바나나
문서3. 길고 노란 바나나 바나나
문서4. 저는 과일이 좋아요.

아이디어 1 — 단어 등장 횟수 세기

주어진 단어를 모두 나열하고, 문서별 등장 횟수를 기록한다.

문서먹고싶은사과바나나길고노란저는과일이좋아요벡터
1111000000[1,1,1,0,0,0,0,0,0]
2110100000[1,1,0,1,0,0,0,0,0]
3000211000[0,0,0,2,1,1,0,0,0]
4000000111[0,0,0,0,0,0,1,1,1]

아이디어 2 — 차원에 의미 부여하기

각 차원이 특정 의미를 갖도록 정의할 수도 있다. (예: v1 = 과일 단어 비율, v2 = 주관적 표현 비율)

문서과일 단어 비율주관적 표현 비율벡터
11/3 ≈ 0.331/3 ≈ 0.33[0.33, 0.33]
21/3 ≈ 0.331/3 ≈ 0.33[0.33, 0.33]
32/4 = 0.50[0.5, 0]
41/3 ≈ 0.332/3 ≈ 0.67[0.33, 0.67]

대표적인 벡터화 방식

  • 카운트 기반
  • 임베딩 기반 : Word2Vec, Doc2Vec, 생성형 AI 기반 임베딩

5. 카운트 기반 문서 유사도 — Bag of Words

절차

  1. 고유 단어 추출 — 불용어(조사, 접속사 등)를 제거하면 정확도가 올라감
  2. 각 단어에 인덱스 부여 — 사전순 혹은 등장 순서대로
  3. 문서별 등장 빈도수 기록 — 문서 내 등장 횟수를 해당 단어 인덱스 위치에 기록

코드 (scikit-learn)

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으로 나옴
  • 어휘 크기만큼 벡터 길이가 커져 메모리/연산 부담이 큼

6. 임베딩 기반 문서 유사도

워드 임베딩 : 텍스트를 의미를 반영한 숫자 벡터로 변환하는 기법

Word Embedding의 아이디어

  • 단순히 단어 등장 횟수를 세는 방식(BoW)으로는 '사과'와 '배'가 과일이라는 의미적 유사도를 반영하기 어려움
  • 임베딩 과정을 통해 서로 의미가 비슷한 단어·문장이 고차원 벡터 공간에서 가까이 위치하도록 학습시킴
  • 학습된 벡터들은 코사인 유사도 등으로 간단히 비교 가능 → 훨씬 정교한 유사도 계산

대표적인 임베딩 기반 모델

모델설명
Word2Vec단어 관계를 학습해 각 단어를 의미 벡터로 만드는 방법
Doc2Vec문서와 단어 관계를 함께 학습해 문장/문단 전체를 하나의 벡터로 만드는 방법
생성형 AI 기반 임베딩대규모 언어 모델(LLM)에서 제공하는 임베딩을 활용

7. Word2Vec

핵심 개념 : 분포 가설

분포 가설 : "서로 비슷한 맥락에서 등장하는 단어들은 의미도 비슷하다."

  • 주변 단어를 보고 중심 단어를 맞히거나(또는 그 반대로) 학습하면, 단어 간 의미 관계가 유사한 벡터로 학습됨

학습 방식 두 가지

  • CBOW (Continuous BoW) : 주변 단어들을 입력(맥락)으로 받고, 그 중심 단어를 예측
  • Skip-gram : 중심 단어를 입력으로 받고, 주변 단어들을 예측

실습 흐름

데이터 수집      →  '대한민국 헌법.txt' 파일 활용
데이터 전처리    →  토큰화(띄어쓰기/형태소 기준), 한글 외 표현 제거, 불용어 제거
임베딩 모델 학습  →  Word2Vec 모델 사용

① 라이브러리

from gensim.models.word2vec import Word2Vec
from kiwipiepy import Kiwi   # 형태소 분석기
import re                    # 문자열 정리용 정규표현식
  • Gensim : 자연어 처리 라이브러리. Word2Vec, Doc2Vec 등 임베딩 알고리즘 지원
  • Kiwipiepy : 한국어 NLP 특화 라이브러리. 형태소 분석, 불용어 제거 등 기초 전처리 지원

② 데이터 수집 & 전처리

# 데이터 수집
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)

④ 모델 학습 (100차원 벡터로 변환)

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)

8. Doc2Vec

도큐먼트 임베딩 : 여러 단어로 이루어진 문장·문서를 통째로 임베딩하는 방법

핵심 아이디어

  • 문서별로 고유 문서 태그(문서 ID) 를 부여
  • Word2Vec과 유사하게 중심 단어와 주변 단어를 예측하되, 문서를 대표하는 문서 태그를 은닉층에 함께 학습
  • 이 문서 태그 임베딩이 결국 해당 문서를 대표하는 벡터가 됨
  • 즉 "문서 태그 + 단어 임베딩"을 동시에 업데이트

코드

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))

결과가 만족스럽지 못할 수 있는 이유

  1. 문장 길이가 너무 짧고 단어 수가 적음 → 학습 정보 부족
  2. 주제별 키워드가 불충분하거나 겹치는 어휘가 많음 (핵심 단어가 충분히 반복돼야 함)
  3. 학습 파라미터 및 데이터 규모 한계 → 토픽별 임베딩이 명확히 분리되지 않을 수 있음

Word2Vec vs Doc2Vec 정리

구분Word2VecDoc2Vec
학습 아이디어주변/중심 단어 예측. 단어만 임베딩 벡터로 학습문서에 태그(ID) 부여. 태그 벡터 + 단어 벡터 함께 학습
결과각 단어를 의미적 벡터로 표현. 단어 간 유사도 계산이 용이각 문서를 하나의 벡터로 표현. 문서 간 유사도를 직접 계산 가능
장점학습·적용이 상대적으로 간단문장/문서 전체의 맥락·주제를 반영하는 벡터를 얻을 수 있음
단점문장 단위 유사도엔 별도 연산 필요. 문서 전체 맥락 벡터화엔 부족Word2Vec보다 학습 절차가 복잡. 데이터셋이 충분히 커야 품질 보장
활용 예시단어 간 의미 유사도 판단, 단어 유추문서 분류, 문서 군집화

9. 생성형 AI 기반 임베딩

GPT 계열 같은 대규모 언어 모델(LLM)에서 제공하는 임베딩 기능을 활용

특징

  • LLM은 이미 엄청난 텍스트 코퍼스(말뭉치)로 사전 학습되어 있음
  • 복잡한 자연어 전처리(토큰화, 불용어 제거 등)가 불필요
  • 문장/문서를 입력하면 모델 내부의 숨은 표현을 활용해 의미가 반영된 벡터를 얻을 수 있음
  • API로 텍스트를 전달 → 임베딩 벡터를 반환받음 (OpenAI, Gemini 등)

실습 : OpenAI 임베딩 모델

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)   # ['파이썬언어']

RAG와의 연결

  • 완성한 recommendations 함수는 임베딩된 데이터를 토대로 코사인 유사도를 활용해 유사한 문서를 찾는 역할을 한다.
  • 이는 RAG의 Retrieval 단계와 동일하다.
  • 이 결과를 LLM에 전달해 답변을 작성하도록 추가 작업을 하면 → 기초적인 RAG를 구현하게 된다.

📌 참고 — pickle로 벡터 데이터 직렬화

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)

✅ 한눈에 정리

  • 문서 유사도는 문서가 얼마나 비슷한지 수치화한 것이며, 추천·검색·분류 등에 쓰인다.
  • 대표 측정법은 자카드 / 유클리드 / 코사인 유사도 → 코사인은 크기가 아닌 방향을 비교한다.
  • 유사도를 구하려면 텍스트를 벡터화해야 한다.
    • 카운트 기반(BoW) : 단순하지만 의미를 반영 못 하고 벡터가 비대해진다.
    • 임베딩 기반 : 의미가 비슷한 단어를 가까이 배치 → Word2Vec(단어), Doc2Vec(문서), 생성형 AI(LLM).
  • LLM 임베딩 + 코사인 유사도로 유사 문서를 찾는 것이 RAG의 Retrieval 단계다.
profile
뭘봐

0개의 댓글