멀티 벡터 (Late Interaction) 임베딩 모델과 Sentence Transformers
이 글은 Hugging Face 블로그의 Multi-Vector (Late Interaction) Embedding Models with Sentence Transformers를 한국어로 번역한 글입니다.
멀티 벡터 (Late Interaction) 임베딩 모델과 Sentence Transformers
Sentence Transformers 는 검색 보강 생성, 시맨틱 검색 등과 같은 응용에 사용하고 학습하는 임베딩 및 reranker 모델을 위한 Python 라이브러리입니다. v6.0 업데이트로 네 번째 모델 유형 MultiVectorEncoder이 추가되었으며, ColBERT 스타일의 Late Interaction 검색용입니다. 어떠한 PyLate 체크포인트와 어떤 Stanford-NLP ColBERT 체크포인트도 그대로 로드되며, 시각적 문서 검색용 colpali-engine 모델도 Dense, Sparse, 그리고 reranker 모델에 대해 이미 사용하는 같은 익숙한 API를 통해 사용할 수 있습니다.
일반 임베딩 모델이 텍스트 전체를 하나의 벡터로 압축하는 반면, 멀티-벡터 모델은 토큰당 하나의 벡터를 유지하고 쿼리를 문서에 대해 MaxSim 연산자로 매칭합니다. 이는 단일 벡터가 평균화해 버려야 하는 토큰 수준의 매칭 정보를 보존하므로 일반적으로 더 강력한 검색을 제공하지만, 인덱스 크기가 커지는 대가가 있습니다. 또한 문장 텍스트 검색의 최첨단으로도 작용하며, 여기에 포함된 텍스트 쿼리가 페이지 이미지에 직접 매칭되고 OCR 단계가 앞뒤로 없다는 점이 특징입니다.
이 블로그 포스트에서는 이들 모델의 사용 방법을 보여드립니다: 다양한 체크포인트 포맷 로딩, 인코딩 및 스코어링, 검색 스택에의 연결, 페이지 이미지에서의 실행, 그리고 인덱스를 합리적으로 유지하는 방법. 아래 모든 내용은 일반 pip install -U sentence-transformers에서 실행됩니다.
목차
- 멀티-벡터 모델이란 무엇인가?
- 설치
- 모델 로딩
- 질의 및 문서 인코딩
- MaxSim으로 점수 산정
- 시맨틱 검색
- 검색 및 재정렬
- 인덱싱
- 시각적 문서 검색
- 오디오 검색
- 비디오 검색
- 해석 가능성
- 토큰 풀링
- 추론 속도 향상
- 모델 평가
- PyLate 또는 colpali-engine에서 오신 분들
- 지원하는 모델
- 감사의 말씀
- 추가 리소스
멀티-벡터 모델이란 무엇인가?
밀집 임베딩 모델은 텍스트를 읽고 하나의 고정 크기 벡터를 반환합니다. 모델이 포착한 모든 정보는 그 벡터에 들어가야 하며, 유사도는 두 요약 간의 하나의 점곱으로 계산됩니다. 이 방식은 매우 잘 작동하지만, 특정 방식으로 손실이 발생하는 압축이 존재합니다: 희귀한 엔티티, 정확한 식별자, 또는 긴 구절의 핵심 조항 하나가 동일 벡터 내에서 공간을 차지하기 위해 경쟁합니다. 여러 요구사항이 한 번에 주어지는 질의는 동일한 한계에 부딪힙니다. 예를 들어 “나무 다리가 있는 초록 소파와 둥근 쿠션”의 경우, 네 가지 요소를 하나의 점으로 혼합해야 하므로 다리가 잘못된 초록 소파가 사실 요청한 소파와 가까이 매칭될 수 있습니다.
멀티-벡터 모델(일명 늦은 인터랙션 또는 ColBERT 스타일 모델, ColBERT paper 이후)은 그 압축을 건너뜁니다. 같은 트랜스포머를 실행하되 토큰 임베딩을 하나의 벡터로 풀링하는 대신 각 토큰 임베딩을 작은 차원으로 투영하고 모두를 유지합니다. 보통 128 차원이 되고, 9토큰 문서는 9x128 매트릭스가 됩니다.
질의와 문서 간의 상호작용은 스코어링 시점까지 연기되며, 이는 이름의 “늦은 인터랙션”의 근거가 됩니다. 크로스-인코더는 초기에 상호작용합니다: 두 텍스트가 함께 모델을 통과하므로 정확하지만, 새로운 질의마다 각 문서를 다시 인코딩해야 하므로 사전 계산이 거의 불가능합니다. 위의 Dense 임베딩 모델이 다루는 바이-인코더는 상호작용이 거의 없고(완성된 두 요약 간의 하나의 점곱), 이로 인해 컬렉션을 한 번 인코딩하고 빠르게 질의할 수 있습니다. 늦은 인터랙션은 그 사이에 위치합니다: 문서는 여전히 독립적으로 인코딩되어 오프라인 인덱싱이 가능하지만, 점수 산정은 모든 질의 토큰을 모든 문서 토큰에 대해 비교합니다. 이로써 두 요소가 더 많이 상호작용할 여지가 남습니다.

