리서치

PAPERS / 3번째 글

고차원에서 거리는 무의미해진다는데 임베딩은 왜 멀쩡한가 — Beyer 1999를 384차원에서 재현했다

iid 좌표에서 차원이 오르면 최근접과 최원접이 같아진다는 Beyer(1999)의 상대대비를 재현하니 384차원 균등난수에서 0.25까지 떨어졌다. 그런데 같은 지표를 scifact 실제 임베딩 384차원에 재니 0.83으로, iid 64차원 수준이었다. 정규화 때문이 아니다 — PCA로 잰 내재 차원이 참여율 65, 두 측정이 같은 수를 가리켰다.

PALDYN Team17 MIN READ

「고차원에서는 모든 점이 서로 같은 거리에 놓여 최근접 이웃이 무의미해진다」는 문장은 검색을 다루는 글에 거의 관용구처럼 등장한다. 그런데 우리가 매일 쓰는 임베딩은 384차원, 1,536차원인데도 검색이 멀쩡히 된다(scifact nDCG@10 0.6451). 관용구가 맞다면 이럴 수 없다. 이 글은 그 관용구의 출처인 논문을 384차원에서 직접 재현하고, 같은 지표를 실제 임베딩에 대 봐서 왜 임베딩이 예외인지를 숫자로 잇는다. PCA 자체는 주성분 분석이 맡으므로 여기서는 그것으로 잰 한 값만 쓴다.

재현하려는 주장 한 문장

Beyer, Goldstein, Ramakrishnan, Shaft (1999), "When Is 'Nearest Neighbor' Meaningful?", ICDT 1999. 이 논문의 정리 1은 이렇게 말한다. 질의점에서 데이터점까지의 거리 ∥Xd∥\lVert X_d \rVert 에 대해 그 상대분산이 차원과 함께 0으로 가면, 즉

lim⁡d→∞Var⁡ ⁣(∥Xd∥E ∥Xd∥)=0\lim_{d\to\infty} \operatorname{Var}\!\left(\frac{\lVert X_d\rVert}{\mathbb{E}\,\lVert X_d\rVert}\right) = 0

이면 임의의 ε>0\varepsilon>0 에 대해 P ⁣[Dmax⁡≤(1+ε) Dmin⁡]→1P\!\left[D_{\max} \le (1+\varepsilon)\,D_{\min}\right] \to 1 이다. 여기서 Dmax⁡D_{\max}·Dmin⁡D_{\min} 은 질의점에서 가장 먼 점·가장 가까운 점까지의 거리다. 논문은 각 좌표가 독립 동일분포(iid)이면 이 조건이 성립한다고 밝힌다.

말로 풀면 이렇다. 최원접과 최근접의 비가 1로 수렴한다 — 가장 가까운 점과 가장 먼 점의 거리가 사실상 같아진다. 이 벌어짐을 한 숫자로 재는 것이 상대대비(relative contrast)이고, 이 글에서 내내 쓴다.

RC=Dmax⁡−Dmin⁡Dmin⁡\mathrm{RC} = \frac{D_{\max} - D_{\min}}{D_{\min}}

RC가 크면 가까운 점과 먼 점이 뚜렷이 갈리고(검색이 의미 있고), 0에 가까우면 다 같은 거리라 최근접이라는 개념이 흐물흐물해진다. 재현할 주장은 이 한 문장이다: iid 좌표에서는 차원이 오르면 RC가 0으로 간다. 논문의 다른 부분(색인 실패 조건, 워크로드 분석)은 다루지 않는다.

재현 블록 1 — iid에서 RC가 0으로 가는 것을 본다

균등난수와 가우시안난수 각각에서 2차원부터 1,024차원까지 RC를 잰다. 데이터점 2만 개, 질의점 200개, 유클리드 거리다. 이어서 같은 지표를 scifact 실제 임베딩 384차원에 대고, 대조군으로 384차원 단위구 위 균등난수도 잰다(우리 임베딩이 L2 정규화돼 있으므로 정규화가 원인인지 가른다).

import numpy as np
rng = np.random.default_rng(0)

def rc(X, q):  # 상대대비 (Dmax - Dmin)/Dmin, 질의 평균 · 유클리드
    out = []
    for i in range(q.shape[0]):
        d = np.linalg.norm(X - q[i], axis=1); d = d[d > 1e-12]
        out.append((d.max() - d.min()) / d.min())
    return float(np.mean(out))

