리서치

LAB / 19번째 글

코퍼스가 커지면 저차원은 더 빨리 무너진다: 손실은 문서 수의 로그에 비례했다

scifact를 500·1,000·2,500·5,183문서로 잘라 차원별 nDCG@10을 다시 쟀다. 차원 축소의 손실은 문서 수의 로그에 거의 정확히 비례했고(R² 0.94~0.997), 128차원은 문서가 열 배 될 때마다 3.28%p씩 더 잃었다. 앞서 못 박은 '128차원이 무릎'은 5,183문서짜리 숫자였다.

PALDYN Team22 MIN READ

차원 무릎 실험에서 384차원 임베딩을 PCA로 줄이며 무릎을 찾았다. 영어에서는 128차원이 메모리 3분의 1에 품질 94.7%를 지키는 자리였고, 그것을 "여기까지는 값을 치를 만하다"는 결론으로 적었다.

그 숫자에는 조건이 하나 숨어 있었다. 5,183문서짜리 코퍼스에서 잰 값이라는 것이다. 실무의 인덱스는 대개 그보다 크고, 차원을 고르는 사람은 문서 수가 다르다는 이유로 무릎이 움직일 거라고는 잘 생각하지 않는다. 128이라는 숫자만 남고 5,183은 떨어져 나간다.

이 글은 그 실험이 낸 결론을 스스로 감사하는 실험이다. 코퍼스 크기를 흔들면서 같은 곡선을 다시 그려, 무릎이 고정된 숫자인지 코퍼스 크기의 함수인지를 가른다. PCA가 무엇을 하는지는 PCA: 고차원 데이터를 압축하는 차원 축소가 맡는다.

실험 설계

자르는 규칙

검색 실험대가 저장한 .npy를 그대로 읽는다. 인코딩을 다시 하지 않으므로 스윕 전체가 11초에 끝난다.

  • 코퍼스: BEIR scifact 5,183문서 · 질의 300개 · all-MiniLM-L6-v2 · nDCG@10
  • 자르는 크기: 500 / 1,000 / 2,500 / 5,183문서
  • 차원: 384(원본) / 128 / 64 / 32

자를 때 규칙이 하나 필요하다. 정답으로 쓰인 문서는 어느 크기에서도 빼지 않는다. 300개 질의의 qrels가 가리키는 문서는 중복을 빼면 283편인데, 이것을 무작위로 날려 버리면 코퍼스가 작아질수록 정답이 아예 없는 질의가 늘어나 지표가 떨어진다. 그러면 우리가 재려는 것(차원 축소의 손해)과 반대 방향의 잡음이 섞인다. 283편을 항상 넣고 나머지를 무작위로 채운다.

그다음 규칙은 시드 다섯 개다. 어느 문서가 뽑히느냐가 결과를 흔들 수 있으니, 크기마다 시드 0~4로 다섯 번 뽑아 평균과 산포를 함께 본다. 5,183은 코퍼스 전체라 뽑을 것이 없어 다섯 번 모두 같은 값이 나온다 — 그 자리의 산포 0.0000이 나머지 자리의 산포를 읽는 눈금이 된다.

재현 블록

pip install numpy scikit-learn
import numpy as np
from sklearn.decomposition import PCA

SIZES, DIMS = [500, 1000, 2500, 5183], [128, 64, 32]
disc = 1.0 / np.log2(np.arange(2, 12))
D, Q, G = (np.load(f"scifact_{s}.npy") for s in ("D", "Q", "gold"))
need = np.where(G.any(axis=0))[0]          # 정답으로 쓰인 문서는 어느 크기에서도 뺄 수 없다
unit = lambda X: X / np.linalg.norm(X, axis=1, keepdims=True)


def ndcg10(Q, D, G):
    top = np.argsort(-(Q @ D.T), axis=1)[:, :10]
    return float(np.mean([(G[i][top[i]].astype(float) * disc).sum()
                          / disc[:min(int(G[i].sum()), 10)].sum() for i in range(len(Q))]))


def subset(n, seed):
    rng = np.random.default_rng(seed)
    rest = np.setdiff1d(np.arange(D.shape[0]), need)
    return np.sort(np.concatenate([need, rng.choice(rest, n - len(need), replace=False)]))