MaxSim 연산자
점수 산정은 MaxSim을 사용합니다: 각 질의 토큰에 대해 문서 토큰에 대한 가장 높은 유사도를 취한 후, 그 최대값들을 질의 전체에 걸쳐 합산합니다.
토큰 임베딩이 L2로 정규화되어 있기 때문에, 위의 각 점곱은 [-1, 1]에서의 코사인 유사도이며, 전체 합은 [-num_query_tokens, num_query_tokens] 범위 안에 들어옵니다.
연산자를 소프트 얼라인먼트로 읽을 수 있습니다: 모든 질의 토큰은 가장 잘 설명해 주는 하나의 문서 토큰을 가리키며, 점수는 문서가 질의를 전반적으로 얼마나 잘 뒷받침하는지에 달려 있습니다.
정합은 어휘적일 필요가 없으며, 토큰 임베딩은 맥락화되어 있기 때문입니다. lightonai/mLateOn로 “Where do penguins live?”를 인코드하고 질의 토큰 live가 최상의 매치를 inhabit에서 0.94로 찾는 예를 보십시오. 이 토큰은 서로 문자도 공유하지 않는 단어입니다! 이는 어휘 기반 검색이 할 수 없는 점입니다. BM25 및 그 유사체는 용어 자체를 필요로 하므로 동의어와 의역은 그들을 지나갑니다. 물론 Dense 임베딩 모델도 그 격차를 메웁니다. 늦은 인터랙션이 추가하는 점은, 정확한 매치가 중요한 경우에도(Maximum 같은 경우) 그 토큰을 단독으로 남겨두는 것이며, 단일 벡터 모델이 모든 것을 다른 것으로 평균해 버려야 하는 상황에서도 동일합니다. 또한 1:N 관계가 아니라는 점도 흔합니다. 여러 질의 토큰이 자주 같은 문서 토큰에 정착하기 때문입니다.
얻는 이점과 비용
검색 품질이 향상됩니다. 특히 문서의 특정 부분이 관련성을 결정하는 질의에서, 위의 소파 예시처럼 각 요구가 고유의 증거를 찾는 다중 요구 질의에서, Dense 모델의 압축이 다른 분포에 대해 학습된 데이터에서 작동하는 경우 등에서 말이죠. 이 압축은 학습 데이터의 질의에서 배운 것이므로 모델은 필요한 부분만 남기고 나머지는 제거합니다. 생산 질의가 정확히 어떤 정보를 요구하는지 포함될 수 있습니다. 효과는 문서 길이가 길어질수록 커지는데, 더 많은 텍스트를 같은 고정 벡터에 담아야 하기 때문입니다.
비용은 인덱스 크기입니다. 토큰 하나당 벡터 하나를 저장하는 것은 문서당 벡터 하나를 저장하는 것보다 훨씬 더 많은 벡터를 필요로 하며, 차원 수가 작아진 것만으로 충분히 보정되지 않습니다. lightonai/LateOn로 4,874개의 Natural Questions 패시지를 인코딩하면 608,414개의 토큰 벡터가 생성되었고 패시지당 평균 124.8개였습니다:
| 표현 방식 | 벡터 수 | 차원 수 | float32 크기 |
|---|---|---|---|
Dense, all-MiniLM-L6-v2 |
4,874 | 384 | 7.5 MB |
Dense, gte-modernbert-base |
4,874 | 768 | 15.0 MB |
다중 벡터, LateOn |
608,414 | 128 | 311.5 MB |
그 저장 용량은 MiniLM 인덱스의 약 42배에 달하며 패시지당 약 62 KiB에 이릅니다. 다만 인덱스는 보통 압축되며, 예를 들어 동일한 608,414 벡터가 fast-plaid 인덱스로 92 MB를 차지합니다. PLAID는 벡터 자체 대신 중심 벡터와 양자화된 잔차를 저장하기 때문입니다. 규모를 보면, 4,874 패시지를 대상으로 한 4096차원의 Dense 모델인 Qwen3-Embedding-8B 은 약 80 MB가 필요하므로, 압축된 멀티-벡터 인덱스는 이미 운영 중인 Dense 인덱스와 같은 범위에 속합니다. 벡터 수를 줄이기 전에 Token Pooling이 먼저 작동하고, Retrieve and Rerank 은 인덱스 자체를 만들지 않습니다.
PyLate 는 이 포스트 전반에 걸쳐 등장하므로 간단히 정리합니다: Sentence Transformers 는 Dense 및 Sparse 모델을 다루었지만 Late Interaction 은 다루지 못했고, 그래서 LightOn 이 이를 보완하기 위해 PyLate 를 위에 구축하여 이 모델들이 필요로 하는 학습, 추론, 검색 파트를 더했습니다. 아래에서 로드하는 많은 내용은 그와 함께 학습되었고, LightOn 도 그 주위를 둘러싼 생태계를 구축하여 fast-plaid를 포함시키고, Indexing 에서 Late-Interaction 인덱스를 제공합니다. v6.0 부터는 이 기능들이 Sentence Transformers 자체에 내재되어 있습니다.
트레이드오프를 염두에 두고, 이제 모델을 작동시켜 봅시다.
설치
멀티-벡터 모델은 간단한 설치로 작동합니다:
pip install -U sentence-transformers
ColPali 스타일의 시각적 문서 검색을 위해서는 이미지 의존성도 필요합니다(모든 확장 기능은 Installation 를 참조하고, 멀티모달 지원은 일반적으로 Multimodal Embedding & Reranker Models 를 참조하십시오):
pip install -U "sentence-transformers[image]"
[!NOTE] Sentence Transformers v6.0은
transformersv5.x,torch2.2+, 그리고huggingface-hubv1.x를 필요로 합니다. 이들 중 어느 하나라도 더 낮은 버전으로 고정하면 먼저 업그레이드를 계획하십시오. 전체 변경 목록은 Migration Guide를 참고하십시오.
모델 로딩
멀티-벡터 모델 로딩은 다른 Sentence Transformers 모델 로딩과 똑같이 보입니다:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
작동하는 모델을 찾으려면 Hub의 multi-vector and sentence-transformers tags 태그를 찾으세요. 이 태그가 달린 어떤 모델이든 위의 로딩 방식으로 로드되며, PyLate 체크포인트로 시작했든, Stanford-NLP ColBERT 체크포인트였든, 또는 ColPali 계열의 시각적 문서 검색용 모델이었든 상관없이 동일하게 동작합니다. 우리는 이 태그를 작동하는 모든 모델에 붙이기 위해 생태계를 정비 중이며 목록은 계속 확장 중입니다.
그 아래에서, MultiVectorEncoder 는 수년간 발표된 포맷 각각을 읽습니다. 그래서 PyLate와 Stanford-NLP 체크포인트는 태그가 아직 추가되지 않았더라도 바로 로드됩니다:
from sentence_transformers import MultiVectorEncoder
# Native Sentence Transformers checkpoints. PyLate builds on the same schema,
# so any PyLate checkpoint loads identically
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)
# Any Stanford-NLP ColBERT checkpoint, detected via the `HF_ColBERT` architecture
# marker. The inline projection weight and the recipe come from `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")
# A bare transformer: a fresh random projection is appended, so training is required
model = MultiVectorEncoder("answerdotai/ModernBERT-base")
시각적 문서 검색 모델은 예외입니다. ColPali 계열 체크포인트는 colpali-engine 고유 포맷으로 제공되며 이는 Sentence Transformers가 활용할 수 있는 정보를 담고 있지 않으므로 로드하기 전에 저장소에 작은 구성이 필요합니다. 대부분의 작업은 이미 완료되어 병합되기를 기다리고 있습니다. 현재 상태와 오늘날 로드하는 방법은 Supported Models를 참조하십시오.
체크포인트 구성 내용 점검
멀티-벡터 모델은 체크포인트마다 다른 조합 매개변수를 담고 있습니다: 질의와 문서에 대한 마커 접두사, 길이 상한, 질의를 [MASK] 토큰으로 패딩하는지 여부, 그리고 점수 산정 시 건너뛸 토큰을 결정하는 토큰들. 이 모든 설정은 모듈 구성에 포함되어 있으므로 print(model) 은 로드한 내용을 정확히 보여줍니다. 아래는 원래 ColBERTv2 체크포인트로, 모든 질의를 정확히 32토큰으로 패딩하고 문서를 180에서 잘라냅니다:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
print(model)
"""
MultiVectorEncoder(
(0): Transformer({..., 'document_length': 180,
'query_expansion': {'strategy': 'fixed', 'attend': False, 'token': None, 'length': 32}})
(1): Dense({'in_features': 768, 'out_features': 128, 'bias': False, ...})
(2): MultiVectorMask({'skiplist_words': ['!', '"', '#', ...], 'skiplist_tasks': ['document'], ...})
(3): Normalize({...})
)
"""
print(model.prompts)
# {'query': '[unused0] ', 'document': '[unused1] '}
그건 전형적인 ColBERT 파이프라인입니다: 컨텍스트화된 토큰 임베딩을 생성하는 Transformer, 각 임베딩을 128차원으로 투영하는 토큰-레벨 Dense, 점수 산정 시 계산에 포함될 토큰을 결정하는 MultiVectorMask, 그리고 토큰-레벨 Normalize입니다. 다른 체크포인트들은 서로 다른 값을 채웁니다. lightonai/GTE-ModernColBERT-v1 은 같은 네 모듈을 [Q] 및 [D] 프롬프트와 함께 사용하며, 질의 확장 없이 상한은 48 및 300입니다.
대부분의 경우, 출시된 체크포인트 각각이 자체 구성을 갖추고 있어 이 부분을 건드릴 필요가 거의 없습니다. bare 백본으로 모델을 구성할 때에만 다루게 되는데, 이는 Creating Custom Models에서 다루고 있습니다.
다만 한 가지 값은 당신의 데이터에 대해 확인해 보는 것이 가치가 있습니다. document_length 은 잘라내므로 그 기준을 넘는 것은 인덱스에 도달하지 못합니다. 예를 들어 LateOn의 상한이 300인 경우 662토큰 패시지는 273 벡터로 반환되고, 남은 부분은 그대로 사라진 셈입니다. 이들 체크포인트의 대다수는 짧은 패시지를 대상으로 훈련되었으므로 cap보다 긴 청크를 한 번의 호출에서 늘릴 수 있지만, 그 경우 학습 당시의 길이를 넘어 작동하게 되어 인덱스 크기가 대략 비례적으로 증가합니다. 멀티-벡터 모델은 이를 대체로 잘 견딥니다. 상위 수준의 장문의 검색 벤치마크에서의 multilingual 형제들 간의 차이는 아래와 같습니다: mLateOn scores 77.92 against mDenseOn’s 51.59.
질의 및 문서 인코딩
멀티-벡터 모델은 비대칭적입니다: 질의와 문서는 서로 다른 접두사, 서로 다른 길이 상한, 서로 다른 점수 산정 마스크를 거칩니다. Dense 모델이 서로 교환 가능하다는 것과 달리, 정확한 임베딩을 얻으려면 encode_query()와 encode_document()가 필요합니다:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/mLateOn")
queries = ["What is the capital of France?"]
documents = [
"Paris is the capital of France.",
"Berlin is the capital and largest city of Germany, by both area and population.",
]
query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(documents)
print(query_embeddings[0].shape)
# (10, 128)
print(document_embeddings[0].shape, document_embeddings[1].shape)
# (10, 128) (19, 128)
되돌아오는 것을 주목하세요: 입력마다 모양이 다른 2D 텐서의 목록입니다. 각 텐서의 모양은 (num_tokens, embedding_dim)입니다. Dense 임베딩과 달리 이를 하나의 직사각형 텐서로 쌓을 수 없으므로 각 입력마다 토큰 수가 다릅니다. 두 번째 문서는 첫 번째 문서보다 길어서 더 높은 매트릭스로 반환됩니다.
각 호출은 모델의 고유 조합법칙을 적용합니다. encode_query 은 질의 마커를 앞에 붙이고, 체크포인트가 요구하면 질의를 고정 길이로 확장하며, 질의 길이에 맞춥니다. encode_document 은 문서 마커를 앞에 붙이고 문서 길이에서 캡하며, 점수 산정 마스크에서 건너뛴 토큰(대부분의 체크포인트에서 구두점)을 제거합니다.
일반적인 encode() 인수는 여전히 적용되므로, batch_size, show_progress_bar, convert_to_numpy, device, 그리고 다중 프로세스 풀도 기대하는 대로 작동합니다:
document_embeddings = model.encode_document(
documents,
batch_size=64,
show_progress_bar=True,
)
MaxSim으로 점수 산정
model.similarity() 는 모든 페어의 MaxSim 매트릭스를 계산합니다:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
query_embeddings = model.encode_query(["Which planet is known as the Red Planet?"])
document_embeddings = model.encode_document([
"Venus is often called Earth's twin because of its similar size and proximity.",
"Mars, known for its reddish appearance, is often referred to as the Red Planet.",
"Jupiter, the largest planet in our solar system, has a prominent red spot.",
"Saturn, famous for its rings, is sometimes mistaken for the Red Planet.",
])
scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
# tensor([[10.7942, 11.1104, 10.9743, 11.0811]])
Mars가 마땅히 이깁니다. 근소한 차이가 난 후보들을 주목해 보십시오: Saturn은 “the Red Planet”이라는 구절을 정확히 포함하고 있으며, Jupiter는 붉은 반점이 있는 행성이라 세 문서 모두에서 토큰 단위 연산자가 활용될 여지가 충분합니다. 순서가 중요한 점은 바로 이 때문입니다.
점수는 자주 이렇게 근접하게 위치합니다. GLInt 가 전체 후보 풀에서 분산을 측정하는 방식으로 이를 보여주며, MaxSim 은 질의 토큰당 최대값을 취하므로 문서는 보통 각 질의 토큰에 대해 괜찮은 최적 매치를 제공하고 점수는 바닥에서 시작합니다. 맥락화된 토큰 임베딩은 비등방성으로 굴절하는 쐐기형 콘에 모여 분포를 퍼뜨리지 않기 때문에 임의의 토큰 쌍도 높은 점수를 얻는 경향이 있습니다.
또한 model.similarity_pairwise() 가 있습니다. 이미 매칭된 쌍이 있고 전체 유사도 행렬 대신 쌍 점수만 필요할 때 사용합니다:
scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
# tensor([10.7942])
점수의 크기와 MeanMaxSim
MaxSim 은 질의 토큰 수에 따라 합산되므로, 질의 구성에 따라 점수의 규모가 달라집니다. 따라서 서로 다른 질의 구성의 모델 간에 점수를 비교할 수 없습니다. 위의 Red Planet 질의를 LateOn은 12개의 토큰으로 인코드합니다. 동일한 질의와 문서를 ColBERTv2에 넣으면 모든 질의가 정확히 32토큰으로 패딩되고 자르는 방식으로 점수를 내는데, 점수 범위가 완전히 다르게 나타납니다:
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... same encode_query / encode_document / similarity calls ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])
한 모델 내의 순서만 보면 충분하지만, 점수를 한정된 스케일로 보고 싶다면 모델의 유사도 함수를 MeanMaxSim으로 바꿔서 질의 토큰 수로 나누게 하십시오. LateOn에서 다시 보자면:
model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
# or on an already-loaded model: model.similarity_fn_name = "meanmaxsim"
print(model.similarity(query_embeddings, document_embeddings))
# tensor([[0.8995, 0.9259, 0.9145, 0.9234]])
이제 모든 점수는 [-1, 1]에서의 평균 코사인 유사도이며, 실제로는 [0, 1]만 보게 됩니다.
시맨틱 검색
코퍼스가 작다면 전체에 대한 exhaustive MaxSim 이 가장 간단하게 작동합니다. 코퍼스를 한 번 인코딩하고 모든 질의에 대해 점수화합니다:
import time
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
# Several questions share an answer passage, so drop repeats but keep the order
corpus = list(dict.fromkeys(dataset["answer"])) # 5,000 rows -> 4,874 passages
model = MultiVectorEncoder("lightonai/LateOn")
corpus_embeddings = model.encode_document(corpus, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query])
scores = model.similarity(query_embeddings, corpus_embeddings)[0] # 98ms
top_scores, top_indices = scores.topk(3)
print(f"Search took {(time.perf_counter() - start) * 1000:.1f}ms")
for score, index in zip(top_scores.tolist(), top_indices.tolist()):
print(f"{score:.4f} {corpus[index][:100]}")
"""
Search took 122.7ms
11.9192 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieved
11.7591 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contest
11.6710 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fou
"""
그 4,874개의 패시지는 RTX 3090에서 20초 만에 인코딩되었고, 각 검색은 전체적으로 약 120ms 정도 걸리며 그 대부분은 608,414 토큰 벡터에 대한 MaxSim 점수 산정 때문입니다. 이것은 정확하지만, 전체 코퍼스 토큰 수에 선형적으로 비례하여 확장되며 모든 토큰 벡터를 메모리에 보관하므로 수천 개의 문서 정도가 있을 때 사용하십시오. 이 스크립트의 실행 가능 버전은 semantic_search.py입니다.
그 크기 이상으로 가면 실제 늦은 인터랙션 인덱스가 필요합니다. 이는 Sentence Transformers에서 제공하지 않지만 필요하지 않기도 합니다. 이 인덱스는 encode_document가 생성한 것을 저장하므로 여기에서 인코딩하고 토큰 임베딩을 이를 다룰 수 있는 다른 엔진에 넘깁니다. Indexing 는 네 가지 옵션에 대한 작동 중인 스니펫을 제공하며, 바로 아래 섹션은 인덱스를 건너뛰는 방법을 다룹니다.
검색 및 재정렬
늦은 상호작용의 품질은 늦은 상호작용 인덱스를 유지하지 않고도 얻을 수 있습니다. 멀티-벡터 모델을 your reranker로 사용하면 빠른 바이-인코더가 대형 코퍼스를 소수의 후보로 축소한 뒤 멀티-벡터 모델이 그 후보들만 재점수합니다:
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder, SentenceTransformer
from sentence_transformers.util import semantic_search
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:50000]")
corpus = list(dict.fromkeys(dataset["answer"]))
retriever = SentenceTransformer("jinaai/jina-embeddings-v5-text-nano-retrieval")
reranker = MultiVectorEncoder("perplexity-ai/pplx-embed-v1-late-0.6b", trust_remote_code=True)
# First stage: index the corpus once with a fast bi-encoder
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
# Retrieve the top 50
query = "when did richmond last play in a preliminary final"
hits = semantic_search(retriever.encode_query([query], convert_to_tensor=True), corpus_embeddings, top_k=50)[0]
candidates = [corpus[hit["corpus_id"]] for hit in hits]
# Second stage: rescore just those candidates with MaxSim
query_embeddings = reranker.encode_query([query])
document_embeddings = reranker.encode_document(candidates)
scores = reranker.similarity(query_embeddings, document_embeddings)[0]
for index in scores.argsort(descending=True)[:3].tolist():
print(f"{scores[index].item():.4f} {candidates[index][:100]}")
후보 50개만이 멀티-벡터로 인코딩되므로 인덱스는 일반 Dense 인덱스로 유지되고 토큰 벡터는 일시적입니다. 이는 크로스-인코더가 Retrieve-and-Rerank 스택에서 하는 역할과 같지만, 멀티-벡터 모델은 후보 하나당 상당히 저렴합니다. 문서를 한 배치로 인코딩하고 행렬 곱으로 점수를 계산하며 질의-문서 쌍마다 한 번의 순전파를 수행하는 대신에 그리하세요. 실행 가능한 스크립트는 retrieve_rerank.py이며 두 단계의 시간 측정을 출력합니다.
인덱싱
여러 벡터 데이터베이스가 멀티-벡터를 네이티브로 인덱싱하고 점수 산정합니다: v1.10 이후 Qdrant, v1.29 이후 Weaviate, 수년간 Vespa, v0.15.0 이후 LanceDB, 그리고 Postgres에 MaxSim 연산자를 추가하는 VectorChord가 있습니다. PyLate의 멀티-벡터 검색을 위한 비연관 기능으로 인덱스를 더했으며, Milvus 는 v2.6.4에서 합류했습니다. 서버를 전혀 실행하고 싶지 않다면 LightOn의 fast-plaid 은 pip install만으로 가능하고 PLAID를 직접 구현하며, PyLate 은 이를 더 완전한 검색 스택으로 래핑합니다.
다른 몇 가지는 부분적으로 제공합니다. OpenSearch 및 Elasticsearch 은 MaxSim으로 후보를 재점수화할 수는 있지만 그 위에서 검색은 지원하지 않으며, Elasticsearch 필드는 기술 프리뷰 및 엔터프라이즈급으로 제공됩니다. turbopuffer 는 비공개 베타에서 늦은 인터랙션 인덱싱을 제공합니다.
아래의 스니펫들은 텍스트를 인덱싱하지만 텍스트에 특화된 내용은 없습니다. encode_document 는 문서가 패시지이든 페이지 이미지이든 오디오 클립이든 비디오이든 간에 같은 토큰-벡터 매트릭스 목록을 반환합니다. 따라서 Visual Document Retrieval의 ColPali 스타일 모델은 이러한 형태로 그대로 적용됩니다. 문서당 벡터 수가 늘어나므로 Token Pooling 를 더 빨리 활용하게 됩니다.
fast-plaid, Qdrant, Weaviate, Vespa 는 모두 encode_document가 반환하는 것을 정확히 받아들이므로 코드 구조는 클라이언트 라이브러리까지 동일합니다. 아래는 4,874개의 패시지와 Semantic Search 예시의 608,414 토큰 벡터를 대상으로 실행된 각 기술의 동작 예시 스니펫입니다. 각 스니펫은 한 대의 머신(RTX 3090, i7-13700K)에서 생성된 인제스션 및 질의 시간을 담고 있으며, 코드가 보여주는 것 외에는 추가 튜닝이 없습니다. 네 기술 모두 이 섹션에서 98ms 걸린 model.similarity보다 더 빠르게 질의에 응답했고, 세 가지는 CPU에서 실행되며, fast-plaid만 유일하게 GPU를 사용합니다.
네 기술 모두 이 글의 앞부분에서 포괄적으로 계산된 PyTorch MaxSim과 동일한 순서로 세 개의 패시지를 반환했고, 세 데이터베이스는 그 점수를 소수점 4자리까지 재현합니다! 이는 이들의 스니펫이 모든 문서를 점수화하기 때문이며, 이 규모에서 실행이 가능하고 정확도에서 근사치를 제거합니다. design: fast-plaid 는 설계상 근사이므로 점수가 약간 다를 수 있습니다. 각 항목 아래의 주석은 근사 인덱스로 바꿀 때의 차이를 설명하며, 이것이 랭킹이 드리프트하기 시작하는 지점입니다.
fast-plaid
[fast-plaid](https://github.com/lightonai/fast-plaid) 는 LightOn의 PLAID의 Rust 구현으로, ColBERT가 원래 그것을 중심으로 구축되었는 인덱스입니다. 서버를 시작할 필요 없고, `encode_document` 가 반환하는 텐서를 변환 없이 읽습니다. ```python # pip install sentence-transformers datasets fast-plaid from datasets import load_dataset from fast_plaid import search from sentence_transformers import MultiVectorEncoder dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]") corpus = list(dict.fromkeys(dataset["answer"])) model = MultiVectorEncoder("lightonai/LateOn") query = "when did richmond last play in a preliminary final" document_embeddings = model.encode_document(corpus, batch_size=32) query_embedding = model.encode_query(query) fast_plaid = search.FastPlaid(index="natural-questions", device="cuda") # 4,874 documents (608,414 token vectors) indexed in 5s fast_plaid.create(documents_embeddings=document_embeddings) results = fast_plaid.search(queries_embeddings=query_embedding.unsqueeze(0), top_k=3) # 11ms for index, score in results[0]: print(f"{score:.4f} {corpus[index][:90]}") """ 11.8828 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve 11.7676 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes 11.6758 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo """ ``` `index` 인자는 라벨이 아니라 디렉토리이므로 인덱스가 구성되는 대로 디스크에 저장됩니다. 같은 경로에 새로운 `FastPlaid` 를 가리키면 매번 임베딩에서 다시 빌드하지 않고 검색용으로 열거나 문서를 추가하도록 열 수 있습니다. 이 코퍼스에서 이는 92 MB를 차지하며, 원시 float32 벡터의 311.5 MB에 비해 작습니다. 이 네 가지 중 근사적 인덱스인 것은 단 하나이며, 이 섹션에서의 점수는 exhaustive MaxSim과 일치하지 않는 곳이기도 합니다. PLAID 는 중심 벡터를 사용해 잔차를 양자화하고 저장하므로, 세 점수는 앞서 계산된 11.9192 / 11.7591 / 11.6710에 대해 양 방향으로 미세하게 차이가 납니다. 이 순위는 여기서는 영향을 받지 않으며, 이것이 PLAID가 감수하는 타협입니다. 이 인덱스는 이보다 훨씬 큰 코퍼라를 대상으로 설계되어 모든 것을 스캔하는 옵션이 아니라는 점이 특징입니다.Qdrant
[Qdrant](https://qdrant.tech/documentation/concepts/vectors/) 는 서버가 필요합니다: `docker run -p 6333:6333 qdrant/qdrant`. 클라이언트에는 서버가 필요 없는 로컬 모드(`QdrantClient(":memory:")`)도 있지만, 이는 순수 파이썬 재구현이므로 실험 용도에 더 적합합니다. ```python # pip install sentence-transformers datasets qdrant-client from datasets import load_dataset from qdrant_client import QdrantClient, models from sentence_transformers import MultiVectorEncoder dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]") corpus = list(dict.fromkeys(dataset["answer"])) model = MultiVectorEncoder("lightonai/LateOn") query = "when did richmond last play in a preliminary final" document_embeddings = model.encode_document(corpus, batch_size=32) query_embedding = model.encode_query(query) client = QdrantClient("http://localhost:6333") client.create_collection( collection_name="natural-questions", vectors_config=models.VectorParams( size=model.get_embedding_dimension(), distance=models.Distance.COSINE, multivector_config=models.MultiVectorConfig( comparator=models.MultiVectorComparator.MAX_SIM ), # MaxSim never walks the HNSW graph, so skip building one hnsw_config=models.HnswConfigDiff(m=0), ), ) # 4,874 documents (608,414 token vectors) ingested in 26.3s client.upload_points( collection_name="natural-questions", points=[ models.PointStruct(id=idx, vector=embedding, payload={"text": text}) for idx, (embedding, text) in enumerate(zip(document_embeddings, corpus)) ], batch_size=64, ) results = client.query_points( collection_name="natural-questions", query=query_embedding, limit=3, with_payload=True, ).points # 18ms for result in results: print(f"{result.score:.4f} {result.payload['text'][:90]}") """ 11.9192 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve 11.7591 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes 11.6710 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo """ ``` `MAX_SIM` 은 Qdrant가 제공하는 유일한 비교 수단이며, `hnsw_config=HnswConfigDiff(m=0)` 은 레이트 인터랙션 필드에 대한 그들의 권고안으로, 벡터가 그래프 탐색이 아닌 재점수화에 사용되기 때문입니다. Qdrant 측에서도 레이트 인터랙션은 후보 수백 개를 재정렬하는 용도로만 사용하는 것을 권장하며, 이는 [Retrieve and Rerank](#retrieve-and-rerank) 패턴입니다. 4,874 문서에서 전체 스캔은 18ms로 정확하지만, 이를 일반화해서는 안 됩니다.Weaviate
[Weaviate](https://docs.weaviate.io/weaviate/tutorials/multi-vector-embeddings) 역시 서버가 필요합니다: `docker run -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.34.0`. 멀티-벡터 지원은 1.29 이상이 필요하며, 임베디드 모드는 Windows에서 사용할 수 없습니다. ```python # pip install sentence-transformers datasets weaviate-client import weaviate from datasets import load_dataset from sentence_transformers import MultiVectorEncoder from weaviate.classes.config import Configure, DataType, Property from weaviate.classes.query import MetadataQuery dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]") corpus = list(dict.fromkeys(dataset["answer"])) model = MultiVectorEncoder("lightonai/LateOn") query = "when did richmond last play in a preliminary final" document_embeddings = model.encode_document(corpus, batch_size=32) query_embedding = model.encode_query(query) client = weaviate.connect_to_local() collection = client.collections.create( "Documents", # self_provided turns on MaxSim late interaction vector_config=[Configure.MultiVectors.self_provided(name="colbert")], properties=[Property(name="text", data_type=DataType.TEXT)], ) # 4,874 documents (608,414 token vectors) ingested in 41s with collection.batch.fixed_size(batch_size=64) as batch: for text, embedding in zip(corpus, document_embeddings): batch.add_object(properties={"text": text}, vector={"colbert": embedding.tolist()}) results = collection.query.near_vector( near_vector=query_embedding.tolist(), target_vector="colbert", limit=3, return_metadata=MetadataQuery(distance=True), ) # 17ms for result in results.objects: # Weaviate reports the MaxSim score as a negated distance print(f"{-result.metadata.distance:.4f} {result.properties['text'][:90]}") """ 11.9192 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve 11.7591 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes 11.6710 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo """ client.close() ``` Defaults are enough here: Weaviate의 동적 `ef` 는 상위 3 질의에서 100으로 수렴하며, 이 랭킹은 32 이상부터 이미 정확합니다. 이 여백은 Weaviate의 문제가 아니라 임베딩의 특성에 속하므로 기본값이 유지된다고 가정하기보다 자신의 모델에서 확인하는 편이 좋습니다. Weaviate는 MUVERA 인코딩도 지원하여, 우리 테스트에서 수집 시간이 3배, 질의 속도가 1.8배 빨라졌습니다. 그러나 이 규모에서 이 속도 향상의 대가로 얻는 정확도 손실은 큽니다: 상위 50 내에도 실제로 올바른 세 번째 패시지가 나타나지 않았습니다.Vespa
[Vespa](https://docs.vespa.ai/en/tensor-user-guide.html) 역시 컨테이너에서 실행되지만 `pyvespa`가 이를 시작해 주므로 별도의 `docker run`가 필요 없습니다. ```python # pip install sentence-transformers datasets pyvespa from datasets import load_dataset from sentence_transformers import MultiVectorEncoder from vespa.deployment import VespaDocker from vespa.package import ( ApplicationPackage, Document, Field, FirstPhaseRanking, Function, RankProfile, Schema, ) dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]") corpus = list(dict.fromkeys(dataset["answer"])) model = MultiVectorEncoder("lightonai/LateOn") query = "when did richmond last play in a preliminary final" document_embeddings = model.encode_document(corpus, batch_size=32) query_embedding = model.encode_query(query) # "dt" is a mapped dimension over the variable token count, "x" the dense 128-dim vector package = ApplicationPackage( name="colbert", schema=[ Schema( name="doc", document=Document(fields=[ Field(name="text", type="string", indexing=["summary"]), Field(name="colbert", type="tensor
Grabette: 로봇-조작 데이터를 기록하기 위한 오픈 시스템.
*함께 공유 데이터셋을 구축합니다.*
이 글은 Hugging Face 블로그의 Grabette: an open system to record robot-manipulation data를 한국어로 번역한...
Thinking Machines의 Inkling에 오신 것을 환영합니다
이 글은 Hugging Face 블로그의 Welcome Inkling by Thinking Machines를 한국어로 번역한 글입니다.
파이토치의 프로파일링(3부): 어텐션이 전부다
이 글은 Hugging Face 블로그의 Profiling in PyTorch (Part 3): Attention is all you profile를...
네이티브-스피드 vLLM transformers 모델링 백엔드
이 글은 Hugging Face 블로그의 Native-speed vLLM transformers modeling backend를 한국어로 번역한 글입니다.
LeRobot v0.6.0: 상상하고, 평가하고, 개선하기
이 글은 Hugging Face 블로그의 LeRobot v0.6.0: Imagine, Evaluate, Improve를 한국어로 번역한 글입니다.
Hugging Face와 Cerebras가 Gemma 4를 실시간 음성 AI로 선보입니다
이 글은 Hugging Face 블로그의 Hugging Face and Cerebras bring Gemma 4 to real-time voice...
Hugging Face 모델 페이지에서 Every Eval Ever 결과 보기
이 글은 Hugging Face 블로그의 Featuring Every Eval Ever Results on Hugging Face Model Pages를...
HF Jobs에서 vLLM 서버를 한 명령으로 실행하기
이 글은 Hugging Face 블로그의 Run a vLLM Server on HF Jobs in One Command를...
FFASR Leaderboard 소개: 현실 세계에서의 ASR 벤치마크
이 글은 Hugging Face 블로그의 Introducing the FFASR Leaderboard: Benchmarking ASR in the Real World를...
매주 AI, 오픈 도구, 그리고 휴먼 인 더 루프가 포함된 huggingface_hub 배포
이 글은 Hugging Face 블로그의 Shipping huggingface_hub every week with AI, open tools, and a...
Transformers.js에서 제안된 Cross-Origin Storage API 실험하기
이 글은 Hugging Face 블로그의 Experimenting with the proposed Cross-Origin Storage API in Transformers.js를 한국어로...
Beyond LoRA: 가장 인기 있는 미세조정 기법을 이길 수 있을까?
이 글은 Hugging Face 블로그의 Beyond LoRA: Can you beat the most popular fine-tuning technique?를...
충분히 에이전트적인가요? 자체 도구로 오픈 모델 벤치마킹하기
이 글은 Hugging Face 블로그의 Is it agentic enough? Benchmarking open models on your own...
에이전트 기반 리소스 탐색: 에이전트에게 도구·스킬·다른 에이전트 검색을 맡기다
이 글은 Hugging Face 블로그의 Agentic Resource Discovery: Let agents search를 한국어로 번역한 글입니다.
GitHub CI를 Hugging Face Jobs로 마이그레이션
이 글은 Hugging Face 블로그의 Migrating Your GitHub CI to Hugging Face Jobs를 한국어로 번역한...
오픈 소스 커뮤니티가 에이전틱 RL을 위한 OpenEnv에 힘을 싣다
이 글은 Hugging Face 블로그의 The Open Source Community is backing OpenEnv for Agentic RL를...
PyTorch에서의 프로파일링(Part 1): torch.profiler에 대한 초보자 가이드
이 글은 Hugging Face 블로그의 Profiling in PyTorch (Part 1): A Beginner’s Guide to torch.profiler를...
Reachy Mini를 로컬에서 완전히 실행하기
이 글은 Hugging Face 블로그의 Reachy Mini goes fully local를 한국어로 번역한 글입니다.
OlmoEarth v1.1: 더 효율적인 지구관측 모델 제품군
이 글은 Hugging Face 블로그의 OlmoEarth v1.1: A more efficient family of models를 한국어로 번역한...
PaddleOCR 3.5: Transformers 백엔드를 활용한 OCR 및 문서 파싱 작업 실행
이 글은 Hugging Face 블로그의 PaddleOCR 3.5: Running OCR and Document Parsing Tasks with a...
Hugging Face Transformers 한글화 - 초벌 번역기 사용법
Hugging Face Transformers 문서를 한글로 번역하는 초벌 번역기 사용 방법을 안내하는 가이드입니다. 😊

