Field Log · Entry

벡터와 임베딩 입문: Cosine Similarity가 RAG 검색이 되는 원리 (3/10)

질문과 세 문서 임베딩의 방향을 비교해 가장 가까운 문서를 찾는 벡터 공간

오늘의 결론

  • embedding은 텍스트를 비교 가능한 실수 벡터로 바꾼 결과입니다.
  • cosine similarity는 두 벡터의 길이보다 방향이 얼마나 비슷한지 봅니다.
  • 높은 similarity는 “의미가 완전히 같다”거나 “정답일 확률”을 뜻하지 않습니다.
  • 검색 품질은 차원 수보다 모델의 학습 목표, 데이터, chunk, metric의 조합에 달려 있습니다.

앞 글에서 문자열이 token ID로 바뀌는 과정을 봤습니다. 하지만 정수 ID 자체에는 거리가 없습니다. ID 101과 ID 102가 가깝다고 의미도 비슷한 것은 아닙니다. ID는 vocabulary의 주소일 뿐입니다.

의미를 비교하려면 텍스트를 연속적인 공간의 좌표로 바꿔야 합니다. 그 좌표가 embedding입니다.

질문 벡터와 문서 벡터의 cosine similarity

그림 1. query와 같은 방향에 가까운 document vector를 먼저 반환한다. 실제 embedding 공간은 2차원이 아니라 수백~수천 차원이다.


스칼라, 벡터, 행렬

개발자 관점에서 먼저 데이터 모양을 잡겠습니다.

이름shapeRAG 예시
스칼라0.83하나의 값similarity score
벡터[0.2, -0.7, 0.4](d,)문서 한 개의 embedding
행렬여러 벡터를 쌓은 배열(n, d)n개 문서의 embedding index

문서가 100만 개이고 embedding dimension이 768이라면, 개념적으로는 (1_000_000, 768) 행렬을 검색하는 셈입니다. 실제 vector DB는 이 행렬을 빠르게 찾기 위한 index와 metadata 저장 구조를 더합니다.

One-hot에서 dense embedding으로

vocabulary가 [펌프, 밸브, 사과]라면 one-hot은 이렇게 표현할 수 있습니다.

펌프 = [1, 0, 0]
밸브 = [0, 1, 0]
사과 = [0, 0, 1]

one-hot은 단어를 구분하지만 모든 서로 다른 단어의 거리가 같습니다. 펌프와 밸브가 사과보다 관련 있다는 사실을 담지 못합니다.

dense embedding은 학습을 통해 의미와 사용 맥락을 좌표에 분산시킵니다.

펌프 = [0.85, 0.72, 0.05]
밸브 = [0.78, 0.68, 0.09]
사과 = [0.03, 0.11, 0.91]

각 차원을 “기계성”, “과일성”처럼 사람이 정확히 이름 붙일 수 있는 것은 아닙니다. 정보는 많은 차원에 분산되어 있습니다. 중요한 것은 학습 결과 비슷한 문맥의 텍스트가 검색 목적에 유용한 기하 구조를 만든다는 점입니다.

초기 word2vec 연구는 대규모 말뭉치에서 단어의 연속 벡터 표현을 효율적으로 학습할 수 있음을 보였습니다. 현대 RAG는 보통 단어 하나가 아니라 문장이나 passage 전체를 하나의 벡터로 만드는 encoder를 사용합니다.

Dot product는 무엇을 재는가

두 벡터 ab의 dot product는 같은 위치의 값을 곱해 더합니다.

a = [1, 2]
b = [3, 4]

a · b = 1×3 + 2×4 = 11

dot product는 방향 정렬과 벡터 크기의 영향을 함께 받습니다. 방향이 같아도 한 벡터의 크기가 크면 점수가 커집니다.

q  = [1, 0]
d1 = [1, 0]     → q · d1 = 1
d2 = [10, 0]    → q · d2 = 10

