본문 바로가기
TYLER SONGBlog
블로그 목록
LLM Research

선형대수 1: 벡터의 내적이 왜 "유사도"가 되는가

벡터, 행렬, 내적, norm은 LLM 논문과 코드 어디에나 등장하는 기본 언어다. 오늘은 벡터를 화살표로, 행렬을 "공간을 바꾸는 변환"으로 직관적으로 이해하고, NumPy로 코사인 유사도와 L2 정규화를 직접 계산해본다. 이 개념은 임베딩 유사도 계산, attention의 핵심 연산으로 이어지므로 지금 손으로 확실히 익혀둔다.

송민성7분 읽기

오늘의 목표

LLM 관련 논문이나 코드를 읽다 보면 "임베딩 벡터", "코사인 유사도", "내적", "정규화" 같은 말이 계속 나온다. 오늘 목표는 이 단어들이 실제로 무엇을 계산하는 건지, 손으로 숫자를 넣어보고 그림으로 이해하는 것이다. 수학 공식을 외우는 게 아니라 "이게 왜 이런 뜻인지" 감을 잡는 게 목표다.

개념과 직관

벡터(vector)는 그냥 숫자들의 목록이다. [3, 4] 같은 게 벡터다. 2차원이면 화살표로 그릴 수 있다. 원점에서 (3, 4) 지점까지 뻗은 화살표라고 생각하면 된다. LLM에서 단어 하나, 문장 하나가 수백~수천 차원짜리 벡터(임베딩, embedding, 의미를 숫자로 표현한 벡터)로 표현된다. 차원이 커져서 그림으로 못 그릴 뿐, 개념은 똑같다.

벡터 덧셈과 스칼라곱: 벡터끼리 더하면 화살표를 이어붙이는 것과 같다. [1,2] + [3,1] = [4,3]. 스칼라곱(scalar multiplication, 벡터에 그냥 숫자 하나를 곱하는 것)은 화살표의 길이만 늘리거나 줄인다. 방향은 안 변한다 (음수를 곱하면 반대 방향).

내적(dot product): 두 벡터를 각 자리끼리 곱하고 다 더한 값이다.

text
dot([a1, a2], [b1, b2]) = a1*b1 + a2*b2

이게 왜 "유사도"랑 관련 있을까? 내적에는 기하적으로 이런 의미가 있다: 내적 = |벡터A의 길이| * |벡터B의 길이| * cos(두 벡터 사이 각도). 즉 두 벡터의 방향이 비슷할수록(각도가 0에 가까울수록) cos값이 1에 가까워져서 내적이 커진다. 방향이 정반대면 내적이 음수가 된다. 직각이면 내적은 0이다.

비유하자면, 내적은 "한 벡터를 다른 벡터 방향으로 투영(projection, 그림자를 떨어뜨리듯 한 방향으로 눌러 찌부러뜨리는 것)했을 때 얼마나 겹치는가"를 재는 것이다. 손전등을 벽에 수직으로 비추면 그림자가 길게 생기고, 비스듬히 비추면 그림자가 짧아지는 것과 비슷하다.

norm (벡터의 크기): 벡터 화살표의 길이를 재는 방법이다.

  • L2 norm: sqrt(a1^2 + a2^2 + ...) — 우리가 아는 일반적인 직선 거리 (피타고라스 정리).
  • L1 norm: |a1| + |a2| + ... — 각 성분의 절댓값을 그냥 다 더한 것. 격자로 된 도시에서 대각선으로 못 가고 블록 단위로만 이동할 때의 거리(맨해튼 거리)라고 생각하면 된다.

코사인 유사도(cosine similarity): 내적을 두 벡터 길이의 곱으로 나눈 값이다.

text
cosine_similarity(A, B) = dot(A, B) / (norm(A) * norm(B))

이렇게 나누면 벡터의 "길이"는 무시하고 순수하게 "방향"만 비교하게 된다. 값의 범위는 -1(정반대 방향)에서 1(완전히 같은 방향) 사이다. LLM에서 두 문장 임베딩이 "의미적으로 비슷한가"를 잴 때 이 코사인 유사도를 자주 쓴다.

