임베딩 하나가 384차원 fp32면 1,536바이트다. fp16으로 담으면 768바이트, int8이면 384바이트, 부호만 남기면 48바이트가 된다. 32배 차이는 인덱스를 램에 올릴 수 있느냐 없느냐를 가르는 크기라서, 압축의 대가를 정확히 아는 것이 곧 저장 설계다.
문제는 그 대가를 무엇으로 재느냐다. 모델 가중치를 int8로 줄이는 이야기 — 스케일과 영점, 대칭·비대칭 양자화 — 는 양자화 완전 정복이 맡는다. 그쪽의 관심사는 출력이 얼마나 달라지는가지만, 검색에서는 점수가 조금 달라지는 것 자체는 아무래도 좋다. 순위가 바뀌지 않으면 사용자에게 보이는 결과는 완전히 같기 때문이다. 그래서 이 글이 재는 것은 오차가 아니라 순위가 얼마나 뒤집히는가 하나다.
측정 지표
top-10 유지율
fp32 원본으로 뽑은 top-10을 정답으로 두고, 압축한 벡터로 뽑은 top-10이 그중 몇 개를 그대로 데려오는지 센다. 열 개 중 아홉 개가 같으면 0.9다. 이 값을 top-10 유지율이라고 부르겠다.
nDCG@10
여기에 실제 검색 품질(nDCG@10)을 함께 낸다. 둘은 다른 질문이다. 유지율은 "원본과 얼마나 같은가"고, nDCG는 "사용자에게 얼마나 좋은가"다. 원본이 8등에 놓았던 문서와 11등에 놓았던 문서가 자리를 바꾸면 유지율은 떨어지지만 품질은 거의 안 변할 수 있다.
간격과 교란
그리고 왜 그렇게 되는지를 설명할 값 하나를 더 잰다. 압축은 점수에 잡음을 얹는 일이고, 순위가 뒤집히려면 그 잡음이 이웃한 두 문서의 점수 차이보다 커야 한다. 그래서 두 값을 같은 자로 재서 나눈다.
- 간격 — 10위와 11위의 점수 차이. 이 둘이 뒤바뀌는 것이 유지율을 깎는 가장 흔한 사건이다.
- 교란 σ — 압축 전후 점수 차이의 표준편차.
두 값 모두 질의마다 점수 분포를 z 정규화한 뒤에 잰다. 그래야 코사인 점수를 쓰는 fp32와 −384~384 범위의 해밍 점수를 쓰는 1비트를 같은 자로 비교할 수 있다. 원점수 그대로 비교하면 1비트의 교란이 크게 나오는 것이 단위 탓인지 정보 손실 탓인지 구분되지 않는다.
실험대
검색 실험대가 남긴 .npy 파일을 읽는다. 인코딩을 다시 하지 않으므로 이 실험 전체가 5초 안에 끝난다.
코퍼스 셋
코퍼스는 셋이다.
| 문서 | 질의 | 모델 | 기준 nDCG@10 | |
|---|---|---|---|---|
| 가우시안 난수 | 20,000 | 200 | — | — |
| BEIR scifact | 5,183 | 300 | all-MiniLM-L6-v2 |
0.6451 |
| KorQuAD | 960 | 300 | multilingual-e5-small |
0.8877 |
난수 코퍼스가 들어 있는 이유가 이 글의 절반이다. 벡터 압축과 인덱스 성능을 재는 코드는 데이터를 구하기 귀찮아서 np.random.normal로 만든 행렬을 쓰는 경우가 흔한데, 그 관행이 실제로 얼마나 틀린 답을 주는지를 같은 표 안에서 보이려고 넣었다.
압축 방식 다섯
압축 방식은 다섯이다. int8은 스케일을 어디서 잡느냐로 둘로 갈랐다 — 행렬 전체에서 최대 절댓값 하나를 잡는 방식과 벡터마다 따로 잡는 방식이다. 1비트도 둘이다. 질의와 문서를 모두 부호만 남기는 대칭(이것이 곧 해밍 거리다)과, 문서만 부호로 줄이고 질의는 fp32로 두는 비대칭이다. 비대칭은 인덱스 크기가 대칭과 완전히 같다 — 질의 벡터는 검색할 때 한 개만 있으면 되므로 저장 비용에 들어가지 않는다.
재현
pip install numpy
import numpy as np
K, disc = 10, 1.0 / np.log2(np.arange(2, 12))
boot = np.random.default_rng(0)
I = lambda X: X
F16 = lambda X: X.astype(np.float16).astype(np.float32)
BIT = lambda X: np.where(X >= 0, 1.0, -1.0).astype(np.float32)
def i8(X, axis):
s = np.abs(X).max(axis=axis, keepdims=True) / 127
return np.round(X / s).astype(np.int8).astype(np.float32) * s
G8, R8 = lambda X: i8(X, None), lambda X: i8(X, 1)
SCHEMES = [("fp32", I, I, 4), ("fp16", F16, F16, 2), ("int8-global", G8, G8, 1),
("int8-row", R8, R8, 1), ("1bit-sym", BIT, BIT, .125), ("1bit-asym", I, BIT, .125)]
def top10(S):
idx = np.argpartition(-S, K, axis=1)[:, :K]
r = np.arange(len(S))[:, None]
return idx[r, np.argsort(-S[r, idx], axis=1)]
def z(S):
return (S - S.mean(1, keepdims=True)) / S.std(1, keepdims=True)
def nd(t, g):
return np.array([(g[i][t[i]] * disc).sum() / disc[:min(int(g[i].sum()), K)].sum()
for i in range(len(t))])
def run(name, Q, D, g=None):
S0 = z(Q @ D.T)
base, srt = top10(S0), np.sort(S0, axis=1)[:, ::-1]
gap = float((srt[:, 9] - srt[:, 10]).mean())
ref, bs = (None if g is None else nd(base, g)), boot.integers(0, len(Q), (1000, len(Q)))
print(f"\n[{name}] docs={len(D)} queries={len(Q)} dim={D.shape[1]} "
f"rank10-11 gap={gap:.4f}z")
print(f" {'scheme':<11}{'B/vec':>6}{'top10-keep':>11}{'nDCG@10':>9}{'noise':>8}"
f"{'gap/noise':>10} 95% CI of dnDCG")
for lab, fq, fd, b in SCHEMES:
S = z(fq(Q) @ fd(D).T)
t, sig = top10(S), float((S - S0).std())
kp = float(np.mean([len(set(a) & set(c)) / K for a, c in zip(base, t)]))
v, ci = float("nan"), ""
if g is not None:
d = nd(t, g) - ref
lo, hi = np.percentile(d[bs].mean(1), [2.5, 97.5])
v = ref.mean() + d.mean()
ci = f"[{lo:+.4f}, {hi:+.4f}]" + ("" if lo <= 0 <= hi else " significant")
print(f" {lab:<11}{b * D.shape[1]:6.0f}{kp:11.4f}{v:9.4f}{sig:8.4f}"
f"{(gap / sig if sig else np.inf):10.2f} {ci}")
for seed in range(3):
r = np.random.default_rng(seed)
X, Y = r.normal(size=(20_000, 384)), r.normal(size=(200, 384))
run(f"gaussian seed={seed}", (Y / np.linalg.norm(Y, axis=1, keepdims=True)).astype(np.float32),
(X / np.linalg.norm(X, axis=1, keepdims=True)).astype(np.float32))
run("scifact", np.load("scifact_Q.npy"), np.load("scifact_D.npy"), np.load("scifact_gold.npy"))
P, gk = np.load("korquad_P.npy"), np.load("korquad_gold.npy")
G = np.zeros((len(gk), len(P)), dtype=np.uint8)
G[np.arange(len(gk)), gk] = 1
run("korquad", np.load("korquad_Q.npy"), P, G)
python3 quant.py
마지막 열은 압축 전후 nDCG@10 차이의 95% 부트스트랩 신뢰구간이다(질의 300개를 1,000번 복원 추출). 구간이 0을 품으면 이 표본에서는 차이를 말할 수 없다는 뜻이고, 그때만 significant 표시가 빠진다.
실제 출력
[gaussian seed=0] docs=20000 queries=200 dim=384 rank10-11 gap=0.0291z
scheme B/vec top10-keep nDCG@10 noise gap/noise 95% CI of dnDCG
fp32 1536 1.0000 nan 0.0000 inf
fp16 768 1.0000 nan 0.0003 99.32
int8-global 384 0.9700 nan 0.0158 1.84
int8-row 384 0.9825 nan 0.0102 2.85
1bit-sym 48 0.0990 nan 0.8534 0.03
1bit-asym 48 0.2310 nan 0.6349 0.05
[gaussian seed=1] docs=20000 queries=200 dim=384 rank10-11 gap=0.0295z
scheme B/vec top10-keep nDCG@10 noise gap/noise 95% CI of dnDCG
fp32 1536 1.0000 nan 0.0000 inf
fp16 768 1.0000 nan 0.0003 101.28
int8-global 384 0.9745 nan 0.0156 1.90
int8-row 384 0.9815 nan 0.0102 2.91
1bit-sym 48 0.0895 nan 0.8522 0.03
1bit-asym 48 0.2180 nan 0.6347 0.05
[gaussian seed=2] docs=20000 queries=200 dim=384 rank10-11 gap=0.0269z
scheme B/vec top10-keep nDCG@10 noise gap/noise 95% CI of dnDCG
fp32 1536 1.0000 nan 0.0000 inf
fp16 768 1.0000 nan 0.0003 91.77
int8-global 384 0.9690 nan 0.0172 1.56
int8-row 384 0.9795 nan 0.0102 2.63
1bit-sym 48 0.0980 nan 0.8511 0.03
1bit-asym 48 0.2210 nan 0.6351 0.04
[scifact] docs=5183 queries=300 dim=384 rank10-11 gap=0.0462z
scheme B/vec top10-keep nDCG@10 noise gap/noise 95% CI of dnDCG
fp32 1536 1.0000 0.6451 0.0000 inf [+0.0000, +0.0000]
fp16 768 0.9993 0.6451 0.0002 305.93 [+0.0000, +0.0000]
int8-global 384 0.9887 0.6444 0.0082 5.60 [-0.0042, +0.0029]
int8-row 384 0.9910 0.6445 0.0052 8.81 [-0.0018, +0.0000]
1bit-sym 48 0.5617 0.5844 0.5419 0.09 [-0.0842, -0.0370] significant
1bit-asym 48 0.6900 0.6228 0.3742 0.12 [-0.0431, -0.0028] significant
[korquad] docs=960 queries=300 dim=384 rank10-11 gap=0.0645z
scheme B/vec top10-keep nDCG@10 noise gap/noise 95% CI of dnDCG
fp32 1536 1.0000 0.8877 0.0000 inf [+0.0000, +0.0000]
fp16 768 0.9993 0.8877 0.0006 101.14 [+0.0000, +0.0000]
int8-global 384 0.9803 0.8822 0.0212 3.04 [-0.0109, -0.0012] significant
int8-row 384 0.9863 0.8863 0.0160 4.04 [-0.0039, +0.0000]
1bit-sym 48 0.2400 0.4401 0.9926 0.06 [-0.4920, -0.4006] significant
1bit-asym 48 0.3433 0.6306 0.8539 0.08 [-0.2986, -0.2158] significant
전체 4.5초. 시드를 세 번 바꾼 난수 코퍼스에서 유지율이 셋째 자리까지만 흔들리므로(1비트 0.08950.0990, int8-row 0.97950.9825) 아래 결론은 단일 실행의 우연이 아니다.
발행 전 자기검사로 가상환경을 새로 만들어 numpy를 다시 깔고 같은 스크립트를 한 번 더 돌렸다. 44줄이 한 글자도 다르지 않았다. 이 실험에는 시간을 재는 값이 없고 난수도 시드로 고정돼 있어 산포가 0인 것이 맞는 결과다.
압축의 대가
fp16
fp16은 재 볼 필요도 없는 수준이다. 메모리를 절반으로 줄이는데 난수에서는 유지율이 1.0000, 두 실제 코퍼스에서는 0.9993이다. 0.9993은 질의 300개 × 10칸 = 3,000칸 중 두 칸이 다르다는 뜻이고, nDCG는 소수점 넷째 자리까지 원본과 같다. 간격 대비 교란이 100~306배라 순위를 뒤집을 힘 자체가 없다.
int8
int8도 사실상 공짜다. 메모리 4분의 1에 유지율 0.98~0.99를 지킨다. 다만 여기서는 스케일을 어디서 잡는지가 갈린다. 벡터마다 스케일을 따로 잡으면 세 코퍼스 모두에서 전역 스케일보다 낫다 — scifact 0.9910 대 0.9887, KorQuAD 0.9863 대 0.9803, 난수 0.9812 대 0.9712(세 시드 평균)다. 벡터별 스케일은 벡터 하나당 fp32 4바이트를 더 쓰므로 384바이트가 388바이트가 되는데, 1% 늘려서 얻는 값으로는 싸다.
품질 쪽에서는 int8-global이 KorQuAD에서 유일하게 유의한 손실을 냈다(−0.0055, 구간 [−0.0109, −0.0012]). 나머지 int8 조합은 전부 구간이 0을 품는다. 메모리를 4분의 1로 줄이면서 잃은 것을 이 표본에서는 증명할 수 없다는 것이 int8에 대한 판정이다.
1비트
1비트는 완전히 다른 이야기다. 32배 압축의 대가로 scifact는 유지율 0.5617, KorQuAD는 0.2400까지 떨어진다. KorQuAD의 nDCG는 0.8877에서 0.4401로 절반이 날아간다. 순위가 흔들리는 정도가 아니라 다른 검색기가 된다.
난수와 실제 코퍼스
난수 코퍼스
1비트를 세 코퍼스에 나란히 놓으면 이렇다.
| 코퍼스 | 1비트-대칭 유지율 | 난수 대비 |
|---|---|---|
| 가우시안 난수(3시드 평균) | 0.0955 | 1.0배 |
| KorQuAD 960문단 | 0.2400 | 2.5배 |
| BEIR scifact 5,183문서 | 0.5617 | 5.9배 |
난수 코퍼스만 보고 결론을 냈다면 "1비트는 top-10의 90%를 잃는다, 쓸 수 없다"가 된다. scifact만 보면 "절반은 남는다, 재채점을 붙이면 쓸 만하다"가 된다. 같은 코드에서 5.9배 다른 답이 나온다.
이유는 난수 벡터가 서로 너무 비슷하기 때문이다. 384차원 단위 구면에 균등하게 뿌린 벡터 2만 개는 어느 질의에서 봐도 거의 다 직교에 가깝고, 상위권 점수가 좁은 띠 안에 몰린다. 10위와 11위의 z 간격이 0.0291인데 scifact는 0.0462다. 실제 임베딩에는 "정말 관련 있는 몇 개"가 위쪽에 떨어져 있어 간격이 넓고, 그만큼 잡음을 견딘다.
그러니 난수 코퍼스로 벡터 압축이나 인덱스를 벤치마크하면 실제보다 비관적인 답이 나온다. 이 함정은 ANN 알고리즘 파라미터 튜닝에서도 그대로 반복되는데, 그건 따로 잰다.
KorQuAD 역전
여기서 실험이 계획을 벗어난다. 난수 대 실제라는 구도만 놓고 보면 KorQuAD는 실제 임베딩이니 scifact와 비슷하게 나와야 한다. 그런데 0.2400이다. 문서 수가 5분의 1(960 대 5,183)인데 유지율은 절반 아래다. 코퍼스가 작을수록 후보가 적어 순위가 덜 흔들릴 것 같지만 반대로 나왔다.
그리고 KorQuAD의 10-11위 간격은 0.0645z로 셋 중 가장 넓다. 간격만으로도 설명이 안 된다.
설명은 반대쪽 항에 있다. KorQuAD의 1비트 교란 σ는 0.9926으로, scifact의 0.5419보다 1.8배 크다. 부호만 남겼을 때 잃는 정보가 그만큼 많다는 뜻이다. 간격이 넓어도 잡음이 더 빠르게 커지면 순위는 무너진다.
비의 순서
그래서 유지율을 예측하는 값은 간격도 교란도 아니라 둘의 비다. 세 코퍼스 × 네 손실 방식 12개 점을 비 순서로 세우면 이렇게 된다(난수는 seed=0).
| 간격/교란 | 코퍼스·방식 | top-10 유지율 |
|---|---|---|
| 0.03 | 난수 · 1비트-대칭 | 0.0990 |
| 0.05 | 난수 · 1비트-비대칭 | 0.2310 |
| 0.06 | KorQuAD · 1비트-대칭 | 0.2400 |
| 0.08 | KorQuAD · 1비트-비대칭 | 0.3433 |
| 0.09 | scifact · 1비트-대칭 | 0.5617 |
| 0.12 | scifact · 1비트-비대칭 | 0.6900 |
| 1.84 | 난수 · int8-전역 | 0.9700 |
| 2.85 | 난수 · int8-벡터별 | 0.9825 |
| 3.04 | KorQuAD · int8-전역 | 0.9803 |
| 4.04 | KorQuAD · int8-벡터별 | 0.9863 |
| 5.60 | scifact · int8-전역 | 0.9887 |
| 8.81 | scifact · int8-벡터별 | 0.9910 |
12개 중 11개가 순서대로다. 어긋난 한 자리는 0.9825와 0.9803으로 차이가 0.0022이고, 이 실험대에서 질의 한 건이 만드는 0.33%p보다 작다. fp16은 비가 92~306으로 이미 포화 구간이라 이 순서에서 뺐다 — 비가 대략 10을 넘으면 유지율이 0.99에 붙어 더 오를 자리가 없다.
즉 압축이 안전한지는 코퍼스가 진짜인지 가짜인지가 아니라, 그 코퍼스에서 이웃 문서들이 얼마나 벌어져 있고 압축이 점수를 얼마나 흔드는지로 정해진다. 난수가 위험한 이유는 가짜라서가 아니라 그 비가 유독 낮은 데이터라서다.
질의 비압축
1비트 두 방식의 차이가 이 구도를 한 번 더 확인해 준다. 문서만 부호로 줄이고 질의를 fp32로 두면 유지율이 scifact 0.5617 → 0.6900, KorQuAD 0.2400 → 0.3433, 난수 0.0955 → 0.2233(3시드 평균)으로 오른다. 셋 다 비도 함께 오른다.
인덱스 크기는 두 방식이 완전히 같다. 압축한 것은 문서 쪽뿐이고, 질의 벡터는 검색할 때마다 한 개 만들었다 버리는 값이라 저장에 들어가지 않는다. 그런데도 대칭 해밍을 쓰는 구현이 흔한 이유는 비트 연산으로 거리를 셀 수 있어서지 품질 때문이 아니다. 품질만 놓고 보면 질의를 이진화할 이유가 없다.
다만 이 이득으로도 1비트는 여전히 못 쓴다. 0.6900도 top-10의 3할이 바뀐 것이고, 두 실제 코퍼스 모두 nDCG 손실이 유의하다(scifact −0.0223, KorQuAD −0.2571). 1비트를 쓰려면 압축 자체가 아니라 뒤에 붙이는 재채점이 있어야 하는데, 그게 다음 글이다.
꺾이는 지점
int8까지
int8까지가 공짜다. 메모리 4분의 1에 top-10 유지율 0.98~0.99, nDCG 손실은 네 조합 중 셋이 신뢰구간 안이다. 여기서 벡터별 스케일을 쓰면 1% 더 쓰고 손실을 절반으로 줄인다.
1비트부터
1비트에서 손해로 돌아선다. 간격/교란 비가 int8의 1.848.81에서 0.030.12로 한 자릿수 아래 두 칸을 떨어지고, 유지율이 0.99대에서 0.10~0.69로 무너진다. 중간이 없다 — 이 실험대에는 int8과 1비트 사이에 놓을 압축이 없고, 실제로 그 사이가 절벽이다. 1비트를 단독으로 쓰는 것은 어떤 코퍼스에서도 답이 아니다.
그런데 32배 압축은 포기하기에 아까운 크기라서, 실무에서는 이진 인덱스로 후보를 넓게 뽑고 원본 fp32로 다시 채점하는 2단 구조를 쓴다. 후보를 몇 개까지 넓혀야 원본 품질을 되찾는지, 그 대가로 지연이 얼마나 늘어나는지를 같은 세 코퍼스에서 잰다.
측정 환경과 한계
환경
| 항목 | 값 |
|---|---|
| OS | Linux 6.18.5 x86_64, glibc 2.39 |
| CPU / RAM | Intel Xeon @ 2.80GHz, 4 vCPU / 15GB |
| Python | 3.11.15 |
| numpy | 2.4.6 |
| 임베딩 생성 | sentence-transformers 5.7.0, transformers 5.15.0, torch 2.13.0 (CPU) |
| 모델 | sentence-transformers/all-MiniLM-L6-v2, intfloat/multilingual-e5-small |
| 데이터 | BeIR/scifact, BeIR/scifact-qrels, KorQuAD/squad_kor_v1 |
| 측정일 | 2026-08-11 |
이 글의 스크립트 자체는 numpy만 있으면 돌아간다. 위 .npy 네 개를 만드는 데만 sentence-transformers가 필요하고, 그 코드는 실험대 글에 있다.
표본 크기
질의 300개(난수는 200개)라 한 건이 0.33%p다. 0.9910과 0.9887처럼 0.2%p대 차이는 이 표본에서 순위를 매길 수 없다. 부트스트랩 구간을 함께 낸 이유가 이것이다.
일반화 범위
코퍼스 셋, 모델 둘이다. 384차원 소형 모델 둘에서 나온 값이고, 1,024차원 모델이나 다른 도메인에서 비가 어디쯤 잡히는지는 확인하지 않았다. 특히 KorQuAD가 유독 1비트에 약한 것이 한국어 때문인지 e5 계열 때문인지 문단 길이 때문인지 이 실험은 가르지 못한다 — 코퍼스와 모델이 붙어 있어서다.
간격/교란 비도 관찰된 순서지 유도된 법칙이 아니다. 12개 점에서 11개가 맞았다는 것뿐이고, 다른 데이터에서 깨질 수 있다. 압축을 결정하기 전에 자기 코퍼스에서 이 비를 직접 재라는 것이 이 글이 줄 수 있는 전부다.
스케일과 속도
int8 스케일 방식은 둘만 봤다. 백분위 클리핑이나 차원별 스케일은 재지 않았다.
그리고 디스크·램 절감만 봤고 속도는 보지 않았다. int8 인덱스가 실제로 더 빠른지는 dequantize 비용과 SIMD 지원에 달려 있어 구현마다 다르다. 차원을 줄이는 쪽은 연산량 자체가 줄지만 양자화는 그렇지 않다.

