리서치

PAPERS / 12번째 글

k1=1.2·b=0.75는 한국어에서도 최적인가: KLUE-MRC에서는 최적이었고, 갈린 것은 언어가 아니라 질문과 지문의 겹침이었다

BM25 기본값 k1=1.2·b=0.75를 scifact·KorQuAD·KLUE-MRC 세 코퍼스에서 11×12 격자로 다시 쟀다. KorQuAD의 최적 k1은 0.5였지만 KLUE-MRC에서는 1.2가 최적이었다. KLUE 안에서 질문과 지문의 겹침이 낮은 3분의 1만 떼면 최적 k1이 2.0까지 올라갔다.

PALDYN Team29 MIN READ

BM25의 k1·b를 한국어에서 다시 쟀다는 KorQuAD에서 최적 k1이 0.3 근처였다고 적었습니다. 논문이 적은 2의 7분의 1이고, 지금 Lucene 기본값 1.2보다도 한참 아래입니다. 그 글의 한계 절에는 이런 줄이 있었습니다 — KorQuAD 질문은 정답 문단을 보고 만든 것이라 어휘가 크게 겹치고, 어휘가 덜 겹치는 질의에서는 최적 k1이 올라갈 수 있다.

이 글은 그 줄을 잽니다. 「한국어에서는 k1을 내려야 한다」가 한국어의 성질인지, KorQuAD라는 데이터의 성질인지를 가릅니다. 결과를 먼저 적으면 뒤쪽이었습니다. 질문과 지문의 겹침을 일부러 낮춰 만든 한국어 데이터 KLUE-MRC에서는 Lucene 기본값 k1=1.2가 격자 최적과 통계적으로 같았고, 같은 데이터 안에서도 겹침이 낮은 질문일수록 최적 k1이 올라갔습니다.

어휘 검색과 밀집 검색이 무엇이고 언제 섞는지는 검색 전략이 맡습니다. BM25 식과 k1·b가 점수를 어떻게 바꾸는지는 앞의 재현 글에 식과 표로 있으니 여기서는 되풀이하지 않습니다.

재현하려는 주장

기본값의 출처

BM25의 두 손잡이 중 k1은 같은 낱말이 문서에 여러 번 나올 때 점수를 얼마나 더 쳐 줄지를, b는 긴 문서의 점수를 얼마나 깎을지를 정합니다. 오늘 쓰이는 기본값은 대개 Robertson과 Zaragoza의 서베이 「The Probabilistic Relevance Framework: BM25 and Beyond」(2009)로 거슬러 올라가는데, 이 서베이는 k1을 1.2~2.0 사이, b를 0.75로 두는 것이 무난하다고 권한 것으로 널리 인용됩니다. Lucene은 그 아래쪽 끝인 k1=1.2·b=0.75를 기본값으로 박았고, 이 글에서 쓰는 bm25s는 k1=1.5·b=0.75입니다.

계획은 서베이 원문의 해당 문장을 인용하라고 했지만 받지 못했습니다. 저자 페이지(www.staff.city.ac.uk)와 출판사(dl.acm.org) 둘 다 이 컨테이너의 이그레스 정책에 막혀 curl: (56) CONNECT tunnel failed, response 403으로 끝났습니다. 그래서 원문의 수치는 인용하지 못하고 널리 인용되는 값만 적습니다. 재는 대상은 그 값 자체이고, 원문 확인이 빠졌다는 것은 맨 아래 한계 절에 다시 적습니다.

앞 재현의 물음

앞 글은 KorQuAD 문단 960개·질의 5,774개에서 k1을 0까지 내리며 봉우리를 찾았고, 작은 k1이 이긴 이유를 질의어 적중률로 설명했습니다. 질의에 든 낱말 종류 중 몇 %가 그 문단에 들어 있는가입니다. 작은 k1은 질의어를 몇 종류나 담았는지를, 큰 k1은 한 낱말을 몇 번 되풀이했는지를 보는데, KorQuAD의 정답 문단은 질의어를 골고루 담은 문단이라 앞쪽이 이겼다는 것이었습니다.

이 설명이 맞다면 예측이 하나 나옵니다. 질문과 지문이 덜 겹치는 데이터에서는 「종류를 세는 것」의 이점이 줄고, 최적 k1이 기본값 쪽으로 올라가야 합니다. 이 예측을 언어를 바꾸지 않고 시험할 수 있는 데이터가 있습니다.

세 코퍼스

겹침이 다른 셋

