
Qdrant Multivectors를 사용하여 ColBERT, SigLIP, BGE를 단일 Qdrant 포인트 내에서 활용한 다중 측면
요약
단일 임베딩의 한계를 극복하기 위해 ColBERT, SigLIP, BGE를 활용한 다중 벡터(multi-vector) 시맨틱 검색 엔진 구축 방법을 소개합니다. 제품의 사양, 이미지, 리뷰를 각각 별도의 벡터 공간에 저장하여 검색 정확도를 높이는 아키텍처를 다룹니다.
핵심 포인트
- 단일 벡터 압축 방식의 정보 손실 문제 해결
- ColBERT(사양), SigLIP(시각), BGE(리뷰)를 활용한 의도 분리
- Qdrant의 Multivectors 기능을 이용한 다중 측면 검색 구현
- 다양한 데이터 유형에 최적화된 전문화된 벡터 필드 활용
데이터베이스에 접근하기 전, ColBERT, SigLIP, BGE를 사용하여 사용자 의도를 분리하는 다중 벡터 (multi-vector) 시맨틱 검색 엔진을 어떻게 구축했는지 소개합니다.
6개월 전, 저는 소규모 이커머스 카탈로그를 위한 시맨틱 검색 엔진을 구축하고 있었습니다. 모든 것이 괜찮아 보였습니다. 좋은 임베딩 (embedding) 모델과 코사인 유사도 (cosine similarity)를 사용하고 있었고, 검색 결과도 합리적으로 보였습니다.
그러던 중 다음과 같이 검색했습니다:
"아치 지원이 좋은 방수 검정 하이킹 부츠 (Waterproof black hiking boots with good arch support)."
첫 번째 결과는 완벽했습니다.
두 번째 결과는 방수 재킷이었습니다.
세 번째 결과는 검정색 첼시 부츠 한 켤레였습니다.
기술적으로 검색이 틀린 것은 아니었습니다. 모든 결과가 쿼리 (query)와 시맨틱하게 유사했습니다.
하지만 제가 고객이라면 신경 쓰지 않았을 것입니다. 저는 아치 지원 기능이 있는 하이킹 부츠를 원했지, 단순히 몇 개의 유사한 단어를 공유하는 제품을 원한 것이 아니었습니다.
더 나은 임베딩 모델을 시도해 보았습니다. 청킹 (chunking) 전략을 변경했습니다. 더 많은 메타데이터 (metadata)를 추가했습니다. 결과가 약간 개선되었지만, 근본적인 문제는 사라지지 않았습니다.
문제는 모델이 아니었습니다.
하나의 임베딩이 제품 전체를 나타낼 수 있다는 가정이 문제였습니다.
제품에는 기술 사양, 이미지, 고객 리뷰가 있습니다. 이것들은 서로 다른 종류의 정보임에도 불구하고, 저는 이 모든 것을 단일 벡터 (single vector)로 압축하고 있었습니다.
그래서 시스템을 다시 구축했습니다.
제품당 하나의 임베딩을 사용하는 대신, 사양, 이미지, 리뷰 분석 결과에 대해 별도의 벡터 공간 (vector spaces)을 저장했습니다. 임베딩하기 전에 각 쿼리를 다양한 유형의 의도로 분리하고, 그 위에 가벼운 개인화 레이어 (personalization layer)를 추가했습니다.
이 글에서는 제가 이를 어떻게 구축했는지, 왜 이 아키텍처 (architecture)를 선택했는지, 그리고 이러한 추가적인 복잡성이 실제로 언제 가치가 있는지에 대해 설명합니다.
멘탈 모델 (The mental model): 코드 한 줄을 읽기 전에
전체 파이프라인 (pipeline)을 한눈에 보여드립니다. 이것을 한 번 읽고 기억해 두시면, 나머지 글의 내용을 훨씬 빠르게 이해하실 수 있습니다.
읽는 방법:
이 시스템을 동일한 제품을 평가하는 세 명의 전문가라고 생각하십시오.
사용자가 "아치 지원이 좋은 방수 검정색 하이킹 부츠"라고 검색하면, 쿼리 (query)는 세 가지 질문으로 나뉩니다:
- 방수가 되는가? → 사양 전문가 (ColBERT)
- 검정색 하이킹 부츠처럼 보이는가? → 시각 전문가 (SigLIP)
- 고객들이 아치 지원에 대해 찬사를 보내는가? → 리뷰 전문가 (BGE)
각 전문가는 동일한 Qdrant 포인트 (point) 내에 저장된 서로 다른 벡터 필드 (vector field)를 검색합니다. 텍스트 및 리뷰 검색이 먼저 후보를 지명하고, 시각 채널이 최종 점수 산정 (scoring)을 수행하며, 개인화 (personalization)가 결과를 재순위화 (rerank) 합니다.
하나의 임베딩 (embedding)이 모든 것을 나타내도록 강제하는 대신, 시스템은 독립적인 증거 조각들을 평가하고 랭킹 (ranking) 시점에 이를 결합합니다.
핵심 문제: 하나의 벡터는 모든 것을 표현할 수 없습니다
누군가가 "아치 지원이 좋은 방수 검정색 하이킹 부츠"를 검색할 때, 그들은 단 하나의 질문을 하는 것이 아닙니다. 그들은 동시에 세 가지 질문을 하고 있는 것입니다:
첫 번째는 사양에 관한 질문입니다: "이것은 방수가 되는가?" 이는 제품 설명과 기술 데이터 시트 (technical data sheets)를 통해 답변됩니다.
두 번째는 시각에 관한 질문입니다: "검정색 하이킹 부츠처럼 보이는가?" 이는 이미지에 의해 답변됩니다. 이미지에 대한 텍스트 설명이 아니라, 실제 이미지입니다.
세 번째는 사회적 질문입니다: "사람들이 아치 지원이 좋다고 말하는가?" 이는 고객 리뷰를 통해 답변됩니다.
어떤 단일 임베딩 모델도 이 세 가지 신호 (signals)를 동일한 충실도 (fidelity)로 포착할 수 없습니다. 이들을 하나로 합치면, 세 가지 모두의 근처에 대략적으로 위치하지만 그 어느 것 하나도 정밀하지 않은 벡터를 얻게 됩니다.
이것이 바로 하이킹 부츠 검색 결과에 첼시 부츠 (Chelsea boots)가 나타나는 이유입니다. 둘 다 검정색 신발이기 때문입니다. 시각적 신호는 잘 작동하고 있지만, 아치 지원 신호는 완전히 소실되었습니다.
해결책은 제품을 하나의 사물로 취급하는 것을 멈추고, 각각 독립적으로 검색 가능한 별개의 신호들의 집합으로 취급하기 시작하는 것입니다.
제가 Qdrant를 선택한 이유
결정을 내리기 전에 Pinecone, Weaviate, 그리고 Qdrant를 평가했습니다. 결정적인 요인은 이름이 지정된 다중 벡터 (named multivector) 필드였습니다.
문서당 각각 고유한 차원 (dimensionality)과 비교 함수 (comparison function)를 가진 여러 종류의 벡터를 저장해야 했습니다. Pinecone은 이를 네이티브로 지원하지 않습니다. Weaviate의 다중 벡터 (multivector) 기능은 제가 시작했을 당시 여전히 발전 중이었습니다.
Qdrant의 API는 깔끔합니다. 컬렉션 (collection) 생성 시점에 각각 다른 차원, 거리 측정 방식 (distance metrics), 그리고 HNSW 설정을 가진 이름이 지정된 벡터 설정 (named vector configs)을 정의할 수 있습니다.
저를 설득한 또 다른 요소는 update_vectors였습니다. 실제 운영 중인 카탈로그에서는 다음과 같은 상황이 발생합니다:
- 제품에 새로운 이미지가 추가됩니다. 컬렉션을 재구축하지 않고도 시각적 행렬 (visual matrix)을 확장해야 합니다.
- 리뷰가 매일 들어옵니다. 각 새로운 리뷰는 리뷰 행렬 (review matrix)에 행을 추가합니다.
Qdrant는 기존 벡터 필드를 가져오고, 새로운 벡터를 연결(concatenate)한 뒤, 업데이트된 행렬을 다시 푸시(push)할 수 있게 함으로써 이 두 가지를 모두 처리합니다. 컬렉션은 계속 유지되며, 쿼리 (query) 실행도 계속 작동합니다.
여기서는 버전이 중요합니다. 저는 qdrant-client>=1.15.0과 Qdrant v1.15.3 버전을 사용하고 있습니다. 다중 벡터 (Multivectors)는 v1.10부터 사용 가능했지만, 다단계 검색 (multi-stage search)을 깔끔하게 작동하게 만드는 query_points 프리페치 (prefetch) API는 v1.14에서 안정화되었습니다.
시스템이 저장하는 것: 하나의 포인트, 세 개의 행렬
모든 제품은 단일 Qdrant 포인트 (point)가 됩니다. 그 포인트는 세 개의 이름이 지정된 벡터 필드를 가집니다:
# src/commerce_engine/qdrant_store.py
VISUAL_VECTOR = "visual_vectors" # 768-d SigLIP 이미지 임베딩 (embedding)
...
세 가지 모두 비교자로 MAX_SIM을 사용합니다. 전체 컬렉션 설정은 다음과 같습니다:
def vector_params(size: int, *, hnsw_m: int | None = None) -> models.VectorParams:
hnsw_config = None
if hnsw_m is not None:
...
TEXT_VECTOR에 hnsw_m=0을 설정한 것은 의도적인 것입니다. m=0으로 설정하면 해당 필드에 대한 HNSW 그래프 인덱싱(indexing)이 완전히 비활성화됩니다.
보통 이는 성능 측면에서 재앙이 될 수 있습니다. 하지만 여기서는 두 가지 이유로 인해 그렇지 않습니다:
- 텍스트 벡터 (Text vectors)는 1단계 검색 (first-stage retrieval)에 사용되지 않습니다. 이들은 프리페치 (prefetch) 단계에서 이미 반환된 소수의 후보군 세트에 대해 ColBERT 재순위화기 (reranker)로 사용됩니다. 후보군이 20~50개 정도일 때는, 그래프 탐색 오버헤드가 절약되는 비용보다 더 크기 때문에 전수 조사 방식의 행렬 비교 (brute-force matrix comparison)가 HNSW보다 더 빠릅니다.
- 인덱스 메모리를 절약합니다. 토큰 레벨 (token-level) 행렬은 풀링된 벡터 (pooled vectors)보다 크기가 더 큽니다.
text_vectors에 대한 HNSW 그래프를 건너뛰면 메모리 사용량 (memory footprint)을 줄일 수 있으며, 이는 제품당 수천 개의 토큰에 걸쳐 96차원 행렬을 저장할 때 복합적으로 작용하여 큰 효과를 냅니다.
페이로드 (payload) 또한 별도로 인덱싱되어 Qdrant가 점수 산정 (scoring) 전에 필터링을 수행할 수 있습니다:
def create_payload_indexes(client, collection):
# 키워드 필드 (Keyword fields)
for field in ["brand", "category", "region", "color", "size", "product_id"]:
...
이는 보기보다 훨씬 중요합니다. 인덱스가 존재하면 Qdrant는 벡터 점수 산정 전에 페이로드 필터링 (payload filtering)을 실행합니다. availability=True 또는 price <= 150.0을 필터링하면 임베딩 비교가 실행되기 전에 후보군을 제거합니다. 인덱스가 없으면 사후 필터링 (post-filtering)이 수행되는데, 이는 더 느리고 일관되지 않은 결과 개수를 생성합니다. 쿼리를 실행하기 전에 필터링 가능한 필드들을 인덱싱하세요.
세 가지 임베딩 파이프라인 (The three embedding pipelines)
포인트 (point) 내의 각 벡터 필드는 서로 다른 임베딩 파이프라인 (embedding pipeline)에서 생성됩니다. 동일한 제품이 수집 (ingestion)되기 전에 세 가지 파이프라인을 모두 거칩니다.

