# RAG

임베딩 검색에서 코사인 유사도와 정규화는 왜 쓸까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
임베딩 검색에서 코사인, 내적, 유클리드 중 무엇을 고르고, 벡터 정규화는 왜 해야 할까?
답
길이 1로 정규화된 벡터에서는 내적, 코사인, 유클리드의 순위가 같았지만, 정규화를 빼자 문단 검색 질문 20개 모두에서 내적 상위 5개가 코사인과 달랐다.
조건
intfloat/multilingual-e5-small(384차원), sentence-transformers 6.1.0, Qdrant 1.19.1, 한국어 문장 24개, 문단 3,200개와 질문 20개

환경

임베딩 모델
intfloat/multilingual-e5-small(384차원, 모델 파일 471MB)
라이브러리
sentence-transformers 6.1.0, torch CPU 전용 빌드
벡터 DB
Docker의 Qdrant 1.19.1
데이터
직접 만든 한국어 문장 24개, 발행 글 54편의 문단 3,200개, 검색 질문 20개
목차
  1. 실습 준비
  2. 실습 1: 정규화된 벡터에서는 순위가 같습니다
  3. 실습 2: 정규화하지 않은 벡터에서는
  4. 실습 3: Qdrant 거리 설정이 하는 일
  5. 정리

이번 시간에는 내적과 코사인, 유클리드 거리가 언제 같은 순위를 내고 언제 갈라지는지, 다국어 소형 임베딩 모델과 Qdrant로 정규화 전후를 측정하며 배워가는 시간입니다.

Qdrant에 컬렉션을 만들려면 벡터 크기와 함께 거리 함수를 정해야 해요. 선택지는 Cosine, Dot, Euclid, Manhattan입니다.

이 가운데 Cosine, Dot, Euclid는 어느 것을 골라도 검색 결과가 같을 때가 있고, 크게 달라질 때가 있어요. 어느 쪽인지는 모델이 내놓는 벡터의 길이에 달려 있습니다.

실습 준비

이 글의 명령은 모두 실습 코드의 embedding-similarity/ 디렉터리에서 실행합니다.

  • compose.yaml: Qdrant와 Python 클라이언트 컨테이너 정의
  • requirements.txt: 클라이언트 컨테이너 패키지 버전
  • emb.py: 하위 명령마다 실습 하나를 맡는 실습 코드
  • sentences.jsonl: 비교용 한국어 문장 24개
  • questions.jsonl: 검색 질문 20개
  • prep.py: 문서 디렉터리의 글을 문단 청크로 나눔

그 밖의 파일은 데이터·보조 스크립트입니다.

임베딩 모델은 intfloat/multilingual-e5-small이에요. 384차원 벡터를 만들고 한국어를 지원합니다. 모델 파일은 471MB이고, 처음 불러올 때 Hugging Face에서 받아요.

compose.yaml로 Qdrant와 Python 클라이언트 컨테이너를 띄울게요. 모델 파일과 pip 캐시는 프로젝트 밖의 hong_ml_cache 볼륨에 두어서, compose 프로젝트를 지워도 다시 받지 않게 했어요. Qdrant 데이터 디렉터리(qdrant_storage 볼륨)도 클라이언트 컨테이너에 읽기 전용으로 붙어 있지만 이 글에서는 쓰지 않아요. ARTICLES_DIR에는 검색 대상으로 쓸 문서 디렉터리를 지정해 주세요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export ARTICLES_DIR=/path/to/articles

dc() { docker compose -p emb "$@"; }

py() { dc exec -T client python "$@"; }

docker volume create hong_ml_cache
dc up -d
dc exec -T client pip install -q -r requirements.txt
mkdir -p out

info는 모델을 불러와 구성을 출력해요.