KLUE 벤치마크(Park 외, 2021, arXiv:2105.09680)의 독해 과제 KLUE-MRC는 질문을 만들 때 지문의 낱말을 그대로 따 오지 못하게 했습니다. 논문은 그 결과를 이렇게 적습니다 — 조사와 어미를 뺀 형태소로 잰 겹침 비율이 KorQuAD 1.0(70%)보다 거의 10%p 낮고, 질문 유형별로는 1형(바꿔 말하기)과 3형(답 없음)이 55~59%, 2형(여러 문장 추론)이 68%다. 같은 한국어인데 겹침만 낮은 코퍼스가 생긴 셈입니다.

여기에 언어가 다른 대조군으로 scifact를 더했습니다. 질의가 논문 초록이 아니라 과학적 주장문이라 초록과 겹침이 낮습니다.

KorQuAD KLUE-MRC scifact
언어 한국어 한국어 영어
코퍼스 검증 분할의 고유 문단 960개 검증 분할의 고유 지문 5,309개 초록 5,183편
질의 5,774개 답이 있는 질문 4,008개 test 300개
정답 질의당 1개 질의당 1개 질의당 평균 1.13개
토큰화 kiwi 형태소 kiwi 형태소 소문자 영숫자
지표 R@1 R@1 nDCG@10

KLUE-MRC의 지문 5,309개에는 답 없는 질문(3형)의 지문도 들어 있습니다. 질의로는 쓰지 않고 방해 문서로만 남겼습니다. 한국어 두 코퍼스는 정답이 하나라 1위를 맞혔는지(R@1)를 보고, scifact는 정답이 여럿인 질의가 있어 순위 전체를 보는 nDCG@10을 씁니다. 판정은 모두 같은 방식입니다 — 질의를 1,000번 되뽑는 부트스트랩으로 기본값과 격자 최적의 차이에 95% 구간을 붙이고, 같은 질의에 두 설정을 다 걸었으므로 질의별 차이를 짝지어 되뽑습니다.

재현 블록

pip install bm25s kiwipiepy numpy datasets
import re, sys, time, numpy as np, bm25s
from datasets import load_dataset

t0, name = time.perf_counter(), sys.argv[1]
if name == "scifact":  # 영어 · 주장문 질의 · 정답 문서 여럿 · 소문자 영숫자 토큰
    c, g = load_dataset("BeIR/scifact", "corpus")["corpus"], {}
    qt = {str(q["_id"]): q["text"] for q in load_dataset("BeIR/scifact", "queries")["queries"]}
    ids, docs = {str(d["_id"]): i for i, d in enumerate(c)}, [(d["title"] + " " + d["text"]).strip() for d in c]
    for r in load_dataset("BeIR/scifact-qrels")["test"]:
        g.setdefault(str(r["query-id"]), []).append(ids[str(r["corpus-id"])])
    qs, gold = [qt[q] for q in sorted(g, key=int)], [g[q] for q in sorted(g, key=int)]
    tok = lambda ts: [re.findall(r"[a-z0-9]+", t.lower()) for t in ts]
else:  # 한국어 · 정답 문단 하나 · kiwi 형태소. klue-mrc는 답이 있는 질문만 질의로 쓴다
    from kiwipiepy import Kiwi
    kiwi = Kiwi(); tok = lambda ts: [[m.form for m in s] for s in kiwi.tokenize(ts)]
    val = load_dataset(*(("KorQuAD/squad_kor_v1",) if name == "korquad" else ("klue/klue", "mrc")))["validation"]
    rows = [r for r in val if not r.get("is_impossible", False)]
    docs = sorted({r["context"] for r in val}); pi = {p: i for i, p in enumerate(docs)}
    qs, gold = [r["question"] for r in rows], [[pi[r["context"]]] for r in rows]
dt, qtk = tok(docs), tok(qs)
cover = [np.mean([len(set(q) & set(dt[j])) / max(len(set(q)), 1) for j in gs]) for q, gs in zip(qtk, gold)]
vocab = {w: i for i, w in enumerate(sorted({w for d in dt for w in d}))}
dtok = bm25s.tokenization.Tokenized([[vocab[w] for w in d] for d in dt], vocab)
qi = [[vocab[w] for w in q if w in vocab] for q in qtk]
live = [i for i, q in enumerate(qi) if q]
qtok, dead = bm25s.tokenization.Tokenized([qi[i] for i in live], vocab), np.setdiff1d(np.arange(len(qs)), live)
G = np.array([np.isin(np.arange(len(docs)), gs) for gs in gold])
disc = 1 / np.log2(np.arange(2, 12)); idcg = np.array([disc[:min(len(gs), 10)].sum() for gs in gold])
print(f"[{name}] docs={len(docs)} queries={len(qs)} tok/doc={np.mean([len(d) for d in dt]):.0f} "
      f"질의어 적중률={np.mean(cover):.3f} prep={time.perf_counter() - t0:.0f}s")
