late interaction은 문서를 벡터 하나로 뭉개는 대신 토큰마다 벡터를 남겨 두었다가 질의와 견주는 검색 방식이다. 이 글은 문서 하나를 벡터 하나로 평균 낼 때 얼마만큼의 정보가 사라지는지 직접 재고, 그 손실을 되찾는 쪽이 무엇을 대신 치러야 하는지까지 따라간다.
분명히 있는 문서가 안 나온다

사내 문서 수천 편을 벡터DB에 넣고, 질문이 오면 관련 문서를 먼저 찾아와 모델에게 읽히는 검색 증강 생성(RAG, Retrieval-Augmented Generation) 파이프라인을 붙였다. 그런데 답이 분명히 적혀 있는 문서가 상위 10개에 올라오지 않는다. 임베딩 모델을 더 큰 것으로 바꿔도 순위는 거의 그대로다. 청크 크기를 반으로 줄여 봐도 그 문서는 여전히 10위 밖에 있다.
이 어긋남에는 특징이 있다. 짧고 주제가 하나뿐인 문서는 잘 찾아온다. 안 나오는 쪽은 늘 길고, 서로 다른 이야기가 한 문서에 여러 개 섞여 있는 쪽이다.
버그를 찾는 눈으로 보면 여기서 막힌다. 코드에도 인덱스 설정에도 틀린 곳이 없기 때문이다. 문서 하나를 벡터 하나로 저장하기로 정한 순간, 이 검색기는 어떤 손실을 치르기로 이미 정한 것이다. 그 손실의 크기는 짐작이 아니라 계산으로 나온다. 이 글에서는 그 값을 직접 재고, 값을 치르지 않는 방법이 무엇을 대신 내놓아야 하는지를 단계별로 확인한다.
평균 풀링이 지우는 것
검색기가 문서와 질의를 견주는 방식부터 짚는다. 임베딩 모델은 글 조각을 숫자 벡터 하나로 바꾸는데, 그 벡터의 방향이 의미를 담도록 학습돼 있다. 두 글이 비슷한 이야기를 하면 두 벡터가 비슷한 쪽을 가리킨다. 그래서 유사도는 두 벡터가 이루는 각도로 재고, 이 값을 코사인 유사도라고 부른다. 방향이 같으면 1, 직각이면 0이다.
모델이 실제로 벡터를 내놓는 단위는 토큰이다. 문단 하나를 넣으면 토큰 수만큼 벡터가 나오는데, 그대로 두기엔 너무 많으니 벡터DB에 넣기 전에 하나로 접는다. 가장 흔한 접는 법이 평균 풀링, 토큰 벡터를 전부 더해 개수로 나누는 것이다. 모델에 따라서는 문장 전체를 대표하도록 학습된 특수 토큰([CLS])의 은닉 상태를 그대로 쓰기도 한다 — 뒤에 나오는 BGE-M3는 여러 검색 모드를 겸하는데, 그중 밀집(dense) 검색이 이 방식이다. 어느 쪽이든 문단 하나가 벡터 하나로 접히고, 벡터DB에는 그 하나만 들어간다.
한 문서가 한 가지 이야기만 한다면 평균은 그 방향을 그대로 가리킨다. 접을 것이 애초에 하나뿐이기 때문이다. 여러 이야기가 섞이면 사정이 달라진다.
그림 1처럼 서로 다른 쪽을 가리키던 화살표가 하나로 합쳐지면, 남는 것은 어느 원래 방향도 아닌 가운데 방향 하나다. 크기를 알려면 재야 한다. 그래서 합성 임베딩으로 직접 쟀다.
차원 64짜리 단위구에서 주제 방향 k개를 뽑고, 주제마다 토큰 12개를 그 둘레에 흩뿌린다. 문서는 이 토큰 12k개이고, 질의는 첫 주제 방향에서 뽑은 토큰 8개다. 토큰이 자기 주제와 이루는 코사인은 0.8585로 맞췄다. 실제 임베딩에서 한 문단 안 토큰들이 갖는 응집도에 가까운 값이다.
이 실험은 검색 벤치마크가 아니다. 합성 임베딩 위에서 기하만 재현한 논증이고, 실제 임베딩 모델이 학습으로 갖게 되는 성질(이방성, 어휘 중복, 문서 길이 분포)은 모사하지 않았다. 여기서 나오는 값은 주제가 늘 때 평균이 얼마나 희석되는지만 말한다. 실제 검색 성능 수치는 뒤에서 전부 논문 1차 출처를 인용한다.
희석은 1/√k를 따른다
설정은 그대로 두고 주제 수 k만 1에서 32까지 키웠다. 잰 값은 질의와 문서 평균 벡터 사이의 코사인이다. k마다 독립 시행 500회씩 돌렸고, 매 시행 주제와 토큰을 새로 뽑았다.
import numpy as np
D, SIGMA, N_TOK, M_QUERY, TRIALS = 64, 0.6, 12, 8, 500
unit = lambda v: v / np.linalg.norm(v, axis=-1, keepdims=True)
def tokens_around(rng, topics, n): # 주제 방향 둘레로 토큰을 흩뿌린다
base = np.repeat(topics, n, axis=0)
noise = rng.standard_normal(base.shape).astype(np.float32) * (SIGMA / np.sqrt(D))
return unit(base + noise)
rng = np.random.default_rng(42)
for k in [1, 2, 4, 8, 16, 32]:
cos = []
for _ in range(TRIALS):
topics = unit(rng.standard_normal((k, D)).astype(np.float32))
doc = tokens_around(rng, topics, N_TOK) # 문서 = 주제 k개 x 토큰 12개
q = tokens_around(rng, topics[:1], M_QUERY) # 질의 = 첫 주제만
cos.append(unit(q.mean(0)) @ unit(doc.mean(0))) # 평균끼리 코사인
m = float(np.mean(cos))
print(f"k={k:2d} 단일벡터 {m:.4f} x sqrt(k) {m * np.sqrt(k):.4f}")k= 1 단일벡터 0.9642 x sqrt(k) 0.9642
k= 2 단일벡터 0.6821 x sqrt(k) 0.9646
k= 4 단일벡터 0.4762 x sqrt(k) 0.9523
k= 8 단일벡터 0.3420 x sqrt(k) 0.9673
k=16 단일벡터 0.2457 x sqrt(k) 0.9829
k=32 단일벡터 0.1685 x sqrt(k) 0.9533k가 32배가 되는 동안 코사인은 0.9642에서 0.1685로 떨어졌다. 오른쪽 열에 규칙이 있다. 코사인에 √k를 곱한 값이 0.9523과 0.9829 사이에만 머물고, 평균은 0.9641에 변동폭은 3.2%다. 이 곱은 사실상 상수다. 관계를 그대로 식으로 적으면 다음 한 줄이다.
식 1이 그 규칙이고, 유도는 두 줄이면 끝난다. 토큰을 평균 내면 주제마다 그 방향이 하나씩 남으니 문서 평균은 주제 방향 k개를 더한 것에 가깝다. 목표 주제 성분은 그 합에서 1/k로 줄어든다. 반면 벡터의 길이는 나머지 주제들이 거의 직교하게 더해지는 덕에 √k로만 자란다. 정규화하며 둘이 나뉘면 1/√k가 남는다.
상수 자리의 0.9641은 k=1에서 잰 0.9642와 사실상 같다. 이 값은 질의가 자기 주제를 얼마나 잘 겨냥하는가를 뜻하고, 희석은 거기에 1/√k를 곱하는 일이다. 주제가 4개면 신호가 절반, 16개면 4분의 1로 접힌다.
그림 2에 실측 산점과 이론선을 겹쳐 두었다. 500회 시행의 p10과 p90도 함께 그렸는데, k가 커질수록 이 띠가 아래로 넓게 벌어진다. k=32의 p10은 0.024다. 운 나쁜 시행에서는 목표 주제의 신호가 거의 남지 않는다. 그림에 함께 놓인 수평선은 아직 설명하지 않은 방식의 점수다. 그 방식은 순위 이야기를 한 번 거친 뒤에 다룬다.
희석은 순위를 무너뜨린다
코사인이 반으로 줄었다는 사실만으로는 검색이 실패했다고 말할 수 없다. 검색기가 정말 실패했는지는 순위로 물어야 한다. 그래서 두 번째 실험을 짰다.
문서 2000편짜리 코퍼스를 만들고 그중 한 편에만 질의 주제를 심는다. 질의 200회를 던져 정답 문서가 상위 10위 안에 들어온 비율을 센다. 이 비율이 recall@10이다.
여기서 한 가지를 조심했다. 나머지 1999편도 정답과 똑같이 주제를 k개씩 갖게 만들었다. 정답만 주제가 여럿이면 희석이 정답에만 걸려 지표가 저절로 무너진다. 그건 실험이 아니라 결론을 미리 데이터에 심어 두는 일이다.
결과가 그림 3이다. 단일 벡터의 recall@10은 주제가 1~2개일 때 1.000이다. 4개에서 0.935로 처음 흔들리고, 8개에서 0.650, 16개에서 0.265로 내려간다. 32개에서는 0.145다. 질의 200회 중 171회가 정답을 10위 밖에 두고 돌아온다는 뜻이다. 토큰별 벡터를 그대로 두고 견주는 쪽은 같은 구간에서 계속 1.000이었다.
헷갈리는 이웃을 심어도 결과는 거의 움직이지 않았다. 질의마다 20편을 질의 주제와 코사인 0.6인 이웃 주제 문서로 바꿔 심었더니, 단일 벡터의 recall@10은 1.5~3.5%p 낮아지는 데 그쳤다. 무너진 이유는 비슷한 문서가 많아서가 아니다. 평균 그 자체다.
이 1.000은 조심해서 읽어야 한다. late interaction이 검색을 다 푼다는 뜻이 아니다. 이 실험은 평균이 지우는 것만 재도록 설계돼 있어서, 평균을 내지 않는 방식은 그 안에서 잃을 것이 애초에 없다. 실제로 주제 8개로 조건을 고정하고 이웃 문서를 코사인 0.99까지 바짝 붙여 심으면 토큰별 벡터 쪽 recall@10도 0.805로 내려온다. 같은 지점의 단일 벡터는 0.375다. 그리고 이 방식의 진짜 병목은 품질이 아니라 비용인데, 그 비용을 세려면 방식부터 정확히 봐야 한다.
late interaction의 MaxSim은 질의가 온 뒤에 견준다
평균이 문제라면 처방은 하나다. 평균을 내지 않는 것. ColBERT(Khattab & Zaharia, 2020)가 제안한 late interaction이 그 방식이고, 이름이 곧 설명이다. 질의와 문서가 만나는 시점을 뒤로 미룬다. 문서 쪽 토큰 벡터를 접지 않고 그대로 인덱스에 넣어 두었다가, 질의가 들어온 다음에야 토큰 대 토큰으로 견준다.
식 2이 그 점수이고 MaxSim이라고 부른다. 질의 토큰 하나를 집어 문서의 모든 토큰과 견주고 가장 잘 맞는 값 하나만 취한다. 질의 토큰마다 그렇게 하나씩 골라 전부 더한다. 이 연산에는 문서를 하나로 뭉개는 자리가 없다.
아래 코드는 합 대신 평균을 쓴다. 질의 토큰 수가 8로 고정이라 순위는 바뀌지 않고, 값이 앞의 코사인과 같은 범위에 놓여 읽기 편하다. k는 앞과 똑같이 1에서 32까지 키운다.
import numpy as np
D, SIGMA, N_TOK, M_QUERY, TRIALS = 64, 0.6, 12, 8, 500
unit = lambda v: v / np.linalg.norm(v, axis=-1, keepdims=True)
def tokens_around(rng, topics, n):
base = np.repeat(topics, n, axis=0)
noise = rng.standard_normal(base.shape).astype(np.float32) * (SIGMA / np.sqrt(D))
return unit(base + noise)
def maxsim(q, doc): # 질의 토큰마다 문서 토큰과의 최대 코사인
return float((q @ doc.T).max(axis=1).mean())
rng = np.random.default_rng(42)
for k in [1, 2, 4, 8, 16, 32]:
s = []
for _ in range(TRIALS):
topics = unit(rng.standard_normal((k, D)).astype(np.float32))
doc = tokens_around(rng, topics, N_TOK)
q = tokens_around(rng, topics[:1], M_QUERY)
s.append(maxsim(q, doc))
print(f"k={k:2d} MaxSim {np.mean(s):.4f}")k= 1 MaxSim 0.7989
k= 2 MaxSim 0.7983
k= 4 MaxSim 0.7989
k= 8 MaxSim 0.7991
k=16 MaxSim 0.7979
k=32 MaxSim 0.7997k가 1에서 32로 가는 동안 MaxSim은 0.7979와 0.7997 사이에 머문다. 변동폭 0.23%다. 앞에서 단일 벡터가 같은 구간에서 0.9642에서 0.1685로 떨어졌던 것과 나란히 놓으면 차이가 분명하다. 평평한 쪽은 희석될 자리가 없다.
그림 4은 이 계산이 무엇을 고르는지 펼쳐 놓은 것이다. 행마다 표시된 칸이 전부 같은 주제 블록에 모여 있고, 나머지 세 주제의 토큰 36개는 점수에 한 톨도 기여하지 않는다. 평균 풀링에서 신호를 √k로 나누던 것이 바로 저 36개다. MaxSim은 그것들을 지우지 않는다. 질의 토큰마다 가장 잘 맞는 칸을 하나씩 고르고 나면, 나머지는 점수에 닿을 자리가 없을 뿐이다.
대가는 인덱스 비대 — 논문이 잰 것
지우지 않으려면 들고 있어야 한다. 값은 세어 보면 바로 나온다. 단일 벡터는 문서 하나에 벡터 하나지만, 토큰별로 들고 있으면 문서 하나에 12k개다. 주제가 8개인 문서는 96개, 32개인 문서는 384개다. 점수 계산에는 질의 토큰 수까지 곱해진다. 내적 1회로 끝나던 것이 96k회가 되고, 주제 32개에서는 질의-문서 한 쌍마다 3072회다.
여기까지가 직접 만들어 본 토이에서 나온 값이다. 아래부터는 실제 코퍼스와 실제 모델로 남이 잰 값이고, 전부 논문 1차 출처에서 가져온다. 앞의 수치는 기하가 왜 그렇게 되는지를 말하고, 뒤의 수치는 그 기하가 실제 시스템에서 얼마짜리인지를 말한다. 둘을 한 문장 안에서 섞지 않는다.
ColBERT 연구팀은 BERT 기반 리랭커와 견주어 지연을 100분의 1 수준으로(2 orders of magnitude) 줄이고 질의당 부동소수점 연산 횟수(FLOPs)를 1만분의 1 수준으로 줄였다고 보고한다. 저자 자가평가 수치다. 다만 초판 ColBERT의 인덱스는 여전히 컸다. ColBERTv2(Santhanam et al., NAACL 2022)는 토큰 벡터를 가까운 중심점과 잔차로 쪼개 보관하는 residual compression으로 저장 공간을 6–10× 줄였다.
남은 문제는 속도였다. MaxSim은 범용 근사 최근접 인덱스에 그대로 얹히지 않는다. PLAID(Santhanam et al., 2022)는 중심점만으로 후보를 먼저 쳐내는 단계를 앞에 붙였다.
그렇게 해서 vanilla ColBERTv2 대비 GPU에서 최대 7×, CPU에서 최대 45× 지연을 줄였다고 보고한다. 1억 4천만 passage 규모에서도 GPU 질의 지연이 수십 밀리초대다. 전용 엔진이 있어야 닿는 수치라는 점은 마지막에 다시 나온다.
텍스트를 아예 버리면 — ColPali
여기까지는 텍스트를 임베딩한다는 전제를 건드리지 않았다. ColPali(Faysse et al., ICLR 2025)는 그 전제를 뺀다. 문서 페이지를 이미지 그대로 비전-언어 모델에 넣어 패치별 벡터를 얻고, 질의와는 똑같이 late interaction으로 견준다. 문자 인식(OCR)도, 레이아웃 분석도, 청킹도 파이프라인에서 사라진다.
성적은 ViDoRe 벤치마크로 잰다. 지표는 nDCG@5(normalized Discounted Cumulative Gain)인데, 정답 문서가 상위 5개 안에서 얼마나 위쪽에 놓였는지를 순위 가중치로 매긴 점수이고 0에서 100 사이로 적는다. ColPali의 평균은 81.3이다. 저자들이 비교 대상으로 세운 최고 텍스트 파이프라인(Unstructured + captioning + BGE-M3)은 67.0이다.
인덱싱 속도도 앞선다. 페이지당 0.39초인데, 텍스트 파이프라인은 7.22초가 걸린다. 표와 그림이 많은 문서에서 텍스트 추출 단계를 거치는 동안 정보가 얼마나 많이 사라지고 있었는지를 뒤집어 보여주는 수치다.
대가는 같은 자리로 돌아온다. float16 기준 저장은 페이지당 257.5 KB이고, 단일 벡터 BGE-M3는 8.60 KB다. 약 30배다. 그림 5에 두 방식을 저장축과 품질축에 함께 찍어 두었다. 두 점을 잇는 점선이 이 맞바꿈의 크기다 — 저장 약 30배를 내주고 nDCG@5 14.3점을 받는다. 저자들은 centroid pooling이나 binary quantization으로 저장 비용을 약 100분의 1로 줄일 수 있다고 본다.
왜 아직 기본값이 아닌가
품질에서 앞서는데도 프로덕션 RAG의 기본값은 여전히 단일 벡터다. late interaction과 다중 벡터 검색만 다루는 첫 워크숍(LIR, ECIR 2026)의 제안서를 읽으면 이유가 성능 바깥에 있다는 것이 드러난다. 저자들이 사용성 항목에서 적은 대목은 이렇다 — 다중 벡터 검색은 대부분의 실무자에게 익숙한 소프트웨어·인덱싱 스택과 호환되지 않는다. 남은 과제를 저자들 스스로 세 가지로 추려 놓은 문장이 제안서 초록에 있다.
efficiency, usability, and integrations into fully fledged systems
Clavié 외, LIR 워크숍 제안서(2025)저자들이 남은 과제로 꼽은 것은 품질이 아니었다. 효율과 사용성, 그리고 기존 시스템에 어떻게 끼워 넣느냐가 그 자리를 차지한다. 정확도는 이미 이 목록에서 빠져 있다.
이 세 가지를 수치로 옮겨 보면 저장이 먼저 걸리는데, 그 크기는 압축 여부와 무엇을 남기느냐에 따라 크게 갈린다. ColBERTv2 연구팀은 패시지 900만 편짜리 MS MARCO에서 잔차 압축을 마친 인덱스가 16~25 GiB로, 전형적인 단일 벡터 모델의 저장량과 비슷한 수준이라고 적었다. 반대편 끝에는 페이지를 이미지 그대로 들고 있는 ColPali가 있다. 앞에서 본 대로 약 30배다. 그리고 어느 쪽이든 인덱스는 문서 수가 아니라 코퍼스의 총 토큰 수를 따라 자란다 — 문서를 늘린 만큼이 아니라 글자를 늘린 만큼 커진다는 뜻이다.
도입 여부는 품질 논쟁보다 저장 예산에서 먼저 갈린다. 그런데 곱할 배수가 하나로 정해져 있지 않으니, 쓰려는 구현부터 확인해야 한다 — 잔차 압축을 하는가, 토큰 하나당 벡터를 몇 차원으로 남기는가, 텍스트가 아니라 페이지 이미지를 넣는가. 이 셋이 인덱스 크기를 몇 배에서 몇십 배까지 갈라놓는다. 계산해 본 규모를 감당할 수 없으면 전면 교체 대신 재순위가 남는다 — 1차 검색은 단일 벡터로 두고, 상위 후보 몇십 편에만 late interaction을 얹는 방식이다.
이제 맨 앞의 장면으로 돌아간다. 답이 들어 있는데도 상위 10개에 안 올라오던 그 문서는 대개 여러 이야기가 섞인 긴 문서였다. 검색기는 그것을 벡터 하나로 접었고, 목표 주제의 신호는 그 순간 √k로 나뉘어 있었다. 임베딩 모델을 바꿔도 이 나눗셈은 사라지지 않는다. 청크를 잘게 자르면 k가 줄어 신호는 돌아오지만, 이번엔 문맥이 잘려 나간다.
그러니 이것은 고칠 버그가 아니다. 인덱스를 설계할 때 이미 치르기로 한 값이다. 단일 벡터는 recall을 얼마간 내주는 대신 아무 벡터 스토어에나 그냥 꽂히는 편의를 얻는다. 그 값이 얼마인지는 이제 계산할 수 있다 — 주제가 k개면 신호는 √k로 나뉜다.
더 읽기
- ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT (Khattab & Zaharia, 2020) — https://arxiv.org/abs/2004.12832 · 후속 ColBERTv2 https://arxiv.org/abs/2112.01488 · PLAID https://arxiv.org/abs/2205.09707
- ColPali: Efficient Document Retrieval with Vision Language Models (Faysse et al., ICLR 2025) — https://arxiv.org/abs/2407.01449
- LIR: The First Workshop on Late Interaction and Multi Vector Retrieval, ECIR 2026 (Clavié et al., 2025) — https://arxiv.org/abs/2511.00444