현대 희소 신경 검색: 이론에서 실전까지
현대 희소 신경 검색: 이론에서 실전까지 (Modern Sparse Neural Retrieval: From Theory to Practice)
프로덕션을 돌리면서 모든 현대 솔루션을 공부할 시간을 찾는 건 좀처럼 쉽지 않아요. 밀집 리트리버, 하이브리드 리트리버, 늦은 상호작용… 이것들은 어떻게 동작하고, 어디에 가장 잘 어울릴까요? 리트리버들을 Amazon에서 상품 비교하듯 쉽게 비교할 수 있다면 얼마나 좋을까요!
우리는 가장 인기 있는 현대 희소 신경 검색 모델들을 탐구해서 여러분이 이해하기 쉽게 풀어 놓았어요. 이 글을 다 읽으면 희소 신경 검색의 현재 지형과, 하늘 높은 NDCG 점수를 자랑하는 복잡하고 수학이 잔뜩 든 연구 논문들을 압도당하지 않고 헤쳐 나가는 법을 분명히 이해하게 될 거예요.
이 글의 첫 번째 부분은 이론적이라 현대 희소 신경 검색에서 쓰이는 다양한 접근을 비교해요. 두 번째 부분은 더 실용적이라, 현대 희소 신경 검색의 최고 모델인 SPLADE++를 Qdrant에서 어떻게 쓸 수 있는지와, 언제 희소 신경 검색을 솔루션으로 골라야 할지에 대한 권장 사항을 보여 줘요.
출처: 공식문서
희소 신경 검색: 키워드 기반 리트리버가 의미를 이해하는 것처럼
BM25 같은 키워드 기반(Lexical) 리트리버는 설명 가능성이 좋아요. 문서가 쿼리와 매칭되면 그 이유를 이해하기 쉽죠. 쿼리 용어가 문서에 존재하고, 그 용어가 드물다면 검색에서 더 중요하기 때문이에요.

정확한 용어 매칭 메커니즘 덕분에 검색에서 초고속이에요. 용어를 그 용어가 나타나는 문서 목록으로 역매핑하는 간단한 역색인(inverted index) 은 수백만 개의 문서를 확인하는 시간을 아껴 줘요.

Lexical 리트리버는 검색 작업에서 여전히 강력한 베이스라인이에요. 하지만 설계상 어휘 불일치와 의미론적 불일치의 간극을 메울 수 없어요. 온라인 스토어에서 "tasty cheese"를 검색하는데 쇼핑 바구니에 "Gouda"나 "Brie"를 넣을 기회가 없다고 상상해 보세요.
문서와 쿼리를 밀집 벡터 표현으로 인코딩하는 머신러닝 모델에 기반한 밀집 리트리버는 이 간극을 넘어 "a piece of Gouda"를 찾아 줄 수 있어요.

하지만 여기서 설명 가능성은 피해를 봐요. 왜 이 쿼리 표현이 이 문서 표현과 가까울까? 왜 "cheese"를 검색하는데 "mouse traps"도 함께 제안될까? 이 벡터 표현의 각 숫자는 무엇을 의미할까? 어느 것이 치즈맛을 포착하고 있을까?
단단한 이해 없이는 결과 품질과 리소스 소비의 균형을 맞추기가 어려워져요. 가설적으로 어떤 문서든 쿼리와 매칭될 수 있으므로, 정확 매칭의 역색인에 의존하는 건 현실적이지 않아요. 그렇다고 밀집 리트리버가 본질적으로 느리다는 뜻은 아니에요. 다만 lexical 검색은 충분히 오래 존재해서, 재사용할 가치가 있는 몇 가지 효과적인 아키텍처 선택에 영감을 줬어요.
조만간 누군가는 이렇게 말했어야 했어요. "잠깐, BM25처럼 시간이 지나도 안정적인데 의미론적 이해까지 갖춘 게 필요하다면 어떡하지?"
희소 신경 검색의 진화
"flabbergasting murder" 이야기를 검색한다고 상상해 보세요. *"Flabbergasting"*은 드물게 쓰이는 단어라, 키워드 기반 리트리버(예: BM25)는 그것에 엄청난 중요도를 부여할 거예요. 그 결과 어떤 범죄와도 무관하지만 뭔가 "flabbergasting"한 것을 언급한 텍스트가 상위 결과에 뜰 가능성이 높아요.
BM25에서처럼 문서에서 용어 빈도를 중요도의 대리 지표로 쓰는 대신, 용어의 중요도를 직접 예측하면 어떨까요? 목표는 드물지만 영향력 없는 용어에는 같은 빈도를 가진 중요한 용어보다 훨씬 작은 가중치를 주는 거예요. BM25 시나리오에서는 둘 다 똑같이 취급받을 테니까요.
어떻게 한 용어가 다른 용어보다 중요한지 판단할 수 있을까요? 단어의 영향력은 의미와 관련돼 있고, 그 의미는 맥락(그 단어를 둘러싼 단어들)에서 파생될 수 있어요. 그래서 밀집 맥락 임베딩 모델이 등장하게 됐죠.
모든 희소 리트리버는 용어의 맥락적 밀집 벡터 표현을 만드는 모델을 가져와 그것이 희소 표현을 만들도록 가르친다는 아이디어에 기반해요. 아주 흔히 Bidirectional Encoder Representations from the Transformers (BERT)가 기본 모델로 쓰이고, 그 위에 표현을 희소화하는 아주 단순한 훈련 가능한 신경망이 추가돼요. 이 작은 신경망을 훈련하는 것은 보통 MS MARCO 데이터셋에서 쿼리와 그에 관련·무관한 문서를 샘플링한 뒤, 관련성을 향하는 방향으로 신경망의 파라미터를 옮기는 방식으로 이뤄져요.
희소 신경 검색의 선구자