K1S, BS, score = [0, 0.1, 0.2, 0.3, 0.5, 0.75, 0.9, 1.2, 1.5, 2.0, 3.0], [0, .1, .2, .3, .4, .5, .6, .7, .75, .8, .9, 1.0], {}
for k1 in K1S:  # 질의별 점수: 정답이 여럿(scifact)이면 nDCG@10, 하나면 R@1
    for b in BS:
        idx = bm25s.BM25(k1=k1, b=b, method="lucene"); idx.index(dtok, show_progress=False)
        r = np.zeros((len(qs), 10), dtype=np.int64)
        r[live], _ = idx.retrieve(qtok, k=10, show_progress=False)
        rel = np.take_along_axis(G, r, 1); rel[dead] = False
        score[(k1, b)] = (rel * disc).sum(1) / idcg if name == "scifact" else rel[:, 0].astype(float)
metric = "nDCG@10" if name == "scifact" else "R@1"
print(f"  {metric:<8}b=" + "".join(f"{b:>7}" for b in BS))
for k1 in K1S:
    print(f"  k1={k1:<5}  " + "".join(f"{score[(k1, b)].mean():>7.4f}" for b in BS))
best = max(score, key=lambda kb: score[kb].mean())
boot = np.random.default_rng(0).integers(0, len(qs), size=(1000, len(qs)))
print(f"  격자 최적 k1={best[0]} b={best[1]} {metric}={score[best].mean():.4f}")
for lab, kb in (("Lucene 1.2/0.75", (1.2, .75)), ("bm25s 1.5/0.75", (1.5, .75)), ("k1=2.0/0.75", (2.0, .75))):
    d = score[kb] - score[best]
    lo, hi = np.percentile(d[boot].mean(1) * 100, [2.5, 97.5])
    print(f"  {lab:<16}{score[kb].mean():.4f}  최적 대비 {d.mean() * 100:+.2f}%p [{lo:+.2f}, {hi:+.2f}]")
print(f"  elapsed={time.perf_counter() - t0:.0f}s")
python3 bm25_grid.py scifact
python3 bm25_grid.py korquad
python3 bm25_grid.py klue-mrc

k1은 0에서 3.0까지 11개, b는 0에서 1.0까지 0.1 간격에 0.75를 더해 12개로, 코퍼스마다 132칸입니다. 첫 줄의 「질의어 적중률」은 앞 글과 같은 정의로, 조사·어미까지 모든 토큰을 셉니다.

실제 출력

[scifact] docs=5183 queries=300 tok/doc=225 질의어 적중률=0.585 prep=4s
  nDCG@10 b=      0    0.1    0.2    0.3    0.4    0.5    0.6    0.7   0.75    0.8    0.9    1.0
  k1=0       0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174 0.6174
  k1=0.1     0.6329 0.6330 0.6337 0.6350 0.6347 0.6319 0.6329 0.6318 0.6320 0.6320 0.6347 0.6348
  k1=0.2     0.6330 0.6348 0.6365 0.6397 0.6386 0.6403 0.6394 0.6408 0.6415 0.6426 0.6451 0.6447
  k1=0.3     0.6388 0.6405 0.6432 0.6428 0.6457 0.6473 0.6463 0.6483 0.6478 0.6487 0.6504 0.6521
  k1=0.5     0.6463 0.6496 0.6562 0.6552 0.6573 0.6555 0.6606 0.6598 0.6566 0.6570 0.6618 0.6644
  k1=0.75    0.6519 0.6553 0.6587 0.6612 0.6615 0.6615 0.6617 0.6633 0.6608 0.6606 0.6625 0.6643
  k1=0.9     0.6490 0.6557 0.6597 0.6608 0.6611 0.6616 0.6587 0.6567 0.6583 0.6621 0.6633 0.6634
  k1=1.2     0.6482 0.6461 0.6560 0.6584 0.6602 0.6609 0.6648 0.6637 0.6622 0.6649 0.6664 0.6641
  k1=1.5     0.6417 0.6496 0.6545 0.6594 0.6597 0.6629 0.6662 0.6637 0.6644 0.6677 0.6656 0.6652
  k1=2.0     0.6417 0.6496 0.6544 0.6578 0.6604 0.6641 0.6611 0.6614 0.6616 0.6638 0.6655 0.6644
  k1=3.0     0.6395 0.6487 0.6492 0.6494 0.6534 0.6592 0.6563 0.6565 0.6582 0.6573 0.6572 0.6571
  격자 최적 k1=1.5 b=0.8 nDCG@10=0.6677
  Lucene 1.2/0.75 0.6622  최적 대비 -0.55%p [-1.07, -0.07]
  bm25s 1.5/0.75  0.6644  최적 대비 -0.33%p [-0.67, -0.06]
  k1=2.0/0.75     0.6616  최적 대비 -0.61%p [-1.51, +0.27]
  elapsed=58s