N, M = 20000, 200
print("iid 좌표에서 차원이 오르면 상대대비가 0으로 간다 (Beyer 1999)")
print(f"{'d':>6}{'uniform[0,1]^d':>16}{'gaussian':>12}")
iid = {}
for d in (2, 4, 8, 16, 32, 64, 128, 256, 384, 512, 1024):
    Xu = rng.random((N, d)); qu = rng.random((M, d))
    Xg = rng.standard_normal((N, d)); qg = rng.standard_normal((M, d))
    iid[d] = rc(Xu, qu)
    print(f"{d:>6}{iid[d]:>16.4f}{rc(Xg, qg):>12.4f}")

D = np.load("scifact_D.npy").astype(np.float64)   # 5183 x 384, L2 정규화 (실험대에서 저장)
Q = np.load("scifact_Q.npy").astype(np.float64)
rc_real = rc(D, Q[:M])
Xs = rng.standard_normal((N, 384)); Xs /= np.linalg.norm(Xs, axis=1, keepdims=True)
qs = rng.standard_normal((M, 384)); qs /= np.linalg.norm(qs, axis=1, keepdims=True)
near = min(iid, key=lambda k: abs(iid[k] - rc_real))
print(f"\nscifact 실제 임베딩 384차원   상대대비 = {rc_real:.4f}")
print(f"단위구 위 균등난수 384차원    상대대비 = {rc(Xs, qs):.4f}  (정규화만으로는 안 살아난다)")
print(f"실임베딩 상대대비는 iid uniform d={near} (={iid[near]:.4f}) 수준")

scifact_D.npy·scifact_Q.npy는 실험대 글이 저장한 384차원 임베딩이다. 없으면 그 글의 인코딩 블록을 먼저 돌린다.

pip install numpy scikit-learn
python3 concentration.py

실제 출력

iid 좌표에서 차원이 오르면 상대대비가 0으로 간다 (Beyer 1999)
     d  uniform[0,1]^d    gaussian
     2        424.9008    588.0399
     4         30.3154     33.8249
     8          6.6501      7.2460
    16          2.6290      2.9247
    32          1.3113      1.4774
    64          0.7719      0.8775
   128          0.4856      0.5619
   256          0.3212      0.3636
   384          0.2539      0.2858
   512          0.2159      0.2446
  1024          0.1479      0.1672

scifact 실제 임베딩 384차원   상대대비 = 0.8317
단위구 위 균등난수 384차원    상대대비 = 0.2275  (정규화만으로는 안 살아난다)
실임베딩 상대대비는 iid uniform d=64 (=0.7719) 수준

차원에 따른 상대대비와 실제 임베딩의 자리

정리는 재현됐다. 균등난수의 RC는 2차원에서 424, 8차원에서 6.65, 64차원에서 0.77, 384차원에서 0.25로 떨어진다. 가우시안도 같은 모양이다. 384차원에서 RC 0.25는 「가장 먼 점이 가장 가까운 점보다 25%만 멀다」는 뜻이고, 1,024차원에서는 15%로 더 좁아진다. iid 좌표에서 차원의 저주는 실재한다.

그런데 scifact 실제 임베딩 384차원의 RC는 0.83이다. 같은 차원의 iid 균등난수(0.25)보다 3배 이상 크다. 가장 먼 문서가 가장 가까운 문서보다 83% 멀다 — 최근접이 흐물흐물하기는커녕 또렷하게 갈린다. 정리의 결론이 임베딩에는 적용되지 않는다.

정규화 때문이 아니다. 우리 임베딩은 L2 정규화돼 단위구 위에 놓이는데, 같은 단위구 위의 균등난수는 RC가 0.23으로 iid 384차원과 다를 바 없다. 벡터를 구 위에 올린다고 저주가 풀리지 않는다. RC 0.83은 임베딩의 분포 자체에서 나온다.

마지막 줄이 그 실마리다. 실제 임베딩의 RC(0.83)는 iid 균등난수로 치면 64차원(0.77) 수준이다. 384차원 벡터가 64차원 iid 데이터처럼 행동한다. 왜 64인가는 다음 블록이 답한다.

재현 블록 2 — 내재 차원을 PCA로 잰다

임베딩이 384개의 축을 진짜로 다 쓰고 있다면 RC는 0.25여야 한다. 0.83이라는 것은 실제로 쓰이는 축이 384보다 훨씬 적다는 뜻이다. 이 「실제로 쓰이는 축의 수」가 내재 차원(intrinsic dimension)이고, PCA로 잰다 — 주성분마다 데이터가 얼마나 퍼져 있는지(설명분산)를 크기순으로 쌓아, 전체 분산의 95%를 담는 데 몇 개가 필요한지를 센다.