최초의 희소 리트리버 중 하나인 Deep Contextualized Term Weighting framework (DeepCT)의 저자들은 문서와 쿼리의 각 고유 단어에 대해 정수형 단어 중요도 값을 따로 예측해요. 기본 BERT 모델이 만든 맥락 표현 위에 선형 회귀 모델을 쓰고, 모델의 출력은 반올림돼요.
문서가 데이터베이스에 업로드되면 문서에서 단어의 중요도는 훈련된 선형 회귀 모델로 예측되고, BM25 리트리버의 용어 빈도와 같은 방식으로 역색인에 저장돼요. 그리고 검색 과정은 BM25와 동일해요.
왜 DeepCT는 완벽한 솔루션이 아닐까? 선형 회귀를 훈련하려면 저자들이 각 단어 중요도의 참값(ground truth)을 제공해야 모델이 "정답이 무엇인지 볼" 수 있어요. 이 점수를 쿼리-문서 관련성을 진정으로 표현하는 방식으로 정의하기는 어려워요. 다섯 페이지 문서에서 가져온 단어가 쿼리에 가장 관련이 높은 단어라면 어떤 점수를 받아야 할까요? 두 번째로 관련 있는 단어는? 세 번째는?
관련성 목적 함수로 하는 희소 신경 검색

문서 전체가 쿼리에 관련 있는지 아닌지를 정의하는 건 훨씬 쉬워요. 그래서 DeepImpact 희소 신경 리트리버의 저자들은 쿼리와 문서 사이의 관련성을 훈련 목적 함수로 직접 사용했어요. 문서 단어의 BERT 맥락 임베딩을 가져와 단순한 2-레이어 신경망을 통해 단일 스칼라 점수로 변환하고, 쿼리와 겹치는 각 단어의 점수를 합산해요. 훈련 목적은 이 점수가 쿼리와 문서 사이의 관련성을 반영하게 만드는 거예요.
왜 DeepImpact는 완벽한 솔루션이 아닐까? 텍스트를 밀집 벡터 표현으로 변환할 때 BERT 모델은 단어 수준에서 동작하지 않아요. 때로 단어를 부분으로 쪼개죠. 예를 들어 "vector"라는 단어는 BERT가 한 조각으로 처리하지만, BERT가 본 적 없는 단어 같은 것은 “Qdrant”가 “Q”, “#dra”, “#nt”로 바뀌듯 단어를 조각으로 자르곤 해요.
DeepImpact 모델(DeepCT 모델처럼)은 BERT가 단어에 대해 만드는 첫 조각을 가져와 나머지를 버려요. 그런데 "Qdrant" 대신 "Q"로 검색하면 무슨 의미가 있을까요?
토크나이제이션 알기

DeepImpact 아키텍처의 문제를 해결하기 위해 Term Independent Likelihood MoDEl (TILDEv2) 모델은 희소 인코딩을 단어 수준이 아니라 BERT 표현 수준에서 생성해요. 그 외에는 저자들이 DeepImpact 모델과 동일한 아키텍처를 사용해요.
왜 TILDEv2는 완벽한 솔루션이 아닐까? 단일 스칼라 중요도 점수 값으로는 단어의 모든 서로 다른 의미를 포착하기에 충분하지 않을 수 있어요. 동음이의어(피자, 칵테일, 꽃, 여자 이름 "Margherita")는 정보 검색의 골칫거리 중 하나예요.
동음이의어를 이해한 희소 신경 리트리버

용어 중요도 점수 하나로는 부족하다면, 용어의 중요도를 벡터 형태로 설명할 수 있어요! COntextualized Inverted List (COIL) 모델의 저자들은 이 아이디어에 기반해 작업했어요. 768차원 BERT 맥락 임베딩을 하나의 값으로 찌부러뜨리는 대신, (유사한 "관련성" 훈련 목적 함수를 통해) 32차원으로 다운프로젝션해요. 게다가 세부사항을 놓치지 않으려 쿼리 용어도 벡터로 인코딩해요.
쿼리 토큰을 나타내는 각 벡터에 대해 COIL은 문서에서 같은 토큰의 가장 가까운 매칭(최대 dot product 사용) 벡터를 찾아요. 예를 들어 "Revolut bank <finance institution>"를 검색하고 데이터베이스의 문서에 "Vivid bank <finance institution> was moved to the bank of Amstel <river>"이라는 문장이 있다면, 두 "bank" 중 첫 번째가 쿼리의 "bank"와 더 큰 dot product 값을 가져서 최종 점수에 반영돼요. 문서의 최종 관련성 점수는 매칭된 쿼리 용어 점수의 합이에요.
왜 COIL은 완벽한 솔루션이 아닐까? 이렇게 중요도 점수를 정의하면 더 깊은 의미론을 포착해요. 더 많은 의미는 그것을 설명하는 데 쓰인 값이 더 많아진다는 뜻이죠. 하지만 모든 용어에 32차원 벡터를 저장하는 것은 훨씬 비싸고, 역색인이 이 아키텍처와 그대로 동작하지 않아요.
뿌리로 돌아가기