[korquad] docs=960 queries=5774 tok/doc=272 질의어 적중률=0.768 prep=10s
  R@1     b=      0    0.1    0.2    0.3    0.4    0.5    0.6    0.7   0.75    0.8    0.9    1.0
  k1=0       0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758 0.8758
  k1=0.1     0.8815 0.8857 0.8883 0.8900 0.8914 0.8923 0.8924 0.8931 0.8931 0.8930 0.8930 0.8931
  k1=0.2     0.8822 0.8867 0.8897 0.8918 0.8926 0.8931 0.8945 0.8952 0.8954 0.8957 0.8961 0.8961
  k1=0.3     0.8824 0.8878 0.8912 0.8937 0.8954 0.8950 0.8968 0.8973 0.8976 0.8975 0.8985 0.8985
  k1=0.5     0.8791 0.8859 0.8911 0.8945 0.8968 0.8982 0.8994 0.8999 0.9008 0.9015 0.8999 0.8999
  k1=0.75    0.8769 0.8831 0.8871 0.8914 0.8952 0.8966 0.8968 0.8982 0.8980 0.8980 0.8980 0.8970
  k1=0.9     0.8755 0.8822 0.8855 0.8892 0.8931 0.8952 0.8966 0.8978 0.8975 0.8975 0.8971 0.8956
  k1=1.2     0.8696 0.8772 0.8817 0.8855 0.8893 0.8916 0.8947 0.8959 0.8944 0.8931 0.8911 0.8881
  k1=1.5     0.8616 0.8725 0.8767 0.8812 0.8843 0.8869 0.8888 0.8890 0.8890 0.8892 0.8862 0.8831
  k1=2.0     0.8481 0.8632 0.8705 0.8739 0.8781 0.8788 0.8807 0.8808 0.8815 0.8808 0.8769 0.8732
  k1=3.0     0.8227 0.8388 0.8498 0.8568 0.8635 0.8651 0.8660 0.8665 0.8660 0.8656 0.8628 0.8583
  격자 최적 k1=0.5 b=0.8 R@1=0.9015
  Lucene 1.2/0.75 0.8944  최적 대비 -0.71%p [-1.09, -0.36]
  bm25s 1.5/0.75  0.8890  최적 대비 -1.25%p [-1.70, -0.81]
  k1=2.0/0.75     0.8815  최적 대비 -1.99%p [-2.51, -1.49]
  elapsed=95s
[klue-mrc] docs=5309 queries=4008 tok/doc=520 질의어 적중률=0.718 prep=27s
  R@1     b=      0    0.1    0.2    0.3    0.4    0.5    0.6    0.7   0.75    0.8    0.9    1.0
  k1=0       0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003 0.7003
  k1=0.1     0.7231 0.7258 0.7273 0.7293 0.7298 0.7320 0.7350 0.7380 0.7385 0.7380 0.7413 0.7425
  k1=0.2     0.7335 0.7373 0.7403 0.7428 0.7463 0.7495 0.7525 0.7550 0.7562 0.7575 0.7582 0.7602
  k1=0.3     0.7403 0.7453 0.7505 0.7540 0.7575 0.7592 0.7622 0.7640 0.7650 0.7665 0.7692 0.7710
  k1=0.5     0.7512 0.7552 0.7615 0.7660 0.7705 0.7735 0.7767 0.7822 0.7847 0.7864 0.7872 0.7874
  k1=0.75    0.7527 0.7632 0.7677 0.7737 0.7792 0.7872 0.7904 0.7932 0.7937 0.7949 0.7949 0.7957
  k1=0.9     0.7510 0.7627 0.7740 0.7794 0.7854 0.7892 0.7924 0.7947 0.7959 0.7977 0.7977 0.7984
  k1=1.2     0.7532 0.7642 0.7737 0.7844 0.7902 0.7957 0.7974 0.7987 0.7984 0.7997 0.7984 0.7977
  k1=1.5     0.7507 0.7625 0.7735 0.7824 0.7899 0.7947 0.7977 0.7979 0.7962 0.7977 0.7977 0.7974
  k1=2.0     0.7448 0.7547 0.7645 0.7750 0.7834 0.7889 0.7917 0.7937 0.7932 0.7912 0.7899 0.7879
  k1=3.0     0.7293 0.7403 0.7475 0.7572 0.7642 0.7717 0.7764 0.7764 0.7754 0.7715 0.7712 0.7710
  격자 최적 k1=1.2 b=0.8 R@1=0.7997
  Lucene 1.2/0.75 0.7984  최적 대비 -0.12%p [-0.30, +0.05]
  bm25s 1.5/0.75  0.7962  최적 대비 -0.35%p [-0.75, +0.02]
  k1=2.0/0.75     0.7932  최적 대비 -0.65%p [-1.22, -0.10]
  elapsed=220s

