검색창에 글자가 들어오는 도중에 검색을 미리 쏠지 말지는 지연을 줄이는 흔한 수법입니다. 사용자가 아직 타이핑을 끝내지 않았고 음성 인식은 부분 결과만 넘겨 준 상태인데, 그 절반짜리 질의로 미리 인덱스를 두드려 두면 완성되는 순간 결과가 이미 준비돼 있습니다. 문제는 그 절반짜리 질의가 얼마나 쓸 만한가입니다.
「질의는 길수록 좋다」는 말은 방향만 알려 줄 뿐 결정에 쓸 수 없습니다. 필요한 것은 몇 토큰부터 미리 쏴도 되는가라는 숫자 하나입니다. 그래서 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 토크나이저로 질의를 토큰 열로 만들고, 앞에서 개만 남긴 뒤 다시 문자열로 되돌려 인코딩합니다. 토크나이저가 무엇을 어떻게 쪼개는지는 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초 |
절대 시간은 이 환경에서만 맞는 값입니다. 이 글의 결론은 전부 조건 사이의 상대 비교입니다.
읽어주셔서 감사합니다. 😊