COIL 저자들이 후속으로 만든 Universal COntextualized Inverted List (UniCOIL)은 중요도 점수로 벡터 대신 스칼라 값을 만드는 것으로 돌아가고, 다른 모든 COIL 설계 결정은 그대로 둬요. 리소스 소비를 최적화하지만, COIL 아키텍처에 묶여 있던 깊은 의미론 이해는 다시 사라져요.
어휘 불일치를 아직 해결했나?
정확 매칭 기반의 검색에서는, 용어 중요도를 예측하는 방법이 아무리 정교해도 쿼리 용어가 전혀 없는 관련 문서를 매칭할 수 없어요. 레시피 책에서 "pizza"를 검색하면 "Margherita"를 찾을 수 없어요.
이 문제를 푸는 방법은 이른바 문서 확장(document expansion) 이에요. 이 문서를 검색하는 잠재적 쿼리에 있을 법한 단어들을 덧붙여 보자고요. 그러면 "Margherita" 문서는 "Margherita pizza"가 돼요. 이제 "pizza"에 대한 정확 매칭이 동작할 거예요!

희소 신경 검색에서 쓰이는 문서 확장에는 두 가지 유형이 있어요. 외부(external) 확장(한 모델이 확장을, 다른 모델이 검색을 담당)과 내부(internal) 확장(모두 단일 모델이 수행)이에요.
외부 문서 확장
외부 문서 확장은 생성 모델(generative model)(Mistral 7B, Chat-GPT, Claude 모두 생성 모델이에요. 입력 텍스트를 기반으로 단어를 생성하죠)을 사용해 문서를 희소 표현으로 변환하고 정확 매칭 방법을 적용하기 전에 문서에 추가내용을 구성해요.
docT5query를 이용한 외부 문서 확장

docT5query는 가장 많이 쓰이는 문서 확장 모델이에요. 주어진 문서가 답이 될 법한 top-k 쿼리를 생성하도록 훈련된 Text-to-Text Transfer Transformer (T5) 모델에 기반해요. 이렇게 예측된 짧은 쿼리들(최대 ~50-60 단어)에는 반복이 있을 수 있어서, 리트리버가 용어 빈도를 고려한다면 용어 빈도에도 기여해요.
docT5query 확장의 문제는 추론 시간이 아주 길다는 거예요. 어떤 생성 모델이든 마찬가지로, 한 번 실행에 하나의 토큰만 생성할 수 있고 상당한 리소스를 그에 써요.
Term Independent Likelihood MODel (TILDE)를 이용한 외부 문서 확장

Term Independent Likelihood MODel (TILDE)은 외부 확장 방법으로, docT5query에 비해 패시지 확장 시간을 98% 줄여요. 텍스트의 단어들이 서로 독립적이라는 가정을 사용해(마치 말하는 데 단어 순서에 신경 쓰지 않고 단어를 끼워 넣는 것처럼) 문서 확장의 병렬화를 가능하게 해요.
TILDE는 쿼리를 예측하는 대신, 패시지 텍스트를 읽은 뒤 다음에 볼 가장 그럴듯한 용어를 예측해요(쿼리 가능성 패러다임). 문서 텍스트를 기반으로 BERT 어휘의 모든 토큰에 대한 확률 분포를 구해, 그중 top-k를 반복 없이 문서에 덧붙여요.
외부 문서 확장의 문제점: 외부 문서 확장은 데이터베이스에 저장하려는 모든 문서를 확장하고 리트리버에 필요한 계산을 추가로 수행할 시간이라면 compute가 충분하지 않은 많은 프로덕션 시나리오에서 실현 가능하지 않을 수 있어요. 이 문제를 해결하기 위해, 문서를 "내부적으로" 확장하며 모든 것을 한 번에 처리하는 모델 세대가 개발됐어요.
내부 문서 확장
쿼리 용어의 맥락은 신경 쓰지 않는다고 가정해 보세요. 그러면 그것들을 임의의 순서로 결합해 결과를 얻는 독립적인 단어로 취급할 수 있어요. 그러면 문서의 각 맥락화된 용어에 대해, 그 용어가 우리 어휘의 모든 단어에 어떻게 영향을 주는지 미리 계산할 자유가 있어요.
각 문서에 대해 어휘 길이의 벡터가 만들어져요. 이 벡터를 채우기 위해 어휘의 각 단어에 대해, 어떤 문서 용어의 영향이 그것을 고려할 만큼 충분히 큰지 확인해요. 그렇지 않으면 문서 벡터에서 그 어휘 단어의 점수는 0이 돼요. 예를 들어 가장 흔한 영어 단어 50,000개의 어휘에 대해 "pizza Margherita" 문서의 벡터를 미리 계산하면, 두 단어짜리 작은 문서인데도 50,000차원의 0 벡터를 얻어요. 0이 아닌 값은 "pizza", "pizzeria", "flower", "woman", "girl", "Margherita", "cocktail", "pizzaiolo" 같은 것들에만 있을 거예요.
내부 문서 확장을 하는 희소 신경 리트리버