격자 판정

KorQuAD

앞 글의 결론이 그대로 섭니다. 격자 최적은 k1=0.5·b=0.8로 R@1 0.9015이고, Lucene 기본값(0.8944)은 −0.71%p로 구간 [−1.09, −0.36]이 0 아래에 있습니다. k1을 키울수록 나빠지는 모양도 같아서 k1=2.0이면 −1.99%p입니다.

앞 글의 같은 칸과 숫자가 조금 다릅니다. Lucene 기본값이 앞 글에서 0.8968, 여기서 0.8944입니다. 앞 글은 kiwipiepy 0.23.2, 이번은 0.24.0이고 형태소 분석 결과가 조금 달라진 것이 원인으로 보입니다. 어느 쪽이든 「기본값은 최적보다 구간 밖으로 낮다」는 판정은 같습니다. 봉우리 자리도 조금 옮겼습니다. 앞 글은 k1=0.3·b=1.0이었고 이번에는 0.5·0.8인데, 이번 표에서 두 칸의 차이가 0.30%p(0.8985 대 0.9015)라 평평한 언덕 위에서 꼭대기가 한 칸 옮겨 간 정도입니다.

KLUE-MRC

여기서 판정이 갈립니다. 격자 최적이 k1=1.2·b=0.8(R@1 0.7997)로, Lucene 기본값과 k1이 같습니다. 기본값 칸(b=0.75)은 0.7984로 −0.12%p이고 구간 [−0.30, +0.05]이 0을 품습니다. 기본값과 최적을 구별할 수 없다는 뜻입니다. bm25s의 1.5도 −0.35%p [−0.75, +0.02]로 구간 안입니다.

표의 모양도 KorQuAD와 반대입니다. KorQuAD는 k1=0.5 행에서 아래로 내려갈수록 나빠졌는데, KLUE-MRC는 k1=0.1에서 1.2까지 올라가는 내내 좋아지고 1.2·1.5 근처에서 평평해진 뒤 2.0부터 내려갑니다. tf를 아예 안 세는 k1=0 행은 0.7003으로 최적보다 10%p 가까이 낮습니다. KorQuAD에서는 같은 k1=0이 최적과 2.6%p밖에 차이 나지 않았습니다.

scifact

영어 대조군도 기본값 쪽입니다. 격자 최적이 k1=1.5·b=0.8로 bm25s 기본값과 k1이 같고, 그 칸(b=0.75)은 −0.33%p, Lucene 기본값은 −0.55%p입니다. 둘 다 구간이 0 아래로 살짝 나가 있지만(위쪽 끝이 −0.06·−0.07) 크기는 한국어 KorQuAD의 −0.71·−1.25%p보다 작습니다. 질의가 300개뿐이라 격자 표면이 거칠고, 0.75와 0.8 사이를 오가는 칸 하나에서 0.3%p가 움직입니다.

b는 세 코퍼스 모두 0.8에서 최적이었고 0.75와의 차이는 그 행 안에서 0.07~0.33%p였습니다. b=0으로 길이 정규화를 끄는 것이 손해라는 것도 세 표 모두 같습니다. 갈린 것은 k1 하나입니다.

겹침과 k1

KLUE 논문의 겹침

KLUE-MRC가 KorQuAD와 다른 점이 정말 겹침인지부터 확인했습니다. KLUE 논문은 조사·어미를 뺀 형태소로 겹침을 쟀으므로(논문은 KoNLPy의 Twitter 태거) 같은 정의를 kiwi로 다시 셌습니다. 품사 태그가 J(조사)나 E(어미)로 시작하는 형태소를 빼고, 질문의 남은 형태소 중 정답 지문에 있는 것의 비율입니다. 같은 스크립트가 이어서 겹침을 3등분해 묶음마다 k1을 훑습니다.

import sys, time, numpy as np, bm25s
from datasets import load_dataset
from kiwipiepy import Kiwi

t0, name, kiwi = time.perf_counter(), sys.argv[1], Kiwi()
if name == "korquad":
    val = load_dataset("KorQuAD/squad_kor_v1")["validation"]; rows = list(val)
else:
    val = load_dataset("klue/klue", "mrc")["validation"]; rows = [r for r in val if not r["is_impossible"]]