print(f"corpus={D.shape[0]} dim={D.shape[1]} queries={len(Q)} 정답 문서={len(need)}")
print(f"{'n':>6}{'384':>9}{'128':>9}{'64':>9}{'32':>9} |{'128':>7}{'64':>7}{'32':>7}"
      f" 남는 품질 % |{'384':>8}{'128':>8}{'64':>8}{'32':>8} 시드 산포")
keep = {}
for n in SIZES:
    rows = []
    for seed in range(5):
        pick = subset(n, seed)
        Ds, Gs = D[pick], G[:, pick]
        row = [ndcg10(Q, Ds, Gs)]
        for k in DIMS:
            p = PCA(n_components=k, random_state=0).fit(Ds)
            row.append(ndcg10(unit(p.transform(Q)), unit(p.transform(Ds)), Gs))
        rows.append(row)
    a = np.array(rows); m, sp = a.mean(axis=0), a.max(axis=0) - a.min(axis=0)
    keep[n] = [v / m[0] * 100 for v in m[1:]]
    print(f"{n:>6}" + "".join(f"{v:>9.4f}" for v in m) + " |"
          + "".join(f"{v:>7.1f}" for v in keep[n]) + "             |"
          + "".join(f"{v:>8.4f}" for v in sp))

x = np.log10(np.array(SIZES, dtype=float))
print(f"\n{'차원':>5}{'문서 10배당 손실 %p':>22}{'R^2':>8}{'손실 5%p 도달':>15}"
      + "".join(f"{n:>12,}" for n in (20000, 100000, 1000000)) + "  외삽 손실 %p")
for j, k in enumerate(DIMS):
    loss = np.array([100 - keep[n][j] for n in SIZES])
    s, i = np.polyfit(x, loss, 1)
    r2 = 1 - ((loss - (s * x + i)) ** 2).sum() / ((loss - loss.mean()) ** 2).sum()
    print(f"{k:>5}{s:>22.2f}{r2:>8.4f}{10 ** ((5 - i) / s):>15,.0f}"
          + "".join(f"{s * np.log10(n) + i:>12.1f}" for n in (20000, 100000, 1000000)))
time python3 corpus_sweep.py

실제 출력

corpus=5183 dim=384 queries=300 정답 문서=283
     n      384      128       64       32 |    128     64     32 남는 품질 % |     384     128      64      32 시드 산포
   500   0.8388   0.8211   0.7907   0.7123 |   97.9   94.3   84.9             |  0.0068  0.0060  0.0068  0.0232
  1000   0.7912   0.7730   0.7215   0.6338 |   97.7   91.2   80.1             |  0.0152  0.0211  0.0153  0.0079
  2500   0.7103   0.6831   0.6260   0.5262 |   96.2   88.1   74.1             |  0.0068  0.0153  0.0078  0.0202
  5183   0.6451   0.6107   0.5404   0.4530 |   94.7   83.8   70.2             |  0.0000  0.0000  0.0000  0.0000

   차원         문서 10배당 손실 %p     R^2      손실 5%p 도달      20,000     100,000   1,000,000  외삽 손실 %p
  128                  3.28  0.9377          4,940         7.0         9.3        12.6
   64                  9.99  0.9869            433        21.6        28.6        38.6
   32                 14.56  0.9970             98        38.7        48.8        63.4

real	0m11.032s

맨 아랫줄 0.6451 / 0.6107 / 0.5404 / 0.4530은 그 실험의 값과 소수점 넷째 자리까지 같다. 전체 코퍼스에서는 자르는 것이 없으니 같은 계산이고, 같은 숫자가 나온 것이 이 실험이 그때와 같은 자를 쓰고 있다는 확인이다.

남는 품질

절대값과 상대값

표의 왼쪽 네 열만 보면 정반대 결론이 나온다. 384차원 nDCG@10이 500문서에서 0.8388인데 5,183문서에서 0.6451이다. 코퍼스를 열 배로 늘렸더니 품질이 23% 떨어졌다.