py emb.py info
dc exec -T client du -sh /cache/hf/hub/models--intfloat--multilingual-e5-small
SentenceTransformer(
  (0): Transformer({'transformer_task': 'feature-extraction', 'modality_config': {'text': {'method': 'forward', 'method_output_name': 'last_hidden_state'}}, 'module_output_name': 'token_embeddings', 'architecture': 'BertModel'})
  (1): Pooling({'embedding_dimension': 384, 'pooling_mode': 'mean', 'include_prompt': True})
  (2): Normalize({'module_input_name': 'sentence_embedding', 'module_output_name': 'sentence_embedding'})
)
dimension 384  max_seq_length 512
471M	/cache/hf/hub/models--intfloat--multilingual-e5-small

문장 하나가 벡터가 되기까지 세 단계를 거칩니다.

  • Transformer: 문장을 토큰으로 나누고, 토큰마다 384개의 숫자를 만들어요.
  • Pooling: 토큰별 벡터를 평균 내서(pooling_mode: mean) 문장 하나에 벡터 하나를 만들어요.
  • Normalize: 벡터의 길이를 1로 맞춰요.

비교에 쓸 한국어 문장 24개는 직접 만들었어요. 이 사이트에서 다룬 MySQL, Kafka, Redis 주제로 네 묶음을 6개씩 채웠습니다.

  • 같은 뜻(A1~A6): 같은 내용을 다른 말로 쓴 문장 세 쌍이에요. 예: "Redis는 데이터를 메모리에 두기 때문에 빠르다." / "Redis가 빠른 이유는 데이터를 디스크가 아니라 메모리에 저장하기 때문이다."
  • 같은 주제 다른 뜻(B1~B6): 같은 기술을 다루지만 말하는 내용이 다른 세 쌍이에요. 예: "Kafka 컨슈머 lag이 쌓인다." / "Kafka 프로듀서가 같은 메시지를 다시 보낸다."
  • 다른 주제(C1~C6): 기술과 관계없는 일상 문장 6개예요. 예: "주말에 한강공원에서 자전거를 탔다."
  • 길이만 다름(D1~D6): 같은 내용을 한 줄과 긴 문장으로 쓴 세 쌍이에요. 짧은 쪽은 20자 안팎, 긴 쪽은 170자 안팎입니다.

실습 1: 정규화된 벡터에서는 순위가 같습니다

두 벡터 A와 B를 비교하는 방법은 세 가지예요.

  • 내적(dot product): 같은 위치의 값끼리 곱해서 모두 더합니다. A·B = |A| x |B| x cos θ이므로 두 벡터의 길이와 사이 각도를 함께 반영해요. 클수록 가깝습니다.
  • 코사인 유사도: 내적을 두 벡터의 길이로 나눈 값, 곧 cos θ예요. 길이를 지우고 방향만 봅니다. 1이면 같은 방향, 0이면 직각이에요. 클수록 가깝습니다.
  • 유클리드 거리: 두 벡터 끝점 사이의 직선 거리예요. 작을수록 가깝습니다.

벡터의 길이는 L2 노름, 곧 각 값을 제곱해 더한 뒤 제곱근을 씌운 값이에요. 길이가 1이면 |A| = |B| = 1이라 내적은 곧 코사인이고, 유클리드 거리의 제곱도 전개하면 코사인만 남아요.

|A - B|² = |A|² + |B|² - 2 A·B
         = 1 + 1 - 2 cos θ
         = 2 - 2 cos θ

코사인이 클수록 제곱 거리가 작아지고, 제곱근은 순서를 바꾸지 않으므로 유클리드 거리 순위는 코사인 순위와 같아요.

three는 질의 세 개(A1, B1, D1)에 대해 나머지 23개 문장을 세 방법으로 정렬해 상위 6개를 나란히 출력하고, 23개 전체 순위가 같은지, 1위 문장의 제곱 유클리드 거리와 2 - 2cos를 비교해요. 마지막 줄은 276쌍 모두에서 두 값의 차이가 가장 큰 경우입니다.

py emb.py three
L2 노름 최소 1.000000 최대 1.000000

질의 A1: Redis는 데이터를 메모리에 두기 때문에 빠르다.
  내적       A2 D1 B6 B5 D2 D3
  코사인     A2 D1 B6 B5 D2 D3
  유클리드   A2 D1 B6 B5 D2 D3
  23개 순위 모두 같음: True
  1위 A2: 제곱 유클리드 0.0646246  2 - 2cos 0.0646245