docs = sorted({r["context"] for r in val})
pi = {p: i for i, p in enumerate(docs)}
gold = np.array([pi[r["context"]] for r in rows])
qtype = np.array([r.get("question_type", 0) for r in rows])
ana = lambda ts: [[(m.form, m.tag) for m in s] for s in kiwi.tokenize(ts)]
da, qa = ana(docs), ana([r["question"] for r in rows])
# 논문의 겹침 비율: 조사(J*)·어미(E*)를 뺀 질문 형태소 중 지문에 있는 것의 비율
content = lambda toks: {f for f, t in toks if not t.startswith(("J", "E"))}
ov = np.array([len(content(q) & content(da[g])) / max(len(content(q)), 1) for q, g in zip(qa, gold)])
print(f"[{name}] docs={len(docs)} queries={len(rows)} 내용 형태소 겹침={ov.mean():.3f}"
      + "".join(f"  type{t}={ov[qtype == t].mean():.3f}(n={(qtype == t).sum()})" for t in (1, 2) if (qtype == t).any()))

dt, qt = [[f for f, _ in d] for d in da], [[f for f, _ in q] for q in qa]
vocab = {w: i for i, w in enumerate(sorted({w for d in dt for w in d}))}
dtok = bm25s.tokenization.Tokenized([[vocab[w] for w in d] for d in dt], vocab)
qtok = bm25s.tokenization.Tokenized([[vocab[w] for w in q if w in vocab] for q in qt], vocab)
K1S = [0, 0.2, 0.3, 0.5, 0.75, 0.9, 1.2, 1.5, 2.0, 3.0]
hit = {}
for k1 in K1S:
    idx = bm25s.BM25(k1=k1, b=0.75, method="lucene"); idx.index(dtok, show_progress=False)
    r, _ = idx.retrieve(qtok, k=1, show_progress=False)
    hit[k1] = r[:, 0] == gold
cut = np.quantile(ov, [1 / 3, 2 / 3])
groups = {"겹침 하위 1/3": ov <= cut[0], "겹침 중간 1/3": (ov > cut[0]) & (ov <= cut[1]),
          "겹침 상위 1/3": ov > cut[1]}
groups.update({f"type{t}": qtype == t for t in (1, 2) if (qtype == t).any()})
print(f"  R@1 (b=0.75)  겹침 경계 {cut[0]:.3f} / {cut[1]:.3f}")
print(f"  {'묶음':<12}{'n':>6}{'겹침':>7}" + "".join(f"{'k1=' + str(k):>8}" for k in K1S) + "   최적 k1")
for lab, m in groups.items():
    v = [hit[k][m].mean() for k in K1S]
    print(f"  {lab:<12}{m.sum():>6}{ov[m].mean():>7.3f}" + "".join(f"{x:>8.4f}" for x in v)
          + f"   {K1S[int(np.argmax(v))]}")
print(f"  elapsed={time.perf_counter() - t0:.0f}s")
python3 bm25_split.py korquad
python3 bm25_split.py klue-mrc
[korquad] docs=960 queries=5774 내용 형태소 겹침=0.713
  R@1 (b=0.75)  겹침 경계 0.667 / 0.786
  묶음               n     겹침    k1=0  k1=0.2  k1=0.3  k1=0.5 k1=0.75  k1=0.9  k1=1.2  k1=1.5  k1=2.0  k1=3.0   최적 k1
  겹침 하위 1/3     2090  0.563  0.7349  0.7684  0.7727  0.7809  0.7794  0.7799  0.7766  0.7675  0.7579  0.7349   0.5
  겹침 중간 1/3     1762  0.740  0.9364  0.9518  0.9540  0.9552  0.9506  0.9506  0.9484  0.9455  0.9381  0.9228   0.5
  겹침 상위 1/3     1922  0.851  0.9735  0.9818  0.9818  0.9813  0.9787  0.9766  0.9729  0.9693  0.9641  0.9563   0.2
  elapsed=15s
[klue-mrc] docs=5309 queries=4008 내용 형태소 겹침=0.622  type1=0.590(n=2437)  type2=0.672(n=1571)
  R@1 (b=0.75)  겹침 경계 0.562 / 0.700
  묶음               n     겹침    k1=0  k1=0.2  k1=0.3  k1=0.5 k1=0.75  k1=0.9  k1=1.2  k1=1.5  k1=2.0  k1=3.0   최적 k1
  겹침 하위 1/3     1339  0.448  0.4033  0.4862  0.5041  0.5422  0.5609  0.5698  0.5840  0.5892  0.5922  0.5855   2.0
  겹침 중간 1/3     1411  0.637  0.7697  0.8320  0.8405  0.8568  0.8661  0.8632  0.8590  0.8554  0.8505  0.8228   0.75
  겹침 상위 1/3     1258  0.789  0.9388  0.9587  0.9579  0.9618  0.9603  0.9610  0.9587  0.9499  0.9428  0.9245   0.5
  type1         2437  0.590  0.6401  0.7066  0.7210  0.7456  0.7591  0.7657  0.7710  0.7719  0.7719  0.7632   1.5
  type2         1571  0.672  0.7938  0.8332  0.8332  0.8453  0.8472  0.8428  0.8409  0.8339  0.8262  0.7944   0.75
  elapsed=41s

