리서치

LAB / 중급

임베딩 차원을 줄이면 검색은 어디서 무너지는가: 영어의 무릎은 한국어의 무릎이 아니었다

384차원 임베딩을 PCA로 256·128·64·32·16까지 줄이며 검색 품질이 꺾이는 지점을 쟀다. 영어에서는 128차원이 메모리 3분의 1에 품질 94.7%를 지키는 무릎이었지만, 같은 자리에서 한국어는 이미 12.4%를 잃고 있었다.

PALDYN Team15 MIN READ

임베딩을 저장할 때 차원을 줄이면 메모리와 검색 시간이 그대로 줄어든다. 384차원을 128차원으로 만들면 벡터 하나가 1,536바이트에서 512바이트가 된다. 문제는 품질이 얼마나 깎이느냐인데, 이 질문에 흔히 돌아오는 답은 "줄이면 떨어진다"는 방향뿐이다. 방향은 이미 알고 있다. 알아야 하는 것은 어디까지가 사실상 공짜이고 어디서부터 손해로 돌아서는가다.

PCA가 무엇을 하는지 — 공분산 행렬의 고유벡터를 찾아 분산이 큰 축부터 남기는 원리 — 는 PCA: 고차원 데이터를 압축하는 차원 축소가 맡는다. 이 글은 무릎의 위치만 잰다.

실험대

앞 글에서 세운 검색 실험대가 남긴 .npy 네 개를 그대로 읽는다. 인코딩을 다시 하지 않으므로 이 실험 전체가 10초 남짓에 끝난다.

  • 영어: BEIR scifact 5,183문서 · 질의 300개 · all-MiniLM-L6-v2 · nDCG@10 (기준 0.6451)
  • 한국어: KorQuAD 960문단 · 질의 300개 · multilingual-e5-small · Recall@1 (기준 0.7900)

PCA는 문서 임베딩에 적합시키고, 질의와 문서를 같은 변환으로 투영한 뒤 다시 L2 정규화해 코사인 유사도를 쟀다. 정규화를 빼면 투영 후 벡터 길이가 제각각이라 코사인이 내적과 어긋난다.

한 가지 함정이 있다. scikit-learn의 PCA는 조건이 맞으면 randomized SVD를 자동으로 고른다. 이 경우 random_state에 따라 결과가 미세하게 달라지므로, 시드를 하나 박고 끝내면 그 값이 재현 가능한 값인지 우연인지 알 수 없다. 그래서 차원마다 시드 0~4로 다섯 번씩 돌려 산포를 함께 봤다.

재현 블록

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

DIMS = [256, 128, 64, 32, 16]
disc = 1.0 / np.log2(np.arange(2, 12))


def unit(X):
    return X / np.linalg.norm(X, axis=1, keepdims=True)


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


def recall1(Q, P, gold):
    return float((np.argmax(Q @ P.T, axis=1) == gold).mean())


def sweep(name, Q, D, score):
    base = score(Q, D)
    print(f"\n[{name}]  base dim={D.shape[1]}  score={base:.4f}")
    for k in DIMS:
        vals = []
        for seed in range(5):
            p = PCA(n_components=k, random_state=seed).fit(D)
            vals.append(score(unit(p.transform(Q)), unit(p.transform(D))))
        m, lo, hi = np.mean(vals), min(vals), max(vals)
        print(f"  dim={k:<4} score={m:.4f} (spread {hi - lo:.4f})"
              f"  keep={m / base * 100:5.1f}%  mem={k / D.shape[1] * 100:4.1f}%")


D, Q, G = (np.load(f"scifact_{s}.npy") for s in ("D", "Q", "gold"))
sweep("scifact / all-MiniLM-L6-v2 / nDCG@10", Q, D, lambda q, d: ndcg10(q, d, G))

P, Qk, Gk = (np.load(f"korquad_{s}.npy") for s in ("P", "Q", "gold"))
sweep("KorQuAD / multilingual-e5-small / Recall@1", Qk, P, lambda q, d: recall1(q, d, Gk))
time python3 sweep.py

