리서치

LAB / 20번째 글

질의를 앞에서부터 자르면 검색은 몇 토큰에서 무너지는가: 공짜 구간은 없었다

KorQuAD 질의 5,774개를 앞에서부터 4~28토큰으로 잘라 가며 Recall@1을 쟀다. 어디까지가 사실상 공짜인지 찾으려 했는데, 마지막 12%의 꼬리를 자른 28토큰조차 신뢰구간이 0을 배제했다.

PALDYN Team21 MIN READ

검색창에 글자가 들어오는 도중에 검색을 미리 쏠지 말지는 지연을 줄이는 흔한 수법입니다. 사용자가 아직 타이핑을 끝내지 않았고 음성 인식은 부분 결과만 넘겨 준 상태인데, 그 절반짜리 질의로 미리 인덱스를 두드려 두면 완성되는 순간 결과가 이미 준비돼 있습니다. 문제는 그 절반짜리 질의가 얼마나 쓸 만한가입니다.

「질의는 길수록 좋다」는 말은 방향만 알려 줄 뿐 결정에 쓸 수 없습니다. 필요한 것은 몇 토큰부터 미리 쏴도 되는가라는 숫자 하나입니다. 그래서 KorQuAD 질의 5,774개를 앞에서부터 4·8·12·16·20·24·28토큰으로 잘라 가며 Recall@1이 어디서 꺾이는지 쟀습니다.

기대한 그림은 완만한 고원이었습니다. 질의 뒷부분은 어차피 조사와 어미일 테니 어느 지점까지는 잘라도 사실상 손해가 없고, 거기서부터 뚝 떨어지리라는 것이었습니다. 고원은 없었습니다. 질의의 12%만 건드리는 28토큰 절단조차 부트스트랩 신뢰구간이 0을 배제했습니다.

절단 설계

실험대

실험대는 CPU만으로 세우는 검색 실험대에서 세운 것을 그대로 씁니다. KorQuAD validation 5,774행에서 중복을 제거한 960문단이 코퍼스이고, 임베딩 모델은 intfloat/multilingual-e5-small입니다. 달라진 것은 질의 쪽 하나뿐입니다 — 실험대는 300개를 뽑아 썼지만 여기서는 5,774개 전부를 씁니다. 조건마다 차이가 1%p 아래로 내려올 것이 뻔해서 질의를 늘려 해상도를 벌어 두었습니다.

자르는 단위

자르는 단위는 글자가 아니라 토큰입니다. e5 계열이 쓰는 XLM-RoBERTa의 SentencePiece 토크나이저로 질의를 토큰 열로 만들고, 앞에서 nn 개만 남긴 뒤 다시 문자열로 되돌려 인코딩합니다. 토크나이저가 무엇을 어떻게 쪼개는지는 SentencePiece: 언어에 구애받지 않는 토크나이저가 맡으므로 여기서는 자르는 자에 대해서만 짚고 갑니다.

임종석이 여의도 농민 폭력 시위를 주도한 혐의로 지명수배 된 날은?
→ ['▁임', '종', '석', '이', '▁여', '의', '도', '▁농', '민', '▁', '폭력', '▁시', ...]
→ 8토큰으로 자르면 "임종석이 여의도 농"

글자로 자르지 않은 이유는 이 실험이 흉내 내려는 상황이 자동완성이라서입니다. 음성 인식기나 IME가 넘겨 주는 부분 결과의 단위는 글자가 아니라 대략 형태소 덩어리이고, 임베딩 모델이 실제로 보는 단위도 토큰입니다.

문단 임베딩

이 실험에서 문단 쪽은 한 번만 인코딩하고 재사용합니다. 조건마다 바뀌는 것은 질의 임베딩뿐이므로, 표에 나오는 차이는 전부 질의가 짧아져서 생긴 것입니다.

재현 블록

스크립트

pip install torch sentence-transformers datasets numpy

리눅스에서 CUDA 의존까지 받고 싶지 않으면 torch만 먼저 CPU 휠로 깝니다: pip install torch --index-url https://download.pytorch.org/whl/cpu

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)
val = load_dataset("KorQuAD/squad_kor_v1")["validation"]
paras = sorted({r["context"] for r in val})
pidx = {p: i for i, p in enumerate(paras)}
qs = [r["question"] for r in val]
gold = np.array([pidx[r["context"]] for r in val])