첫 줄이 논문과 맞습니다.

겹침 비율 KLUE 논문 우리 측정
KorQuAD 1.0 70% 71.3%
KLUE-MRC 전체 KorQuAD보다 거의 10%p 낮음 62.2% (9.1%p 낮음)
1형 바꿔 말하기 55~59% (3형과 함께) 59.0%
2형 여러 문장 추론 68% 67.2%

논문은 어느 분할에서 쟀는지 밝히지 않았고 태거도 다른데(우리는 두 데이터 모두 validation), 네 칸이 모두 1%p 남짓 안에서 맞습니다. KLUE-MRC가 KorQuAD보다 겹침이 낮은 코퍼스라는 논문의 서술은 재현됩니다. 이것이 이 글의 두 번째 재현입니다.

겹침 삼분위

이제 같은 코퍼스 안에서 겹침만 바꿔 봅니다. 질의를 겹침으로 셋으로 나누고, 셋에 같은 색인(b=0.75)을 걸어 k1을 훑습니다. 코퍼스·문서 길이·토큰화가 다 같으니 남는 차이는 질문의 겹침뿐입니다.

KLUE-MRC에서 최적 k1이 겹침을 따라 움직입니다. 겹침 상위 3분의 1(평균 0.789)은 k1=0.5, 중간(0.637)은 0.75, 하위(0.448)는 2.0입니다. 하위 묶음에서는 k1=0.5가 0.5422, 2.0이 0.5922로 5%p 벌어지고, 상위 묶음에서는 반대로 0.5가 2.0보다 1.9%p 높습니다. 질문 유형으로 나눠도 같습니다. 겹침이 낮은 1형은 최적 k1이 1.5, 겹침이 높은 2형은 0.75입니다.

KorQuAD에서도 방향은 같습니다. 상위는 0.2, 중간과 하위는 0.5입니다. 다만 KorQuAD는 하위 3분의 1도 겹침이 0.563으로 KLUE의 중간 묶음 아래에 머물러, 큰 k1이 이기는 구간까지 내려가지 않습니다. 이 코퍼스에서 큰 k1이 이길 질문이 애초에 적다는 뜻이고, 앞 글이 KorQuAD 전체에서 작은 k1을 얻은 이유가 이것입니다.

꺾이는 지점

한 줄로 적으면 이렇습니다 — 조사·어미를 뺀 겹침의 평균이 0.56 이상인 묶음 다섯(KorQuAD 셋, KLUE 둘)은 최적 k1이 전부 0.2~0.75라 기본값 1.2는 손해 쪽이고, 평균 0.45인 KLUE 하위 묶음에서만 최적이 2.0으로 올라가 기본값이 오히려 모자랍니다. 두 무리가 섞인 KLUE-MRC 전체(겹침 0.62)에서 기본값 1.2가 최적과 구간 안으로 맞은 것은 그 평균입니다.

k1이 왜 이렇게 움직이는지는 앞 글의 설명으로 이어집니다. 질의어를 거의 다 담은 문단이 정답일 때는 종류를 세는 작은 k1이 유리합니다. 질의어의 절반 남짓만 지문에 있을 때는 몇 종류를 담았느냐로는 후보들이 잘 갈리지 않고, 담긴 낱말이 몇 번 되풀이됐느냐가 정답 지문을 가리키는 신호가 됩니다. 이 설명은 이번 측정과 어긋나지 않지만, 정답 지문 안의 tf를 직접 세어 확인하지는 않았습니다.

결론과 한계

논문값과 우리 값

널리 인용되는 권장값 KorQuAD KLUE-MRC scifact
격자 최적 k1 1.2~2.0 0.5 1.2 1.5
격자 최적 b 0.75 0.8 0.8 0.8
Lucene 1.2/0.75의 차이 — −0.71%p (구간 밖) −0.12%p (구간 안) −0.55%p (구간 밖)
판정 k1 불일치 일치 거의 일치