이건 차원과 아무 상관이 없다. 문서가 늘면 방해 문서(distractor)도 늘기 때문이다. 방해 문서는 정답이 아니면서 질의와 그럴듯하게 닮아 상위에 끼어드는 문서를 말한다. 500문서짜리 코퍼스에서 10등 안에 들기는 5,183문서짜리보다 훨씬 쉽다. 그러니 코퍼스 크기가 다른 두 nDCG를 직접 비교하는 것은 자가 다른 두 값을 비교하는 것이다.

우리가 알고 싶은 것은 그 코퍼스에서 384차원이 낸 값의 몇 %가 남는가다. 오른쪽 세 열의 남는 품질이 그것이고, 여기서부터가 실험이다.

차원 500문서 1,000문서 2,500문서 5,183문서 손실이 커진 배수
128 97.9% 97.7% 96.2% 94.7% 2.1%p → 5.3%p (2.5배)
64 94.3% 91.2% 88.1% 83.8% 5.7%p → 16.2%p (2.8배)
32 84.9% 80.1% 74.1% 70.2% 15.1%p → 29.8%p (2.0배)

세 줄이 전부 같은 방향이다. 같은 차원, 같은 모델, 같은 질의인데 코퍼스가 10.4배가 되는 동안 손실이 두세 배로 커진다. 128차원에서 "거의 공짜"였던 2.1%p가 5.3%p가 되고, 64차원의 5.7%p는 16.2%p가 된다.

시드 산포는 최대 0.0232(500문서·32차원)다. 손실의 변화폭이 3~11%p인데 산포는 2%p 안쪽이니, 위 방향은 표본 뽑기의 운으로 설명되지 않는다.

로그 선형 기울기

세 줄이 같은 방향인 것을 넘어, 모양까지 같다. 손실 %p를 문서 수의 로그에 대해 직선으로 맞춰 보면 결정계수가 0.9377 / 0.9869 / 0.9970이다.

차원별 손실이 코퍼스 크기의 로그에 비례하는 그래프

차원 문서 10배당 손실 R² 손실 5%p에 닿는 문서 수
128 3.28%p 0.9377 4,940
64 9.99%p 0.9869 433
32 14.56%p 0.9970 98

손실 5%p 선

「손실 5%p에 닿는 문서 수」 열이 이 실험의 실무적 산출물이다. 품질을 5% 이상 잃지 않겠다는 선을 그으면, 128차원은 4,940문서까지, 64차원은 433문서까지, 32차원은 98문서까지다. 세 값이 한 자릿수씩 갈린다.

그 실험이 잰 코퍼스가 5,183문서였다. 그러니까 그때 128차원이 "메모리 3분의 1에 품질 94.7%"였던 것은 마침 5%p 선에 닿는 자리에서 쟀기 때문이다. 500문서였으면 97.9%가 나와 "거의 공짜"라고 썼을 것이고, 5만 문서였으면 다른 결론이 나왔을 것이다. 무릎의 위치는 모델이 정하는 상수가 아니라 코퍼스 크기의 함수다.

대조 실험

적합 표본 수

여기서 의심할 자리가 하나 있다. 위 스크립트는 코퍼스를 자른 뒤 그 부분집합에 PCA를 적합시킨다. 500문서로 384차원 공분산을 추정하는 것은 표본이 모자란 일이라, 작은 코퍼스에서 PCA가 나쁘게 나와야 정상이다. 그런데 관측된 것은 반대 방향(작은 코퍼스에서 손실이 작다)이라 이 요인이 곡선을 만들었을 리는 없다. 그래도 두 효과가 섞여 기울기를 왜곡했을 수는 있으니 갈라 본다.

PCA를 항상 5,183문서 전체에 적합시키고, 검색만 부분 코퍼스에서 한다. 이러면 「적합 표본 수」가 상수로 고정되고 「방해 문서 수」만 남는다.

import numpy as np
from sklearn.decomposition import PCA

SIZES, DIMS = [500, 1000, 2500, 5183], [128, 64, 32]
disc = 1.0 / np.log2(np.arange(2, 12))
D, Q, G = (np.load(f"scifact_{s}.npy") for s in ("D", "Q", "gold"))
need = np.where(G.any(axis=0))[0]
unit = lambda X: X / np.linalg.norm(X, axis=1, keepdims=True)