model = SentenceTransformer("intfloat/multilingual-e5-small")
tk = model.tokenizer
t = time.perf_counter()
P = model.encode(["passage: " + p for p in paras], batch_size=32,
                 normalize_embeddings=True, show_progress_bar=False)
ids = [tk(q, add_special_tokens=False)["input_ids"] for q in qs]
L = np.array([len(i) for i in ids])
print(f"paragraphs={len(paras)} queries={len(qs)} encode_P={time.perf_counter()-t:.1f}s")
print("query tokens: mean=%.1f median=%d p90=%d max=%d" % (
    L.mean(), np.median(L), np.percentile(L, 90), L.max()))

def run(n):
    texts = qs if n is None else [tk.decode(i[:n]) for i in ids]
    Q = model.encode(["query: " + x for x in texts], batch_size=64,
                     normalize_embeddings=True, show_progress_bar=False)
    rank = np.argsort(-(Q @ P.T), axis=1)[:, :5]
    return rank[:, 0], (rank == gold[:, None]).any(axis=1)

CONDS, T, R5 = (28, 24, 20, 16, 12, 8, 4), {}, {}
T["full"], R5["full"] = run(None)
for n in CONDS:
    T[n], R5[n] = run(n)
H1 = {k: (v == gold) for k, v in T.items()}

B = np.random.default_rng(0).integers(0, len(qs), size=(1000, len(qs)))
print(f"{'cut':>5} {'kept%':>6} {'hit%':>6} {'R@1':>7} {'R@5':>7} {'agree':>6} {'dR@1':>8} {'95% CI of dR@1':>20}")
for k in ["full", *CONDS]:
    kept = 100.0 if k == "full" else 100 * np.mean(np.minimum(L, k) / L)
    cut = 0.0 if k == "full" else 100 * np.mean(L > k)
    lo, hi = np.percentile((H1[k].astype(float) - H1["full"])[B].mean(axis=1), [2.5, 97.5])
    print(f"{str(k):>5} {kept:6.1f} {cut:6.1f} {H1[k].mean():7.4f} {R5[k].mean():7.4f} "
          f"{np.mean(T[k]==T['full']):6.4f} {H1[k].mean()-H1['full'].mean():+8.4f} [{lo:+.4f}, {hi:+.4f}]")

print("\n-- only the queries this cut actually shortens --")
print(f"{'cut':>5} {'n':>6} {'R@1 full':>9} {'R@1 cut':>9} {'delta':>8} {'agree':>7}")
for k in CONDS:
    m = L > k
    print(f"{k:>5} {m.sum():6d} {H1['full'][m].mean():9.4f} {H1[k][m].mean():9.4f} "
          f"{H1[k][m].mean()-H1['full'][m].mean():+8.4f} {np.mean(T[k][m]==T['full'][m]):7.4f}")
python3 query_length.py

실제 출력

paragraphs=960 queries=5774 encode_P=49.0s
query tokens: mean=20.3 median=19 p90=30 max=59
  cut  kept%   hit%     R@1     R@5  agree     dR@1       95% CI of dR@1
 full  100.0    0.0  0.7785  0.9409 1.0000  +0.0000 [+0.0000, +0.0000]
   28   98.1   12.0  0.7759  0.9409 0.9913  -0.0026 [-0.0047, -0.0007]
   24   95.7   23.7  0.7723  0.9387 0.9777  -0.0062 [-0.0095, -0.0031]
   20   90.3   43.5  0.7645  0.9383 0.9494  -0.0140 [-0.0189, -0.0092]
   16   80.4   67.8  0.7432  0.9283 0.8923  -0.0353 [-0.0426, -0.0284]
   12   64.7   89.1  0.6888  0.9046 0.8019  -0.0897 [-0.0996, -0.0802]
    8   44.3   98.6  0.5604  0.8214 0.6330  -0.2180 [-0.2302, -0.2052]
    4   22.2  100.0  0.3150  0.5864 0.3557  -0.4635 [-0.4770, -0.4494]

