학습 자료

코사인 유사도 — 「뜻이 비슷하다」를 컴퓨터가 재는 법


Article

def cos(a, b):
    return sum(x * y for x, y in zip(a, b))   # 길이가 1 이라 내적 = 코사인

그리고 옆에 「코사인 유사도」라고 적혀 있다. 여기서 막히는 게 정상이다. 한 줄 안에 모르는 말이 세 개 들어 있다 — 코사인 · 내적 · 길이 1.

이 글은 그 한 줄을 처음부터 끝까지 푼다. 수학 기호는 안 쓴다. 곱셈과 덧셈, 그리고 초등학교 좌표평면이면 충분하다.


1. 먼저 「비슷하다」를 숫자로 만든다

컴퓨터는 문장을 못 읽는다. 숫자만 다룬다. 그래서 문장을 숫자 몇 개로 바꿔야 한다.

진짜 임베딩 모델은 숫자를 384개 뱉는데, 384개는 그릴 수가 없다. 그러니 숫자 두 개짜리 장난감부터 만든다. 원리는 똑같다.

낱말을 세어 본다

축을 두 개만 정한다. 「환불」이 몇 번 나왔나와 「성분」이 몇 번 나왔나다.

글 세 개를 숫자 두 개로 적으면
글내용「환불」「성분」숫자 두 개
A환불 환불 환불 성분3번1번(3, 1)
BA 를 그대로 두 번 이어 붙인 글6번2번(6, 2)
C환불 성분 성분 성분1번3번(1, 3)

(3, 1) 처럼 순서가 있는 숫자 묶음을 벡터라고 부른다. 어려운 말이 아니다. 그냥 숫자를 나란히 적어 둔 것이다.

그러면 그림이 그려진다

숫자가 두 개면 좌표평면 위의 점이 된다. 가로가 첫 숫자, 세로가 둘째 숫자다.

  4 |
    |
  3 |     C
    |
  2 |                         B
    |
  1 |             A
    |
  0 +------------------------------>
      0   1   2   3   4   5   6   7

      가로 = 「환불」이 나온 횟수
      세로 = 「성분」이 나온 횟수

2. 점 두 개가 「가깝다」 — 재는 방법이 두 개다

점이 생겼으니 이제 「가깝다」를 잴 수 있다. 그런데 방법이 두 가지다.

가까움을 재는 두 가지 방법

① 자로 잰 거리

직선 거리

  • 두 점 사이에 자를 대고 잰다
  • 우리가 「거리」라고 하면 보통 이것
  • 점이 어디 있는지를 본다

② 원점에서 본 각도

코사인이 여기 있다

  • 원점(0, 0)에서 각 점으로 선을 긋는다
  • 그 두 선이 벌어진 각도를 잰다
  • 어느 쪽을 보는지만 본다. 얼마나 멀리 있는지는 안 본다

둘 다 그럴듯하다. 그런데 답이 다르게 나온다. 그게 이 글의 핵심이다.


3. 🔴 거리로 재면 답이 뒤집힌다

우리 세 글에 실제로 자를 대 보자.

거리는 가로 차이와 세로 차이로 직각삼각형을 만들고 빗변을 구하는 것이다. 피타고라스다 — 가로² + 세로² = 빗변².

손으로 재 본다
  1. 01

    A(3, 1) 와 B(6, 2) 의 거리

    가로 차이 6 - 3 = 3 · 세로 차이 2 - 1 = 1

    3 x 3 + 1 x 1 = 10
    제곱근 -> 3.162
  2. 02

    A(3, 1) 와 C(1, 3) 의 거리

    가로 차이 1 - 3 = -2 · 세로 차이 3 - 1 = 2

    2 x 2 + 2 x 2 = 8
    제곱근 -> 2.828
A 에서 잰 거리 (작을수록 가깝다)
A ↔ C (다른 얘기)2.828
A ↔ B (같은 얘기)3.162

C 가 더 가깝다고 나온다. 그런데 이건 말이 안 된다.

왜 그런가 — 글이 길어지면 점이 멀리 간다

같은 얘기를 두 번 쓰면 낱말 수가 두 배가 된다. 그러면 점도 원점에서 두 배 멀리 간다. 뜻은 그대로인데 위치만 바뀐 것이다.

길이가 점수에 섞인다
AB (A 를 두 번 씀)무엇이 바뀌었나
숫자(3, 1)(6, 2)두 배가 됐다
뜻환불 얘기환불 얘기안 바뀌었다
원점에서의 거리3.1626.325두 배가 됐다
원점에서 본 방향오른쪽 아래로 완만히오른쪽 아래로 완만히안 바뀌었다