질의 B1: Kafka 컨슈머 lag이 쌓인다.
  내적       A5 A6 D5 B2 D6 B4
  코사인     A5 A6 D5 B2 D6 B4
  유클리드   A5 A6 D5 B2 D6 B4
  23개 순위 모두 같음: True
  1위 A5: 제곱 유클리드 0.0660661  2 - 2cos 0.0660659

질의 D1: Redis는 명령을 하나씩 처리한다.
  내적       B6 A1 D2 B5 A2 D3
  코사인     B6 A1 D2 B5 A2 D3
  유클리드   B6 A1 D2 B5 A2 D3
  23개 순위 모두 같음: True
  1위 B6: 제곱 유클리드 0.0591981  2 - 2cos 0.0591979

276쌍에서 |제곱 유클리드 - (2 - 2cos)| 최대 4.77e-07

세 질의 모두 23개 순위가 세 방법에서 똑같아요. 제곱 유클리드와 2 - 2cos의 차이는 276쌍 전체에서 가장 큰 경우도 4.77 × 10⁻⁷로, float32 반올림 오차 수준입니다.

그러니 "코사인, 내적, 유클리드는 서로 다른 결과를 낸다"는 말은 정규화된 벡터에서는 맞지 않습니다.

실습 2: 정규화하지 않은 벡터에서는

이번에는 실험을 위해 일부러 Normalize 모듈을 뺍니다. emb.py의 raw_model이 세 모듈 가운데 Transformer와 Pooling만으로 모델을 다시 구성해요.

raw는 같은 24개 문장을 정규화 전 벡터로 바꿔 노름을 보고, 같은 순위 비교를 해요. 끝으로 질의 A1에 대해 코사인 5위인 문장 D2의 벡터를, 방향은 그대로 두고 3배로 늘려 볼게요.

py emb.py raw
정규화 전 L2 노름: 최소 4.567  최대 4.854  평균 4.682
  D4 노름 4.567  168자  94토큰
  D2 노름 4.590  175자  92토큰
  D3 노름 4.593   21자  16토큰
  C4 노름 4.775   23자  21토큰
  A4 노름 4.835   46자  29토큰
  C1 노름 4.854   25자  19토큰
노름과 토큰 수의 상관계수 -0.450
정규화 전 벡터를 직접 나눈 값과 모델 출력의 차이 최대 1.49e-08

질의 A1
  내적       A2 D1 B6 B5 A5 A4
  코사인     A2 D1 B6 B5 D2 D3
  유클리드   A2 D1 B6 B5 D2 D3

질의 B1
  내적       A5 A6 D5 B2 D6 A4
  코사인     A5 A6 D5 B2 D6 B4
  유클리드   A5 A6 D5 B2 D6 B4

질의 D1
  내적       B6 A1 A2 B5 D2 D3
  코사인     B6 A1 D2 B5 A2 D3
  유클리드   B6 A1 D2 B5 A2 D3
24개 질의 중 내적 순위가 코사인과 다른 질의: 상위 5개(순서 포함) 22개, 1위 8개
24개 질의 중 유클리드 순위가 코사인과 다른 질의: 상위 5개(순서 포함) 8개, 1위 3개

질의 A1의 코사인 5위 후보 D2를 같은 방향으로 3배 늘림
  원래: 노름 4.590  내적 9위  코사인 5위  유클리드 5위
  3배: 노름 13.769  내적 1위  코사인 5위  유클리드 23위
  • 정규화 전 벡터의 길이는 4.567~4.854로 문장마다 조금씩 달라요. 정규화 전 벡터를 직접 길이로 나눈 값과 원래 모델 출력의 차이는 1.49 × 10⁻⁸이라, Normalize 모듈이 하는 일은 길이로 나누는 것뿐입니다.
  • 코사인 순위는 정규화된 벡터의 결과와 똑같아요. 코사인은 길이를 나눠서 지우기 때문입니다.
  • 내적 순위는 달라졌어요. 24개 문장을 각각 질의로 썼을 때, 내적 상위 5개(순서 포함)가 코사인과 다른 질의가 22개였고 1위가 다른 질의도 8개였어요. 길이가 1이 아니면 2 - 2cos 관계도 성립하지 않아 유클리드도 8개 질의에서 달라졌습니다.