질문에 대한 답은 「언어로는 안 갈린다」입니다. 한국어 코퍼스 둘이 정반대 판정을 냈고, 영어 대조군은 겹침이 낮은 한국어 쪽과 같은 편에 섰습니다. 그러니 한국어 검색에 BM25를 얹을 때 할 일은 k1을 일률적으로 내리는 것이 아니라 내 질의가 문서와 얼마나 겹치는지를 먼저 재는 것입니다. 사용자가 문서를 보면서 낱말을 따 와 묻는 검색(문서 안 찾기, FAQ 매칭)이면 KorQuAD 쪽이고 k1을 0.5 근처로 내릴 만합니다. 사용자가 문서를 모른 채 자기 말로 묻는 검색이면 KLUE 쪽이고 기본값을 그대로 두는 편이 낫습니다.

앞 글이 내린 「k1을 기본값에서 내리는 것이 거의 공짜」라는 실무 결론은 KorQuAD와 닮은 질의에서만 맞습니다. 이 글은 그 결론에 조건을 붙입니다. 그리고 앞 글의 마지막 권고 — 남의 기본값을 믿는 대신 자기 코퍼스에서 격자를 직접 재라 — 는 오히려 더 단단해졌습니다. 같은 한국어인데 최적 k1이 0.5와 1.2로 갈렸습니다.

확인되지 않은 것

  • 서베이 원문을 못 받았습니다. 권장값 「k1 1.2~2.0, b 0.75」는 원문 확인 없이 널리 인용되는 값을 옮긴 것입니다. 원문이 다른 범위를 적었다면 「논문값」 칸이 바뀝니다.
  • 겹침 하나로 다 설명된다고 하지 않았습니다. KLUE-MRC는 KorQuAD와 겹침만 다른 것이 아니라 지문이 두 배 가까이 길고(형태소 520개 대 272개) 뉴스가 섞여 있으며 코퍼스도 다섯 배 넘게 큽니다. 같은 코퍼스 안의 삼분위 비교가 길이·크기를 고정한 시험이지만, 질문 유형 같은 다른 성질이 겹침과 함께 움직였을 수 있습니다.
  • 삼분위 비교는 b=0.75 한 열에서만 했고 부트스트랩을 붙이지 않았습니다. KLUE 하위 묶음의 k1 1.5·2.0·3.0은 서로 0.7%p 안이라 최적이 정확히 2.0이라고 말할 수는 없습니다. 말할 수 있는 것은 하위 묶음의 최적이 상위 묶음(0.5)보다 확실히 크다는 것까지입니다.
  • scifact는 질의 300개라 격자 표면이 거칩니다. 1.5·0.8이 최적으로 뽑혔지만 이웃 칸들과 0.3%p 안에서 엎치락뒤치락합니다. 영어 쪽은 어간 추출 없이 소문자 영숫자로만 잘랐고, 어간 추출을 넣으면 최적이 움직일 수 있습니다.
  • BM25 변종은 method="lucene" 하나, 토큰화는 한국어에 kiwi 형태소 하나입니다. 앞 글이 본 문자 2-gram은 이번에 다시 재지 않았습니다.
  • KLUE 논문과 우리 겹침은 태거(Twitter 대 kiwi)가 다르고, 논문은 분할을 밝히지 않았습니다. 네 칸이 1%p 남짓 안에서 맞았지만 같은 측정이라고 하지 않습니다.

환경

항목 값
OS Linux 6.18.44 x86_64 (glibc 2.39)
CPU / RAM Intel Xeon @ 2.80GHz, 4 vCPU / 15GB
Python 3.11.17
패키지 bm25s 0.3.13 (method="lucene"), kiwipiepy 0.24.0, numpy 2.4.6, datasets 5.1.0
데이터 KorQuAD/squad_kor_v1 (01aad238) validation · klue/klue mrc (349481ec) validation · BeIR/scifact (b3b53356) · BeIR/scifact-qrels (2938d17d) test
논문 KLUE, arXiv:2105.09680 (ar5iv HTML로 겹침 서술 확인, 2026-10-10). Robertson & Zaragoza(2009) 서베이는 이그레스 차단으로 받지 못함
실행 시간 격자 scifact 58초 · KorQuAD 95초 · KLUE-MRC 220초, 겹침 삼분위 KorQuAD 15초 · KLUE-MRC 41초
측정일 2026-10-10 (KST)

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


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

LATEST

논문의 최신 글

논문2026.08.25

Chinchilla 정독: 20토큰/파라미터는 Table 3에서 나왔다

세 접근법이 낸 예측을 표에서 직접 나눠 봤다. 토큰/파라미터가 20 근처에 머무는 것은 Approach 1 열뿐이고 파라메트릭 적합 열은 23에서 142까지 간다. 세 예측은 실험을 돌린 구간에서 7% 안이지만 예산을 열 자릿수 밀면 2.6배로 벌어진다.

27 MIN