방향만 비교하고 싶을 때는 벡터 길이로 나눈 cosine similarity를 씁니다.

Cosine similarity를 손으로 계산하기

공식은 다음과 같습니다.

cos(q, d) = (q · d) / (||q|| × ||d||)

||q||는 벡터의 L2 norm, 즉 길이입니다.

q = [1, 1]
d = [2, 0]

q · d = 2
||q|| = √(1² + 1²) = √2
||d|| = √(2² + 0²) = 2

cos(q, d) = 2 / (√2 × 2) ≈ 0.707

일반적으로 cosine 값은 -1에서 1 사이입니다.

  • 1에 가까움: 같은 방향
  • 0에 가까움: 직교
  • -1에 가까움: 반대 방향

다만 실제 embedding 모델의 점수 분포는 학습 방식과 데이터에 따라 좁은 구간에 몰릴 수 있습니다. 0.8이 80% 정답 확률이라는 해석은 틀립니다. 점수는 같은 모델·같은 index 안에서 후보를 정렬하는 상대값으로 먼저 봐야 합니다.

정규화하면 cosine과 dot product가 같아진다

각 벡터를 길이 1로 정규화하면 denominator가 1이 됩니다.

q_hat = q / ||q||
d_hat = d / ||d||

cos(q, d) = q_hat · d_hat

그래서 많은 vector search 시스템은 저장 전 벡터를 L2-normalize하고 inner product index로 cosine 검색을 구현합니다. 여기서 실무 함정이 생깁니다.

  • 모델이 이미 normalized embedding을 반환하는가?
  • 애플리케이션이 한 번 더 normalize해도 되는가?
  • index metric은 cosine, inner product, L2 중 무엇인가?
  • query와 document 모두 같은 방식으로 전처리되는가?

metric과 정규화 규칙이 어긋나면 모델은 좋아도 검색 순위가 달라집니다.

Token embedding과 sentence embedding은 다르다

Transformer는 각 token 위치에 벡터를 만듭니다. 문장 길이가 20이고 hidden dimension이 768이면 출력 shape은 대략 (20, 768)입니다. vector DB에 문장 하나를 저장하려면 이를 고정 길이 (768,) 벡터 하나로 모으는 과정이 필요합니다.

대표적인 pooling은 다음과 같습니다.

방식방법주의점
CLS pooling특수 CLS token의 표현 사용그 위치가 sentence embedding으로 학습됐는지 확인
Mean pooling유효 token 벡터 평균padding mask를 빼야 함
Last token마지막 유효 token 사용decoder 계열에서 주로 사용, 학습 방식 의존
Learned pooling별도 layer가 가중 결합모델 규약을 따라야 함

BERT의 CLS를 아무 fine-tuning 없이 꺼낸다고 항상 좋은 semantic search embedding이 되는 것은 아닙니다. Sentence-BERT는 문장 쌍을 효율적으로 비교할 수 있도록 siamese 구조와 학습 목표를 사용해 고정 길이 문장 표현을 만들었습니다.

Query와 document는 같은 문장이 아니다

검색 모델은 질문과 문서가 맡는 역할이 다르다는 점을 학습할 수 있습니다.

query:    E17 알람 원인은?
document: E17은 냉각수 유량이 기준 아래로 떨어질 때 발생한다...

두 텍스트는 단어가 완전히 같지 않아도 질문-답 근거 관계입니다. 일부 embedding 모델은 입력 앞에 query:, passage: 같은 prefix를 요구합니다. 이 규약을 무시하면 benchmark에서 보인 검색 품질이 나오지 않을 수 있습니다.

따라서 모델을 고를 때는 “embedding 차원”보다 다음을 먼저 확인합니다.

  1. retrieval용으로 학습되었는가?
  2. 한국어와 내 도메인을 포함하는가?
  3. query/document instruction 또는 prefix가 필요한가?
  4. 권장 pooling과 normalize 설정은 무엇인가?
  5. 내 질문 집합의 Recall@k로 검증했는가?