import numpy as np
from sklearn.decomposition import PCA
rng = np.random.default_rng(0)

D = np.load("scifact_D.npy").astype(np.float64)
Dc = D - D.mean(0)
pca = PCA(n_components=384, svd_solver="full").fit(Dc)
cum = np.cumsum(pca.explained_variance_ratio_)
print("scifact 임베딩(384차원)의 내재 차원 — PCA 누적 설명분산")
for thr in (0.80, 0.90, 0.95, 0.99):
    print(f"  분산 {int(thr*100)}% 를 담는 차원 수: {int(np.searchsorted(cum, thr) + 1)}")
lam = pca.explained_variance_
print(f"  참여율 d_eff = (Σλ)^2 / Σλ^2 = {(lam.sum()**2)/(lam**2).sum():.1f}   (384 중)")
print(f"  128차원 누적분산 = {cum[127]:.4f}   64차원 = {cum[63]:.4f}")

U = rng.random((D.shape[0], 384)); Uc = U - U.mean(0)
pcaU = PCA(n_components=384, svd_solver="full").fit(Uc)
cumU = np.cumsum(pcaU.explained_variance_ratio_); lamU = pcaU.explained_variance_
print(f"  [대조] iid uniform 384차원: 95% 담는 차원 = {int(np.searchsorted(cumU, 0.95)+1)}, "
      f"128차원 누적 = {cumU[127]:.4f}, d_eff = {(lamU.sum()**2)/(lamU**2).sum():.1f}")
python3 intrinsic.py

실제 출력

scifact 임베딩(384차원)의 내재 차원 — PCA 누적 설명분산
  분산 80% 를 담는 차원 수: 110
  분산 90% 를 담는 차원 수: 166
  분산 95% 를 담는 차원 수: 211
  분산 99% 를 담는 차원 수: 290
  참여율 d_eff = (Σλ)^2 / Σλ^2 = 65.2   (384 중)
  128차원 누적분산 = 0.8386   64차원 = 0.6624
  [대조] iid uniform 384차원: 95% 담는 차원 = 352, 128차원 누적 = 0.4400, d_eff = 357.6

두 가지 방식으로 내재 차원을 재는데 둘이 같은 수를 가리킨다.

하나는 참여율(participation ratio)이다. 고윳값 λi\lambda_i(각 주성분의 분산)로

deff=(∑iλi)2∑iλi2d_{\mathrm{eff}} = \frac{\left(\sum_i \lambda_i\right)^2}{\sum_i \lambda_i^2}

를 계산하면, 분산이 몇 개 축에 실질적으로 몰려 있는지가 나온다. 모든 축이 똑같이 퍼지면 deffd_{\mathrm{eff}} 는 384에 가깝고, 몇 축에만 몰리면 작아진다. scifact 임베딩은 65.2다. 대조군인 iid 균등난수 384차원은 357.6으로 거의 384다 — iid는 모든 축을 고르게 쓴다.

다른 하나가 앞 블록의 「RC로 친 등가 차원 64」다. 상대대비로 잰 등가 차원(64)과 참여율로 잰 내재 차원(65.2)이 거의 같다. 접근이 전혀 다른 두 측정이 같은 수에 모였다는 것이, 이 임베딩이 실은 65차원짜리 데이터라는 강한 증거다. 384라는 숫자는 벡터를 담는 그릇의 크기일 뿐이고, 안에 든 정보는 그 6분의 1 차원에 놓여 있다.

분산 95%를 담는 데는 211차원이 필요하지만(참여율보다 큰 것은 꼬리 축들이 조금씩 분산을 나눠 갖기 때문이다), 어느 잣대로 재도 384보다 한참 낮다. 128차원이 누적분산 83.9%, 64차원이 66.2%를 담는다.

왜 임베딩은 저주를 피하는가

정리는 틀리지 않았다. 틀린 것은 가정이다. Beyer의 조건은 좌표가 iid일 때 성립하고, 그 iid 가정을 임베딩이 정면으로 어긴다. 임베딩의 384개 좌표는 독립이 아니다 — 모델이 의미를 실어 나르려고 축들을 강하게 상관시켜 놓았고, 그 결과 데이터가 65차원짜리 얇은 표면 위에 눕는다. 저주가 걸리는 것은 「384차원」이 아니라 「384개의 독립된 방향으로 고르게 퍼진 데이터」인데, 임베딩은 후자가 아니다.