-- only the queries this cut actually shortens --
  cut      n  R@1 full   R@1 cut    delta   agree
   28    692    0.8772    0.8555  -0.0217  0.9277
   24   1368    0.8640    0.8377  -0.0263  0.9057
   20   2514    0.8365    0.8043  -0.0322  0.8839
   16   3914    0.8099    0.7578  -0.0521  0.8411
   12   5146    0.7907    0.6901  -0.1007  0.7777
    8   5694    0.7808    0.5597  -0.2211  0.6279
    4   5773    0.7786    0.3151  -0.4635  0.3556

표의 열

kept%는 잘린 뒤 남은 토큰의 비율, hit%는 그 절단에 실제로 걸리는 질의의 비율입니다. agree는 잘린 질의가 뽑은 1등이 온전한 질의가 뽑은 1등과 같았던 비율, 곧 일치율입니다. 이 마지막 열이 이 글의 실무적 결론을 혼자 다 지고 있는데, 뒤에서 따로 다룹니다.

전체 실행이 2분 54초였고 그중 49초가 문단 인코딩입니다. 나머지 2분은 질의 8벌을 다시 인코딩한 시간입니다.

손실 곡선

네 번의 재실행

먼저 이 표를 믿어도 되는지부터 확인했습니다. 스크립트를 조건 구성만 바꿔 가며 세 번 돌리고, 마지막에 패키지를 새로 깐 빈 가상환경에서 위 코드를 그대로 한 번 더 돌렸습니다. 네 번 모두 겹치는 조건의 Recall@1이 소수점 넷째 자리까지 전부 같았습니다 — full 0.7785, 24 → 0.7723, 16 → 0.7432, 12 → 0.6888, 8 → 0.5604, 4 → 0.3150. 인코딩과 정렬에 난수가 들어가지 않으니 당연한 결과이고, 흔들리는 것은 벽시계 시간뿐이었습니다(문단 인코딩 48.6초 / 49.6초 / 49.0초 / 48.6초). 위 출력 블록에서 재실행 때 달라지는 값은 encode_P 하나뿐입니다.

짝지은 부트스트랩

그래서 이 실험에서 산포는 반복 실행이 아니라 질의 표본에서 옵니다. 질의 5,774개를 복원추출로 1,000번 다시 뽑아 조건마다 온전한 질의와의 차이를 계산한 것이 표의 마지막 열입니다. 같은 질의에 두 조건을 다 걸어 짝을 지어 뺐으므로, 질의가 원래 쉬웠는지 어려웠는지는 상쇄됩니다.

절단별 손실

질의의 토큰 수 중앙값이 19이고 90번째 백분위가 30입니다. 그러니 28토큰 절단은 질의의 88%를 아예 건드리지 않고, 나머지 12%에서도 평균 1.9%의 토큰만 떼어 냅니다. 이 정도면 「구별 안 됨」이 나오리라 예상했습니다.

나오지 않았습니다. 28토큰 절단의 Recall@1 손실은 0.26%p이고 95% 신뢰구간이 −0.47%p에서 −0.07%p로, 0을 포함하지 않습니다. 이후 모든 조건도 마찬가지입니다.

자른 위치 남은 토큰 걸리는 질의 Recall@1 손실 0을 배제하는가
자르지 않음 100.0% — 0.7785 — —
28토큰 98.1% 12.0% 0.7759 0.26%p 예
24토큰 95.7% 23.7% 0.7723 0.62%p 예
20토큰 90.3% 43.5% 0.7645 1.40%p 예
16토큰 80.4% 67.8% 0.7432 3.53%p 예
12토큰 64.7% 89.1% 0.6888 8.97%p 예
8토큰 44.3% 98.6% 0.5604 21.80%p 예
4토큰 22.2% 100.0% 0.3150 46.35%p 예

「질의는 길수록 좋다」가 이 코퍼스에서는 문자 그대로 성립합니다. 마지막 한두 토큰까지도 값을 하고 있고, 잘라도 되는 무료 구간이라는 것은 없습니다.

다만 손실의 크기는 전혀 균일하지 않습니다. 20토큰에서 16토큰으로 네 토큰을 더 자를 때 손실은 1.40%p에서 3.53%p로 2.5배가 되고, 12에서 8로 갈 때는 8.97%p에서 21.80%p로 2.4배가 됩니다. 반면 28에서 24로 갈 때는 0.26%p에서 0.62%p입니다. 절대량으로 보면 앞쪽 절반은 거의 평평하고 뒤쪽에서 절벽이 섭니다.

1%p 기준선