노름 차이가 6% 정도인데도 순위가 이만큼 흔들리는 건, 같은 묶음 안의 코사인 값이 0.89~0.91처럼 좁게 몰려 있어서 길이의 작은 차이가 각도 차이를 뒤집기 때문입니다. 0.89~0.91은 py emb.py look이 찍는 같은 주제 다른 뜻 쌍의 코사인 범위이고, 출력은 싣지 않았어요. D2를 같은 방향으로 3배 늘리면 코사인 순위는 5위 그대로인데, 내적 순위는 9위에서 1위로 올라가요.

정규화하지 않은 벡터에서 내적을 쓰면 문장의 뜻과 관계없이 길이가 긴 벡터가 상위를 차지할 수 있어요. 이 모델은 sentence-transformers 구성에 Normalize가 들어 있으니 코사인(또는 정규화한 뒤 내적)을 쓰라는 뜻이고, 정규화 없이 내적으로 학습한 모델도 있으니 다른 모델을 쓰실 때는 모델 카드와 출력 벡터의 노름을 먼저 확인해 주세요.

실습 3: Qdrant 거리 설정이 하는 일

이번에는 이 사이트에 발행된 글 54편으로 실험해 볼게요.

prep.py는 ARTICLES_DIR 디렉터리의 매니페스트(글 목록을 담은 JSON 파일)에 등록된 글에서 SVG 도식과 코드 블록을 지우고, 빈 줄로 나뉜 문단 하나를 청크(검색 단위로 잘라 둔 글 조각) 하나로 삼아요. embed는 문단마다 passage: 를, questions.jsonl의 질문 20개마다 query: 를 붙여 정규화 전 벡터로 바꾸고, numpy에서 내적과 코사인 상위 5개를 비교해요.

py prep.py
py emb.py embed
문서 54편, 문단 청크 3,200개
청크 길이: 평균 163자, 중앙값 139자, 최대 1324자
청크 3,200개 x 384차원, 512토큰 초과 2개
정규화 전 노름: 최소 4.276  최대 5.036  평균 4.657
노름과 글자 수의 상관계수 0.014
질문 20개 중 정규화 전 내적과 코사인의 상위 5개 구성이 다른 질문 20개, 1위가 다른 질문 16개

정규화 전 벡터로 내적을 쓰면 질문 20개 모두 상위 5개 구성이 코사인과 달랐고, 16개 질문은 1위부터 달랐어요.

dist는 같은 정규화 전 벡터를 Cosine, Dot, Euclid 컬렉션에 하나씩 넣고 질문 20개로 상위 5개를 검색해요. exact=True로 근사 검색(HNSW 색인)을 빼고 전체를 비교했고, 저장된 벡터는 scroll의 with_vectors=True로 다시 꺼내 노름을 잽니다. 그다음 정규화한 벡터로 같은 일을 반복해요.

py emb.py dist
[정규화 전 벡터]
  raw-cosine   저장된 벡터 3200개 노름: 최소 1.0000 최대 1.0000
  raw-dot      저장된 벡터 3200개 노름: 최소 4.2756 최대 5.0364
  raw-euclid   저장된 벡터 3200개 노름: 최소 4.2756 최대 5.0364
  Cosine 상위 5개가 numpy 코사인과 같은 질문 20/20
  Dot    상위 5개가 numpy 내적과 같은 질문 20/20
  Dot    상위 5개(순서 포함)가 Cosine과 같은 질문 0/20
  Euclid 상위 5개(순서 포함)가 Cosine과 같은 질문 4/20
