리서치

LAB / 28번째 글

중복 문서가 Recall을 부풀리는 방식: 10%만 다섯 벌로 넣었더니 nDCG는 0.645에서 0.747로 올랐고, 찾은 문서는 줄었다

scifact 5,183편에 같은 문서를 2·3·5벌씩 넣고 채점했다. 위치마다 정답을 세는 채점에서는 nDCG@10이 1을 넘었고, 문서 10%만 다섯 벌로 넣자 0.6451이 0.7474로 올랐다. 같은 결과를 고유 문서로 세면 R@10은 2.84%p 떨어졌고 상위 10칸의 서로 다른 문서는 7.72개였다.

PALDYN Team28 MIN READ

지난 글은 문서가 늘 때 색인을 다시 만드는 비용을 쟀습니다. 문서가 늘다 보면 같은 문서가 두 번 들어가는 일도 생깁니다. 같은 PDF를 두 경로로 올리거나, 수집기가 같은 페이지를 주소만 바꿔 다시 긁거나, 버전만 다른 문서가 나란히 쌓이는 식입니다. 이 글은 그런 중복이 검색 지표를 어떻게 움직이는지 잽니다.

결과를 먼저 적습니다. 문서의 10%만 다섯 벌씩 넣었더니 nDCG@10이 0.6451에서 0.7474로 10%p 넘게 올랐습니다. 같은 실행을 「찾아낸 서로 다른 문서」로 다시 세면 Recall@10은 2.84%p 떨어졌고, 상위 10칸에 서는 서로 다른 문서는 10개에서 7.72개로 줄었습니다. 지표가 오른 것은 검색이 나아져서가 아니라 채점이 같은 정답을 여러 번 셌기 때문입니다.

중복을 찾아내는 기법 자체는 데이터 중복 제거가, 우리 뉴스 아카이브에서 근사 중복을 찾은 실측은 MinHash로 뉴스 중복 발표 찾기가 맡습니다. 여기서는 중복이 이미 들어간 색인에서 지표가 어떻게 어긋나는지만 봅니다. nDCG와 Recall의 정의는 RAG 평가에 있습니다.

실험 설계

중복을 넣는 다섯 방식

코퍼스는 CPU만으로 세우는 검색 실험대의 scifact 그대로입니다. 과학 논문 초록 5,183편, test 질의 300개, 정답 쌍(qrels) 339행이고, all-MiniLM-L6-v2로 인코딩해 코사인 점수로 상위 10개를 뽑습니다. 아무것도 넣지 않은 이 상태가 기준이고 nDCG@10은 0.6451, Recall@10은 0.7833입니다.

여기에 중복을 다섯 방식으로 넣습니다.

  • 정확 중복 2벌·3벌·5벌: 코퍼스 전체를 통째로 한 번, 두 번, 네 번 더 붙입니다. 같은 문서가 같은 임베딩으로 여러 번 들어갑니다.
  • 근사 중복 2벌: 원본 옆에 초록의 첫 문장 하나를 뺀 사본을 붙입니다. 글자가 조금 다른 판본이 나란히 있는 경우입니다.
  • 10% 문서만 5벌: 무작위로 고른 518편(시드 0)만 네 벌을 더 붙입니다. 실제 코퍼스에서는 전부가 아니라 일부만 겹치므로 이쪽이 현실에 가깝습니다.

정확 중복은 점수가 원본과 똑같아 순위에서 동점이 납니다. 동점은 안정 정렬(kind="stable")로 풀어 원본이 사본보다 앞에 서게 했습니다. 동점을 어떻게 푸느냐에 따라 「원본 id만 아는 채점」의 값이 달라지기 때문에 규칙을 못 박아 둔 것입니다.

두 가지 채점

중복이 들어가면 「정답을 찾았다」를 세는 방법이 갈립니다. 사본은 원본과 내용이 같으니 정답 문서의 사본도 정답으로 치는 것이 자연스럽습니다. 문제는 그다음입니다.

위치셈은 상위 10칸을 하나씩 보며 그 칸의 문서가 정답이면 1을 세는 방식입니다. 실험대 글의 채점 코드가 정확히 이 방식이고(rel.sum() / len(gold[q])), 손으로 짠 평가 코드 대부분이 이렇게 생겼습니다. 같은 정답이 세 칸을 차지하면 세 번 셉니다.