「0을 배제한다」와 「신경 쓸 만하다」는 다른 이야기입니다. 검색 품질을 1%p 아래로 흔드는 변화는 대개 다른 요인에 묻히므로, 실무 기준선을 손실 1%p로 잡고 그 안에 드는 가장 짧은 절단을 찾으면 24토큰입니다. 24토큰은 0.62%p로 선 안쪽, 20토큰은 1.40%p로 선 바깥쪽입니다.

한국어 질의 24토큰은 이 코퍼스에서 평균 39자쯤 됩니다. 질의의 76%는 애초에 그보다 짧아서 아예 잘리지도 않습니다.

절단에 걸린 질의

희석된 평균

전체 평균은 잘리지 않은 질의에 희석됩니다. 그래서 각 절단에 실제로 걸린 질의만 따로 뽑아 다시 쟀습니다. 두 번째 표가 그것입니다.

24토큰 절단은 1,368개 질의를 건드리는데, 그 질의들만 보면 손실이 0.62%p가 아니라 2.63%p입니다. 전체 평균의 4.2배입니다. 28토큰도 0.26%p가 아니라 2.17%p입니다. 짧은 절단으로 갈수록 걸리는 질의가 늘어 두 숫자가 수렴하고, 4토큰에서는 사실상 모든 질의가 걸려 46.35%p로 같아집니다.

긴 질의의 난이도

뜻밖의 것이 하나 나왔습니다. 긴 질의는 원래 더 쉽습니다. 28토큰보다 긴 질의 692개의 온전한 상태 Recall@1은 0.8772인데, 전체 평균은 0.7785입니다. 10%p 가까이 높습니다. 20토큰보다 긴 2,514개도 0.8365로 여전히 평균 위입니다. 질의가 길다는 것은 고유명사나 한정어가 더 붙어 있다는 뜻이니 검색이 쉬워지는 것이 자연스럽습니다.

이 사실이 결론의 방향을 한 번 더 밀어 줍니다. 절단으로 잃는 것은 어려운 질의가 아니라 원래 잘 맞히던 질의입니다. 자를수록 쉬운 문제를 일부러 어렵게 만드는 셈입니다.

1등 일치율

자동완성 프리페치의 관점에서는 잘린 질의가 정답을 맞혔는지보다 완성된 질의와 같은 답을 냈는지가 중요합니다. 미리 받아 둔 결과를 그대로 보여 줄 수 있는지가 거기서 갈리기 때문입니다. agree 열이 그 값입니다.

자른 위치 1등 일치율(전체) 1등 일치율(잘린 질의만)
28토큰 99.13% 92.77%
24토큰 97.77% 90.57%
20토큰 94.94% 88.39%
16토큰 89.23% 84.11%
12토큰 80.19% 77.77%
8토큰 63.30% 62.79%
4토큰 35.57% 35.56%

일치율은 Recall@1보다 훨씬 가파르게 떨어집니다. 16토큰에서 Recall@1 손실은 3.53%p인데 1등이 바뀐 질의는 10.77%입니다. 미리 받아 둔 결과 열 건 중 한 건은 버려야 한다는 뜻이고, 캐시 적중률로 프리페치의 값을 계산하는 쪽에서는 이쪽이 맞는 숫자입니다.

12토큰에서 일치율이 80%로 떨어지고 8토큰에서는 63%가 됩니다. 프리페치를 하되 결과를 그대로 확정해 보여 주지는 않는 설계라면 12토큰까지 내려가도 되고, 미리 받은 것을 그대로 확정하는 설계라면 20토큰 위에서 쏘는 편이 안전합니다.

꺾이는 지점

손실의 증가율

24토큰까지는 손실이 1%p 안이고, 20토큰 아래로 내려가는 순간 네 토큰마다 손실이 2.4~2.5배가 됩니다.

프리페치 경계

미리 받은 결과를 확정해 보여 줄 생각이면 20토큰, 다시 검색할 여지를 두면 12토큰이 경계이고, 8토큰 아래는 어느 쪽으로도 쓸 수 없습니다(일치율 63%).

해상도 바닥

계획의 1.06%p

이 실험은 계획 단계에서 「Recall@k·MRR·nDCG는 언제 서로 다른 결론을 내는가」가 낸 이 코퍼스의 Recall@1 신뢰구간 반폭 1.06%p를 해상도 바닥으로 잡고, 그보다 작은 차이는 「구별 안 됨」으로 적기로 하고 시작했습니다.