이 하나의 사실이 앞선 두 글의 관찰을 함께 설명한다.

  • 임베딩 차원을 줄이면 검색은 어디서 무너지는가에서 384차원을 PCA로 128차원까지 줄여도 검색 품질이 94.7%(0.6107/0.6451) 유지된 것은, 버린 256개 축에 애초에 정보가 거의 없었기 때문이다. 내재 차원이 65인 데이터에서 상위 128축을 남기는 것은 손실이 아니라 그릇을 줄이는 것에 가깝다.
  • int8로 줄인 벡터는 순위를 얼마나 흔드는가에서 1비트 양자화의 recall이 가우시안 난수(0.18)와 실제 임베딩(0.68)에서 3.8배 갈린 것도 같은 원인이다. 난수는 384차원을 다 써서 부호만 남기면 거리가 뭉개지지만, 실제 임베딩은 65차원짜리라 부호만으로도 상당한 구조가 남는다.

결과가 꺾이는 지점

차원의 저주를 가르는 것은 그릇의 차원이 아니라 데이터의 내재 차원이다. 임베딩이 384차원에서 멀쩡한 이유는 차원이 낮아서가 아니라 참여율이 65로 낮아서다. 뒤집으면 경계가 보인다 — 데이터의 참여율이 그릇의 차원에 가까워지면 임베딩도 저주로 돌아간다. RC로 보면 자리가 뚜렷하다. 참여율이 64 근처면 RC 0.77로 검색이 또렷하지만, 만약 어떤 임베딩의 참여율이 384에 가깝다면 그 RC는 0.25로 내려앉고 최근접이 흐물흐물해진다. 그러니 「몇 차원까지 안전한가」를 그릇의 크기로 묻는 것은 틀린 질문이고, 물어야 할 것은 「내 임베딩의 참여율은 얼마인가」다.

축소했기 때문에 확인되지 않은 것

  • 코퍼스·모델이 각각 하나다. scifact 5,183문서를 all-MiniLM-L6-v2로 인코딩한 것 하나다. 참여율 65는 이 조합의 값이고, 도메인이 넓은 코퍼스나 큰 모델(1,536차원 등)에서는 다른 수가 나온다. 「내재 차원이 그릇의 6분의 1」이라는 비율을 다른 임베딩으로 외삽하면 안 된다.
  • 질의점 200개로 RC를 쟀다. Beyer의 정리는 극한 명제이고 우리는 유한 표본이다. RC의 절댓값은 질의 표본과 데이터 크기에 따라 흔들린다 — 결론은 「실임베딩 0.83이 iid 384차원 0.25보다 훨씬 크다」는 대소 관계이지 0.83이라는 값 자체가 아니다.
  • 참여율은 내재 차원의 한 가지 잣대다. 선형(PCA 2차 모멘트) 척도라, 데이터가 휜 다양체 위에 있으면 실제 다양체 차원보다 크게 나올 수 있다. 여기서 참여율과 RC-등가 차원이 맞은 것은 이 임베딩에서 두 선형 척도가 일치했다는 뜻이고, 비선형 내재 차원(예: TwoNN)까지 같다는 보장은 아니다.
  • 유클리드 거리로만 쟀다. 임베딩은 정규화돼 있어 코사인과 유클리드가 순위를 같게 매기므로 RC의 대소 결론은 바뀌지 않지만, RC의 값 자체는 거리 함수에 따라 달라진다.

측정 환경

항목 값
OS Linux 6.18 x86_64
CPU / RAM 4 vCPU / 15GB
Python 3.11.15
numpy / scikit-learn 2.4.6 / (PCA svd_solver="full")
데이터 scifact_D.npy·scifact_Q.npy — all-MiniLM-L6-v2 384차원, 실험대에서 저장
난수 np.random.default_rng(0) 고정 · 데이터 2만 · 질의 200
실행 시간 상대대비 스윕 약 3분 · PCA 블록 수 초
자기검사 새 가상환경에 numpy·scikit-learn을 처음부터 깔고 두 스크립트를 다시 돌려 위 출력이 전부 같은지 확인함
측정일 2026-08-22

RC 스윕은 고차원에서 거리 계산이 무거워 약 3분이 든다(질의점마다 2만 점까지의 거리를 잰다). 절대 시간은 결론이 아니고, 결론은 차원별 RC의 대소와 참여율 값으로만 냈다.


읽어주셔서 감사합니다. 😊

LATEST

논문의 최신 글