고유 셈은 상위 10칸의 문서를 먼저 원본 id로 되돌려 중복을 지운 뒤 정답을 셉니다. 같은 정답이 세 칸을 차지해도 한 번입니다. 사용자가 실제로 받아 보는 서로 다른 정보의 양이 이쪽입니다.

여기에 둘을 더 붙였습니다. 정답 목록이 원본 id만 알고 사본은 모르는 경우(색인에는 사본이 들어갔는데 평가 데이터는 그대로인 경우)의 Recall@10, 그리고 상위 10칸에 선 서로 다른 문서의 수입니다. 고유 셈 Recall@10의 기준 대비 차이는 질의를 짝지어 1,000번 되뽑는 부트스트랩으로 95% 구간을 냅니다. 같은 질의 300개에 두 조건을 다 걸었으므로 질의마다 차이를 먼저 구하고 그것을 되뽑습니다.

재현과 출력

인코딩

pip install torch sentence-transformers datasets numpy

리눅스에서 CUDA 의존까지 받고 싶지 않으면 torch만 먼저 CPU 휠로 깝니다: pip install torch --index-url https://download.pytorch.org/whl/cpu. 이번 컨테이너에서는 그 주소가 막혀 PyPI 기본 휠로 깔았고 CPU로만 돌렸습니다.

import re, time, numpy as np
from datasets import load_dataset
from sentence_transformers import SentenceTransformer

t0 = time.perf_counter()
corpus = load_dataset("BeIR/scifact", "corpus")["corpus"]
queries = load_dataset("BeIR/scifact", "queries")["queries"]
qrels = load_dataset("BeIR/scifact-qrels")["test"]
gold = {}
for r in qrels:
    gold.setdefault(str(r["query-id"]), set()).add(str(r["corpus-id"]))
qids = sorted(gold, key=int)
qtext = {str(q["_id"]): q["text"] for q in queries}
dids = [str(d["_id"]) for d in corpus]
docs = [(d["title"] + " " + d["text"]).strip() for d in corpus]


sents = [re.split(r"(?<=[.!?])\s+", d["text"].strip()) for d in corpus]
# 근사 중복: 초록의 첫 문장 하나를 뺀다 (한 문장짜리는 그대로 둔다)
near = [(d["title"] + " " + " ".join(s[1:] if len(s) > 1 else s)).strip()
        for d, s in zip(corpus, sents)]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
tok = model.tokenizer
# 마지막 문장이 256토큰 창 밖에서 시작하면, 그 문장을 빼도 임베딩이 안 바뀐다
head = [len(tok(d["title"] + " " + " ".join(s[:-1]))["input_ids"]) for d, s in zip(corpus, sents)]
print(f"마지막 문장이 {model.max_seq_length}토큰 창 밖에서 시작하는 문서 "
      f"{sum(h >= model.max_seq_length for h in head)}/{len(head)}")
enc = lambda x: model.encode(x, batch_size=64, normalize_embeddings=True, show_progress_bar=False)
D, N, Q = enc(docs), enc(near), enc([qtext[q] for q in qids])
G = np.array([[dids[j] in gold[q] for j in range(len(dids))] for q in qids], dtype=bool)
np.savez("scifact_dup.npz", D=D, N=N, Q=Q, G=G)
same = sum(a == b for a, b in zip(docs, near))
cos = (D * N).sum(1)
print(f"docs={len(docs)} queries={len(qids)} qrels={len(qrels)} 한 문장짜리(사본=원본 글자)={same}")
print(f"원본-근사중복 코사인: 중앙값 {np.median(cos):.4f} 최솟값 {cos.min():.4f} "
      f"· 글자 수 {np.mean([len(x) for x in docs]):.0f} → {np.mean([len(x) for x in near]):.0f}")
print(f"elapsed={time.perf_counter() - t0:.0f}s")
python3 dup_encode.py

원본과 근사 중복 사본을 한 번씩 인코딩해 scifact_dup.npz에 남깁니다. 뒤의 두 스크립트는 이 파일만 읽습니다. 정확 중복은 같은 벡터를 배열로 다시 쌓으면 되므로 인코딩이 필요 없습니다.