바뀐 건 길이뿐이고, 방향은 그대로다. 그러면 방향만 보면 되지 않나 — 그게 두 번째 방법이다.


4. 각도로 재면 답이 맞는다

원점에서 각 점으로 선을 그어 본다.

  4 |
    |
  3 |     C
    |    .
  2 |   ..                 ...B
    |   .             .....
  1 |  ..      ...A...
    |  .  .....
  0 +-O...------------------------->
      0   1   2   3   4   5   6   7

A 와 B 가 같은 선 위에 있다. 당연하다 — B 는 A 를 두 배 한 것이니까. 원점에서 보면 정확히 같은 방향이다. 두 선이 벌어진 각도가 0도다.

C 로 가는 선은 훨씬 위로 꺾여 있다. 53.13도 벌어져 있다.

원점에서 본 각도 (작을수록 비슷하다)
A ↔ B (같은 얘기)0도
A ↔ C (다른 얘기)53.13도

5. 코사인 — 이름에 겁먹지 않는다

여기까지 「각도」라고만 했다. 그런데 코드에서는 각도(도)가 안 나오고 0.8597 같은 게 나온다.

코사인은 각도를 −1 에서 1 사이의 숫자로 바꿔 주는 자다. 그게 전부다.

각도를 코사인으로 바꾸면
벌어진 각도코사인 값뜻
0도+1.000완전히 같은 방향. 제일 비슷하다
30도+0.866거의 같은 방향
45도+0.707꽤 벌어졌다
60도+0.500많이 벌어졌다
90도0.000직각. 아무 상관 없다
120도−0.500반대쪽을 본다
180도−1.000정반대 방향

표가 하나 있으면 끝이다. 삼각함수를 배울 필요가 없다. 「0도면 1, 90도면 0, 180도면 −1」 이 세 개만 기억하면 나머지는 그 사이다.

왜 각도(도)를 그냥 안 쓰고 굳이 코사인으로 바꾸나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

첫째, 방향이 뒤집혀서 헷갈린다. 각도는 작을수록 비슷하고, 코사인은 클수록 비슷하다. 검색 결과를 「점수가 높은 순」으로 줄 세우려면 클수록 좋은 쪽이 편하다.

둘째, 각도를 구하는 게 더 비싸다. 아래 6절에서 보겠지만 코사인은 곱셈과 덧셈만으로 나온다. 각도로 바꾸려면 거기서 역삼각함수를 한 번 더 돌려야 한다. 상품 200개 × 고객 300명을 비교하는데 쓸데없는 계산을 6만 번 더 하는 셈이다.

셋째, 어차피 순서만 필요하다. 코사인이 클수록 각도가 작다는 관계는 절대 안 뒤집힌다. 그러니 각도로 바꿔도 순위가 똑같다. 안 바꾸면 그만이다.


6. 각도를 어떻게 구하나 — 사실 안 구한다

각도기를 대는 게 아니다. 곱해서 더한다. 그게 끝이다.

내적 — 짝끼리 곱해서 다 더한다

A(3, 1) 와 C(1, 3) 의 내적
  1. 01

    첫 번째 숫자끼리 곱한다

    3 × 1 = 3

  2. 02

    두 번째 숫자끼리 곱한다

    1 × 3 = 3

  3. 03

    다 더한다

    3 + 3 = 6 ← 이게 내적이다

코드로는 이 한 줄이다. zip 이 짝을 지어 주고, sum 이 다 더한다.

sum(x * y for x, y in zip(a, b))

코사인은 내적을 길이로 나눈 것이다

내적만으로는 안 된다. 길이가 아직 섞여 있기 때문이다. 그래서 각 벡터의 길이로 나눠서 길이를 지운다.

                   내적
   코사인  =  ───────────────
              A 의 길이 × B 의 길이

길이는 아까 거리 잴 때 쓴 피타고라스와 같다. 원점에서 그 점까지의 거리다.

A(3, 1) 와 C(1, 3) 의 코사인을 손으로
  1. 01

    내적

    3×1 + 1×3 = 6

  2. 02

    A 의 길이

    3×3 + 1×1 = 10 → 제곱근 → 3.1623

  3. 03

    C 의 길이

    1×1 + 3×3 = 10 → 제곱근 → 3.1623

  4. 04

    나눈다

    6 ÷ (3.1623 × 3.1623) = 6 ÷ 10 = 0.6

  5. 05

    표에서 찾아보면

    0.6 은 0.5(60도)와 0.707(45도) 사이다. 실제로 53.13도다 — 앞에서 그림으로 본 그 각도다