def ndcg10(Q, D, G):
    top = np.argsort(-(Q @ D.T), axis=1)[:, :10]
    return float(np.mean([(G[i][top[i]].astype(float) * disc).sum()
                          / disc[:min(int(G[i].sum()), 10)].sum() for i in range(len(Q))]))


fit = {k: PCA(n_components=k, random_state=0).fit(D) for k in DIMS}   # 항상 5,183문서로 적합
print("PCA는 5,183문서 전체에 적합시키고 검색만 부분 코퍼스에서 한다")
print(f"{'n':>6}{'384':>9}{'128':>9}{'64':>9}{'32':>9} |{'128':>7}{'64':>7}{'32':>7}  남는 품질 %")
for n in SIZES:
    rows = []
    for seed in range(5):
        rng = np.random.default_rng(seed)
        rest = np.setdiff1d(np.arange(D.shape[0]), need)
        pick = np.sort(np.concatenate([need, rng.choice(rest, n - len(need), replace=False)]))
        Ds, Gs = D[pick], G[:, pick]
        rows.append([ndcg10(Q, Ds, Gs)] + [ndcg10(unit(fit[k].transform(Q)),
                                                  unit(fit[k].transform(Ds)), Gs) for k in DIMS])
    m = np.array(rows).mean(axis=0)
    print(f"{n:>6}" + "".join(f"{v:>9.4f}" for v in m) + " |"
          + "".join(f"{v / m[0] * 100:>7.1f}" for v in m[1:]))
PCA는 5,183문서 전체에 적합시키고 검색만 부분 코퍼스에서 한다
     n      384      128       64       32 |    128     64     32  남는 품질 %
   500   0.8388   0.8184   0.7721   0.6949 |   97.6   92.1   82.8
  1000   0.7912   0.7735   0.7144   0.6273 |   97.8   90.3   79.3
  2500   0.7103   0.6862   0.6218   0.5294 |   96.6   87.5   74.5
  5183   0.6451   0.6107   0.5404   0.4530 |   94.7   83.8   70.2

곡선이 그대로 남는다. 128차원은 97.6 → 94.7, 64차원은 92.1 → 83.8, 32차원은 82.8 → 70.2로 내려간다. 원래 실험(97.9 → 94.7, 94.3 → 83.8, 84.9 → 70.2)과 방향도 크기도 거의 같다.

즉 적합 표본 수는 이 곡선을 만들지 않았다. 만드는 것은 방해 문서 수다. 차원을 줄이면 서로 다른 문서가 같은 자리로 뭉개지는데, 뭉개진 자리에 후보가 몇 개나 들어 있느냐가 정답을 밀어내는 확률을 정한다. 문서가 열 배면 뭉개진 한 칸에 들어오는 방해 문서도 열 배다.

한국어 기울기

그 실험은 한국어에 공짜 구간이 없다는 것까지 봤다. 기울기도 다른지 KorQuAD로 같은 실험을 한다.

문단이 960개뿐이라 자를 폭이 좁다. 자리를 만들려고 질의를 100개로 줄여 정답 문단을 95편으로 낮추고, 100 / 200 / 400 / 960문단에서 잰다. 문단 수가 128보다 작아지는 자리가 생기므로 PCA는 위 대조 실험처럼 960문단 전체에 적합시킨다.

import numpy as np
from sklearn.decomposition import PCA

SIZES, DIMS = [100, 200, 400, 960], [128, 64, 32]
P, Qk, gold = (np.load(f"korquad_{s}.npy") for s in ("P", "Q", "gold"))
sel = np.sort(np.random.default_rng(0).choice(len(Qk), 100, replace=False))
Q, g = Qk[sel], gold[sel]
need = np.unique(g)
unit = lambda X: X / np.linalg.norm(X, axis=1, keepdims=True)
fit = {k: PCA(n_components=k, random_state=0).fit(P) for k in DIMS}   # 960문단 전체로 적합

