코사인 유사도 — 「뜻이 비슷하다」를 컴퓨터가 재는 법
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) |
| B | A 를 그대로 두 번 이어 붙인 글 | 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. 🔴 거리로 재면 답이 뒤집힌다
우리 세 글에 실제로 자를 대 보자.
거리는 가로 차이와 세로 차이로 직각삼각형을 만들고 빗변을 구하는 것이다. 피타고라스다 — 가로² + 세로² = 빗변².
- 01
A(3, 1) 와 B(6, 2) 의 거리
가로 차이
6 - 3 = 3· 세로 차이2 - 1 = 13 x 3 + 1 x 1 = 10 제곱근 -> 3.162 - 02
A(3, 1) 와 C(1, 3) 의 거리
가로 차이
1 - 3 = -2· 세로 차이3 - 1 = 22 x 2 + 2 x 2 = 8 제곱근 -> 2.828
C 가 더 가깝다고 나온다. 그런데 이건 말이 안 된다.
왜 그런가 — 글이 길어지면 점이 멀리 간다
같은 얘기를 두 번 쓰면 낱말 수가 두 배가 된다. 그러면 점도 원점에서 두 배 멀리 간다. 뜻은 그대로인데 위치만 바뀐 것이다.
| A | B (A 를 두 번 씀) | 무엇이 바뀌었나 | |
|---|---|---|---|
| 숫자 | (3, 1) | (6, 2) | 두 배가 됐다 |
| 뜻 | 환불 얘기 | 환불 얘기 | 안 바뀌었다 |
| 원점에서의 거리 | 3.162 | 6.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도 벌어져 있다.
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. 각도를 어떻게 구하나 — 사실 안 구한다
각도기를 대는 게 아니다. 곱해서 더한다. 그게 끝이다.
내적 — 짝끼리 곱해서 다 더한다
- 01
첫 번째 숫자끼리 곱한다
3 × 1 = 3 - 02
두 번째 숫자끼리 곱한다
1 × 3 = 3 - 03
다 더한다
3 + 3 = 6← 이게 내적이다
코드로는 이 한 줄이다. zip 이 짝을 지어 주고, sum 이 다 더한다.
sum(x * y for x, y in zip(a, b))
코사인은 내적을 길이로 나눈 것이다
내적만으로는 안 된다. 길이가 아직 섞여 있기 때문이다. 그래서 각 벡터의 길이로 나눠서 길이를 지운다.
내적
코사인 = ───────────────
A 의 길이 × B 의 길이
길이는 아까 거리 잴 때 쓴 피타고라스와 같다. 원점에서 그 점까지의 거리다.
- 01
내적
3×1 + 1×3= 6 - 02
A 의 길이
3×3 + 1×1 = 10→ 제곱근 → 3.1623 - 03
C 의 길이
1×1 + 3×3 = 10→ 제곱근 → 3.1623 - 04
나눈다
6 ÷ (3.1623 × 3.1623)=6 ÷ 10= 0.6 - 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번 + 덧셈 |
- 짝끼리 곱한다1번째끼리, 2번째끼리, … 384번째끼리
- 다 더한다384개를 합친다
- 그게 코사인길이가 1이니 나눌 게 없다
9. 진짜 모델로 재 본다
이제 4교시의 그 숫자를 읽을 준비가 됐다. 실제로 돌린 결과다.
- 차원 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등이다.
🔴 그런데 셋이 거의 붙어 있다
막대 셋이 거의 같은 높이다. 각도로 바꿔 보면 더 잘 보인다 — 30.72도와 35.49도. 정답과 완전히 무관한 문장의 차이가 겨우 4.8도다.
왜 무관한 문장까지 0.8 이 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
넷 다 같은 언어 · 같은 도메인 · 비슷한 길이의 문장이기 때문이다. 모델 입장에서는 전부 「한국어로 쓴 화장품 쇼핑몰 문장」이다.
8절에서 본 것처럼 384개 축은 사람이 읽을 수 없다. 그 축들 중 상당수가 「한국어인가」 · 「존댓말인가」 · 「상품 얘기인가」 같은 공통점에 반응한다. 그러니 다 같은 쪽을 본다.
모델마다 이 폭이 다르다. 어떤 모델은 0.2~0.9 로 넓게 벌리고, e5 계열은 좁게 모인다. 좁다고 나쁜 모델이 아니다 — 순위만 맞으면 된다.
그리고 모델은 낱말을 세지 않는다
1절의 장난감에서는 같은 글을 두 번 쓰면 코사인이 정확히 1.0 이었다. 진짜 모델로 해 보면 다르다.
- "환불하고 싶은데 어떻게 하나요"
- "환불하고 싶은데 어떻게 하나요 환불하고 싶은데 어떻게 하나요"
- 코사인 0.9880
- (낱말을 세는 장난감이었다면 정확히 1.0000 이 나왔을 것이다)
10. 전체를 한 장으로
- 문장환불하고 싶은데 어떻게 하나요
- 임베딩 모델뜻을 보고 숫자 384개를 만든다
- 벡터[0.0596, -0.0368, …] · 길이 1
- 곱해서 더한다저장해 둔 문서 벡터 하나하나와
- 코사인 점수0.8597 · 0.8451 · 0.8142 …
- 큰 순서대로 줄 세운다상위 몇 개만 꺼낸다
- 벡터로 뜻을 잰다
- 벡터
- 차원
- 내적
- 길이
- 정규화
- 코사인 유사도
자주 막히는 것
코사인 값이 음수도 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
수학적으로는 −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 에 어떻게 저장할 것인가 — 그리고 왜 소수점을 잘라도 되는가.