차원이 크면 항상 좋은가

차원 d가 커지면 더 많은 정보를 담을 여지는 있지만 저장 공간과 연산량도 커집니다.

대략적인 벡터 원본 크기
= 문서 수 × dimension × 원소당 byte

100만 문서, 1,536차원, float32라면 벡터 원본만 약 6.1 GB입니다. ANN index와 metadata, 복제본은 별도입니다. 차원 수만 보고 모델을 선택하면 비용을 놓칩니다.

또한 MTEB는 embedding 방법 하나가 모든 task에서 지배적이지 않다는 점을 보여 주었습니다. 2025년의 MMTEB는 평가 범위를 250개 이상의 언어와 다양한 task로 넓혔습니다. leaderboard의 전체 평균보다 한국어 retrieval과 내 도메인 질문에 맞는 평가가 중요합니다.

Exact search와 ANN

문서가 적으면 query vector와 모든 document vector의 similarity를 계산할 수 있습니다. 이를 exact search라고 생각하면 됩니다.

문서가 수백만 개로 늘면 매 요청마다 전부 비교하기 비쌉니다. 그래서 vector DB는 HNSW, IVF 같은 Approximate Nearest Neighbor(ANN) index로 가능성이 높은 영역만 탐색합니다.

ANN의 “approximate”는 embedding 자체가 대충이라는 뜻이 아닙니다. 가장 가까운 이웃을 찾는 검색을 더 빠르게 하면서 일부 recall을 교환한다는 뜻입니다. 이후 글에서 index별 trade-off를 따로 다룹니다.

10분 실습: cosine 검색 직접 구현하기

작은 행렬이라면 vector DB 없이도 원리를 확인할 수 있습니다.

import numpy as np

documents = np.array([
    [0.9, 0.8, 0.1],  # pump alarm
    [0.1, 0.2, 0.9],  # fruit
    [0.7, 0.7, 0.2],  # valve inspection
], dtype=np.float32)
query = np.array([0.8, 0.9, 0.1], dtype=np.float32)

def l2_normalize(x):
    return x / np.linalg.norm(x, axis=-1, keepdims=True)

doc_norm = l2_normalize(documents)
query_norm = query / np.linalg.norm(query)
scores = doc_norm @ query_norm

for idx in np.argsort(-scores):
    print(idx, float(scores[idx]))

이 코드는 query와 모든 문서의 exact cosine ranking입니다. 실제 RAG의 vector DB는 이 연산을 대규모로 빠르게 수행하고, 결과에 문서 본문과 metadata를 붙여 줍니다.

자주 하는 오해

“embedding은 모델이 이해한 의미 그 자체다.”

embedding은 특정 학습 목표와 데이터가 만든 표현입니다. 숫자·부정·고유명사·도메인 약어를 충분히 구분하지 못할 수 있습니다.

“cosine threshold 하나를 모든 질문에 적용하면 된다.”

점수 분포는 query 종류와 corpus에 따라 다릅니다. threshold는 validation set에서 precision/recall과 함께 정해야 합니다.

“vector DB를 바꾸면 embedding 품질이 좋아진다.”

DB는 주로 저장과 ANN 검색을 담당합니다. embedding 모델의 의미 표현과 index의 탐색 오차를 분리해 봐야 합니다.

스스로 확인하기

  1. token ID와 embedding vector는 어떻게 다른가?
  2. L2-normalized vector에서는 왜 dot product가 cosine similarity가 되는가?
  3. cosine score 0.82를 “정답 확률 82%”라고 말하면 왜 안 되는가?
  4. 현재 embedding 모델의 prefix, pooling, normalization 규약을 확인했는가?

다음 글에서는 이런 벡터를 포함한 수많은 parameter가 어떻게 학습되는지 logit, loss, gradient를 연결합니다.

참고자료