표본과 짝짓기

그 바닥이 두 가지 이유로 내려갔습니다. 하나는 표본입니다 — 1.06%p는 질의 300개 기준이고 여기서는 5,774개를 썼습니다. 다른 하나가 더 중요한데, 재는 대상 자체가 다릅니다. 1.06%p는 Recall@1 값 하나의 신뢰구간이고, 이 글이 판정에 쓰는 것은 같은 질의에 두 조건을 다 걸어 짝을 지어 뺀 차이의 신뢰구간입니다. 질의가 원래 쉬웠는지 어려웠는지가 상쇄되므로 후자가 훨씬 좁습니다 — 28토큰 조건에서 반폭이 0.20%p였습니다.

그래서 원래 계획대로였다면 「구별 안 됨」으로 적혔을 28·24·20토큰의 손실이 전부 판정 가능한 값이 되었습니다. 해상도 바닥은 코퍼스에 붙은 상수가 아니라 표본 크기와 짝짓기 여부의 함수이고, 앞 글의 1.06%p를 상수처럼 인용하면 이 표의 위쪽 세 줄을 통째로 잃습니다.

한계와 측정 환경

말할 수 없는 것

  • 코퍼스 하나, 모델 하나입니다. KorQuAD 960문단은 위키백과 문서이고 질의는 사람이 그 문단을 보고 만든 질문입니다. 실제 검색창에 들어오는 질의는 이보다 짧고 문법이 덜 갖춰져 있으므로, 여기서 나온 24토큰은 그대로 옮길 수 있는 값이 아닙니다.
  • 앞에서 자르는 것은 타이핑 도중을 완벽하게 흉내 내지 못합니다. 사용자는 어절 중간에서도 멈추지만 이 실험은 토큰 경계에서 자릅니다. 실제 부분 입력은 여기보다 조금 더 나쁠 것입니다.
  • 잘린 뒤 다시 문자열로 되돌리는 과정이 원문과 완전히 같지는 않습니다. SentencePiece 디코딩은 공백 처리를 정규화하므로, 4토큰처럼 극단으로 짧은 조건에서는 이 정규화가 결과에 섞여 들어갑니다.
  • 문단은 자르지 않았습니다. 512토큰에서 문서가 잘릴 때 무엇을 잃는지는 512토큰에서 잘리는 문서는 얼마를 잃는가가 맡습니다.
  • 960문단 코퍼스는 작습니다. 문서가 많아지면 짧은 질의가 더 빨리 무너질 가능성이 있는데, 코퍼스 크기가 안전선을 어떻게 미는지는 코퍼스가 커지면 저차원은 더 빨리 무너진다가 차원 쪽에서 다뤘습니다. 질의 길이 쪽으로는 재지 않았습니다.

검색 질의를 손보는 기법 자체는 RAG 쿼리 재작성이 맡으므로 여기서는 반복하지 않았습니다.

환경

항목 값
OS Linux 6.18.44 x86_64 (glibc 2.39)
CPU Intel Xeon @ 2.10GHz, 4코어 (torch.set_num_threads(4))
Python 3.11.15
패키지 torch 2.14.0, sentence-transformers 6.0.1, datasets 5.0.1, numpy 2.4.6
모델 intfloat/multilingual-e5-small (리비전 614241f6)
데이터 KorQuAD/squad_kor_v1 validation 5,774행 → 중복 제거 960문단
측정일 2026-09-07
전체 실행 2분 54초

절대 시간은 이 환경에서만 맞는 값입니다. 이 글의 결론은 전부 조건 사이의 상대 비교입니다.


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

LATEST

과학·실험의 최신 글

과학·실험2026.08.19

KV 캐시 메모리 공식을 실측으로 검증했다: 13칸이 오차 없이 맞았고, 4배 큰 모델이 캐시는 3분의 1이었다

공식으로 예측한 바이트와 past_key_values를 실제로 재서 나온 바이트가 세 모델 13개 칸에서 전부 비 1.0000으로 맞았다. 오차가 아니라 항등식이다. 그런데 토큰당 캐시는 파라미터 수와 반대로 간다 — GPT-2(124M)가 72KB, Qwen2.5-0.5B(494M)가 24KB다.

25 MIN