실제 출력

[scifact / all-MiniLM-L6-v2 / nDCG@10]  base dim=384  score=0.6451
  dim=256  score=0.6370 (spread 0.0000)  keep= 98.7%  mem=66.7%
  dim=128  score=0.6107 (spread 0.0000)  keep= 94.7%  mem=33.3%
  dim=64   score=0.5404 (spread 0.0000)  keep= 83.8%  mem=16.7%
  dim=32   score=0.4530 (spread 0.0000)  keep= 70.2%  mem= 8.3%
  dim=16   score=0.2960 (spread 0.0000)  keep= 45.9%  mem= 4.2%

[KorQuAD / multilingual-e5-small / Recall@1]  base dim=384  score=0.7900
  dim=256  score=0.7433 (spread 0.0100)  keep= 94.1%  mem=66.7%
  dim=128  score=0.6920 (spread 0.0100)  keep= 87.6%  mem=33.3%
  dim=64   score=0.5947 (spread 0.0233)  keep= 75.3%  mem=16.7%
  dim=32   score=0.4180 (spread 0.0167)  keep= 52.9%  mem= 8.3%
  dim=16   score=0.2420 (spread 0.0067)  keep= 30.6%  mem= 4.2%

real	0m10.484s

PCA 차원 축소에 따른 검색 품질 곡선

영어: 128차원이 무릎이다

한 칸씩 내려갈 때 잃는 품질을 보면 자리가 분명해진다.

차원 nDCG@10 남는 품질 이 칸에서 추가로 잃은 몫 메모리
384 0.6451 100% 100%
256 0.6370 98.7% 1.3%p 66.7%
128 0.6107 94.7% 4.0%p 33.3%
64 0.5404 83.8% 10.9%p 16.7%
32 0.4530 70.2% 13.6%p 8.3%
16 0.2960 45.9% 24.3%p 4.2%

256차원까지는 사실상 공짜다(1.3%p). 128차원은 메모리를 3분의 1로 줄이면서 품질 94.7%를 지키니 값을 치를 만하다. 손해로 돌아서는 곳은 128 → 64 구간이다. 여기서 처음으로 두 자리 손실(10.9%p)이 나고, 메모리는 33.3%에서 16.7%로 겨우 16.6%p 더 줄어든다. 아끼는 것보다 잃는 것이 커지는 지점이 정확히 여기다.

16차원은 품질의 절반 이상이 사라진다. 384개 축에 담긴 의미를 16개로 눌러 담으려니 서로 다른 문서가 같은 자리로 뭉개진다.

산포는 다섯 시드 모두 0.0000이다. scifact 규모에서는 randomized SVD의 난수가 순위를 뒤집을 만큼 결과를 흔들지 못한다.

한국어: 무릎이 그 자리에 없다

계획 단계의 예상은 "한국어에서도 무릎이 같은 자리인지 교차 확인한다"였다. 같지 않았다.

차원 Recall@1 남는 품질 시드 산포 영어와의 격차
384 0.7900 100%
256 0.7433 94.1% 0.0100 −4.6%p
128 0.6920 87.6% 0.0100 −7.1%p
64 0.5947 75.3% 0.0233 −8.5%p
32 0.4180 52.9% 0.0167 −17.3%p
16 0.2420 30.6% 0.0067 −15.3%p

영어가 "공짜"라고 부른 256차원에서 한국어는 이미 5.9%를 잃는다. 영어의 1.3%p와 견주면 네 배가 넘는다. 영어의 무릎인 128차원에서 한국어는 12.4%를 잃고 있고, 이는 영어가 64차원까지 내려가서야 겪는 수준(16.2%)에 가깝다.

한국어 쪽에는 공짜 구간이 없다. 첫 칸부터 값을 치른다. 굳이 무릎을 하나 짚어야 한다면 256차원이지만, 그것은 "공짜라서"가 아니라 "그나마 손실이 6% 밑이라서"다.