시각적 파이프라인 (Visual pipeline): SigLIP
제품 이미지는 google/siglip-base-patch16-224를 거칩니다.
SigLIP은 이미지와 텍스트 임베딩 (embeddings)이 정렬된 시각-언어 모델 (vision-language model)입니다. 정렬되었다는 것은 "검은색 하이킹 부츠"와 같은 텍스트 쿼리를 인코딩하여 이미지 임베딩과 직접 비교할 수 있음을 의미합니다. 별도의 브릿지 모델 (bridge model)이나 별도의 정렬 단계가 필요하지 않습니다.
# src/commerce_engine/embeddings.py
def image_patches(self, image_path: Path) -> list[list[float]]:
...
그리고 그에 상응하는 텍스트 측면의 쿼리 인코딩은 다음과 같습니다:
import torch
def visual_query(self, query: str) -> list[list[float]]:
...
두 출력 모두 저장 및 쿼리 전에 L2 정규화 (L2-normalized)됩니다. 이를 통해 코사인 유사도 (cosine similarity)가 내적 (dot product)과 동일해지며, 이는 Qdrant가 계산하기에 더 빠릅니다.
각 제품에 대해 저장되는 형태 (shape)는 행이 하나인 행렬인 [1, 768]입니다. 기술적으로는 요소가 하나인 2D 멀티벡터 (multivector)이지만, 이를 평탄한 벡터 (flat vector) 대신 행렬로 저장함으로써 인터페이스를 일관되게 유지하고, 나중에 추가적인 이미지 패치 (image patches)를 추가하는 과정을 간단하게 만듭니다.
텍스트 파이프라인 (Text pipeline): ColBERT
제품 제목과 사양서 (spec sheets)는 FastEmbed의 answerdotai/answerai-colbert-small-v1를 거칩니다. 이 모델은 풀링 (pooling) 없이 토큰당 96차원의 토큰 수준 임베딩 (token-level embeddings)을 생성합니다.
def text_late(self, texts: list[str]) -> list[list[list[float]]]:
return [
embedding.astype(float).tolist()
...
반환 형태는 [num_texts, num_tokens, 96]입니다. 하나의 제품 제목에 대해 [num_tokens, 96] 형태의 행렬을 얻게 됩니다. "TrailForge StormShield Black Hiking Boots. support: nylon shank and molded arch support. upper: black ripstop textile. waterproof_rating: IPX6 waterproof membrane for heavy rain"와 같이 사양이 많은 제품은 약 25~30개의 토큰으로 토큰화되므로, 저장된 행렬은 [~28, 96]이 됩니다.
텍스트 문서는 Product 모델에서 조립됩니다:
# src/commerce_engine/models.py
def text_document(self) -> str:
...
제목을 먼저 작성한 다음, 모든 사양(spec)을 키-값(key-value) 쌍으로 포함합니다. 사양 키(support, waterproof_rating, upper)가 텍스트로 포함되어 있어, ColBERT가
하나의 제품. 하나의 포인트. 세 개의 행렬(matrices), 각각이 서로 다른 검색 문제를 해결합니다.
쿼리 분해(Query decomposition): 임베딩 전 의도 라우팅
사용자가 쿼리를 제출하면 첫 번째 단계는 분해(decomposition)입니다. 쿼리는 세 개의 벡터 필드 모두로 직접 전달되지 않습니다.
# src/commerce_engine/query.py
TEXT_KEYWORDS = {"waterproof", "water-resistant", "rain", "membrane", "insulated", "leather"}
...
"Waterproof black hiking boots with good arch support"라는 쿼리의 경우, 분해 결과는 다음과 같습니다:
text_terms = ["waterproof"]→ ColBERT 사양 매칭(spec matching)으로 라우팅visual_terms = ["black", "hiking", "boots"]→ SigLIP 시각적 매칭(visual matching)으로 라우팅review_terms = ["support", "arch", "good"]→ BGE 리뷰 매칭(review matching)으로 라우팅
QueryPlan 모델은 이러한 용어들을 다시 하위 쿼리 문자열(sub-query strings)로 결합하는 세 가지 속성을 제공합니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기