A 와 B 로도 해 보면 내적 3×6 + 1×2 = 20, 길이는 3.1623 과 6.3246, 나누면 20 ÷ 20 = 1.0 이다. 0도. 정확히 같은 방향이라는 뜻이다.

🔴 왜 「곱해서 더하고 길이로 나눈 것」이 하필 각도가 되나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

이건 증명이 필요한 이야기고, 몰라도 코드를 쓰는 데 아무 지장이 없다. 실제로 실무에서 이 증명을 떠올리는 사람은 없다.

감만 잡고 싶다면 이렇게 보면 된다 — 두 벡터의 방향이 같을수록 같은 자리의 숫자가 같이 커지고 같이 작아진다. 곱해서 더하면 그 값이 커진다.

반대로 한쪽이 큰 자리에서 다른 쪽이 작으면 곱이 작아지고, 부호가 반대면 음수가 나와 합을 깎는다. 곱해서 더한 값이 곧 「같이 움직인 정도」다.

그 값을 길이로 나누는 이유는 3절에서 본 그대로다 — 글이 길어서 커진 몫을 빼기 위해서다.


7. 정규화 — 나눗셈을 미리 없앤다

여기서 교안의 그 옵션이 나온다.

encode_kwargs={"normalize_embeddings": True}

정규화(normalize)는 「길이를 1로 맞춘다」는 뜻이다. 방법은 간단하다 — 각 숫자를 그 벡터의 길이로 나눈다.

길이로 나누면 어떻게 되나

A(3, 1) · B(6, 2) · C(1, 3)

A = (3, 1)
길이 3.1623
→ (0.9487, 0.3162)
B = (6, 2)
길이 6.3246
→ (0.9487, 0.3162)
C = (1, 3)
길이 3.1623
→ (0.3162, 0.9487)

길이가 1인 점들은 전부 반지름 1짜리 원 위에 올라간다.

1.0 | oooooooo
    |        oCooooo
    |              oooo
    |                 ooo
    |                   ooo
    |                     ooo
    |                       oo
    |                        oo
    |                         o*
    |                          o
    |                          oo
    |                           o
0.0 +-O--------------------------------->
      0                         1

      * = A 와 B 가 겹친 자리 (0.9487, 0.3162)
      C = (0.3162, 0.9487)

그래서 나눗셈이 사라진다

코사인은 「내적 ÷ (길이 × 길이)」인데, 두 길이가 모두 1이면 분모가 1이다. 1로 나누는 건 안 나누는 것과 같다.

정규화를 하느냐 마느냐

안 하면

매번 나눈다

  • 곱해서 더하고 (384번)
  • A 의 길이를 구하고 (384번 더)
  • B 의 길이를 구하고 (384번 더)
  • 나눈다

미리 해 두면

이걸 쓴다

  • 곱해서 더한다 (384번)
  • 끝
  • 저장할 때 딱 한 번 길이로 나눠 뒀기 때문
  • 비교는 몇만 번 하니 미리 해 두는 쪽이 압도적으로 싸다

이제 교안의 그 한 줄이 읽힌다.

def cos(a, b):
    return sum(x * y for x, y in zip(a, b))   # 길이가 1 이라 내적 = 코사인

주석까지 그대로 읽으면 — **「미리 길이를 1로 맞춰 뒀으니, 나누는 단계를 건너뛰고 곱해서 더하기만 하면 그게 코사인이다」**는 뜻이다.


8. 384차원은 상상하지 않아도 된다

지금까지 숫자 두 개로 했다. 진짜는 384개다. 여기서 많이들 겁을 먹는다.

상상할 필요가 없다. 계산이 똑같기 때문이다.

숫자가 몇 개든 하는 일은 같다
숫자 개수부르는 이름그릴 수 있나코사인 계산
2개2차원종이에 그린다곱셈 2번 + 덧셈
3개3차원입체로 그린다곱셈 3번 + 덧셈
4개4차원못 그린다곱셈 4번 + 덧셈
384개384차원못 그린다곱셈 384번 + 덧셈
숫자가 384개여도 절차는 이 셋뿐이다
  1. 짝끼리 곱한다1번째끼리, 2번째끼리, … 384번째끼리
  2. 다 더한다384개를 합친다
  3. 그게 코사인길이가 1이니 나눌 게 없다

