RAG 검색기의 로그를 열어 보면 자주 마주치는 화면이 있습니다. 질의 하나에 대해 상위 10개 문서의 코사인 유사도가 0.834, 0.831, 0.828, 0.826, 0.821, ... 하는 식으로 소수 셋째 자리에서만 갈립니다. 그래서 「유사도 0.75 미만은 버린다」는 문턱을 두면 아무것도 안 걸러지고, 0.83으로 올리면 정답까지 같이 날아갑니다. 지난 글에서 무작위 초평면 해싱의 recall을 확률로 계산할 때 「데이터의 각도 분포를 먼저 보라」고 적었는데, 그 각도 분포 자체가 한쪽으로 쏠려 있으면 어떤 인덱스를 얹어도 순위가 흐려집니다.
이 글은 그 쏠림에 이름을 붙이고, 재는 법을 정하고, 펴는 변환 하나를 유도한 다음, 그 변환이 무엇을 대가로 치르는지까지 숫자로 확인합니다.
코사인은 원점에서 재는 각이다
먼저 짚을 것이 하나 있습니다. 이방성(anisotropy)은 점 구름이 모든 방향으로 고르게 퍼져 있지 않고 특정 방향으로 몰려 있는 성질입니다. 반대말이 등방성이고, 등방적인 구름은 어느 방향에서 보나 같은 모양입니다.
임베딩이 이방적일 때 코사인 유사도가 왜 망가지는지는 2차원 그림 하나로 끝납니다. 내적과 코사인을 다룬 글에서 코사인 유사도는
로 정의했습니다. 여기서 는 원점에서 두 벡터를 바라봤을 때의 각입니다. 그러니까 코사인 유사도가 재는 것은 두 점 사이의 거리가 아니라, 원점이라는 한 자리에서 두 점을 봤을 때 벌어지는 각입니다.
왼쪽과 오른쪽의 점 구름은 같은 구름입니다. 좌표를 통째로 평행이동했을 뿐이라 점들 사이의 거리는 하나도 안 변했습니다. 그런데 표시한 두 점의 각은 에서 로 벌어졌고, 코사인은 0.97에서 0.69로 떨어졌습니다.
원인은 하나입니다. 왼쪽은 원점이 구름 바깥에 있어서 모든 점이 원점에서 볼 때 「비슷한 방향」에 놓입니다. 구름이 원점에서 멀수록 그 각은 더 작아지고, 결국 무관한 두 점의 코사인도 1에 가까워집니다. 오른쪽은 원점이 구름 한가운데에 있어서 점들이 사방으로 흩어져 보입니다.
임베딩 벡터의 평균이 0이 아니면 이 일이 그대로 일어납니다. 언어 모델의 임베딩은 대부분 평균이 0에서 꽤 떨어져 있고, 그 평균 벡터의 길이가 개별 벡터 길이에 견줄 만큼 큰 경우도 흔합니다. 그러면 모든 벡터가 그 평균 방향 근처의 좁은 원뿔 안에 놓입니다.
공분산 행렬로 쏠림을 재기
「몰려 있다」를 눈으로 보는 것이 아니라 숫자로 재려면 공분산 행렬이 필요합니다. 벡터 개를 행으로 쌓은 에 대해
이 공분산 행렬입니다. 는 벡터들의 평균입니다. 는 대칭이고 양반정치(positive semi-definite)이므로 대칭 행렬의 직교 대각화에 따라
로 쪼갤 수 있습니다. 의 열이 서로 직교하는 축(고유벡터)이고, 는 그 축 방향으로 데이터가 퍼진 정도(분산)입니다. 고윳값과 고유벡터를 다룬 글에서 세운 그대로입니다.
여기서 쏠림을 재는 값 둘이 나옵니다.
설명 분산비는 축 하나가 전체 분산에서 차지하는 몫, 즉 입니다. 상위 몇 축의 값을 더해 누적으로 쓰는 것이 보통입니다.
참여비(participation ratio)는 「실질적으로 몇 개 축이 일하고 있는가」를 하나의 수로 답합니다.
고윳값이 전부 같으면 이 값이 정확히 가 되고, 한 축에만 몰려 있으면 1에 가까워집니다. 그래서 유효 차원이라고 읽습니다. 분자는 합의 제곱, 분모는 제곱의 합이라 코시–슈바르츠 부등식이 를 보장합니다.
64차원 벡터 4,000개를 만들어 재 보면 이렇습니다.
64개 축이 있는데 분산의 절반이 축 두 개에 있고, 유효 차원은 6.1입니다. 「64차원 임베딩」이라고 부르지만 실제로 데이터가 쓰는 방향은 여섯 개 남짓이라는 뜻입니다.
원인은 둘이고 처방도 둘이다
쏠림을 만드는 것은 서로 다른 두 가지입니다.
| 원인 | 재는 법 | 처방 |
|---|---|---|
| 평균이 0이 아니다 | 를 벡터의 평균 길이와 비교 | 평균 빼기 |
| 몇 축의 분산이 유독 크다 | 설명 분산비, 유효 차원 | whitening 또는 상위 축 제거 |
위 데이터는 평균 벡터의 길이가 21.8이고 개별 벡터의 평균 길이가 24.6입니다. 벡터 길이의 거의 전부가 「모두가 공유하는 방향」인 셈입니다. 여기에 상위 세 축의 과대 분산이 겹쳐 있습니다.
평균 빼기는 한 줄입니다. 위 그림의 오른쪽 패널이 그 결과이고, 원점을 구름 한가운데로 옮기는 것 말고는 아무것도 안 합니다.
whitening(백색화)은 한 걸음 더 갑니다. 평균을 뺀 데이터에 행렬 하나를 곱해 모든 축의 분산을 1로, 축들 사이 상관을 0으로 만드는 변환입니다. 이름은 모든 주파수가 같은 세기로 섞인 백색 잡음에서 왔습니다. 찾는 것은
를 만족하는 입니다. 변환 뒤 공분산은 이므로 조건은 이고, 여기서 를 고르면 됩니다.
Σ의 −1/2 제곱은 세 걸음이다
이라는 표기가 낯설다면 대각화를 거쳐 읽으면 바로 풀립니다. 이고 가 직교행렬()이므로
입니다. 이것이 정말 의 제곱인지는 두 번 곱해 보면 확인됩니다.
가운데의 가 로 사라지는 것이 전부입니다. 이렇게 만든 은 자기 자신이 대칭이라 대칭 제곱근이라고 부르고, 이 선택을 쓴 whitening을 ZCA whitening이라고 합니다.
식을 오른쪽부터 읽으면 세 걸음이 보입니다.
이 좌표축을 고유벡터 방향으로 돌리고, 이 축마다 배를 해서 퍼진 축은 줄이고 눌린 축은 늘리고, 가 원래 좌표계로 되돌립니다. 마지막 되돌리기가 있어서 결과가 원래 좌표축과 최대한 가깝게 유지됩니다. 처럼 되돌리기를 생략해도 공분산은 똑같이 가 되지만(PCA whitening), 축이 통째로 돌아가 있어 원래 차원의 뜻이 사라집니다.
4,000개로 직접 재 보기
의미가 있는 주제 200개를 만들고 주제마다 문장 20개씩, 모두 4,000개 벡터를 만들었습니다. 그다음 인코더가 흔히 덧씌우는 것 — 큰 공통 평균과 세 축의 과대 분산 — 을 얹었습니다. 「정답」을 아는 데이터이므로 순위가 실제로 좋아졌는지를 recall로 잴 수 있습니다.
import numpy as np
rng = np.random.default_rng(11)
d, K, per = 64, 200, 20 # 64차원, 주제 200개, 주제당 20문장
N = K * per
centers = rng.normal(size=(K, d))
true = np.repeat(centers, per, axis=0) + rng.normal(size=(N, d)) * 0.55
label = np.repeat(np.arange(K), per)
Q, _ = np.linalg.qr(rng.normal(size=(d, d))) # 임의의 직교 축
scale = np.ones(d)
scale[:3] = [7.0, 4.2, 3.0] # 지배 축 셋
X = (true @ Q) * scale @ Q.T + Q[:, 0] * 22.0 # + 공통 평균
def evaluate(A, tag):
An = A / np.linalg.norm(A, axis=1, keepdims=True)
S = An @ An.T
np.fill_diagonal(S, -np.inf)
top = np.argsort(-S, axis=1)[:, :per - 1] # 상위 19개
recall = (label[top] == label[:, None]).mean()
i = rng.integers(0, N, 200000)
j = rng.integers(0, N, 200000)
m = i != j
c = (An[i][m] * An[j][m]).sum(1) # 무작위 쌍의 코사인
print(f"{tag:20s} 평균 {c.mean():+.3f} 표준편차 {c.std():.3f} recall@19 {recall:.3f}")
evaluate(X, "원본")
Xc = X - X.mean(0)
evaluate(Xc, "평균 제거")
w, V = np.linalg.eigh(np.cov(Xc, rowvar=False))
W = V @ np.diag(w ** -0.5) @ V.T # Σ^(-1/2)
evaluate(Xc @ W, "+ whitening")
np.linalg.eigh는 대칭 행렬 전용이라 고윳값을 오름차순 실수로 돌려줍니다. w ** -0.5가 이고, 앞뒤로 를 곱한 것이 그대로 입니다.
결과는 이렇습니다.
| 처리 | 코사인 평균 | 표준편차 | 1퍼센타일 | 99퍼센타일 | recall@19 |
|---|---|---|---|---|---|
| 원본 | +0.739 | 0.169 | +0.119 | +0.934 | 0.901 |
| 평균 제거 | +0.001 | 0.325 | −0.681 | +0.696 | 0.853 |
| 평균 제거 + whitening | −0.001 | 0.124 | −0.267 | +0.286 | 1.000 |
원본에서는 아무 관계도 없는 두 문장의 코사인이 평균 0.739이고, 99퍼센타일이 0.934입니다. 서두의 「0.75 문턱이 아무것도 안 거른다」가 정확히 이 숫자입니다. 무작위 쌍의 절반 이상이 0.75를 넘으니 문턱이 하는 일이 없습니다.
읽을 때 조심할 자리가 세 군데 있습니다.
첫째, 평균 제거만으로는 recall이 오히려 떨어졌습니다(0.901 → 0.853). 코사인 분포는 0을 중심으로 예쁘게 펴졌는데 순위는 나빠졌습니다. 분포가 보기 좋아진 것과 검색이 잘되는 것은 다른 일입니다. 평균을 빼면 「모두가 공유하던 방향」이 사라지는데, 그 방향이 순위에 기여하던 몫도 같이 사라집니다.
둘째, 순위를 살린 것은 축별 분산을 고른 것입니다. whitening을 얹은 뒤에야 recall이 1.000이 됐습니다. 이 데이터에서 진짜 의미는 64축에 고르게 흩어져 있었는데 상위 세 축이 과대 분산으로 그것을 덮고 있었습니다. 축마다 를 곱하는 것이 그 덮개를 걷어내는 일입니다.
셋째, whitening 뒤의 코사인 표준편차는 0.124로 가장 작습니다. 값이 더 좁아졌는데 순위는 완벽해졌습니다. 유사도의 절대값과 순위의 품질은 서로 다른 축이라는 말이고, 그래서 「0.9면 비슷한 문서」 같은 절대 임계값은 후처리를 한 번이라도 거치면 그대로 무의미해집니다.
대가를 계산한다
여기까지만 보면 whitening을 안 할 이유가 없어 보입니다. 대가가 셋 있습니다.
하나 — Σ를 어디서, 몇 개로 적합했는가
을 만들려면 로 나눠야 하는데, 표본이 적으면 작은 의 추정값이 통째로 잡음입니다. 공분산을 제대로 세우려면 표본이 보다 넉넉히 많아야 하고, 이면 가 특이행렬이라 역제곱근이 아예 없습니다.
같은 데이터에서 표본 개만 뽑아 를 적합한 뒤 전체 4,000개에 적용해 봤습니다().
| Σ를 적합한 표본 수 | 코사인 표준편차 | recall@19 |
|---|---|---|
| 2,000 | 0.125 | 1.000 |
| 400 | 0.133 | 0.999 |
| 128 | 0.171 | 0.957 |
| 80 | 0.258 | 0.695 |
| 70 | 0.419 | 0.449 |
까지는 멀쩡한데 에서 0.449로 무너집니다. 차원의 여섯 배로도 부족했다는 뜻입니다. 실무에서 이 절벽을 밟는 자리는 대개 「검색 코퍼스 전체가 아니라 최근 문서 몇백 개로 를 다시 적합」할 때입니다. 작은 고윳값에 하한을 두는 축소 추정, 즉 이나 를 쓰는 것이 표준 방어입니다.
둘 — 지배 축이 잡음이라는 보장이 없다
위 실험은 진짜 의미가 64축에 고르게 흩어져 있고 상위 세 축이 인코더가 덧씌운 껍데기라고 가정했습니다. 그 가정을 뒤집어 봤습니다. 이번에는 주제를 가르는 정보가 상위 여덟 축에만 있고 나머지 축은 잡음뿐입니다.
| 처리 | 코사인 평균 | 표준편차 | recall@19 |
|---|---|---|---|
| 원본 | +0.774 | 0.112 | 0.648 |
| 평균 제거 | +0.000 | 0.261 | 0.751 |
| 평균 제거 + whitening | −0.000 | 0.122 | 0.110 |
코사인 분포는 앞의 실험과 거의 똑같이 예쁘게 펴졌는데 recall은 0.751에서 0.110으로 떨어졌습니다. whitening은 분산이 큰 축을 눌러 분산이 작은 축과 같은 무게로 만드는 변환이고, 큰 축에 신호가 있으면 그것이 정확히 신호를 죽이는 일이 됩니다. 그리고 코사인 분포만 봐서는 두 경우를 구별할 수 없습니다 — 표준편차 0.124와 0.122가 각각 recall 1.000과 0.110입니다. 분포 그림은 진단이 아닙니다. 정답이 있는 평가셋으로 재는 수밖에 없습니다.
셋 — 전부 아니면 전무가 아니다
두 극단 사이에 눈금이 있습니다. 상위 몇 축만 통째로 빼는 방법(all-but-the-top)은 whitening보다 덜 과격합니다. 첫 실험의 데이터로 재면 이렇습니다.
| 처리 | 코사인 표준편차 | recall@19 |
|---|---|---|
| 평균 제거만 | 0.325 | 0.853 |
| 상위 1축 제거 | 0.217 | 0.971 |
| 상위 2축 제거 | 0.159 | 0.997 |
| 상위 3축 제거 | 0.138 | 1.000 |
| 상위 5축 제거 | 0.141 | 1.000 |
| 전체 whitening | 0.124 | 1.000 |
지배 축이 셋이었으니 세 축만 빼도 whitening과 같은 자리에 도착합니다. 축을 빼는 쪽은 작은 로 나누는 일이 없어 위의 소표본 절벽을 밟지 않고, 몇 축을 뺄지가 눈금이라 평가셋으로 고를 수 있습니다. 「상위 몇 축이 지배적인가」는 앞에서 그린 고윳값 스펙트럼이 그대로 답을 줍니다.
그래서 0.75 문턱은 무엇이었나
서두의 화면으로 돌아가면 이제 세 가지를 말할 수 있습니다.
상위 문서의 유사도가 0.83에 몰려 있던 것은 검색기가 못해서가 아닙니다. 무관한 쌍의 코사인이 이미 평균 0.74였기 때문에 0.83이 「꽤 비슷함」이 아니라 「평균보다 약간 위」였을 뿐입니다. 절대값을 읽으려던 것이 애초에 잘못된 독법이고, 재야 할 것은 그 코퍼스의 무작위 쌍 분포입니다. 그 분포의 평균과 표준편차를 알면 임계값을 「평균에서 몇 표준편차 위」로 다시 쓸 수 있습니다.
그리고 후처리를 얹기로 했다면, 고칠 대상이 평균인지 축별 분산인지를 먼저 갈라야 합니다. 평균이 원인이면 평균 빼기 한 줄이고, 축별 분산이 원인이면 whitening이나 상위 축 제거입니다. 둘을 구별하는 값이 와 유효 차원이고, 둘 다 공분산 행렬 한 번이면 나옵니다.
마지막으로, 어느 쪽이든 정답이 있는 평가셋 없이는 고른 것이 옳은지 알 수 없습니다. 위에서 표준편차 0.124와 0.122가 recall 1.000과 0.110으로 갈렸습니다. 분포를 예쁘게 만드는 일과 검색을 잘하게 만드는 일은 다르고, 다르다는 것을 확인하는 유일한 방법이 재는 것입니다.
정리
- 코사인 유사도는 원점에서 재는 각이다. 점 구름이 원점에서 멀면 무관한 점들끼리도 각이 작아지고 코사인이 1에 가까워진다. 좌표를 평행이동해도 거리는 안 변하지만 코사인은 크게 변한다.
- 이방성은 데이터가 특정 방향으로 몰려 있는 성질이고, 공분산 행렬의 고윳값 분포로 잰다. 설명 분산비 와 참여비 두 값이면 충분하다.
- 실험한 64차원 데이터는 분산의 절반이 축 두 개에 있고 유효 차원이 6.1이었다. 무관한 두 벡터의 코사인이 평균 0.739였다.
- whitening은 를 만드는 변환이고, 이다. 회전 · 축별로 배 · 되돌리기 세 걸음이다.
- 평균 제거만으로는 recall이 0.901에서 0.853으로 떨어졌고, whitening까지 얹어야 1.000이 됐다. 코사인 분포가 예뻐지는 것과 순위가 좋아지는 것은 다른 일이다.
- 대가 셋: 를 표본 70개로 적합하면 recall이 0.449로 무너진다(). 지배 축에 신호가 있으면 whitening이 recall을 0.751에서 0.110으로 죽인다. 그리고 코사인 분포만으로는 두 경우를 구별할 수 없다 — 표준편차 0.124와 0.122가 recall 1.000과 0.110이었다.
- 전부 아니면 전무가 아니다. 상위 축만 빼면 작은 고윳값으로 나누는 일이 없고, 가 평가셋으로 고를 수 있는 눈금이 된다. 이 데이터에서는 3축 제거가 전체 whitening과 같은 자리였다.
- 절대 임계값(「0.9면 비슷한 문서」)은 후처리를 한 번이라도 거치면 무의미해진다. 임계값은 그 코퍼스의 무작위 쌍 분포를 기준으로 다시 써야 한다.
읽어주셔서 감사합니다. 😊