행렬(matrix)을 선형변환으로 보기: 행렬은 숫자를 표로 늘어놓은 것이지만, 벡터에 행렬을 곱하면 "벡터를 다른 공간으로 옮기는 함수"처럼 작동한다. 예를 들어 회전 행렬을 곱하면 점이 원점을 중심으로 빙글 돌아간다. 이걸 "선형변환(linear transformation)"이라고 부른다. 신경망의 한 층(layer)이 하는 일이 정확히 이거다 — 입력 벡터에 행렬을 곱해서 다른 공간으로 옮기는 것.

코드로 직접 해보기

먼저 원리를 손으로 구현해보고, 그다음 NumPy 함수로 검증한다.

python
import numpy as np # 1. 내적을 직접 구현해보기 (원리 확인용) def my_dot(a, b): total = 0 for x, y in zip(a, b): total += x * y return total a = np.array([3, 4]) b = np.array([4, 3]) print(my_dot(a, b)) # 직접 짠 함수 print(np.dot(a, b)) # NumPy 함수, 값이 같아야 정상
python
# 2. norm 계산 a = np.array([3, 4]) l2 = np.sqrt(np.sum(a**2)) # 직접 계산 l2_np = np.linalg.norm(a) # NumPy 함수 (기본이 L2) l1_np = np.linalg.norm(a, ord=1) # L1 norm print(l2, l2_np) # 5.0 5.0 (3-4-5 직각삼각형) print(l1_np) # 7.0 (3 + 4)
python
# 3. 코사인 유사도 def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) a = np.array([1, 0]) b = np.array([1, 1]) c = np.array([-1, 0]) print(cosine_similarity(a, a)) # 1.0, 완전히 같은 방향 print(cosine_similarity(a, b)) # 0.707..., 45도 각도 print(cosine_similarity(a, c)) # -1.0, 정반대 방향
python
# 4. L2 정규화 (벡터를 길이 1로 만들기) def l2_normalize(v): return v / np.linalg.norm(v) v = np.array([3, 4]) v_norm = l2_normalize(v) print(v_norm) # [0.6, 0.8] print(np.linalg.norm(v_norm)) # 1.0 확인
python
# 5. 회전 행렬로 2D 점 변환 후 시각화 import matplotlib.pyplot as plt theta = np.radians(90) # 90도 회전 rotation_matrix = np.array([ [np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)] ]) point = np.array([1, 0]) rotated = rotation_matrix @ point # 행렬곱, @ 연산자 사용 print(rotated) # 대략 [0, 1]에 가까워야 함 (부동소수점 오차 있음) plt.figure(figsize=(4,4)) plt.axhline(0, color='gray', linewidth=0.5) plt.axvline(0, color='gray', linewidth=0.5) plt.quiver(0, 0, point[0], point[1], angles='xy', scale_units='xy', scale=1, color='blue', label='원래 벡터') plt.quiver(0, 0, rotated[0], rotated[1], angles='xy', scale_units='xy', scale=1, color='red', label='회전 후') plt.xlim(-2, 2) plt.ylim(-2, 2) plt.legend() plt.gca().set_aspect('equal') plt.title('90도 회전 변환') plt.savefig('rotation.png') plt.show()

오늘의 실험

두 벡터 사이 각도를 0도부터 180도까지 바꿔가며 내적과 코사인 유사도가 어떻게 변하는지 확인한다.

python
import numpy as np a = np.array([1, 0]) # 기준 벡터, x축 방향 고정 for deg in [0, 30, 45, 60, 90, 120, 150, 180]: theta = np.radians(deg) b = np.array([np.cos(theta), np.sin(theta)]) # 각도 deg 만큼 회전된 단위벡터 dot_val = np.dot(a, b) cos_sim = dot_val / (np.linalg.norm(a) * np.linalg.norm(b)) print(f"각도 {deg:>3}도 | 내적 {dot_val:.3f} | 코사인유사도 {cos_sim:.3f}")

