그 논문은 앞에서 잘라도 되는 임베딩을 만드는 법에 대한 것이다. 그러면 그렇게 만들지 않은 임베딩을 앞에서 자르면 어떻게 되는가. 이 글은 그 질문 하나를 재는 글이다.
차원을 줄이면 검색이 어디서 무너지는지는 차원 절벽이, PCA 자체는 주성분 분석이 맡는다. 여기서는 자르는 방식 셋을 같은 자로 재는 표 하나만 본다.
MRL의 주장
중첩 손실
Aditya Kusupati 외 (2022), "Matryoshka Representation Learning", arXiv:2205.13147.
MRL(Matryoshka Representation Learning)은 하나의 임베딩 안에 여러 크기의 임베딩이 겹쳐 들어가도록 학습시키는 방법이다. 미리 정한 차원 집합 의 각 마다 앞 차원만 잘라 쓴 벡터로도 손실을 계산해 전부 더한다.
논문은 이미지 쪽에 을, 언어 모델 쪽에 을 쓴다.
재현할 문장
재현할 주장은 이 문장이다.
The first m-dimensions … of the Matryoshka Representation is an information-rich low-dimensional vector, at no additional training cost, that is as accurate as an independently trained m-dimensional representation.
여기서 우리가 붙잡을 것은 뒷부분이 아니라 앞부분이다. "앞 차원을 그냥 잘라 써도 된다"는 성질이 MRL 학습으로 만들어지는 것인가, 아니면 임베딩이 원래 어느 정도 갖고 있는 것인가.
Rand. FS
이 질문에 논문은 이미 답을 하나 갖고 있다. Table 2의 Rand. FS 열이 그것이다. 이름과 달리 무작위가 아니고, 논문이 정의를 적어 뒀다.
Random Feature Selection (Rand. FS): considering the first m dimensions of FF-2048 for NN lookup
FF-2048은 MRL 없이 보통 방식으로 학습한 2048차원 모델이다. 즉 Rand. FS는 MRL로 학습하지 않은 모델을 앞에서 그냥 자른 것이고, 이 글이 재려는 것과 같은 조작이다. ImageNet 1-NN 정확도(%)로 논문이 보고한 값은 이렇다.
| 차원 | Rand. FS (그냥 자름) | SVD | FF (그 차원으로 따로 학습) | MRL |
|---|---|---|---|---|
| 2048 | 71.19 | 71.21 | 71.19 | 70.97 |
| 512 | 68.77 | 71.06 | 70.18 | 70.82 |
| 256 | 65.75 | 70.67 | 69.72 | 70.62 |
| 128 | 60.91 | 69.63 | 69.35 | 70.52 |
| 64 | 49.91 | 67.04 | 69.41 | 70.17 |
| 32 | 32.91 | 60.78 | 68.84 | 69.46 |
| 16 | 12.06 | 46.02 | 66.77 | 67.91 |
| 8 | 2.36 | 19.14 | 58.93 | 62.19 |
이 표를 유지율, 곧 전체 차원의 점수 대비 줄인 차원의 점수가 몇 %인지로 다시 읽으면 주장이 선명해진다. 그냥 자른 쪽은 2048차원 대비 256에서 92.4%, 128에서 85.6%, 64에서 70.1%, 32에서는 46.2%밖에 남지 않는다. MRL은 같은 자리에서 99.5% / 99.4% / 98.9% / 97.9%다. 그냥 자르면 64차원부터 3할이 날아간다는 것이 논문이 그린 그림이다.
우리 실험은 이 그림을 다른 자리에서 다시 그린다. 과제가 다르고(ImageNet 분류가 아니라 문서 검색), 모델이 다르고(ResNet50이 아니라 문장 임베딩), 축소 방법 하나가 더 있다(논문의 SVD는 분류층의 저계수 근사인데 우리는 임베딩 자체의 PCA다). 그러니 절댓값을 맞대는 것이 아니라 유지율의 모양이 같은지 본다.
모델 짝
모델 셋
이 실험이 성립하려면 MRL로 학습한 모델과 학습하지 않은 모델이 나머지 조건에서 같아야 한다. 다행히 그런 짝이 공개돼 있다.
| 모델 | MRL | 차원 | 무엇 |
|---|---|---|---|
sentence-transformers/all-MiniLM-L6-v2 |
아님 | 384 | 실험대의 기준 모델 |
tomaarsen/mpnet-base-nli |
아님 | 768 | 대조군 |
tomaarsen/mpnet-base-nli-matryoshka |
맞음 | 768 | 같은 조리법에 MRL 손실만 얹은 것 |
뒤 둘은 같은 사람이 같은 베이스 모델(mpnet-base)과 같은 데이터(SNLI + MultiNLI)로 학습한 짝이고, matryoshka 쪽 저장소에는 학습 스크립트가 함께 올라와 있어 무엇이 달랐는지 확인할 수 있다.
loss = losses.MatryoshkaLoss(model, loss, [768, 512, 256, 128, 64])
중첩 차원이 768·512·256·128·64 다섯이다. 32와 16은 학습에 쓰이지 않은 자리이므로, 그 아래에서 무슨 일이 나는지가 이 실험의 덤이다.
세 모델의 ID는 모두 집필 전에 실제로 불러 확인했다. 게이트된 저장소(meta-llama/*, google/gemma-*)는 후보에서 뺐다.
입력 길이 상한
여기서 조용한 함정을 하나 밟았다. 두 mpnet 모델의 sentence_bert_config.json을 열어 보면 이렇다.
tomaarsen/mpnet-base-nli {"max_seq_length": 75}
tomaarsen/mpnet-base-nli-matryoshka {"max_seq_length": 384}
같은 조리법의 짝인데 입력 길이 상한이 다섯 배 차이가 난다. 그대로 돌리면 대조군은 문서를 75토큰에서 자른 채 인코딩하고 MRL 모델은 384토큰까지 읽는다. scifact 문서는 제목과 초록을 합쳐 그보다 훨씬 길기 때문에, 두 모델의 품질 차이가 손실 함수 때문인지 읽은 글자 수 때문인지 갈 수 없게 된다.
에러는 나지 않는다. 처음 돌렸을 때 드러난 유일한 신호는 MRL 모델 쪽 인코딩이 유독 느리다는 것이었고, 그게 아니었으면 못 봤을 것이다. 그래서 두 mpnet 모델 모두 max_seq_length = 128로 명시해 맞췄다. 기준 모델인 MiniLM은 기본값(256)을 그대로 둔다 — 그쪽은 실험대의 공개된 값과 맞는지 확인하는 데 쓰기 때문이다.
모델 카드에 적힌 설정은 짝이라고 같지 않다. 두 모델을 비교하는 실험에서는 이름과 계보가 아니라 config를 대조해야 한다.
절단과 PCA
재현 블록 1
pip install torch sentence-transformers datasets numpy scikit-learn
import time, numpy as np, torch
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
from sklearn.decomposition import PCA
torch.manual_seed(0)
corpus = load_dataset("BeIR/scifact", "corpus")["corpus"]
queries = load_dataset("BeIR/scifact", "queries")["queries"]
qrels = load_dataset("BeIR/scifact-qrels")["test"]
gm = {}
for r in qrels:
gm.setdefault(str(r["query-id"]), set()).add(str(r["corpus-id"]))
qids = sorted(gm, key=int)
docs = [(d["title"] + " " + d["text"]).strip() for d in corpus]
dids = [str(d["_id"]) for d in corpus]
qtext = {str(q["_id"]): q["text"] for q in queries}
G = np.array([[dids[j] in gm[q] for j in range(len(dids))] for q in qids], dtype=bool)
np.save("G.npy", G)
disc = 1.0 / np.log2(np.arange(2, 12))
unit = lambda X: X / np.linalg.norm(X, axis=1, keepdims=True)
def ndcg10(Q, D): # 질의별 nDCG@10 — 평균 내지 않고 돌려준다
top = np.argsort(-(Q @ D.T), axis=1)[:, :10]
return np.array([(G[i][top[i]] * disc).sum() / disc[:min(G[i].sum(), 10)].sum()
for i in range(len(Q))])
MODELS = [("all-MiniLM-L6-v2", "sentence-transformers/all-MiniLM-L6-v2", None),
("mpnet-base-nli", "tomaarsen/mpnet-base-nli", 128),
("mpnet-base-nli-matryoshka", "tomaarsen/mpnet-base-nli-matryoshka", 128)]
print(f"corpus={len(docs)} queries={len(qids)} qrels={len(qrels)}", flush=True)
for name, mid, msl in MODELS:
t0 = time.perf_counter(); m = SentenceTransformer(mid); tl = time.perf_counter() - t0
if msl:
m.max_seq_length = msl # 두 mpnet을 같은 입력 길이로 맞춘다
t0 = time.perf_counter()
D = m.encode(docs, batch_size=64, normalize_embeddings=True, show_progress_bar=False)
Q = m.encode([qtext[q] for q in qids], batch_size=64, normalize_embeddings=True,
show_progress_bar=False)
te = time.perf_counter() - t0
np.save(f"D_{name}.npy", D); np.save(f"Q_{name}.npy", Q)
full = D.shape[1]; base = ndcg10(Q, D).mean()
print(f"\n[{name}] dim={full} max_seq_length={m.max_seq_length} "
f"nDCG@10={base:.4f} load {tl:.1f}s encode {te:.1f}s", flush=True)
print(f" {'dim':>5}{'앞에서 자름':>13}{'유지':>8}{'PCA':>11}{'유지':>8}", flush=True)
for d in (512, 256, 128, 64, 32, 16):
if d >= full:
continue
tr = ndcg10(unit(Q[:, :d]), unit(D[:, :d])).mean()
p = PCA(n_components=d, random_state=0).fit(D)
pc = ndcg10(unit(p.transform(Q)), unit(p.transform(D))).mean()
print(f" {d:>5}{tr:>13.4f}{tr / base * 100:>7.1f}%{pc:>11.4f}{pc / base * 100:>7.1f}%",
flush=True)
python3 mrl.py
자른 뒤에 다시 정규화하는 것이 중요하다. 앞 차원만 남기면 벡터의 길이가 짧아지고, 그 길이는 문서마다 다르다. 정규화를 빼면 코사인이 아니라 내적을 재는 셈이 되어 길이가 긴 문서가 위로 올라온다 — 축소가 아니라 그 편향을 재게 된다.
출력 1
corpus=5183 queries=300 qrels=339
[all-MiniLM-L6-v2] dim=384 max_seq_length=256 nDCG@10=0.6451 load 1.8s encode 97.6s
dim 앞에서 자름 유지 PCA 유지
256 0.6349 98.4% 0.6370 98.7%
128 0.6016 93.3% 0.6107 94.7%
64 0.4841 75.0% 0.5404 83.8%
32 0.3371 52.3% 0.4530 70.2%
16 0.1580 24.5% 0.2960 45.9%
[mpnet-base-nli] dim=768 max_seq_length=128 nDCG@10=0.3557 load 6.4s encode 329.7s
dim 앞에서 자름 유지 PCA 유지
512 0.3476 97.7% 0.3451 97.0%
256 0.3203 90.0% 0.3429 96.4%
128 0.2485 69.9% 0.3189 89.7%
64 0.1676 47.1% 0.2516 70.7%
32 0.0858 24.1% 0.1634 46.0%
16 0.0296 8.3% 0.0936 26.3%
[mpnet-base-nli-matryoshka] dim=768 max_seq_length=128 nDCG@10=0.3197 load 2.2s encode 332.7s
dim 앞에서 자름 유지 PCA 유지
512 0.3123 97.7% 0.2994 93.6%
256 0.2927 91.5% 0.2958 92.5%
128 0.2193 68.6% 0.2732 85.4%
64 0.1767 55.3% 0.2065 64.6%
32 0.0858 26.8% 0.1330 41.6%
16 0.0379 11.9% 0.0854 26.7%
MiniLM 줄이 이 표의 검사 항목이다. 전체 384차원의 nDCG@10이 0.6451이고 PCA 열이 0.6370 / 0.6107 / 0.5404 / 0.4530 / 0.2960인데, 차원 절벽이 공개한 값과 소수점 넷째 자리까지 같다. 같은 실험대 위에 있다는 뜻이다.
남긴 비율
세 가지가 보인다. 첫째, 그냥 자르는 것은 생각만큼 무너지지 않는다. 그리고 무너지는 자리를 정하는 것은 차원 수가 아니라 남긴 비율, 곧 전체 차원 가운데 남긴 차원의 몫이다. MRL로 학습하지 않은 두 모델의 절단 유지율을 남긴 비율로 세워 보면 거의 겹친다.
| 남긴 비율 | MiniLM (384차원 기준) | mpnet-base-nli (768차원 기준) |
|---|---|---|
| 2/3 | 98.4% (256차원) | 97.7% (512차원) |
| 1/3 | 93.3% (128차원) | 90.0% (256차원) |
| 1/6 | 75.0% (64차원) | 69.9% (128차원) |
| 1/12 | 52.3% (32차원) | 47.1% (64차원) |
| 1/24 | 24.5% (16차원) | 24.1% (32차원) |
두 모델은 층 수도 학습 데이터도 다른데 같은 비율에서 같은 유지율을 낸다. 1/3까지는 9할이 남고, 1/6에서 7할, 1/12에서 절반이다. MRL 없이도 이 정도는 견딘다는 뜻이라 "앞에서 자르면 안 된다"는 통념은 이 자리에서 이미 반쯤 깨진다.
PCA와 MRL 모델
둘째, 그래도 PCA가 절단을 이긴다. MiniLM의 64차원에서 절단은 75.0%인데 PCA는 83.8%다. mpnet의 128차원에서는 69.9% 대 89.7%로 20%p 가까이 벌어진다. 두 방식이 맞먹는 구간은 남긴 비율 2/3 언저리뿐이고, 그 아래에서는 축을 다시 고르는 쪽이 계속 앞선다.
셋째, MRL로 학습한 모델이 그 판을 뒤집지 못했다. matryoshka 쪽의 128차원 절단은 68.6%로, 같은 모델의 PCA(85.4%)보다 낮고 MRL로 학습하지 않은 모델의 PCA(89.7%)보다도 낮다. 전체 차원의 품질도 0.3197 대 0.3557로 대조군보다 낮은데, 방향 자체는 논문과 어긋나지 않는다 — 논문의 Table 2도 2048차원에서 MRL 70.97, FF 71.19로 MRL이 근소하게 아래다. 다만 우리 쪽 차이가 그보다 크다.
여기까지는 표를 눈으로 읽은 것이다. 유지율의 차이가 표본 300개에서 판정할 만한 크기인지는 다음 블록이 정한다.
앞 m차원
재현 블록 2
주장의 핵심은 "차원을 줄여도 된다"가 아니라 "앞에서부터" 줄여도 된다는 것이다. 그렇다면 같은 개수의 차원을 아무 데서나 골랐을 때보다 앞 개가 나아야 한다. MRL 학습이 한 일이 정확히 그것이기 때문이다.
같은 임베딩 파일을 다시 읽어 재므로 추가 인코딩이 없다.
import numpy as np
from sklearn.decomposition import PCA
G = np.load("G.npy"); nq = len(G)
disc = 1.0 / np.log2(np.arange(2, 12))
unit = lambda X: X / np.linalg.norm(X, axis=1, keepdims=True)
rng = np.random.default_rng(0)
BS = rng.integers(0, nq, (2000, nq))
DIMS = (512, 256, 128, 64, 32, 16)
MODELS = ("all-MiniLM-L6-v2", "mpnet-base-nli", "mpnet-base-nli-matryoshka")
def ndcg10(Q, D):
top = np.argsort(-(Q @ D.T), axis=1)[:, :10]
return np.array([(G[i][top[i]] * disc).sum() / disc[:min(G[i].sum(), 10)].sum()
for i in range(len(Q))])
def ci(d):
lo, hi = np.percentile(d[BS].mean(1), [2.5, 97.5])
return lo, hi, ("동률" if lo <= 0 <= hi else ("A 승" if lo > 0 else "B 승"))
R = {}
print("A = 앞에서 자름, B = 같은 개수의 차원을 무작위로 고름 (시드 5개 평균)\n")
print(f"{'모델':<28}{'dim':>5}{'앞':>9}{'무작위':>9}{'차이':>9}{'95% 구간':>22} 판정")
for name in MODELS:
D = np.load(f"D_{name}.npy"); Q = np.load(f"Q_{name}.npy"); full = D.shape[1]
R[name] = {"full": ndcg10(Q, D)}
for d in DIMS:
if d >= full:
continue
tr = ndcg10(unit(Q[:, :d]), unit(D[:, :d]))
rs = np.mean([ndcg10(unit(Q[:, s]), unit(D[:, s])) for s in
[np.random.default_rng(k).choice(full, d, replace=False) for k in range(5)]],
axis=0)
R[name][d] = tr
lo, hi, tag = ci(tr - rs)
print(f"{name:<28}{d:>5}{tr.mean():>9.4f}{rs.mean():>9.4f}{tr.mean() - rs.mean():>+9.4f}"
f"{f'[{lo:+.4f}, {hi:+.4f}]':>22} {tag}")
print("\n\n같은 조리법의 두 mpnet — 절단 유지율의 차이가 MRL 손실이 만든 것인가")
print("A = MRL 학습 모델의 유지율, B = MRL 아닌 모델의 유지율\n")
a, b = "mpnet-base-nli-matryoshka", "mpnet-base-nli"
fa, fb = R[a]["full"], R[b]["full"]
print(f"{'dim':>5}{'MRL 유지':>10}{'비MRL 유지':>12}{'차이':>9}{'95% 구간':>22} 판정 훈련 지점")
for d in DIMS:
ra = R[a][d][BS].mean(1) / fa[BS].mean(1)
rb = R[b][d][BS].mean(1) / fb[BS].mean(1)
lo, hi = np.percentile(ra - rb, [2.5, 97.5])
tag = "동률" if lo <= 0 <= hi else ("A 승" if lo > 0 else "B 승")
pa, pb = R[a][d].mean() / fa.mean(), R[b][d].mean() / fb.mean()
print(f"{d:>5}{pa * 100:>9.1f}%{pb * 100:>11.1f}%{(pa - pb) * 100:>+8.1f}%p"
f"{f'[{lo * 100:+.1f}, {hi * 100:+.1f}]':>22} {tag}"
f" {'예' if d in (512, 256, 128, 64) else '아니오'}")
python3 check.py
출력 2
A = 앞에서 자름, B = 같은 개수의 차원을 무작위로 고름 (시드 5개 평균)
모델 dim 앞 무작위 차이 95% 구간 판정
all-MiniLM-L6-v2 256 0.6349 0.6341 +0.0008 [-0.0126, +0.0145] 동률
all-MiniLM-L6-v2 128 0.6016 0.5959 +0.0056 [-0.0160, +0.0275] 동률
all-MiniLM-L6-v2 64 0.4841 0.4829 +0.0013 [-0.0241, +0.0279] 동률
all-MiniLM-L6-v2 32 0.3371 0.3205 +0.0167 [-0.0173, +0.0516] 동률
all-MiniLM-L6-v2 16 0.1580 0.1460 +0.0120 [-0.0174, +0.0425] 동률
mpnet-base-nli 512 0.3476 0.3359 +0.0117 [-0.0001, +0.0238] 동률
mpnet-base-nli 256 0.3203 0.3082 +0.0120 [-0.0065, +0.0310] 동률
mpnet-base-nli 128 0.2485 0.2464 +0.0022 [-0.0224, +0.0260] 동률
mpnet-base-nli 64 0.1676 0.1581 +0.0095 [-0.0147, +0.0351] 동률
mpnet-base-nli 32 0.0858 0.1052 -0.0194 [-0.0419, +0.0023] 동률
mpnet-base-nli 16 0.0296 0.0429 -0.0133 [-0.0282, +0.0026] 동률
mpnet-base-nli-matryoshka 512 0.3123 0.3041 +0.0082 [-0.0038, +0.0208] 동률
mpnet-base-nli-matryoshka 256 0.2927 0.2724 +0.0202 [-0.0015, +0.0429] 동률
mpnet-base-nli-matryoshka 128 0.2193 0.2257 -0.0064 [-0.0291, +0.0174] 동률
mpnet-base-nli-matryoshka 64 0.1767 0.1480 +0.0287 [+0.0056, +0.0519] A 승
mpnet-base-nli-matryoshka 32 0.0858 0.0918 -0.0060 [-0.0277, +0.0161] 동률
mpnet-base-nli-matryoshka 16 0.0379 0.0368 +0.0011 [-0.0155, +0.0191] 동률
같은 조리법의 두 mpnet — 절단 유지율의 차이가 MRL 손실이 만든 것인가
A = MRL 학습 모델의 유지율, B = MRL 아닌 모델의 유지율
dim MRL 유지 비MRL 유지 차이 95% 구간 판정 훈련 지점
512 97.7% 97.7% -0.1%p [-4.0, +4.1] 동률 예
256 91.5% 90.0% +1.5%p [-3.9, +7.3] 동률 예
128 68.6% 69.9% -1.3%p [-7.4, +4.9] 동률 예
64 55.3% 47.1% +8.2%p [+1.3, +14.6] A 승 예
32 26.8% 24.1% +2.7%p [-3.8, +9.7] 동률 아니오
16 11.9% 8.3% +3.5%p [-1.6, +8.8] 동률 아니오
64차원
판정은 부트스트랩으로 냈다 — 질의 300개를 복원 추출로 2,000번 다시 뽑아 차이의 평균이 떨어지는 95% 구간을 구하고, 그 구간이 0을 품으면 동률이다.
첫 표에서 앞 차원이 무작위로 고른 차원을 이긴 자리는 17번 중 한 번, matryoshka 모델의 64차원(+0.0287, 구간 [+0.0056, +0.0519])뿐이다. MRL로 학습하지 않은 두 모델은 열한 자리 모두 동률이고, mpnet-base-nli의 32·16차원에서는 앞쪽이 오히려 무작위보다 낮게 나왔다(−0.0194, −0.0133). 구간이 0을 품어 동률로 판정됐을 뿐이다.
둘째 표도 같은 자리를 가리킨다. 두 mpnet의 절단 유지율이 갈린 곳은 64차원 하나로, MRL 쪽이 55.3% 대 47.1%, +8.2%p(구간 [+1.3, +14.6])다. 나머지 다섯 자리는 동률이다. 64는 학습 스크립트의 중첩 차원 가운데 맨 아래이고, 그보다 위인 512·256·128은 훈련 지점인데도 차이가 구간 안에 들었다. 훈련에 안 쓰인 32·16도 동률이다.
판정
논문과의 대조
차원 수를 나란히 놓을 수는 없다 — 논문의 전체 차원은 2048이고 우리 모델은 768과 384다. 그래서 유지율과 방향으로만 견준다.
| 논문이 말하는 것 | 논문 수치 (ImageNet 1-NN) | 이 실험대 (scifact nDCG@10) | 판정 |
|---|---|---|---|
| MRL 아닌 모델을 그냥 자르면 크게 잃는다 | 남긴 비율 1/32에서 70.1%, 1/64에서 46.2% | 1/12에서 47.1%, 1/24에서 24.1% | 방향 일치 |
| MRL 모델은 잘라도 거의 그대로다 | 64차원에서 98.9%, 32차원에서 97.9% | 64차원에서 55.3%, 32차원에서 26.8% | 재현 안 됨 |
| 그래서 MRL이 그냥 자른 것보다 낫다 | 유지율로 64차원에서 +28.8%p (98.9% 대 70.1%) | 유지율로 64차원에서 +8.2%p, 나머지 다섯 자리 동률 | 부분적 |
| 앞 차원이라는 자리가 의미를 갖는다 | Rand. FS가 이 성질을 못 가진다는 것이 전제 | 17번 중 16번이 무작위 차원과 동률 | 한 자리만 |
| MRL은 최대 차원에서 조금 손해 | 2048차원에서 70.97 대 71.19 | 0.3197 대 0.3557 | 방향 일치, 크기 큼 |
첫 줄과 마지막 줄은 살아남았고 가운데 셋은 이 체크포인트에서 성립하지 않았다. 다시 말하지만 이것이 MRL이라는 방법에 대한 판정은 아니다. 우리가 돌린 것은 논문 저자의 모델이 아니라 커뮤니티가 같은 손실로 학습한 별개의 체크포인트이고, 그 학습이 얼마나 잘 됐는지는 우리가 보증할 수 없다. 이 표가 말하는 것은 「MRL 손실로 학습했다」는 라벨만으로 절단 내성을 가정하면 안 된다는 것이다.
꺾이는 지점
남긴 비율 1/3까지는 앞에서 그냥 잘라도 PCA와 큰 차이가 없다. 1/6 아래로 내려가면 PCA가 15~20%p 앞선다.
숫자로 못 박으면 이렇다. 남긴 비율 2/3에서 절단과 PCA의 차이는 MiniLM 0.3%p, mpnet 0.7%p로 사실상 없다. 1/3에서 1.4%p와 6.4%p로 벌어지고, 1/6에서 8.8%p와 19.8%p가 된다. 여기가 공짜에서 손해로 도는 자리다.
실무로 옮기면 한 줄이 된다. 768차원 임베딩을 256차원까지 줄일 거면 v[:256]으로 충분하고, 그 아래로 갈 거면 PCA를 맞추거나 그 차원에서 학습된 모델을 구해야 한다. 그리고 MRL로 학습됐다는 표시가 붙어 있어도 이 선이 저절로 내려가지는 않는다 — 우리가 잰 체크포인트에서 선이 내려간 자리는 64차원 하나뿐이었고, 그마저 유지율은 55.3%로 쓸 수 있는 값이 아니었다.
실험의 범위
확인되지 않은 것
- 과제가 다르다. 논문은 ImageNet 분류와 최근접 이웃 조회를, 우리는 scifact 문서 검색을 쟀다. 유지율의 모양을 견주는 것까지가 이 대조가 할 수 있는 전부이고, 논문의 수치를 검산한 것이 아니다.
- MRL 모델이 논문의 모델이 아니다. 우리가 쓴 것은 sentence-transformers 커뮤니티가 MRL 손실로 학습한 mpnet이고, 논문의 ResNet50·BERT 실험과 학습 규모·데이터가 전혀 다르다. MRL이 잘 되게 학습됐는지 자체를 우리가 보증하지 못한다 — 위 결과에서 MRL 쪽이 기대만큼 안 나오면 방법의 문제인지 이 체크포인트의 문제인지 가를 수 없다.
- 코퍼스 하나, 질의 300개다. 부트스트랩 구간을 붙였지만 300개 표본에서 몇 %p 크기는 구간이 넓다.
- 두 mpnet을 128토큰으로 맞춘 것은 짝을 맞추기 위한 선택이고 품질을 위한 선택이 아니다. scifact 문서는 그보다 길어 두 모델 모두 뒷부분을 못 읽는다. 절댓값이 낮은 이유의 상당 부분이 여기에 있다.
- PCA는 코퍼스를 보고 맞춘다. 문서 5,183개로 적합한 축이라 새 문서가 들어오면 다시 맞춰야 한다. 그냥 자르는 쪽에는 그런 비용이 없다 — 표에는 안 나오는 차이라 여기 적어 둔다.
측정 환경
| 항목 | 값 |
|---|---|
| OS / CPU | Linux 6.18.44 x86_64 / Intel Xeon @ 2.10GHz, 4코어 |
| Python | 3.11.15 |
| 패키지 | torch 2.13.0, sentence-transformers 6.0.0, datasets 5.0.1, numpy 2.4.6, scikit-learn 1.9.0 |
| 모델 리비전 | MiniLM 1110a243, mpnet-base-nli 8d633e39, mpnet-base-nli-matryoshka ea34095b |
| 데이터 | BeIR/scifact — 문서 5,183개, test 질의 300개, qrels 339개 |
| 측정 날짜 | 2026-08-23 |
블록 1의 총 실행 시간은 13분 11초였다 — 모델 셋의 인코딩이 97.6초, 329.7초, 332.7초이고 나머지는 스윕이다. 이 루틴이 정한 글 한 편 5분 상한을 넘는다. 110M 파라미터 모델 둘이 문서 5,183개를 4코어 CPU에서 인코딩하는 값이 대부분이라 두 mpnet의 max_seq_length를 384에서 128로 낮춰 줄인 것이 이 수치이고, 그 이상 줄이면 짝을 맞추는 조건이나 코퍼스를 건드려야 해서 여기서 멈췄다. 블록 2는 저장된 임베딩만 읽어 4.6초에 끝난다.
절대 시간은 결론이 아니다. 위의 모든 판정은 같은 기계에서 잰 유지율의 대소로만 냈다.
읽어주셔서 감사합니다. 😊