왜 이렇게 갈리는지는 이 실험만으로 단정할 수 없다. 다만 두 축이 동시에 다르다는 사실은 짚어 둘 만하다. 모델이 다르고(단일 언어 vs 100개 언어 다국어), 지표가 다르다(순위 품질 nDCG@10 vs 1등만 세는 Recall@1). Recall@1은 1등이 2등으로 밀리기만 해도 통째로 0점이 되므로 같은 왜곡에도 더 크게 반응한다. 다국어 모델이 384차원에 훨씬 많은 언어를 욱여넣은 탓에 축 하나하나가 더 바쁘다는 설명도 가능하지만, 둘을 가르려면 같은 코퍼스에 단일 언어 모델과 다국어 모델을 나란히 돌려야 한다. 이 실험은 그것을 하지 않았다.

한국어 쪽 산포는 0이 아니다. 최대 0.0233(64차원)까지 벌어지는데, 이는 300개 질의 중 7건이 시드에 따라 등수가 바뀐다는 뜻이다. 한국어 결과에서는 2%p 미만의 차이를 의미 있게 읽으면 안 된다.

"설명 분산 95%" 규칙은 이 무릎을 못 찾는다

PCA로 차원을 고를 때 가장 흔히 쓰는 기준은 누적 설명 분산이 95%가 되는 차원을 잡는다는 규칙이다. 검색에서도 통할까. 같은 임베딩으로 재 봤다.

import numpy as np
from sklearn.decomposition import PCA

for name, f in (("scifact / MiniLM", "scifact_D.npy"), ("KorQuAD / e5-small", "korquad_P.npy")):
    D = np.load(f)
    ev = PCA(n_components=384, random_state=0).fit(D).explained_variance_ratio_.cumsum()
    print(f"\n[{name}]  n={D.shape[0]}")
    for k in (256, 128, 64, 32, 16):
        print(f"  dim={k:<4} 설명 분산 {ev[k-1]*100:5.1f}%")
    for thr in (0.90, 0.95, 0.99):
        print(f"  설명 분산 {thr*100:.0f}% 도달에 필요한 차원 = {int(np.searchsorted(ev, thr)) + 1}")
[scifact / MiniLM]  n=5183
  dim=256  설명 분산  97.9%
  dim=128  설명 분산  83.9%
  dim=64   설명 분산  66.2%
  dim=32   설명 분산  50.3%
  dim=16   설명 분산  37.0%
  설명 분산 90% 도달에 필요한 차원 = 166
  설명 분산 95% 도달에 필요한 차원 = 211
  설명 분산 99% 도달에 필요한 차원 = 290

[KorQuAD / e5-small]  n=960
  dim=256  설명 분산  95.3%
  dim=128  설명 분산  77.9%
  dim=64   설명 분산  58.9%
  dim=32   설명 분산  43.0%
  dim=16   설명 분산  30.9%
  설명 분산 90% 도달에 필요한 차원 = 201
  설명 분산 95% 도달에 필요한 차원 = 253
  설명 분산 99% 도달에 필요한 차원 = 329

규칙이 두 방향으로 다 빗나간다.

영어에서 95% 규칙은 211차원을 고르라고 한다. 그런데 실제로는 128차원(설명 분산 83.9%)에서 품질 94.7%가 남는다. 규칙을 따르면 쓸 수 있는 것보다 1.6배 큰 벡터를 저장하게 된다. 설명 분산 83.9%라는 숫자만 보면 "16%를 버렸다"고 읽히지만 검색 품질은 5.3%만 잃었다.

한국어에서는 반대로 안심시킨다. 95% 규칙은 253차원을 고르라고 하고, 256차원의 설명 분산은 95.3%로 기준을 통과한다. 그런데 그 자리에서 Recall@1은 이미 5.9%가 사라진 뒤다. 규칙은 초록불을 켜는데 검색은 이미 값을 치르고 있다.