Sparse Transformer Matching (SPARTA) 모델의 저자들은 BERT 모델과 BERT 어휘(약 30,000 토큰)를 사용해요. BERT 어휘의 각 토큰에 대해, 그것과 문서의 맥락화된 토큰들 사이의 최대 dot product를 찾고 상당한(0이 아닌) 영향의 임계값을 학습해요. 그다음 추론 시점에는 그 문서에서 쿼리 토큰들의 점수를 모두 합하기만 하면 돼요.
왜 SPARTA는 완벽한 솔루션이 아닐까? MS MARCO 데이터셋으로 훈련된 많은 희소 신경 리트리버(SPARTA 포함)는 MS MARCO 테스트 데이터에서 좋은 결과를 보이지만, 일반화(다른 데이터로 작업)와 관련해서는 BM25보다 나쁘게 동작할 수 있어요.
현대 희소 신경 검색의 SOTA

Sparse Lexical and Expansion Model (SPLADE) 모델군의 저자들은 내부 문서 확장 아이디어에 밀집 모델 훈련 기법을 더했고, 검색 품질이 눈에 띄게 좋아졌어요.
- SPARTA 모델은 구조상 충분히 희소하지 않아서, SPLADE 모델군의 저자들은 명시적 **희소성 정규화(sparsity regularisation)**를 도입해 모델이 너무 많은 0이 아닌 값을 만들지 못하게 했어요.
- SPARTA 모델은 대부분 BERT 모델을 그대로 사용하고 정보 검색 문제의 특수성을 포착할 추가 신경망이 없어서, SPLADE 모델은 BERT 위에 특정 아키텍처 선택으로 작업에 완벽히 맞는 훈련 가능한 신경망을 도입해요.
- SPLADE 모델군은 마지막으로 **지식 증류(knowledge distillation)**를 사용해, 더 큰(그래서 훨씬 느리고 프로덕션 작업에 그리 적합하지 않은) 모델에서 좋은 표현을 예측하는 법을 배워요.
SPLADE 모델군의 마지막 버전 중 하나는 SPLADE++예요. SPLADE++는 SPARTA 모델과 달리 추론 시점에 문서뿐 아니라 쿼리도 확장해요. 다음 섹션에서 이걸 보여 드릴게요.
Qdrant에서 SPLADE++ 사용하기
Qdrant에서는 임베딩용 경량 라이브러리인 FastEmbed로 SPLADE++를 쉽게 쓸 수 있어요.
설치 (Setup)
FastEmbed를 설치합니다.
pip install fastembed
FastEmbed에서 지원하는 희소 텍스트 임베딩 모델을 import합니다.
from fastembed import SparseTextEmbedding
현재 지원되는 모든 희소 텍스트 임베딩 모델을 나열할 수 있어요.
SparseTextEmbedding.list_supported_models()
지원 모델 목록 출력
[{'model': 'prithivida/Splade_PP_en_v1',
'vocab_size': 30522,
'description': 'Independent Implementation of SPLADE++ Model for English',
'size_in_GB': 0.532,
'sources': {'hf': 'Qdrant/SPLADE_PP_en_v1'},
'model_file': 'model.onnx'},
{'model': 'prithvida/Splade_PP_en_v1',
'vocab_size': 30522,
'description': 'Independent Implementation of SPLADE++ Model for English',
'size_in_GB': 0.532,
'sources': {'hf': 'Qdrant/SPLADE_PP_en_v1'},
'model_file': 'model.onnx'},
{'model': 'Qdrant/bm42-all-minilm-l6-v2-attentions',
'vocab_size': 30522,
'description': 'Light sparse embedding model, which assigns an importance score to each token in the text',
'size_in_GB': 0.09,
'sources': {'hf': 'Qdrant/all_miniLM_L6_v2_with_attentions'},
'model_file': 'model.onnx',
'additional_files': ['stopwords.txt'],
'requires_idf': True},
{'model': 'Qdrant/bm25',
'description': 'BM25 as sparse embeddings meant to be used with Qdrant',
'size_in_GB': 0.01,
'sources': {'hf': 'Qdrant/bm25'},
'model_file': 'mock.file',
'additional_files': ['arabic.txt',
'azerbaijani.txt',
'basque.txt',
'bengali.txt',
'catalan.txt',
'chinese.txt',
'danish.txt',
'dutch.txt',
'english.txt',
'finnish.txt',
'french.txt',
'german.txt',
'greek.txt',
'hebrew.txt',
'hinglish.txt',
'hungarian.txt',
'indonesian.txt',
'italian.txt',
'kazakh.txt',
'nepali.txt',
'norwegian.txt',
'portuguese.txt',
'romanian.txt',
'russian.txt',
'slovene.txt',
'spanish.txt',
'swedish.txt',
'tajik.txt',
'turkish.txt'],
'requires_idf': True}]
SPLADE++를 로드합니다.
sparse_model_name = "prithivida/Splade_PP_en_v1"
sparse_model = SparseTextEmbedding(model_name=sparse_model_name)
모델 파일이 진행률을 보여 주며 fetch·다운로드됩니다.
데이터 임베딩하기
장난감 영화 설명 데이터셋을 사용할게요.
영화 설명 데이터셋
descriptions = ["In 1431, Jeanne d'Arc is placed on trial on charges of heresy. The ecclesiastical jurists attempt to force Jeanne to recant her claims of holy visions.",
"A film projectionist longs to be a detective, and puts his meagre skills to work when he is framed by a rival for stealing his girlfriend's father's pocketwatch.",
"A group of high-end professional thieves start to feel the heat from the LAPD when they unknowingly leave a clue at their latest heist.",
"A petty thief with an utter resemblance to a samurai warlord is hired as the lord's double. When the warlord later dies the thief is forced to take up arms in his place.",
"A young boy named Kubo must locate a magical suit of armour worn by his late father in order to defeat a vengeful spirit from the past.",
"A biopic detailing the 2 decades that Punjabi Sikh revolutionary Udham Singh spent planning the assassination of the man responsible for the Jallianwala Bagh massacre.",
"When a machine that allows therapists to enter their patients' dreams is stolen, all hell breaks loose. Only a young female therapist, Paprika, can stop it.",
"An ordinary word processor has the worst night of his life after he agrees to visit a girl in Soho whom he met that evening at a coffee shop.",
"A story that revolves around drug abuse in the affluent north Indian State of Punjab and how the youth there have succumbed to it en-masse resulting in a socio-economic decline.",
"A world-weary political journalist picks up the story of a woman's search for her son, who was taken away from her decades ago after she became pregnant and was forced to live in a convent.",
"Concurrent theatrical ending of the TV series Neon Genesis Evangelion (1995).",
"During World War II, a rebellious U.S. Army Major is assigned a dozen convicted murderers to train and lead them into a mass assassination mission of German officers.",
"The toys are mistakenly delivered to a day-care center instead of the attic right before Andy leaves for college, and it's up to Woody to convince the other toys that they weren't abandoned and to return home.",
"A soldier fighting aliens gets to relive the same day over and over again, the day restarting every time he dies.",
"After two male musicians witness a mob hit, they flee the state in an all-female band disguised as women, but further complications set in.",
"Exiled into the dangerous forest by her wicked stepmother, a princess is rescued by seven dwarf miners who make her part of their household.",
"A renegade reporter trailing a young runaway heiress for a big story joins her on a bus heading from Florida to New York, and they end up stuck with each other when the bus leaves them behind at one of the stops.",
"Story of 40-man Turkish task force who must defend a relay station.",
"Spinal Tap, one of England's loudest bands, is chronicled by film director Marty DiBergi on what proves to be a fateful tour.",
"Oskar, an overlooked and bullied boy, finds love and revenge through Eli, a beautiful but peculiar girl."]
SPLADE++로 영화 설명을 임베딩합니다.
sparse_descriptions = list(sparse_model.embed(descriptions))
SPLADE++가 만든 희소 벡터가 Qdrant에서 어떻게 보이는지 확인할 수 있어요.
sparse_descriptions[0]
이것은 BERT 토큰의 indices(가중치가 0이 아닌)와 이 가중치들의 values로 저장돼요.
SparseEmbedding(
values=array([1.57449973, 0.90787691, ..., 1.21796167, 1.1321187]),
indices=array([ 1040, 2001, ..., 28667, 29137])
)
임베딩을 Qdrant에 업로드하기
qdrant-client를 설치합니다.
pip install qdrant-client
Qdrant Client에는 작은 데이터 볼륨에서 로컬로 실험할 수 있는 간단한 인메모리 모드가 있어요. 또는 실험에 Qdrant Cloud의 무료 티어 클러스터를 쓸 수 있어요.
from qdrant_client import QdrantClient, models
qdrant_client = QdrantClient(":memory:") # Qdrant is running from RAM.
이제 희소 SPLADE++ 임베딩을 업로드할 컬렉션을 만들어 볼게요. 이를 위해 Qdrant가 지원하는 희소 벡터 표현을 사용합니다.
qdrant_client.create_collection(
collection_name="movies",
vectors_config={},
sparse_vectors_config={
"film_description": models.SparseVectorParams(),
},
)
이 컬렉션을 사람이 읽을 수 있게 만들기 위해, 영화 메타데이터(이름, 설명, 영화 길이)를 임베딩과 함께 저장해 볼게요.
영화 메타데이터
metadata = [{"movie_name": "The Passion of Joan of Arc", "movie_watch_time_min": 114, "movie_description": "In 1431, Jeanne d'Arc is placed on trial on charges of heresy. The ecclesiastical jurists attempt to force Jeanne to recant her claims of holy visions."},
{"movie_name": "Sherlock Jr.", "movie_watch_time_min": 45, "movie_description": "A film projectionist longs to be a detective, and puts his meagre skills to work when he is framed by a rival for stealing his girlfriend's father's pocketwatch."},
{"movie_name": "Heat", "movie_watch_time_min": 170, "movie_description": "A group of high-end professional thieves start to feel the heat from the LAPD when they unknowingly leave a clue at their latest heist."},
{"movie_name": "Kagemusha", "movie_watch_time_min": 162, "movie_description": "A petty thief with an utter resemblance to a samurai warlord is hired as the lord's double. When the warlord later dies the thief is forced to take up arms in his place."},
{"movie_name": "Kubo and the Two Strings", "movie_watch_time_min": 101, "movie_description": "A young boy named Kubo must locate a magical suit of armour worn by his late father in order to defeat a vengeful spirit from the past."},
{"movie_name": "Sardar Udham", "movie_watch_time_min": 164, "movie_description": "A biopic detailing the 2 decades that Punjabi Sikh revolutionary Udham Singh spent planning the assassination of the man responsible for the Jallianwala Bagh massacre."},
{"movie_name": "Paprika", "movie_watch_time_min": 90, "movie_description": "When a machine that allows therapists to enter their patients' dreams is stolen, all hell breaks loose. Only a young female therapist, Paprika, can stop it."},
{"movie_name": "After Hours", "movie_watch_time_min": 97, "movie_description": "An ordinary word processor has the worst night of his life after he agrees to visit a girl in Soho whom he met that evening at a coffee shop."},
{"movie_name": "Udta Punjab", "movie_watch_time_min": 148, "movie_description": "A story that revolves around drug abuse in the affluent north Indian State of Punjab and how the youth there have succumbed to it en-masse resulting in a socio-economic decline."},
{"movie_name": "Philomena", "movie_watch_time_min": 98, "movie_description": "A world-weary political journalist picks up the story of a woman's search for her son, who was taken away from her decades ago after she became pregnant and was forced to live in a convent."},
{"movie_name": "Neon Genesis Evangelion: The End of Evangelion", "movie_watch_time_min": 87, "movie_description": "Concurrent theatrical ending of the TV series Neon Genesis Evangelion (1995)."},
{"movie_name": "The Dirty Dozen", "movie_watch_time_min": 150, "movie_description": "During World War II, a rebellious U.S. Army Major is assigned a dozen convicted murderers to train and lead them into a mass assassination mission of German officers."},
{"movie_name": "Toy Story 3", "movie_watch_time_min": 103, "movie_description": "The toys are mistakenly delivered to a day-care center instead of the attic right before Andy leaves for college, and it's up to Woody to convince the other toys that they weren't abandoned and to return home."},
{"movie_name": "Edge of Tomorrow", "movie_watch_time_min": 113, "movie_description": "A soldier fighting aliens gets to relive the same day over and over again, the day restarting every time he dies."},
{"movie_name": "Some Like It Hot", "movie_watch_time_min": 121, "movie_description": "After two male musicians witness a mob hit, they flee the state in an all-female band disguised as women, but further complications set in."},
{"movie_name": "Snow White and the Seven Dwarfs", "movie_watch_time_min": 83, "movie_description": "Exiled into the dangerous forest by her wicked stepmother, a princess is rescued by seven dwarf miners who make her part of their household."},
{"movie_name": "It Happened One Night", "movie_watch_time_min": 105, "movie_description": "A renegade reporter trailing a young runaway heiress for a big story joins her on a bus heading from Florida to New York, and they end up stuck with each other when the bus leaves them behind at one of the stops."},
{"movie_name": "Nefes: Vatan Sagolsun", "movie_watch_time_min": 128, "movie_description": "Story of 40-man Turkish task force who must defend a relay station."},
{"movie_name": "This Is Spinal Tap", "movie_watch_time_min": 82, "movie_description": "Spinal Tap, one of England's loudest bands, is chronicled by film director Marty DiBergi on what proves to be a fateful tour."},
{"movie_name": "Let the Right One In", "movie_watch_time_min": 114, "movie_description": "Oskar, an overlooked and bullied boy, finds love and revenge through Eli, a beautiful but peculiar girl."}]
임베딩된 설명을 영화 메타데이터와 함께 컬렉션에 업로드합니다.
qdrant_client.upsert(
collection_name="movies",
points=[
models.PointStruct(
id=idx,
payload=metadata[idx],
vector={
"film_description": models.SparseVector(
indices=vector.indices,
values=vector.values
)
},
)
for idx, vector in enumerate(sparse_descriptions)
],
)
내장 FastEmbed 통합을 사용해 희소 벡터를 암시적으로 생성할 수도 있어요.
희소 벡터 암시적 생성 (Click to expand)
qdrant_client.upsert(
collection_name="movies",
points=[
models.PointStruct(
id=idx,
payload=metadata[idx],
vector={
"film_description": models.Document(
text=description, model=sparse_model_name
)
},
)
for idx, description in enumerate(descriptions)
],
)
쿼리하기
컬렉션을 쿼리해 볼게요!
query_embedding = list(sparse_model.embed("A movie about music"))[0]
response = qdrant_client.query_points(
collection_name="movies",
query=models.SparseVector(indices=query_embedding.indices, values=query_embedding.values),
using="film_description",
limit=1,
with_vectors=True,
with_payload=True
)
print(response)
희소 벡터 암시적 생성 (Click to expand)
response = qdrant_client.query_points(
collection_name="movies",
query=models.Document(text="A movie about music", model=sparse_model_name),
using="film_description",
limit=1,
with_vectors=True,
with_payload=True,
)
print(response)
출력은 이렇게 생겼어요.
points=[ScoredPoint(
id=18,
version=0,
score=9.6779785,
payload={
'movie_name': 'This Is Spinal Tap',
'movie_watch_time_min': 82,
'movie_description': "Spinal Tap, one of England's loudest bands,
is chronicled by film director Marty DiBergi on what proves to be a fateful tour."
},
vector={
'film_description': SparseVector(
indices=[1010, 2001, ..., 25316, 25517],
values=[0.49717945, 0.19760133, ..., 1.2124698, 0.58689135])
},
shard_key=None,
order_value=None
)]
보시다시피 쿼리와 찾은 영화 설명 사이에 겹치는 단어가 전혀 없는데도, 답이 쿼리에 들어맞아요. 그리고 우리는 여전히 정확 매칭(exact matching) 으로 작업하고 있어요. 이것이 가능한 이유는 SPLADE++가 수행하는 쿼리와 문서의 내부 확장(internal expansion) 덕분이에요.
SPLADE++의 내부 확장
SPLADE++가 답으로 얻은 쿼리와 문서를 어떻게 확장했는지 확인해 볼게요. 이를 위해 Tokenizers라는 HuggingFace 라이브러리를 쓸 거예요. 이걸로 SPLADE++가 사용하는 어휘의 단어 indices를 사람이 읽을 수 있는 형식으로 다시 디코딩할 수 있어요.
먼저 이 라이브러리를 설치해야 해요.
pip install tokenizers
그다음 SPLADE++ 희소 임베딩을 디코딩해 SPLADE++가 입력을 인코딩하는 데 쓴 단어들을 반환하는 함수를 작성해 볼게요. SPLADE++가 부여한 가중치(impact score)를 기준으로 내림차순으로 반환하고 싶어요.
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_pretrained('Qdrant/SPLADE_PP_en_v1')
def get_tokens_and_weights(sparse_embedding, tokenizer):
token_weight_dict = {}
for i in range(len(sparse_embedding.indices)):
token = tokenizer.decode([sparse_embedding.indices[i]])
weight = sparse_embedding.values[i]
token_weight_dict[token] = weight
# Sort the dictionary by weights
token_weight_dict = dict(sorted(token_weight_dict.items(), key=lambda item: item[1], reverse=True))
return token_weight_dict
먼저 함수를 쿼리에 적용해 볼게요.
query_embedding = list(sparse_model.embed("A movie about music"))[0]
print(get_tokens_and_weights(query_embedding, tokenizer))
SPLADE++가 쿼리를 이렇게 확장했어요.
{
"music": 2.764289617538452,
"movie": 2.674748420715332,
"film": 2.3489091396331787,
"musical": 2.276120901107788,
"about": 2.124547004699707,
"movies": 1.3825485706329346,
"song": 1.2893378734588623,
"genre": 0.9066758751869202,
"songs": 0.8926399946212769,
"a": 0.8900706768035889,
"musicians": 0.5638002157211304,
"sound": 0.49310919642448425,
"musician": 0.46415239572525024,
"drama": 0.462990403175354,
"tv": 0.4398191571235657,
"book": 0.38950803875923157,
"documentary": 0.3758136034011841,
"hollywood": 0.29099565744400024,
"story": 0.2697228491306305,
"nature": 0.25306591391563416,
"concerning": 0.205053448677063,
"game": 0.1546829640865326,
"rock": 0.11775632947683334,
"definition": 0.08842901140451431,
"love": 0.08636035025119781,
"soundtrack": 0.06807517260313034,
"religion": 0.053535860031843185,
"filmed": 0.025964470580220222,
"sounds": 0.0004048719711136073
}
그다음 함수를 답에 적용해 볼게요.
query_embedding = list(sparse_model.embed("A movie about music"))[0]
response = qdrant_client.query_points(
collection_name="movies",
query=models.SparseVector(indices=query_embedding.indices, values=query_embedding.values),
using="film_description",
limit=1,
with_vectors=True,
with_payload=True
)
print(get_tokens_and_weights(response.points[0].vector['film_description'], tokenizer))
희소 벡터 암시적 생성 (Click to expand)
response = qdrant_client.query_points(
collection_name="movies",
query=models.Document(text="A movie about music", model=sparse_model_name),
using="film_description",
limit=1,
with_vectors=True,
with_payload=True,
)
print(get_tokens_and_weights(response.points[0].vector["film_description"], tokenizer))
SPLADE++가 답을 이렇게 확장했어요.
{'spinal': 2.6548674, 'tap': 2.534881, 'marty': 2.223297, '##berg': 2.0402722,
'##ful': 2.0030282, 'fate': 1.935915, 'loud': 1.8381964, 'spine': 1.7507898,
'di': 1.6161551, 'bands': 1.5897619, 'band': 1.589473, 'uk': 1.5385966, 'tour': 1.4758654,
'chronicle': 1.4577943, 'director': 1.4423795, 'england': 1.4301306, '##est': 1.3025658,
'taps': 1.2124698, 'film': 1.1069428, '##berger': 1.1044296, 'tapping': 1.0424755, 'best': 1.0327196,
'louder': 0.9229055, 'music': 0.9056678, 'directors': 0.8887502, 'movie': 0.870712, 'directing': 0.8396196,
'sound': 0.83609974, 'genre': 0.803052, 'dave': 0.80212915, 'wrote': 0.7849579, 'hottest': 0.7594193, 'filmed': 0.750105,
'english': 0.72807616, 'who': 0.69502294, 'tours': 0.6833075, 'club': 0.6375339, 'vertebrae': 0.58689135, 'chronicles': 0.57296354,
'dance': 0.57278687, 'song': 0.50987065, ',': 0.49717945, 'british': 0.4971719, 'writer': 0.495709, 'directed': 0.4875775,
'cork': 0.475757, '##i': 0.47122696, '##band': 0.46837863, 'most': 0.44112885, '##liest': 0.44084555, 'destiny': 0.4264851,
'prove': 0.41789067, 'is': 0.40306947, 'famous': 0.40230379, 'hop': 0.3897451, 'noise': 0.38770816, '##iest': 0.3737782,
'comedy': 0.36903998, 'sport': 0.35883865, 'quiet': 0.3552795, 'detail': 0.3397654, 'fastest': 0.30345848, 'filmmaker': 0.3013101,
'festival': 0.28146765, '##st': 0.28040633, 'tram': 0.27373192, 'well': 0.2599603, 'documentary': 0.24368097, 'beat': 0.22953634,
'direction': 0.22925079, 'hardest': 0.22293334, 'strongest': 0.2018861, 'was': 0.19760133, 'oldest': 0.19532987,
'byron': 0.19360808, 'worst': 0.18397793, 'touring': 0.17598206, 'rock': 0.17319143, 'clubs': 0.16090117,
'popular': 0.15969758, 'toured': 0.15917331, 'trick': 0.1530599, 'celebrity': 0.14458777, 'musical': 0.13888633,
'filming': 0.1363699, 'culture': 0.13616633, 'groups': 0.1340591, 'ski': 0.13049376, 'venue': 0.12992987,
'style': 0.12853126, 'history': 0.12696269, 'massage': 0.11969914, 'theatre': 0.11673525, 'sounds': 0.108338095,
'visit': 0.10516077, 'editing': 0.078659914, 'death': 0.066746496, 'massachusetts': 0.055702563, 'stuart': 0.0447934,
'romantic': 0.041140396, 'pamela': 0.03561337, 'what': 0.016409796, 'smallest': 0.010815808, 'orchestra': 0.0020691194}
확장 덕분에 쿼리와 문서가 "music", "film", "sounds" 등에서 겹치므로, 정확 매칭이 동작해요.
핵심 요점: 언제 희소 신경 모델을 검색에 선택할까
희소 신경 검색이 타당한 경우는:
-
키워드 매칭이 중요하지만 초기 검색에 BM25가 부족한 영역에서, 의미론적 매칭(예: 동의어, 동음이의어)이 상당한 가치를 더해요. 이는 의학, 학계, 법률, 전자상거래처럼 브랜드명과 일련번호가 중요한 역할을 하는 분야에서 특히 그렇죠. 밀집 리트리버는 거짓 양성(false positive)을 많이 돌려주는 경향이 있는데, 희소 신경 검색은 그 거짓 양성을 좁히는 데 도움을 줘요.
-
희소 신경 검색은 확장, 특히 큰 데이터셋 작업 시 유용한 옵션이 될 수 있어요. 역색인을 사용한 정확 매칭을 활용하는데, 이는 데이터의 특성에 따라 빠를 수 있어요.
-
전통적인 검색 시스템을 쓰고 있다면, 희소 신경 검색은 그것들과 호환되고 의미론적 간극을 메우는 데 도움을 줘요.
더 알아보기 (Learn more)
- miniCOIL 아티클 — 실용적인 희소 신경 검색을 향한 Qdrant의 시도
- BM42 아티클 — BM25 개량
- FastEmbed 문서 — SPLADE++ 등 임베딩 모델
- 희소 벡터 (Qdrant 문서)
- Qdrant Cloud 무료 티어 클러스터