결과를 표로 기록하면서 이런 것을 관찰한다: 각도가 좁을수록(0도에 가까울수록) 내적과 코사인 유사도가 1에 가까워지고, 90도에서 정확히 0이 되고, 180도에 가까워질수록 -1에 가까워진다. 단위벡터(길이가 1인 벡터)끼리는 내적 값과 코사인 유사도 값이 똑같다는 것도 확인해본다 — 길이가 이미 1이라 나눠도 값이 안 바뀌기 때문이다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM 내부에서 단어와 문장은 전부 고차원 벡터(임베딩)로 표현된다. 두 문장이 의미적으로 비슷한지 비교하거나, 검색·추천에서 "가장 관련 있는 문서"를 찾을 때 코사인 유사도가 기본 도구로 쓰인다. 또한 나중에 배울 attention 메커니즘의 핵심 연산이 바로 벡터들 사이의 내적(query와 key의 내적)이다. 즉 오늘 배운 "내적 = 방향이 얼마나 비슷한가"라는 감각이 나중에 "이 단어가 저 단어를 얼마나 주목해야 하는가"를 이해하는 데 그대로 재사용된다. 행렬을 선형변환으로 보는 관점도 신경망의 한 층이 하는 일을 이해하는 데 필수적이다.

초보자가 막히는 지점

  • 벡터 방향 헷갈림: [3, 4]를 좌표 (3,4)로 그리는 건 쉬운데, 100차원 벡터는 그림으로 상상이 안 된다. 그럴 땐 2D, 3D에서 성립하는 직관(각도, 길이, 투영)이 고차원에서도 수학적으로 똑같이 정의된다고 믿고 넘어가면 된다. 눈으로 볼 수 없을 뿐 계산 방식은 동일하다.
  • 내적과 행렬곱 차원 에러: np.dot(a, b)는 두 벡터의 길이(요소 개수)가 같아야 한다. ValueError: shapes not aligned 같은 에러가 뜨면 a.shape, b.shape을 찍어서 차원부터 확인한다.
  • 코사인 유사도와 내적을 혼동: 벡터 길이가 다르면 내적 값 자체는 커질 수 있지만(단순히 벡터가 길어서), 방향이 다르면 코사인 유사도는 작을 수 있다. 두 개가 다른 것을 잰다는 걸 실험 결과표로 직접 확인해볼 것.
  • 회전 행렬 부호 실수: sin에 마이너스 붙는 위치를 헷갈려서 반대 방향으로 돌아가는 경우가 흔하다. rotation_matrix @ point처럼 행렬을 왼쪽에 두고 벡터를 오른쪽에 곱하는 순서도 주의(순서를 바꾸면 에러가 나거나 다른 결과가 나온다).
  • @ 연산자 vs \*: *는 요소별 곱(element-wise), @는 행렬곱(matrix multiplication)이다. 둘을 헷갈리면 결과가 완전히 다르게 나오니 항상 어떤 연산인지 의식하고 쓴다.

알고리즘 (병행 1시간)

오늘은 LeetCode Easy 1문제, Medium 1문제를 푼다 (배열/벡터 감각을 기르는 문제로 아무거나 선택 가능, 예: Two Sum류의 Easy, Rotate Array나 Matrix 관련 Medium을 추천).

풀 때 다음을 기록으로 남긴다:

  • 접근: 처음에 어떤 방식으로 풀려고 했는지 (브루트포스인지, 더 나은 방법이 떠올랐는지)
  • 시간복잡도: 내가 짠 코드가 대략 O(n)인지 O(n^2)인지 스스로 추정해보고 맞는지 확인
  • 틀린 이유: 틀렸다면 인덱스 범위 문제였는지, 엣지 케이스(빈 배열, 원소 1개 등)를 놓쳤는지, 논리 자체가 틀렸는지 구체적으로 적기

이 기록은 나중에 실력이 어디서 막히는지 패턴을 파악하는 데 쓰인다.

© 2026 Tyler Song