설명 분산 실제 남는 검색 품질 차이
scifact 256차원 97.9% 98.7% +0.8%p
scifact 128차원 83.9% 94.7% +10.8%p
KorQuAD 256차원 95.3% 94.1% −1.2%p
KorQuAD 128차원 77.9% 87.6% +9.7%p

설명 분산과 검색 품질은 같은 방향으로 움직이지만 같은 크기로 움직이지 않고, 코퍼스마다 어긋나는 부호도 다르다. 설명 분산은 임베딩 행렬의 성질이고 검색 품질은 질의와 정답의 관계라서, 잘려 나간 축이 마침 문서를 구별하던 축인지 아닌지는 분산만 봐서 알 수 없다. 차원을 고를 때는 설명 분산이 아니라 쓸 지표를 직접 재야 한다. 스윕 전체가 10초면 끝나므로 대신 쓸 이유도 없다.

꺾이는 지점 한 줄

영어(scifact·MiniLM)는 256차원까지 공짜(−1.3%p), 128차원까지 값어치 있음(94.7% 유지, 메모리 1/3), 64차원부터 손해(−10.9%p). 한국어(KorQuAD·e5-small)는 공짜 구간 없음 — 256차원에서 이미 −5.9%, 128차원에서 −12.4%.

실무로 옮기면 이렇다. 영어 문서 검색이면 128차원으로 저장해 메모리를 3분의 1로 줄여도 좋다. 같은 결정을 한국어 코퍼스에 그대로 적용하면 정답을 1등에 놓던 질의 100건 중 12건을 잃는다. 차원 축소 비율은 언어와 모델을 바꿀 때마다 다시 재야 하는 값이지, 한 번 정해 물려 쓰는 값이 아니다.

한계

  • 코퍼스 하나 · 모델 하나 · 질의 300개씩이다. 두 곡선의 차이가 언어 때문인지, 모델 때문인지, 지표 때문인지 이 실험은 가르지 못한다. 세 요인이 함께 바뀌었다.
  • 지표가 서로 다르다. 영어는 nDCG@10, 한국어는 Recall@1이다. 정답 문단이 하나뿐인 KorQuAD에 nDCG@10을 쓰면 사실상 MRR이 되어 의미가 얕아지고, 정답이 여럿인 scifact에 Recall@1을 쓰면 정보를 버린다. 코퍼스에 맞는 지표를 골랐지만, 그 대가로 두 곡선의 세로축 의미가 완전히 같지는 않다.
  • PCA만 봤다. 차원을 줄이는 방법에는 Matryoshka 임베딩처럼 애초에 잘라 쓰도록 학습된 모델, 프로덕트 양자화, 오토인코더가 있다. 학습된 절단은 PCA보다 훨씬 잘 버틴다고 알려져 있으나 여기서는 재지 않았다.
  • 속도는 재지 않았다. 차원을 줄이면 검색도 빨라지지만, 5,183문서 규모에서는 검색 자체가 0.04초라 의미 있는 차이가 나오지 않는다. 그 축은 코퍼스를 키워서 따로 재야 한다.
  • PCA 적합 비용을 계산에 넣지 않았다. 문서가 늘면 적합도 다시 해야 하고, 새 문서를 넣을 때마다 같은 변환을 유지할지 다시 적합할지가 운영 문제로 남는다.

측정 환경

항목
OS Linux 6.18.5 x86_64, glibc 2.39
CPU / RAM Intel Xeon @ 2.10GHz, 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, intfloat/multilingual-e5-small (2026-08-06 기준 main)
스윕 전체 실행 시간 10.5초 (차원 5개 × 시드 5개 × 코퍼스 2개 = 50회 적합)
자기검사 새 가상환경에서 두 스크립트를 다시 돌려 위 출력이 전부 같은지 확인함
측정일 2026-08-06

KEEP READING

이어 읽기

PALDYN / AI LAB

AI를 이해하고 배우는 데 필요한 개념, 수학, 논문과 실험을 연결해 기록합니다.

OfficialTech Blog© 2026 PALDYN