print(f"paragraphs={P.shape[0]} queries={len(Q)} 정답 문단={len(need)}")
print(f"{'n':>6}{'384':>9}{'128':>9}{'64':>9}{'32':>9} |{'128':>7}{'64':>7}{'32':>7}  남는 품질 %")
keep = {}
for n in SIZES:
    rows = []
    for seed in range(5):
        rng = np.random.default_rng(seed)
        rest = np.setdiff1d(np.arange(P.shape[0]), need)
        pick = np.sort(np.concatenate([need, rng.choice(rest, n - len(need), replace=False)]))
        pos = {d: i for i, d in enumerate(pick)}
        gs = np.array([pos[d] for d in g])
        Ps = P[pick]
        rows.append([float((np.argmax(Q @ Ps.T, axis=1) == gs).mean())]
                    + [float((np.argmax(unit(fit[k].transform(Q)) @ unit(fit[k].transform(Ps)).T,
                                        axis=1) == gs).mean()) for k in DIMS])
    m = np.array(rows).mean(axis=0)
    keep[n] = [v / m[0] * 100 for v in m[1:]]
    print(f"{len(pick):>6}" + "".join(f"{v:>9.4f}" for v in m) + " |"
          + "".join(f"{v:>7.1f}" for v in keep[n]))

x = np.log10(np.array(SIZES, dtype=float))
print(f"\n{'차원':>5}{'문단 10배당 손실 %p':>22}{'R^2':>8}{'영어 기울기':>12}{'배':>7}")
for j, k in enumerate(DIMS):
    loss = np.array([100 - keep[n][j] for n in SIZES])
    s, i = np.polyfit(x, loss, 1)
    r2 = 1 - ((loss - (s * x + i)) ** 2).sum() / ((loss - loss.mean()) ** 2).sum()
    en = {128: 3.28, 64: 9.99, 32: 14.56}[k]
    print(f"{k:>5}{s:>22.2f}{r2:>8.4f}{en:>12.2f}{s / en:>7.2f}")
paragraphs=960 queries=100 정답 문단=95
     n      384      128       64       32 |    128     64     32  남는 품질 %
   100   0.9560   0.9460   0.8840   0.7580 |   99.0   92.5   79.3
   200   0.9260   0.9000   0.7860   0.6380 |   97.2   84.9   68.9
   400   0.8580   0.8080   0.7020   0.5200 |   94.2   81.8   60.6
   960   0.8000   0.7100   0.6000   0.4100 |   88.8   75.0   51.2

   차원         문단 10배당 손실 %p     R^2      영어 기울기      배
  128                 10.46  0.9664        3.28   3.19
   64                 17.05  0.9776        9.99   1.71
   32                 28.34  0.9936       14.56   1.95

128차원에서 한국어의 기울기가 영어의 3.19배다. 문단이 열 배가 될 때 영어가 3.28%p를 잃는 동안 한국어는 10.46%p를 잃는다. 960문단에서 이미 11.2%p를 잃고 있는데, 영어가 그 손실에 닿으려면 회귀선대로라면 50만 문서쯤 가야 한다.

그 실험이 "한국어 쪽에는 공짜 구간이 없다"고 적은 것은 한 코퍼스 크기에서 본 결과였다. 이번 실험은 그 격차가 코퍼스가 커질수록 벌어진다는 것까지 보여 준다. 다만 여기서도 두 축이 동시에 다르다 — 모델(단일 언어 vs 다국어)과 지표(nDCG@10 vs Recall@1)다. 어느 쪽이 기울기를 만드는지는 이 실험이 가르지 못한다.

꺾이는 지점

차원별 상한

128차원은 5,000문서까지 공짜에 가깝고(손실 5%p), 그 위로는 문서가 열 배 될 때마다 3.28%p씩 값을 더 치른다. 64차원의 같은 선은 433문서, 32차원은 98문서다. 코퍼스 크기를 말하지 않고 "몇 차원이면 된다"고 적은 규칙은 전부 그 규칙을 만든 사람의 코퍼스 크기를 함께 적어야 한다.

외삽 구간

