제목·초록·청크에 걸친 다중 표현 검색
제목·초록·청크에 걸친 다중 표현 검색 (multi-representation-search)
| 시간: 45분 | 난이도: 중급 | 출력: GitHub |
|---|
하나의 문서를 단일 임베딩으로 잘 표현하기는 어려워요. 논문 하나에도 제목, 초록, 본문 청크, 카테고리 태그가 있고, 각각 다른 신호를 담고 있어요. 이 넷을 하나의 dense 벡터로 처리하면 제목은 평균에 묻혀 버리고, 하류 추론을 위한 청크 수준의 근거(grounding)도 사라져요.
이 튜토리얼은 각 표현을 의도적으로 활용하는 검색을 구축해요. 표현마다 named vector를 만들고, Query API로 퓨전한 뒤, 문서 수준으로 그룹핑해서 제시해요.
이 튜토리얼은 하이브리드(dense + sparse) 검색을 만들어 본 적 있고, named vector, Query API, Reciprocal Rank Fusion(RRF), BM25에 익숙하다고 가정해요. 하이브리드 검색이 처음이라면 먼저 Text Search 가이드의 하이브리드 검색 섹션부터 시작하세요.
코드로 읽고 싶다면 동반 노트북이 각 단계의 평가 수치와 함께 파이프라인을 단계별로 안내해요.
설정 (Setup)
튜토리얼 전반에 쓰는 Python 패키지를 설치해요:
pip install qdrant-client datasets
이 튜토리얼은 임베딩을 서버 측에서 생성하는 Qdrant Cloud Inference를 사용해요. 무료 티어가 이 튜토리얼의 사용량을 충당해요. 핵심 BM25는 어떤 Qdrant 인스턴스에서도 돌아가지만, dense Cloud Inference는 Cloud 전용이에요. 셀프호스팅하려면 FastEmbed 같은 라이브러리로 dense 벡터를 클라이언트에서 생성하고 models.Document 대신 원시 벡터로 전달하세요.
데이터셋 (Dataset)
gfissore/arxiv-abstracts-2021 데이터셋의 ML/CS arXiv 논문 20,000개(2018년 이후)로 작업해요. 각 논문에는 제목, 초록, 카테고리 태그가 있어서, 초록을 청킹하면 세 가지 텍스트 표현이 되고 카테고리는 필터 가능한 메타데이터가 돼요.
arXiv 초록은 어떤 dense 모델의 컨텍스트 윈도우에도 들어가므로, 이 데이터셋에는 청킹이 엄격히 필수는 아니에요. 그래도 청킹하는 이유는, 파이프라인 형태(청크 수준 검색, 문서 수준 그룹핑)가 프로덕션에서 전체 논문 본문에 쓸 것이기 때문이에요. 고정 길이 문장 청커가 예제를 단순하게 유지해 주지만, 올바른 전략은 문서 구조에 따라 달라져요.
from datasets import load_dataset
ML_CATEGORIES = {"cs.LG", "cs.CV", "cs.CL", "cs.AI", "stat.ML"}
# 비-스트리밍이라 HF가 parquet을 로컬에 캐시. 첫 실행은 ~2.5GB 다운로드, 이후는 즉시.
dataset = load_dataset("gfissore/arxiv-abstracts-2021", split="train")
papers = []
# ID가 대략 시간순이므로 끝에서부터 반복해 2021/2020/2019 논문을 먼저 확보
for i in range(len(dataset) - 1, -1, -1):
if len(papers) >= 20000:
break
row = dataset[i]
if not row["abstract"] or not row["title"]:
continue
# 카테고리가 spa... (생략)
컬렉션 스키마 (Collection Schema)
쿼리를 작성하기 전에 컬렉션을 설계해요. 포인트 단위는 청크예요. 모든 논문의 각 청크가 포인트 하나가 돼요. 제목과 초록 임베딩은 모든 청크에 저장돼서, Query API가 추가 조회 없이 단일 요청으로 이들을 퓨전할 수 있어요.
from qdrant_client import QdrantClient, models
# url과 api_key를 https://cloud.qdrant.io 의 것으로 교체
client = QdrantClient(
url="https://xyz-example.qdrant.io:6333",
api_key="<your-api-key>",
cloud_inference=True,
)
# 384는 아래 모든 dense 벡터에 쓰는 sentence-transformers/all-minilm-l6-v2의 출력 차원
client.create_collection(
collection_name="arxiv_multi_repr",
vectors_config={
"dense_chunk": models.VectorParams(size=384, distance=models.Distance.COSINE),
"dense_title": models.VectorParams(size=384, distance=models.Distance.COSINE),
"dense_abstract": models.VectorParams(size=384, distance=models.Distance.COSINE),
},
sparse_vectors_config={
"sparse_title": models.SparseVectorParams(
modifier=models.Modifier.IDF,
),
},
)
# 'document_id'를 인덱싱해 Query API가 그룹핑할 수 있게 하고, 'tags'를 인덱싱해 카테고리로 필터링
client.create_payload_index(
collection_name="arxiv_multi_repr",
field_name="document_id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="arxiv_multi_repr",
field_name="tags",
field_schema=models.PayloadSchemaType.KEYWORD,
)
각 벡터는 서로 다른 신호를 다뤄요: dense_chunk는 청크 내용, dense_title과 sparse_title은 제목(dense/lexical 각각), dense_abstract는 초록 전체.
카테고리는 tags 페이로드에 keyword index와 함께 있어 쿼리가 카테고리로 사전 필터링할 수 있어요.
제목과 초록 벡터는 같은 문서의 모든 청크에 중복 저장돼요. 저장 공간을 쿼리 단순함으로 맞바꾸는 셈이에요: 컬렉션 하나, Query API 호출 하나면 모든 표현이 어떤 포인트에서든 도달 가능해요.
업로드가 끝나면 Qdrant Cloud UI에서 포인트를 열면 네 개의 named vector가 한 청크에 붙어 있는 걸 볼 수 있어요. dense_chunk는 청크 자신의 임베딩을 담고, dense_title, dense_abstract, sparse_title은 이 논문의 모든 청크에 걸쳐 동일해요.
검색 (Retrieval)
권장 파이프라인은 네 개의 prefetch를 Reciprocal Rank Fusion으로 퓨전하고 결과를 문서별로 그룹핑해요. 검색, 퓨전, 그룹핑이 Query API 호출 하나로 처리돼요:
def retrieve(query, limit=10, group_size=3, tags=None):
dense_query = models.Document(text=query, model=DENSE_MODEL)
sparse_query = models.Document(text=query, model=BM25_MODEL)
# 선택적 카테고리 필터. tags가 주어지면 Qdrant가 후보를 'tags' 페이로드...
...
return client.query_points_groups(
collection_name="arxiv_multi_repr",
prefetch=[
models.Prefetch(query=dense_query, using="dense_chunk", limit=100),
models.Prefetch(query=dense_query, using="dense_title", limit=100),
models.Prefetch(query=dense_query, using="dense_abstract", limit=100),
models.Prefetch(query=sparse_query, using="sparse_title", limit=100),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
query_filter=query_filter,
group_by="document_id",
group_size=group_size,
limit=limit,
).groups
query_points_groups는 prefetch를 실행하고 RRF로 퓨전하며 필터를 적용하고 문서별로 결과를 그룹핑해요. 문서당 청크 수에 따라 prefetch별 limit를 조정해야 할 수 있어요. 그룹핑은 prefetch가 반환한 것만 봐요.
무엇을 prefetch할까
dense_chunk— 본문 내용을 담아요.dense_title— 주제 명명(topical naming)을 담아요. 이 스키마에서 제목은 청크나 초록 텍스트의 일부가 아니므로, 본문이 반복하지 않는 제목 어휘와 매치되는 쿼리는 이 prefetch가 있어야 문서를 표면화할 수 있어요.dense_abstract— 초록을 완전한 의미 단위로 담는 반면dense_chunk는 문서 자체의 더 작은 구간을 인덱싱해요.sparse_title— dense 임베딩이 평균에 묻는 어휘적 히트를 담아요: 희귀 엔티티 이름, 전문 용어, 특정 모델·논문 이름.
어떤 퓨전을 쓸까
Reciprocal Rank Fusion은 raw 점수가 아니라 문서 순위로 prefetch를 결합해서, dense와 sparse 점수가 서로 다른 척도에서 동작할 때 생기는 문제를 피해요. 이 튜토리얼의 기본 설정이자 시작하기 좋은 지점이에요. 일부 시나리오에서는 아래 변형들이 더 강한 결과를 낼 수 있어요.
RRF만으로 부족할 때:
- 가중 RRF (Weighted RRF) — 순위 퓨전 공식에 prefetch별 가중치를 둬요. 데이터에서 한 경로가 확실히 더 강할 때 사용. 가중치는 검증 세트로 튜닝해야 해요. 한 쿼리 클래스를 돕는 가중치가 다른 클래스를 해칠 수 있거든요.
- 분포 기반 점수 퓨전 (DBSF) — 퓨전 전에 각 prefetch의 점수를 공통 범위로 정규화해요. prefetch들이 같은 모델 계열을 공유하고 점수 분포가 안정적일 때 RRF가 버리는 점수 크기(magnitude)를 보존해요.
FormulaQuery로 커스텀 공식 — 점수 결합을 완전 제어:
query=models.FormulaQuery(
formula=models.SumExpression(sum=[
models.MultExpression(mult=[1.0, "$score[0]"]),
models.MultExpression(mult=[0.5, "$score[1]"]),
models.MultExpression(mult=[0.4, "$score[2]"]),
models.MultExpression(mult=[0.3, "$score[3]"]),
]),
)
이 공식에서 $score[i]는 prefetch i의 점수이므로 prefetch= 목록의 순서가 중요해요. defaults 맵은 모든 prefetch에 나타나지 않은 후보에 대한 대체값(여기선 0.0)을 제공해서 공식이 계속 평가되게 해요.
다른 두 퓨전 전략은 이걸 알아서 처리해 줘요: RRF는 점수를 완전히 버리고, DBSF는 합산 전에 각 prefetch를 정규화해요. 커스텀 공식에서는 점수를 직접 정규화해야 하는데, 보통 decay functions를 써요. 전체 FormulaQuery 문법은 Score Boosting 레퍼런스에 있어요. RRF vs DBSF 가이드는 hybrid-search FAQ를 참고하세요.
언제 부스팅하고 언제 재랭킹할까
스코어 부스팅은 검색 점수만으로는 포착되지 않는 랭킹 선호를 표현해요: 최신성, 출처 권위, 지리적 근접성, 콘텐츠 유형. 페이로드 필드를 참조하는 용어로 FormulaQuery를 사용하세요. published_at 필드의 시간 기반 감쇠는 decay functions 레퍼런스의 exp_decay 표현을 쓰세요.
마무리 (Wrapping Up)
다중 표현 검색은 모델 결정이 아니라 스키마 결정이에요. 각 표현이 자신의 named vector를 갖게 되면 Query API가 쿼리 시점에 표현들을 조합해요: 표현별 prefetch, RRF로 퓨전, 문서별 그룹핑.
더 알아보기 (Learn more)
- Hybrid Search Revamped — 선형 가중치 대신 RRF를 쓰는 이유.
- Hybrid Queries 레퍼런스 — 그룹핑을 포함한 전체 Query API 표면.
- Search Relevance 레퍼런스 — formula와 decay 함수 문법.
- Qdrant 공식 문서 — Multi-Representation Search