사내 검색에 문서를 계속 부어 넣다 보면 「문서가 많아지면 검색이 나빠지지 않을까」라는 질문이 언젠가 나옵니다. 답은 대개 「그렇다, 바늘을 찾을 건초더미가 커지니까」로 끝나고 얼마나 나빠지는지는 재지 않습니다. 이 글은 그 크기를 잽니다. 정답 문서는 그대로 두고 방해 문서, 곧 어느 질의의 정답도 아닌 문서만 2배·4배·8배로 늘려 nDCG@10과 Recall@10이 어떻게 움직이는지 봅니다.
결과는 질문을 바꿔 놓았습니다. 같은 양을 부어도 무엇을 붓느냐에 따라 손실이 백 배 넘게 달랐습니다. 같은 분야의 영어 논문을 부으면 코퍼스가 두 배가 될 때마다 nDCG@10이 5.8%p씩 빠졌고, 같은 수의 한국어 위키백과 문단을 부으면 8배까지 키워도 0.14%p였습니다. 품질을 떨어뜨린 것은 문서의 수가 아니라 그 문서가 질의와 얼마나 닮았는가였습니다.
실험 설계
기준 코퍼스
실험대는 CPU만으로 세우는 검색 실험대의 영어 쪽을 그대로 씁니다. BEIR scifact 5,183편과 test 질의 300개, 임베딩 모델 all-MiniLM-L6-v2, 지표 nDCG@10과 Recall@10입니다.
키울 여지를 만들려고 출발점을 작게 잡았습니다. 5,183편의 8분의 1인 647편이 기준(1배) 코퍼스입니다. 여기에는 300개 질의의 정답 문서 283편이 전부 들어 있고, 나머지 364편은 정답이 아닌 scifact 문서에서 무작위로 뽑았습니다. 정답 문서를 빼면 Recall이 떨어지는 이유가 「정답이 없어서」와 「정답이 묻혀서」로 섞이므로, 정답은 어느 크기에서나 항상 코퍼스 안에 있습니다. 그 위에 방해 문서를 얹어 1,294편(2배), 2,588편(4배), 5,176편(8배)으로 키웁니다.
같은 크기에서 어떤 문서를 뽑았는지가 결과를 흔들 수 있으므로 무작위 뽑기를 시드 다섯 개로 반복하고, 질의마다 다섯 번의 값을 평균한 뒤 그 평균으로 판정합니다.
방해 문서 두 종류
방해 문서를 두 종류로 나눠 같은 크기까지 붓습니다.
same— 기준 코퍼스에 안 들어간 나머지 scifact 문서. 같은 분야(생의학 논문 초록)이고 같은 언어입니다. 8배가 되면 사실상 scifact 전체(5,183편 중 5,176편)가 됩니다.korean— KorQuAD 학습 분할의 위키백과 문단 4,529개. 질의와 언어도 분야도 다릅니다.
계획 단계에서는 KorQuAD 쪽이 본 실험이고 scifact 내부 분할은 대조용으로 한 번 더 그리는 곡선이었습니다. 「다른 언어 문서는 쉬운 조건」이라는 것은 알고 있었지만, 얼마나 쉬운지가 이 정도일 줄은 몰랐습니다. 결과가 나온 뒤에는 두 곡선의 차이 자체가 이 글의 결론이 되었습니다.
한국어 문단을 영어 전용 모델로 인코딩하는 것이 이상해 보일 수 있습니다. 그러나 실제 사내 검색에서도 모델이 잘 모르는 문서(다른 언어, 표, 로그, 스캔 잡음)가 코퍼스에 섞여 들어가는 일은 흔합니다. 그런 문서가 검색을 얼마나 방해하는지가 바로 이 조건이 재는 것입니다.
지표와 판정
지표는 실험대와 같습니다. 판정은 짝지은 부트스트랩으로 합니다. 같은 질의에서 「1배일 때의 값」과 「k배일 때의 값」을 빼서 질의별 차이를 만들고, 질의 300개를 1,000번 복원 추출해 그 평균의 95% 구간을 구합니다. 같은 질의끼리 빼므로 질의의 난이도가 상쇄되어 값 하나짜리 구간보다 훨씬 좁아집니다. 이 판정법이 왜 필요한지는 Recall@k·MRR·nDCG는 언제 서로 다른 결론을 내는가가 맡습니다.
한 가지 미리 짚어 둡니다. 정답이 늘 코퍼스에 있는 상태에서 정답 아닌 문서를 더하면 질의 하나의 nDCG는 오를 수가 없습니다 — 정답보다 위로 끼어드는 문서가 생기거나 아무 일도 없거나 둘 중 하나입니다. 그래서 차이의 구간은 위끝이 0을 넘지 않습니다. 「0을 배제했는가」보다 「얼마나 떨어졌는가」를 보는 실험입니다.
재현과 출력
재현 블록
pip install torch sentence-transformers datasets numpy
import time, numpy as np, torch
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
torch.manual_seed(0); torch.set_num_threads(4)
corpus = load_dataset("BeIR/scifact", "corpus")["corpus"]
qtext = {str(q["_id"]): q["text"] for q in load_dataset("BeIR/scifact", "queries")["queries"]}
gold = {}
for r in load_dataset("BeIR/scifact-qrels")["test"]:
gold.setdefault(str(r["query-id"]), set()).add(str(r["corpus-id"]))
qids = sorted(gold, key=int)
docs, dids = [(d["title"] + " " + d["text"]).strip() for d in corpus], [str(d["_id"]) for d in corpus]
N, B = len(docs), len(docs) // 8
ko = sorted({r["context"] for r in load_dataset("KorQuAD/squad_kor_v1")["train"]})
ko = [ko[i] for i in np.random.default_rng(0).permutation(len(ko))[:7 * B]]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2"); t = time.perf_counter()
enc = lambda x: model.encode(x, batch_size=64, normalize_embeddings=True, show_progress_bar=False)
ALL, Q = np.vstack([enc(docs), enc(ko)]), enc([qtext[q] for q in qids])
print(f"scifact={N} korquad_distractors={len(ko)} queries={len(qids)} base={B} encode={time.perf_counter()-t:.1f}s")
R = np.zeros((len(qids), len(ALL)))
for i, q in enumerate(qids):
R[i, [dids.index(d) for d in gold[q]]] = 1
NG, DISC = R.sum(1), 1.0 / np.log2(np.arange(2, 12))
IDCG = np.array([DISC[:int(min(n, 10))].sum() for n in NG])
G = np.flatnonzero(R.sum(0)); OTHER = np.setdiff1d(np.arange(N), G)
S = Q @ ALL.T
print(f"median cos query->best gold {np.median(np.where(R > 0, S, -9).max(1)):.3f} "
f"query->10th scifact {np.median(np.sort(S[:, :N], 1)[:, -10]):.3f} query->best korean {np.median(S[:, N:].max(1)):.3f}")
def score(idx):
top = idx[np.argsort(-(Q @ ALL[idx].T), axis=1)[:, :10]]; rel = np.take_along_axis(R, top, 1)
return (rel * DISC).sum(1) / IDCG, rel.sum(1) / NG, (top >= N).sum(1)
MULT, SEEDS, res = (1, 2, 4, 8), range(5), {}
for s in SEEDS:
o = np.random.default_rng(s).permutation(OTHER); base = np.concatenate([G, o[:B - len(G)]])
for m in MULT:
res["same", m, s] = score(np.concatenate([base, o[B - len(G):m * B - len(G)]]))
res["korean", m, s] = score(np.concatenate([base, N + np.arange((m - 1) * B)]))
BS = np.random.default_rng(0).integers(0, len(qids), size=(1000, len(qids)))
print(f"gold_docs={len(G)} (metrics: mean over {len(SEEDS)} seeds; CI = paired bootstrap vs 1x)")
print(f"{'distractor':>10} {'mult':>4} {'docs':>5} {'nDCG@10':>8} {'seed sd':>8} {'dnDCG':>8} {'95% CI':>18} {'R@10':>7} {'dR@10':>8} {'ko in top10':>11}")
for c in ("same", "korean"):
base_n = np.mean([res[c, 1, s][0] for s in SEEDS], 0); base_r = np.mean([res[c, 1, s][1] for s in SEEDS], 0)
for m in MULT:
n = np.mean([res[c, m, s][0] for s in SEEDS], 0); r = np.mean([res[c, m, s][1] for s in SEEDS], 0)
sd = np.std([res[c, m, s][0].mean() for s in SEEDS]); d = n - base_n
lo, hi = np.percentile(d[BS].mean(1), [2.5, 97.5]); k = np.mean([res[c, m, s][2].mean() for s in SEEDS])
print(f"{c:>10} {m:>4} {m*B:>5} {n.mean():8.4f} {sd:8.4f} {d.mean():+8.4f} [{lo:+.4f}, {hi:+.4f}] {r.mean():7.4f} {(r-base_r).mean():+8.4f} {k:11.3f}")
python3 distractor.py
인코딩은 한 번만 합니다. scifact 5,183편과 한국어 문단 4,529개를 한꺼번에 임베딩해 두고, 크기와 종류를 바꾸는 것은 행 번호를 고르는 일뿐이라 스윕 자체는 몇 초입니다. 실행 시간의 대부분(3분 남짓 중 3분)이 인코딩입니다.
실제 출력
scifact=5183 korquad_distractors=4529 queries=300 base=647 encode=214.5s
median cos query->best gold 0.591 query->10th scifact 0.451 query->best korean 0.191
gold_docs=283 (metrics: mean over 5 seeds; CI = paired bootstrap vs 1x)
distractor mult docs nDCG@10 seed sd dnDCG 95% CI R@10 dR@10 ko in top10
same 1 647 0.8194 0.0046 +0.0000 [+0.0000, +0.0000] 0.9127 +0.0000 0.000
same 2 1294 0.7723 0.0029 -0.0471 [-0.0575, -0.0383] 0.8764 -0.0363 0.000
same 4 2588 0.7138 0.0082 -0.1056 [-0.1243, -0.0889] 0.8337 -0.0790 0.000
same 8 5176 0.6451 0.0000 -0.1743 [-0.2017, -0.1497] 0.7833 -0.1293 0.000
korean 1 647 0.8194 0.0046 +0.0000 [+0.0000, +0.0000] 0.9127 +0.0000 0.000
korean 2 1294 0.8187 0.0046 -0.0007 [-0.0016, +0.0000] 0.9107 -0.0020 0.103
korean 4 2588 0.8187 0.0046 -0.0007 [-0.0016, +0.0000] 0.9107 -0.0020 0.175
korean 8 5176 0.8180 0.0055 -0.0014 [-0.0032, +0.0000] 0.9087 -0.0040 0.299
두 곡선은 같은 기준 코퍼스에서 출발하므로 1배 줄이 똑같습니다(0.8194). 거기서 같은 문서 수만큼 키웠는데 8배에서 한쪽은 0.6451, 다른 쪽은 0.8180입니다.
같은 분야의 방해 문서
두 배마다 잃는 몫
same 곡선을 두 배 단위로 끊어 읽으면 이렇습니다.
| 배수 | 문서 | nDCG@10 | 1배 대비 | 직전 대비 | Recall@10 |
|---|---|---|---|---|---|
| 1 | 647 | 0.8194 | — | — | 0.9127 |
| 2 | 1,294 | 0.7723 | −4.71%p | −4.71%p | 0.8764 |
| 4 | 2,588 | 0.7138 | −10.56%p | −5.85%p | 0.8337 |
| 8 | 5,176 | 0.6451 | −17.43%p | −6.87%p | 0.7833 |
세 구간 모두 신뢰구간이 0에서 멀리 떨어져 있습니다(8배에서 [−20.17, −14.97]). 두 배씩 키울 때마다 잃는 몫은 4.71·5.85·6.87%p로 평균 5.81%p이고, 조금씩 커지는 쪽입니다. 문서 수를 로그 눈금에 놓으면 거의 곧은 직선이 되는데, 이것은 코퍼스가 커지면 저차원은 더 빨리 무너진다가 384차원에서 본 모양과 같습니다. 그 글은 500편에서 0.8388, 5,183편에서 0.6451을 냈고, 이 글의 647편 0.8194는 그 사이의 제자리에 놓입니다.
Recall@10도 같은 방향으로 움직이지만 폭이 작습니다(8배에서 −12.93%p 대 nDCG −17.43%p). 정답이 아예 상위 10칸 밖으로 밀려나는 것보다, 10칸 안에는 남아 있되 몇 칸 아래로 내려가는 일이 더 많다는 뜻입니다. nDCG는 칸의 위치에 값을 매기고 Recall@10은 들었는지만 보므로 이 차이가 납니다.
시드 사이의 표준편차는 가장 큰 곳이 0.0082(4배)입니다. 무작위로 어떤 문서를 뽑았는가보다 몇 편을 넣었는가가 수십 배 큰 효과라는 뜻입니다. 8배의 표준편차가 0인 것은 그 크기에서 시드마다 빠지는 문서가 7편뿐이고, 그 7편이 어느 질의의 상위 10칸에도 들지 않았기 때문입니다.
실험대와의 대조
8배 줄의 nDCG@10 0.6451과 Recall@10 0.7833은 실험대가 scifact 전체에서 낸 값과 소수점 넷째 자리까지 같습니다. 5,176편은 전체에서 7편이 모자란 코퍼스이므로 같아야 정상이고, 같다는 것은 기준 코퍼스를 자르고 다시 붙이는 코드가 정답 문서를 하나도 잃지 않았다는 확인입니다.
거꾸로 읽으면 이 실험의 무게가 보입니다. 실험대의 0.6451은 「이 모델이 scifact에서 내는 품질」로 인용되지만, 그 값의 상당 부분은 모델이 아니라 코퍼스 크기가 정한 것입니다. 같은 모델과 같은 질의로 코퍼스를 8분의 1로 줄이면 0.8194가 나옵니다. 남의 벤치마크 숫자를 내 시스템의 기대치로 옮길 때, 내 코퍼스가 그 벤치마크보다 크거나 작다면 그 숫자는 그대로 옮겨지지 않습니다.
다른 언어의 방해 문서
상위 10칸에 든 한국어
korean 곡선은 거의 평평합니다. 8배에서 nDCG@10이 0.14%p, Recall@10이 0.40%p 떨어졌고, 신뢰구간의 아래끝도 −0.32%p입니다. 같은 분야 문서를 같은 양 넣었을 때의 −17.43%p와 견주면 백 배가 넘게 차이 납니다.
그렇다고 한국어 문단이 상위 10칸에 아예 안 들어간 것은 아닙니다. 맨 오른쪽 열이 질의당 평균 몇 편의 한국어 문단이 상위 10칸에 들었는지를 셉니다 — 2배에서 0.103편, 4배에서 0.175편, 8배에서 0.299편입니다. 질의 열 개 중 세 개꼴로 한국어 문단이 상위 10칸 어딘가에 끼어 있는 셈입니다. 그런데도 nDCG가 거의 안 움직였다는 것은, 끼어든 자리 대부분이 정답보다 아래였다는 뜻입니다. 상위 10칸 중 정답이 아닌 자리를 다른 오답과 바꿔 차지했을 뿐이고 정답을 밀어내지는 못했습니다.
2배와 4배의 nDCG가 0.8187로 같은 것도 같은 이야기입니다. 한국어 문단이 상위 10칸에 든 횟수는 0.103에서 0.175로 늘었지만, 늘어난 것은 정답 아래의 칸이라 점수에 안 잡혔습니다.
코사인 거리
이유는 출력 두 번째 줄의 코사인 유사도 중앙값에 있습니다.
| 질의에서 본 거리 | 중앙값 |
|---|---|
| 가장 가까운 정답 문서 | 0.591 |
| scifact 전체에서 10번째로 가까운 문서 | 0.451 |
| 가장 가까운 한국어 문단 | 0.191 |
정답이 아닌 scifact 문서가 상위 10칸의 문턱에 0.451로 서 있는데, 가장 가까운 한국어 문단조차 그 절반이 안 되는 0.191입니다. 영어로 학습된 모델에게 한국어 문단은 생의학 질의와 닮은 데가 없는 벡터이고, 질의 공간의 반대편 먼 곳에 몰려 있습니다. 몇 개를 붓든 그 무리는 정답과 경쟁하지 않습니다.
같은 분야 문서는 사정이 다릅니다. 정답이 아닌 생의학 초록은 질의와 같은 낱말을 쓰고 같은 주제를 다루므로 질의 근처에 앉습니다. 문서를 두 배로 늘리면 그 근처에 앉는 문서도 대략 두 배가 되고, 그만큼 정답과 자리를 다투는 경쟁자가 늘어납니다. 이 과정을 상위 10칸의 문턱, 곧 질의에서 10번째로 가까운 문서의 유사도로 보면 더 분명해집니다. 문서를 더하면 이 문턱은 오르거나 그대로이거나 둘 중 하나이고 내려가는 일은 없습니다. 정답 문서의 유사도는 그대로이므로, 문턱이 정답 바로 아래까지 올라오면 정답은 그 위로 끼어든 문서에 밀려 한 칸씩 내려가고, 문턱을 넘어서면 10칸 밖으로 나갑니다. 전체 코퍼스에서 정답의 중앙값 0.591과 문턱의 중앙값 0.451 사이에는 0.14의 여유가 있는데, 같은 분야 문서를 붓는 것은 이 여유 구간을 채우는 일이고 한국어 문단을 붓는 것은 0.191 근처, 여유 구간보다 훨씬 아래를 채우는 일입니다.
품질을 깎는 것은 코퍼스 전체의 크기가 아니라 질의 주변의 밀도이고, 그 밀도는 붓는 문서가 질의와 얼마나 닮았느냐로 정해집니다. 이 글의 부제가 「양이 아니라 닮음」인 이유입니다.
결정 규칙
세 규칙
숫자를 판단 기준으로 옮기면 이렇습니다.
- 같은 분야의 문서를 늘릴 때는 두 배마다 nDCG@10 약 5
7%p를 잃는다고 잡는다. 이 실험(scifact, MiniLM, 6475,176편)에서 4.71·5.85·6.87%p였고, 문서가 많을수록 한 번에 잃는 몫이 조금씩 컸습니다. - 주제가 먼 문서를 섞는 것은 품질 걱정의 대상이 아니다. 한국어 문단을 8배까지 부어도 0.14%p였습니다. 이런 문서를 걸러 내는 일은 저장 비용과 인덱싱 시간을 이유로 할 수는 있어도 검색 품질을 이유로 할 근거는 이 실험에서 나오지 않았습니다.
- 코퍼스를 늘리기 전에 새 문서가 질의 근처에 앉는지를 먼저 잰다. 지금 질의 표본에서 「10번째로 가까운 기존 문서」의 유사도를 구하고, 새 문서 중 그 문턱을 넘는 것이 몇 %인지 세면 됩니다. 새 문서를 인코딩하는 비용은 어차피 색인할 때 치를 것이므로, 이 점검에 드는 추가 비용은 질의 표본과의 행렬곱 한 번입니다. 이 실험에서 문턱은 0.451이었고 한국어 문단의 최고값 중앙값은 0.191로 문턱의 절반에도 못 미쳤습니다.
꺾이는 지점
이 곡선에는 「여기까지 공짜」인 구간이 없습니다. 같은 분야 문서는 첫 두 배에서부터 4.71%p를 가져갔고 신뢰구간이 0에서 3.83%p 이상 떨어져 있습니다. 꺾이는 지점을 한 줄로 쓰면 이렇게 됩니다 — 같은 분야 문서는 첫 두 배부터 손해이고 이후 두 배마다 손해가 조금씩 커지며, 다른 언어 문서는 8배까지 공짜입니다. 문서를 붓기로 했다면 손해를 줄이는 길은 붓지 않는 것이 아니라 상위 칸을 다시 가리는 단계, 곧 재순위를 붙이는 것이고, 그 비용은 다음 글이 잽니다.
한계와 측정 환경
한계
- 기준 코퍼스 647편 중 283편(44%)이 정답 문서입니다. 실제 코퍼스에서 정답의 비율은 이보다 훨씬 낮으므로 1배의 0.8194는 실제보다 쉬운 출발점입니다. 기울기(두 배마다 잃는 몫)는 출발점이 쉬울수록 가팔라질 수 있어, 「두 배마다 5.8%p」를 다른 크기의 코퍼스에 그대로 옮길 수는 없습니다.
- 8배가 scifact 전체라 그 이상은 재지 못했습니다. 백만 편 코퍼스에서 같은 기울기가 계속된다고 말할 근거는 이 실험에 없습니다.
- 다른 언어 방해 문서는 일부러 쉬운 조건입니다. 주제는 다르지만 언어가 같은 문서(예를 들어 영어 뉴스)나, 언어는 다르지만 다국어 모델을 쓰는 경우는 재지 않았고, 그 사이의 어디쯤에 손실이 놓일지는 이 글이 말할 수 없습니다.
- 모델 하나(MiniLM, 384차원)와 코퍼스 하나입니다. 질의도 300개라, 차이의 신뢰구간 폭이 8배에서 5%p 남짓입니다.
- 상위 10칸에 든 한국어 문단 수는 질의 300개와 시드 다섯의 평균만 셌습니다. 그 0.299편이 소수의 질의에 몰려 있는지 고르게 퍼져 있는지는 이 출력으로 알 수 없고, 어떤 질의가 한국어 문단을 끌어들이는지도 따로 가르지 않았습니다.
- 방해 문서의 한국어 문단은 KorQuAD 학습 분할에서 뽑았습니다. 다른 실험이 쓰는 validation 960문단과 겹치지 않게 하려는 것이고, 한국어 문단 쪽에는 질의가 없으므로 이 선택이 결과에 영향을 주지는 않습니다.
측정 환경
| 항목 | 값 |
|---|---|
| OS | Linux 6.18.44 x86_64 (glibc 2.39) |
| CPU | Intel Xeon @ 2.10GHz, 4코어 (torch.set_num_threads(4)) |
| Python | 3.11.17 |
| 패키지 | torch 2.14.1, sentence-transformers 6.1.0, transformers 5.19.0, datasets 5.1.0, numpy 2.4.6 |
| 모델 | sentence-transformers/all-MiniLM-L6-v2 (리비전 1110a243) |
| 데이터 | BEIR scifact 5,183편 (b3b53356) / qrels test 339행 (2938d17d), KorQuAD v1 train에서 4,529문단 (01aad238) |
| 측정일 | 2026-10-07 |
| 실행 시간 | 3분 10초 ~ 3분 49초 (인코딩 177~215초) |
스크립트를 다듬으며 이 환경에서 세 번 돌렸고(앞의 두 번은 코사인 유사도 줄을 넣기 전), 위의 최종본을 패키지를 새로 깐 빈 가상환경에서 한 번 더 돌려 대조했습니다. 첫 줄의 encode 초만 179.9·177.2·214.5·177.7초로 흔들렸고 — 214.5초는 다른 설치 작업과 CPU를 나눠 쓴 실행입니다 — nDCG@10·Recall@10·신뢰구간·한국어 문단 수는 네 번 모두 소수점 넷째 자리까지 같았습니다. 코사인 유사도 세 값도 최종본 두 번에서 같았습니다. 절대 시간은 이 환경에서만 맞는 값이고, 이 글의 결론은 전부 조건 사이의 비교입니다.
읽어주셔서 감사합니다. 😊