표의 오른쪽 세 열(2만 / 10만 / 100만)은 회귀선을 늘려 얻은 값이지 잰 값이 아니다. 실측 구간은 500에서 5,183까지, 한 자릿수(10.4배)뿐이다. 100만은 거기서 두 자릿수를 더 간 자리라, 이 표에서 가져갈 것은 숫자가 아니라 방향과 순서다.

그래도 외삽이 말해 주는 것이 하나 있다. 100만 문서에서 32차원의 예측 손실은 63.4%p, 즉 품질의 3분의 1만 남는다는 뜻이다. 이 값이 절반만 맞아도 32차원을 큰 인덱스에 쓰는 선택은 성립하지 않는다. 반대로 128차원의 12.6%p는 "메모리를 3분의 1로 줄이는 값으로 치를 만한가"를 실제로 저울질할 수 있는 크기다. 자기 코퍼스에서 직접 재는 것이 답이고, 이 스윕은 11초면 끝나므로 대신 쓸 이유가 없다.

한계와 측정 환경

한계

  • 코퍼스 하나, 모델 하나로 낸 기울기다. scifact는 과학 논문 초록이라 문체가 좁고, 방해 문서가 서로 비슷하다. 문서 성격이 다양한 코퍼스에서는 뭉개짐이 다르게 일어날 수 있다.
  • 로그 선형은 관찰이지 이론이 아니다. R²가 0.94~0.997로 높지만 점이 크기마다 네 개뿐이다. 다른 함수 꼴(예: 문서 수의 제곱근)로도 이 네 점은 비슷하게 맞는다. 구간이 한 자릿수라 함수 꼴을 가릴 힘이 없다.
  • 차원 축소 방법은 PCA 하나다. 앞에서 그냥 잘라 쓰는 방식이나 Matryoshka로 학습된 모델에서는 기울기가 다를 수 있다. 이 글은 PCA 곡선만 다뤘다.
  • 한국어 쪽은 문단이 960개뿐이라 구간이 더 좁다. 100→960은 9.6배이고, 질의도 100개로 줄여 잡음이 크다. 「3.19배」는 이 조건에서 나온 값이고 자릿수까지 믿을 만한 값은 아니다.
  • 정답 문서를 항상 넣는 규칙 자체가 편향을 만든다. 작은 코퍼스일수록 정답 문서 비율이 높아진다(500문서 중 283편이 정답). 실제로 코퍼스가 작아지는 상황에서는 정답도 함께 줄어들 것이다. 이 실험이 재는 것은 "정답은 그대로 두고 방해 문서만 늘렸을 때"의 효과다.

측정 환경

항목 값
OS Linux 6.18.5 x86_64
CPU / RAM Intel Xeon @ 2.80GHz, 4 vCPU / 15GB
Python 3.11.15
numpy 2.4.6
scikit-learn 1.9.0
입력 검색 실험대가 저장한 scifact_*.npy, korquad_*.npy
모델 리비전 sentence-transformers/all-MiniLM-L6-v2 (1110a243), intfloat/multilingual-e5-small (614241f6)
실행 시간 스윕 11.0초 · 대조 실험 2.8초 · 한국어 1.7초 (스윕은 다시 돌리면 10.8~13.0초로 흔들린다)
자기검사 새 가상환경을 만들어 세 스크립트를 다시 돌려 위 출력이 전부 같은지 확인함
측정일 2026-08-21

.npy 네 개를 다시 만들려면 검색 실험대의 스크립트 두 개를 먼저 돌린다. 이 컨테이너에서 인코딩에 각각 97초와 61초가 들었고, 그 뒤로는 위 세 스크립트가 전부 16초 안에 끝난다.


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

LATEST

과학·실험의 최신 글

과학·실험2026.08.19

KV 캐시 메모리 공식을 실측으로 검증했다: 13칸이 오차 없이 맞았고, 4배 큰 모델이 캐시는 3분의 1이었다

공식으로 예측한 바이트와 past_key_values를 실제로 재서 나온 바이트가 세 모델 13개 칸에서 전부 비 1.0000으로 맞았다. 오차가 아니라 항등식이다. 그런데 토큰당 캐시는 파라미터 수와 반대로 간다 — GPT-2(124M)가 72KB, Qwen2.5-0.5B(494M)가 24KB다.

25 MIN