9. 진짜 모델로 재 본다

이제 4교시의 그 숫자를 읽을 준비가 됐다. 실제로 돌린 결과다.

intfloat/multilingual-e5-small · CPU
  • 차원 384 길이 1.000000
  • 앞 8개: [0.0596, -0.0368, -0.0817, -0.0633, 0.0628, -0.0052, 0.0083, 0.032]
  • 질문: 환불하고 싶은데 어떻게 하나요
  • ↔ 교환·반품 문장 코사인 0.8597 각도 30.72도
  • ↔ 배송 문장 코사인 0.8451 각도 32.32도
  • ↔ 비타민C 문장 코사인 0.8142 각도 35.49도
  • ↔ 자기 자신 코사인 1.0000 각도 0.00도

자기 자신과는 정확히 1.0 · 0도가 나온다. 5절의 표와 맞는다. 그리고 순서가 맞다 — 정답인 교환·반품 문장이 1등이다.

🔴 그런데 셋이 거의 붙어 있다

「환불하고 싶은데 어떻게 하나요」 와의 코사인
교환·반품 문장 (정답)0.8597
배송 문장0.8451
비타민C 문장 (무관)0.8142

막대 셋이 거의 같은 높이다. 각도로 바꿔 보면 더 잘 보인다 — 30.72도와 35.49도. 정답과 완전히 무관한 문장의 차이가 겨우 4.8도다.

왜 무관한 문장까지 0.8 이 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

넷 다 같은 언어 · 같은 도메인 · 비슷한 길이의 문장이기 때문이다. 모델 입장에서는 전부 「한국어로 쓴 화장품 쇼핑몰 문장」이다.

8절에서 본 것처럼 384개 축은 사람이 읽을 수 없다. 그 축들 중 상당수가 「한국어인가」 · 「존댓말인가」 · 「상품 얘기인가」 같은 공통점에 반응한다. 그러니 다 같은 쪽을 본다.

모델마다 이 폭이 다르다. 어떤 모델은 0.2~0.9 로 넓게 벌리고, e5 계열은 좁게 모인다. 좁다고 나쁜 모델이 아니다 — 순위만 맞으면 된다.

그리고 모델은 낱말을 세지 않는다

1절의 장난감에서는 같은 글을 두 번 쓰면 코사인이 정확히 1.0 이었다. 진짜 모델로 해 보면 다르다.

같은 문장을 두 번 이어 붙여서 재면
  • "환불하고 싶은데 어떻게 하나요"
  • "환불하고 싶은데 어떻게 하나요 환불하고 싶은데 어떻게 하나요"
  • 코사인 0.9880
  • (낱말을 세는 장난감이었다면 정확히 1.0000 이 나왔을 것이다)

10. 전체를 한 장으로

「비슷한 뜻 찾기」 전체 흐름
  1. 문장환불하고 싶은데 어떻게 하나요
  2. 임베딩 모델뜻을 보고 숫자 384개를 만든다
  3. 벡터[0.0596, -0.0368, …] · 길이 1
  4. 곱해서 더한다저장해 둔 문서 벡터 하나하나와
  5. 코사인 점수0.8597 · 0.8451 · 0.8142 …
  6. 큰 순서대로 줄 세운다상위 몇 개만 꺼낸다
이 글에서 나온 말들
  • 벡터로 뜻을 잰다
    • 벡터순서 있는 숫자 묶음. (3, 1) 또는 384개
    • 차원숫자가 몇 개인가. 384차원 = 숫자 384개
    • 내적짝끼리 곱해서 다 더한다. sum(x * y ...)
    • 길이원점에서 그 점까지의 거리. 제곱해서 더하고 제곱근
    • 정규화각 숫자를 길이로 나눠 길이를 1로 만든다
    • 코사인 유사도내적 ÷ (길이 × 길이). 길이가 1이면 내적만

자주 막히는 것

코사인 값이 음수도 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

수학적으로는 −1 까지 나올 수 있다. 90도를 넘어 반대쪽을 보면 음수다.

그런데 우리 데이터에서는 거의 안 나온다. 9절에서 본 대로 한국어 문장들은 전부 좁은 범위에 모여 있어서 90도를 넘는 일이 드물다.

그러니 실무에서는 「0.7~1.0 사이에서 순위 싸움」이라고 생각하면 대체로 맞다.

그럼 유클리드 거리는 아예 안 쓰나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