[정규화한 벡터]
  norm-cosine  저장된 벡터 3200개 노름: 최소 1.0000 최대 1.0000
  norm-dot     저장된 벡터 3200개 노름: 최소 1.0000 최대 1.0000
  norm-euclid  저장된 벡터 3200개 노름: 최소 1.0000 최대 1.0000
  Cosine 상위 5개가 numpy 코사인과 같은 질문 20/20
  Dot    상위 5개가 numpy 내적과 같은 질문 20/20
  Dot    상위 5개(순서 포함)가 Cosine과 같은 질문 20/20
  Euclid 상위 5개(순서 포함)가 Cosine과 같은 질문 20/20
[질문 1의 1위 점수]
  norm-cosine  id 366  score 0.908427
  norm-dot     id 366  score 0.908427
  norm-euclid  id 366  score 0.427955
  • Cosine 컬렉션은 저장할 때 벡터를 정규화해요. 노름 4.3~5.0인 벡터를 넣었는데 꺼내 보니 3,200개 모두 1.0000이었습니다.
  • Dot과 Euclid 컬렉션은 넣은 벡터를 그대로 저장해요. 그래서 정규화 전 벡터로는 Dot 상위 5개가 Cosine과 같은 질문이 20개 중 0개였고, Euclid는 4개였습니다.
  • 정규화한 벡터를 넣으면 세 컬렉션의 상위 5개가 20개 질문 모두 같아져요.
  • 같은 1위 문단에 대해 Cosine과 Dot은 0.908427을, Euclid는 거리 0.427955를 돌려줍니다. Euclid 컬렉션에서는 점수가 작을수록 가깝습니다.

거리 함수는 컬렉션을 만들 때 정해지고, 바꾸려면 새 컬렉션을 만들어 다시 넣어야 해요. 그래서 거리 함수는 이렇게 고르시면 됩니다.

  • 모델이 정규화된 벡터를 내놓는다면: 셋 중 무엇을 골라도 순위가 같아요. Cosine을 고르면 나중에 정규화하지 않은 벡터가 섞여 들어와도 Qdrant가 정규화해 줍니다. 미리 정규화했다는 것이 확실하면 Dot도 같은 결과예요.
  • 모델이 정규화하지 않은 벡터를 내놓고 모델 카드가 코사인을 전제로 한다면: Cosine을 고릅니다. Dot을 고르면 위 실험처럼 순위가 크게 바뀌어요.
  • 모델 카드가 정규화 없는 내적을 전제로 한다면: Dot을 고르고 벡터를 정규화하지 않습니다.

정리

범위: intfloat/multilingual-e5-small(384차원), sentence-transformers 6.1.0, Qdrant 1.19.1, 한국어 문장 24개, 발행 글 54편의 문단 3,200개와 질문 20개.

  • 정규화된 벡터에서는 내적, 코사인, 유클리드의 23개 순위가 세 질의 모두 같았고, 제곱 유클리드와 2 - 2cos의 차이는 276쌍에서 최대 4.77 × 10⁻⁷이었습니다.
  • Normalize를 뺀 벡터로는 문장 질의 24개 중 22개, 문단 검색 질문 20개 모두에서 내적 상위 5개가 코사인과 달랐고, 같은 방향으로 3배 늘린 D2는 내적 9위에서 1위가 됐습니다. 다른 모델을 쓸 때는 모델 카드와 출력 벡터의 노름을 먼저 확인하세요.
  • Qdrant Cosine 컬렉션은 노름 4.3~5.0인 벡터를 저장할 때 1.0000으로 정규화했고 Dot과 Euclid는 그대로 저장해, 정규화 전 벡터로 Dot 상위 5개가 Cosine과 같은 질문은 0/20이었습니다. 거리 함수는 컬렉션을 만든 뒤 바꿀 수 없으니, 정규화하지 않는 모델이 코사인을 전제로 하면 Cosine을 고르세요.
  • 같은 1위 문단에 Cosine과 Dot은 점수 0.908427, Euclid는 거리 0.427955를 돌려줬습니다. Euclid 컬렉션의 점수는 작을수록 가깝다고 읽어 주세요.
내 링크 만들기