채점 스크립트의 열은 앞 절의 정의를 그대로 따릅니다. R@10 위치셈과 nDCG@10은 사본도 정답으로 치고 칸마다 셉니다. 분모는 원본 정답의 개수 그대로라, 실험대 글의 채점 코드에 사본이 든 색인을 넣었을 때 나오는 값과 같습니다. R@10 원본qrels는 사본을 정답으로 치지 않고, R@10 고유는 원본 id로 되돌려 중복을 지운 뒤 셉니다.

마지막 문장이 256토큰 창 밖에서 시작하는 문서 3030/5183
docs=5183 queries=300 qrels=339 한 문장짜리(사본=원본 글자)=0
원본-근사중복 코사인: 중앙값 0.9747 최솟값 0.7410 · 글자 수 1499 → 1342
elapsed=230s

채점

import numpy as np
z = np.load("scifact_dup.npz")
D, N, Q, G = z["D"], z["N"], z["Q"], z["G"]
n, ng = len(D), G.sum(1)
rng = np.random.default_rng(0)
part = rng.choice(n, n // 10, replace=False)  # 무작위 10% 문서
CONDS = {"기준 1벌": (D, np.arange(n)),
         "정확 중복 2벌": (np.vstack([D] * 2), np.tile(np.arange(n), 2)),
         "정확 중복 3벌": (np.vstack([D] * 3), np.tile(np.arange(n), 3)),
         "정확 중복 5벌": (np.vstack([D] * 5), np.tile(np.arange(n), 5)),
         "근사 중복 2벌": (np.vstack([D, N]), np.tile(np.arange(n), 2)),
         "10% 문서만 5벌": (np.vstack([D] + [D[part]] * 4), np.r_[np.arange(n), np.tile(part, 4)])}
disc = 1.0 / np.log2(np.arange(2, 12))
idcg = np.array([disc[:min(k, 10)].sum() for k in ng])
print(f"{'조건':<12}{'색인':>7}{'R@10 위치셈':>12}{'nDCG@10':>9}{'R@10 원본qrels':>15}"
      f"{'R@10 고유':>10}{'고유문서':>9}")
uniq_hit = {}
for lab, (X, src) in CONDS.items():
    top = np.argsort(-(Q @ X.T), axis=1, kind="stable")[:, :10]
    rel = np.take_along_axis(G, src[top], 1).astype(float)  # 사본도 정답으로 센다
    orig = rel * (top < n)                                  # qrels가 원본 id만 안다
    hit = np.array([G[i, np.unique(src[t])].sum() for i, t in enumerate(top)]) / ng
    uniq = np.array([len(np.unique(src[t])) for t in top])
    uniq_hit[lab] = hit
    print(f"{lab:<12}{len(X):>8,}{(rel.sum(1) / ng).mean():>12.4f}"
          f"{((rel * disc).sum(1) / idcg).mean():>9.4f}{(orig.sum(1) / ng).mean():>15.4f}"
          f"{hit.mean():>10.4f}{uniq.mean():>9.2f}")
idx = rng.integers(0, len(Q), size=(1000, len(Q)))
print("\n고유 문서 기준 R@10의 기준 대비 차이 · 질의 짝지은 부트스트랩 1,000회")
for lab in list(CONDS)[1:]:
    d = uniq_hit[lab] - uniq_hit["기준 1벌"]
    lo, hi = np.percentile(d[idx].mean(1) * 100, [2.5, 97.5])
    print(f"  {lab:<12}{d.mean() * 100:+7.2f}%p  [{lo:+.2f}, {hi:+.2f}]")
python3 dup_eval.py

실제 출력

조건               색인    R@10 위치셈  nDCG@10   R@10 원본qrels   R@10 고유     고유문서
기준 1벌          5,183      0.7833   0.6451         0.7833    0.7833    10.00
정확 중복 2벌      10,366      1.4759   1.0057         0.7379    0.7379     5.00
정확 중복 3벌      15,549      2.0316   1.2546         0.7109    0.7109     4.00
정확 중복 5벌      25,915      2.9992   1.6284         0.5998    0.5998     2.00
근사 중복 2벌      10,366      1.4419   0.9990         0.7356    0.7457     6.13
10% 문서만 5벌     7,255      1.0293   0.7474         0.7549    0.7549     7.72

고유 문서 기준 R@10의 기준 대비 차이 · 질의 짝지은 부트스트랩 1,000회
  정확 중복 2벌      -4.54%p  [-7.00, -2.50]
  정확 중복 3벌      -7.24%p  [-10.09, -4.74]
  정확 중복 5벌     -18.35%p  [-22.96, -14.21]
  근사 중복 2벌      -3.76%p  [-6.01, -1.94]
  10% 문서만 5벌    -2.84%p  [-4.84, -1.20]

부풀린 지표

위치셈

위치셈 두 열이 먼저 눈에 걸립니다. 코퍼스를 통째로 두 벌 넣었더니 Recall@10이 0.7833에서 1.4759가 되고 nDCG@10은 1.0057이 됐습니다. 다섯 벌이면 Recall@10이 2.9992입니다. 정답이 하나인 질의에서 같은 정답이 다섯 칸을 차지하면 다섯 번 세기 때문입니다. 정의상 1을 넘을 수 없는 지표가 1을 넘었으니 여기까지는 누구라도 이상하다는 것을 알아챕니다.

위험한 것은 마지막 줄입니다. 문서 10%만 다섯 벌로 넣으면 nDCG@10이 0.7474입니다. 1을 넘지 않고 기준(0.6451)보다 10.23%p 높을 뿐이라, 표에 이 숫자만 있으면 「색인을 다시 만들었더니 검색이 좋아졌다」로 읽힙니다. Recall@10 위치셈은 1.0293으로 1을 살짝 넘었지만, 질의 평균이라 일부 질의만 1을 넘어도 이렇게 되고, nDCG만 보는 대시보드라면 그마저 안 보입니다.

고유 문서

같은 실행을 고유 셈으로 다시 세면 방향이 반대입니다. 모든 조건에서 Recall@10이 기준보다 낮고, 부트스트랩 구간이 전부 0 아래에 있습니다. 2벌 −4.54%p, 3벌 −7.24%p, 5벌 −18.35%p, 10%만 5벌 −2.84%p입니다.

원인은 마지막 열에 그대로 있습니다. 정확 중복 2벌이면 상위 10칸의 서로 다른 문서가 정확히 5.00개, 3벌이면 4.00개(10을 3으로 나눈 몫에 나머지 한 칸), 5벌이면 2.00개입니다. 동점인 사본이 원본 바로 뒤에 붙어 칸을 둘씩, 셋씩 차지하니 10칸이 실제로는 5칸, 4칸이 됩니다. 정답 문서가 원래 6~10위에 있던 질의는 그 칸을 사본에 내주고 밀려납니다. 10%만 5벌일 때도 평균 7.72개로, 열 칸 중 두 칸 남짓이 같은 문서의 되풀이입니다.

「원본 qrels」 열은 사본을 모르는 평가 데이터로 센 값인데, 정확 중복에서는 고유 셈과 소수점 넷째 자리까지 같습니다. 원본이 동점의 맨 앞에 서도록 정렬했으니 정답의 원본은 늘 사본보다 먼저 잡히고, 밀려나는 것은 사본입니다. 평가 데이터를 손대지 않고 그대로 두면 이 경우만큼은 지표가 정직하게 떨어진다는 뜻입니다.

채점 고치기

고치는 법은 단순합니다. 상위 k칸을 채점하기 전에 각 칸의 문서를 원본 id로 되돌리고 중복을 지운 다음 세면 됩니다. 이 글의 hit 줄이 그 일을 하는 한 줄이고, 위 표에서 그렇게 센 열은 중복을 넣을 때마다 한 번도 빠짐없이 내려갔습니다. 중복이 실제로 사용자에게 주는 손해와 방향이 같다는 뜻입니다.

원본 id로 되돌리려면 색인에 넣을 때 원본 id를 함께 적어 두어야 합니다. 청크로 잘라 넣는 RAG 색인이라면 대부분 이미 청크마다 원문서 id를 들고 있으니, 채점 코드가 그것을 쓰기만 하면 됩니다. 정확 중복이라면 넣기 전에 본문 해시로 거르는 것이 가장 쌉니다. 다만 아래 근사 중복 절이 보이듯 해시로는 못 거르는 쌍이 남습니다.

그리고 상위 10칸의 서로 다른 문서 수를 지표 옆에 한 열로 같이 적어 둡니다. 계산이 한 줄이고, 이 값이 10 아래로 내려가면 색인에 중복이 들어왔다는 신호가 됩니다. 이 실험에서는 문서 1%가 두 번 들어간 것만으로 9.91이 됐습니다. 지표 하나로는 안 보이는 일이 이 열에서는 첫 단계부터 보입니다.

일부만 겹칠 때

비율 스윕

실제 색인에서는 코퍼스 전체가 두 벌이 되는 일보다 일부가 두 번 들어가는 일이 흔합니다. 그래서 문서 중 p%를 무작위로 골라 한 벌씩만 더 붙이고, p를 1%에서 50%까지 올렸습니다. 고르는 문서가 시드에 따라 달라지므로 시드 다섯 개로 반복해 평균과 범위를 적습니다. 「구간 밖」은 다섯 시드 중 고유 셈 Recall@10 차이의 95% 구간이 0 아래로 나간 횟수입니다.

import numpy as np
z = np.load("scifact_dup.npz")
D, Q, G = z["D"], z["Q"], z["G"]
n, ng = len(D), G.sum(1)
disc = 1.0 / np.log2(np.arange(2, 12))
idcg = np.array([disc[:min(k, 10)].sum() for k in ng])
S = Q @ D.T


def run(part, copies):
    src = np.r_[np.arange(n), np.tile(part, copies - 1)]
    top = np.argsort(-S[:, src], axis=1, kind="stable")[:, :10]
    rel = np.take_along_axis(G, src[top], 1).astype(float)
    hit = np.array([G[i, np.unique(src[t])].sum() for i, t in enumerate(top)]) / ng
    uniq = np.array([len(np.unique(src[t])) for t in top])
    return ((rel * disc).sum(1) / idcg).mean(), hit, uniq.mean()


_, base, _ = run(np.array([], dtype=int), 1)
boot = np.random.default_rng(0).integers(0, len(Q), size=(1000, len(Q)))
print("무작위로 고른 p% 문서만 2벌 · 시드 5개 · 기준 nDCG@10 0.6451, R@10 고유 0.7833")
print(f"{'p':>5}{'nDCG@10 위치셈':>16}{'ΔR@10 고유 %p (범위)':>22}{'구간 밖':>7}{'고유문서':>9}")
for p in (0.01, 0.02, 0.05, 0.1, 0.2, 0.5):
    nd, dr, out, uq = [], [], 0, []
    for seed in range(5):
        part = np.random.default_rng(seed).choice(n, round(n * p), replace=False)
        a, hit, u = run(part, 2)
        d = hit - base
        lo, hi = np.percentile(d[boot].mean(1), [2.5, 97.5])
        nd.append(a); dr.append(d.mean() * 100); uq.append(u); out += hi < 0
    nd_s, dr_s = f"{np.mean(nd):.4f}±{np.std(nd):.4f}", f"{np.mean(dr):+.2f} ({min(dr):+.2f}~{max(dr):+.2f})"
    print(f"{p:>5.0%}{nd_s:>16}{dr_s:>22}{f'{out}/5':>7}{np.mean(uq):>9.2f}")
python3 dup_sweep.py
무작위로 고른 p% 문서만 2벌 · 시드 5개 · 기준 nDCG@10 0.6451, R@10 고유 0.7833
    p     nDCG@10 위치셈      ΔR@10 고유 %p (범위)   구간 밖     고유문서
   1%   0.6472±0.0014   +0.00 (+0.00~+0.00)    0/5     9.91
   2%   0.6543±0.0016   -0.07 (-0.17~+0.00)    0/5     9.82
   5%   0.6627±0.0053   -0.25 (-0.50~+0.00)    0/5     9.59
  10%   0.6831±0.0072   -0.65 (-1.07~-0.40)    1/5     9.16
  20%   0.7144±0.0066   -1.23 (-1.57~-0.57)    4/5     8.44
  50%   0.8178±0.0200   -2.67 (-3.01~-2.40)    5/5     6.91

꺾이는 지점

두 열이 서로 반대로 움직입니다.

먼저 산포를 봅니다. 위치셈 nDCG@10의 시드 간 표준편차가 1%에서 0.0014, 50%에서 0.0200으로 커집니다. 어느 문서가 사본을 얻느냐에 따라 부풂의 크기가 달라지기 때문입니다. 정답 문서가 사본을 얻으면 그 질의의 점수가 한 칸어치 더 붙고, 정답이 아닌 문서가 사본을 얻으면 붙지 않습니다. 같은 비율의 중복이라도 어느 문서에 몰렸느냐가 지표를 정하니, 위치셈 지표는 중복 비율만 알아서는 얼마나 부풀었는지 되짚을 수도 없습니다. 반대로 1%(52편)에서 고유 셈 차이가 다섯 시드 모두 정확히 0.00인 것은, 사본이 끼어든 질의에서도 밀려난 것이 정답이 아니었다는 뜻입니다. 위치셈 nDCG@10은 1%에서부터 오르기 시작해 5%에서 0.6627(+1.76%p), 20%에서 0.7144(+6.93%p), 50%에서 0.8178(+17.27%p)입니다. 한 번도 기준 아래로 내려가지 않습니다. 고유 셈 Recall@10은 같은 구간에서 천천히 내려갑니다.

꺾이는 자리는 이렇게 적을 수 있습니다 — 문서의 5%가 두 번 들어간 데까지는 실제로 찾는 문서가 줄었다고 말할 수 없고(다섯 시드 모두 구간 안, 가장 나쁜 시드가 −0.50%p), 10%에서 다섯 중 하나, 20%에서 다섯 중 넷이 구간 밖으로 나갑니다. 그런데 위치셈 nDCG는 5%에서 이미 1.76%p를 얹어 놓습니다. 즉 공짜 구간은 검색 품질 쪽에만 있고 지표 쪽에는 없습니다. 중복이 몇 %만 섞여도 위치셈 지표는 그만큼 오른 값을 보고합니다.

이 크기는 작지 않습니다. Recall@k·MRR·nDCG는 언제 서로 다른 결론을 내는가는 같은 실험대·같은 질의 300개에서 nDCG@10 차이 0.81%p(384차원 대 256차원)를 짝지은 구간 [−0.57, +2.13]으로 판정 불가로, 2.63%p(256차원 대 128차원)를 유의로 판정했습니다. 문서 10%가 두 번 들어가면 위치셈 nDCG는 +3.80%p 오르니, 그 글의 잣대로도 「유의한 개선」입니다. 같은 10%에서 고유 셈은 평균 −0.65%p이고 다섯 시드 중 넷이 구간 안이라, 검색 쪽 변화는 대개 판정조차 안 됩니다.

근사 중복

256토큰 창

근사 중복은 처음에 초록의 마지막 문장을 빼서 만들었습니다. 그 첫 판의 인코딩 스크립트(위에 실은 것의 이전 판)를 돌리니 원본과 사본의 코사인 중앙값이 1.0000이었습니다. 문장을 뺐는데 임베딩이 바뀌지 않은 것입니다.

원인은 모델의 입력 길이입니다. all-MiniLM-L6-v2는 256토큰에서 입력을 자르는데(max_seq_length), 인코딩 스크립트 첫 줄 출력대로 문서 5,183편 중 3,030편(58%)은 마지막 문장이 256토큰 창 밖에서 시작합니다. 그 문장은 처음부터 모델이 읽지 않았으므로 빼든 말든 같은 벡터가 나옵니다. 그 58%에서는 글자로는 근사 중복인데 모델에게는 정확 중복입니다.

이것은 실무에서 그대로 일어나는 일입니다. 바닥글이나 갱신 날짜처럼 문서 끝만 다른 판본은 해시로는 다른 문서이고, 문서가 모델의 입력 창보다 길면 임베딩으로는 같은 문서입니다. 해시로 정확 중복만 걸러 놓고 안심하면 이런 쌍이 색인에 그대로 남습니다. 문서 끝이 잘리는 문제는 512토큰에서 잘리는 문서는 얼마를 잃는가가 다른 쪽에서 쟀습니다.

첫 문장을 뺀 사본

그래서 근사 중복은 창 안쪽인 첫 문장을 빼서 다시 만들었습니다. 이번에는 코사인 중앙값이 0.9747, 최솟값이 0.7410입니다. 사본이 원본과 같은 자리에 붙지 않고 조금 떨어진 자리에 서므로 동점이 아닙니다.

결과는 정확 중복 2벌과 비슷하지만 덜합니다. 상위 10칸의 서로 다른 문서가 5.00개가 아니라 6.13개이고, 고유 셈 Recall@10 손실은 −4.54%p가 아니라 −3.76%p입니다. 사본이 늘 원본 바로 옆에 서지는 않으니 칸을 덜 먹습니다. 위치셈은 정확 중복과 거의 같이 부풀어 Recall@10 1.4419, nDCG@10 0.9990입니다.

근사 중복에서만 「원본 qrels」와 「고유 셈」이 갈립니다(0.7356 대 0.7457). 사본이 원본보다 높은 점수를 받아 원본을 10위 밖으로 밀어낸 질의가 있기 때문입니다. 사용자는 같은 내용을 받았지만 평가 데이터는 그 문서를 모르니 놓친 것으로 셉니다. 근사 중복이 섞인 색인에서는 평가 데이터를 그대로 두는 쪽도 정직하지 않다는 뜻입니다.

한계와 측정 환경

한계

  • 코퍼스 하나, 모델 하나, 질의 300개입니다. 정답이 질의당 1.13개뿐인 scifact라 같은 정답이 여러 칸을 먹는 효과가 크게 보였습니다. 정답이 수십 개인 코퍼스에서는 부풀림의 크기가 다를 것입니다.
  • 사본을 무작위로 골랐습니다. 실제 중복은 인기 있는 문서, 자주 갱신되는 문서에 몰리므로 정답 문서가 더 자주 겹칠 수 있고 그러면 위치셈은 더 부풉니다.
  • 근사 중복은 「첫 문장 하나 빼기」 한 가지만 만들었고 두 벌까지만 쟀습니다. 사본을 넷 만들려면 서로 다른 변형을 넷 만들어 각각 인코딩해야 하는데, 이 컨테이너에서 한 벌 인코딩이 2분 가까이 걸려 범위 밖에 두었습니다.
  • 동점을 안정 정렬로 풀었습니다. 검색 엔진이 동점을 다른 규칙으로 풀면 「원본 qrels」 열의 값이 달라집니다. 고유 셈과 위치셈은 동점 규칙에 매이지 않습니다.
  • 위치셈이 어디서 쓰이는지는 세지 않았습니다. pytrec_eval처럼 실행 결과를 문서 id 사전으로 받는 도구는 같은 id가 두 번 못 들어가 이 부풀림이 안 생깁니다. 사본에 새 id를 주고 정답을 사본까지 넓히는 순간 다시 생깁니다.

측정 환경

항목 값
OS Linux 6.18.44 x86_64 (glibc 2.39)
CPU Intel Xeon @ 2.80GHz, 4코어 (torch 기본 4스레드)
Python 3.11.17
패키지 torch 2.14.1 (PyPI 기본 휠, CPU로 실행), sentence-transformers 6.1.0, transformers 5.19.0, datasets 5.1.0, numpy 2.4.6
모델 sentence-transformers/all-MiniLM-L6-v2 (리비전 1110a243, max_seq_length 256)
데이터 BeIR/scifact (b3b53356) · BeIR/scifact-qrels (2938d17d) test
측정일 2026-10-10 (KST)
실행 시간 인코딩 3분 50초 · 채점 3초 · 비율 스윕 4초

위 출력은 패키지를 새로 깐 빈 가상환경에서 세 스크립트를 다시 돌린 것이고, 처음 돌린 가상환경의 출력과 숫자가 같았습니다.


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

LATEST

과학·실험의 최신 글

과학·실험2026.10.09

한국어 질의로 영어 문서를 찾을 때 잃는 것: R@1이 30%p 빠졌고, 두 언어를 섞으면 영어 문서는 1위에 한 번도 안 올랐다

사람이 옮긴 한영 병렬 지문 488편과 질문 900개로 multilingual-e5-small의 언어 건너기를 쟀다. 한국어 질의로 영어 문서를 찾으면 R@1이 0.85에서 0.55로 떨어졌고, 같은 지문의 두 언어판을 한 코퍼스에 넣자 1위는 900번 모두 질의와 같은 언어 쪽이었다.

23 MIN
과학·실험2026.10.08

인코딩 배치 크기의 무릎: 배치 8이 꼭대기였고 그 뒤로는 메모리만 자랐다

CPU에서 임베딩 인코딩의 배치 크기를 1·8·32·128·256으로 올리며 문서/초와 최대 상주 메모리를 함께 쟀다. 4스레드에서는 배치 8이 1보다 17~24% 빨랐고 그 뒤로는 오히려 느려졌다. 1스레드에서는 배치가 처리량을 전혀 올리지 못했고, 메모리는 어느 경우에나 배치에 정비례해 1~2GB까지 자랐다.

24 MIN