쓴다. 다만 길이가 뜻을 갖는 데이터에서 쓴다 — 예를 들어 키와 몸무게로 사람을 비교할 때는 「크다/작다」가 정보다. 거기서 길이를 지우면 안 된다.

🔴 그리고 재미있는 사실 하나 — 길이를 1로 맞춰 두면 둘이 사실상 같은 자가 된다. 원 위의 점끼리는 각도가 벌어질수록 직선 거리도 정확히 같이 멀어지기 때문이다.

그래서 정규화한 벡터에서는 「코사인으로 1등인 것」과 「거리로 1등인 것」이 항상 같다. 벡터 DB 가 둘 다 지원하면서도 대개 같은 결과를 주는 이유가 이것이다.

벡터 하나를 열어 보면 뭘 알 수 있나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

아무것도 알 수 없다. [0.0596, -0.0368, ...] 는 사람에게 아무 말도 안 한다. 8절에서 본 대로 384개 축에 이름이 없기 때문이다.

그리고 거꾸로 돌려서 원래 문장을 복원할 수도 없다. 임베딩은 한쪽으로만 가는 길이다.

🔴 이게 3일차 마스킹이 중요한 이유다. 후기에 전화번호가 들어 있으면 벡터로 만들기 전에 지워야 한다. 벡터가 된 다음에는 지울 수도, 들어 있는지 확인할 수도 없다.

「환불」과 「반품」은 글자가 하나도 안 겹치는데 어떻게 이어지나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

실제로 확인해 보면 set("환불") & set("반품") 은 빈 집합이다. 글자로 세는 검색은 이 둘을 절대 못 잇는다.

임베딩 모델은 글자를 안 본다. 학습할 때 「환불」과 「반품」이 비슷한 문맥에 나오는 걸 수없이 봤고, 그래서 비슷한 방향의 숫자를 내놓게 됐다.

그 덕에 4교시의 결과가 뒤집힌다 — 글자 겹침으로 재면 정답인 교환·반품 문장이 꼴찌(0.06)고 무관한 비타민C 문장이 1등(0.11)이다. 벡터로 재면 순서가 바로 선다.

384개를 다 곱하면 느리지 않나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

안 느리다. 곱셈 384번은 요즘 컴퓨터에게 아무것도 아니다. 상품 200개와 비교해도 76,800번인데 눈 깜짝할 사이다.

🔴 진짜 느린 건 벡터를 만드는 쪽이다. 2일차에서 재 보면 모델을 메모리에 올리는 데만 6~10초가 걸리고, 조각 1,560개를 벡터로 만드는 데 20초가 걸린다.

만드는 건 비싸고 비교하는 건 싸다. 그래서 미리 다 만들어 DB 에 넣어 두고, 질문이 들어올 때는 질문 하나만 벡터로 만들어 비교한다.


요약

한 줄씩
질문답
벡터가 뭔가순서 있는 숫자 묶음. 숫자가 둘이면 평면 위의 점이 된다
왜 숫자로 바꾸나컴퓨터는 글자를 못 읽는다. 숫자여야 가까움을 계산할 수 있다
🔴 왜 거리를 안 쓰나글이 길면 점이 멀리 간다. 뜻은 그대로인데 점수가 달라진다
그래서 뭘 쓰나원점에서 본 각도. 길이를 무시하고 방향만 본다
코사인이 뭔가각도를 −1~1 숫자로 바꾸는 자. 0도=1 · 90도=0 · 180도=−1
어떻게 구하나짝끼리 곱해서 다 더하고(내적), 두 길이로 나눈다
🔴 정규화미리 길이를 1로 맞춰 둔다 → 나눗셈이 사라진다 → sum(x * y ...) 한 줄
384차원숫자가 384개라는 뜻. 상상 안 해도 된다 — 곱셈이 384번일 뿐
🔴 0.8597 은 높은가모른다. 이 모델은 무관한 문장도 0.81 을 준다. 순위만 본다
문턱값을 정해도 되나안 된다. 0.7 이상만 고르면 전부 통과한다

이 글은 2일차 4교시(「글자 대신 뜻을 잰다」)의 6절과 7절을 풀어 쓴 것이다. 같은 숫자를 쓰고 있으니 교안을 다시 열어 보면 그때는 읽힐 것이다.

다음 시간(5교시)에는 이 벡터를 DB 에 넣는 문제가 나온다. 숫자 384개를 SQLite 에 어떻게 저장할 것인가 — 그리고 왜 소수점을 잘라도 되는가.

Share
  • 코사인 유사도
  • 임베딩
  • 벡터
  • RAG
  • 입문