Qdrant에서 하이브리드 검색 튜닝하기

Qdrant에서 하이브리드 검색 튜닝하기 (How to Tune Hybrid Search in Qdrant)

퓨전을 튜닝하기 전에, 사전 튜닝 체크를 통해 인덱스 상태를 확인하고 라벨링된 베이스라인을 잡아 두세요.

하이브리드 검색은 밀집(dense) 후보 목록과 희소(sparse) 후보 목록을 가져온 뒤, 그것들을 하나의 랭킹으로 퓨전해요. 밀집 prefetch는 비슷한 의미를 찾고, 희소 prefetch는 일치하는 키워드를 찾아요. 퓨전은 prefetch가 돌려준 후보들을 재정렬하므로, 두 목록 모두에 없는 문서는 결과에 나타날 수 없어요.

출처: 공식문서

퓨전이 어느 한쪽 prefetch보다 낫다는 걸 먼저 확인하기

튜닝 전에, 밀집 검색 단독, 희소 검색 단독, 그리고 기본 Reciprocal Rank Fusion(RRF)을 k=2, 동일 가중치로 비교해 보세요. 세 가지 모두 nDCG@10으로 점수를 매겨요. 이 메트릭은 상위 10개 결과를 평가하면서, 상위에 가까운 관련 문서에 더 많은 점수를 줍니다.

Qdrant의 기본값은 k=2예요. 원래 RRF 논문은 60을 쓰는데, 이는 Qdrant의 수식에서 k=61에 해당해요. 그 간극이 바로 이 글의 대부분이 다루는 주제입니다.

참고: 이 글의 측정치는 코퍼스 크기, 문서·쿼리 형태, 관련성 작업이 서로 다르게 선택된 다섯 개의 공개 데이터셋을 사용하므로, 이 퓨전 델타는 방향성을 나타내는 것으로 읽어 주세요. 규모는 5,183에서 100,000 문서까지 다양해요. 각 컬렉션은 하나의 샤드에 한 배치로, 양자화나 필터 없이 구축됐고, 밀집 검색에는 all-MiniLM-L6-v2, 희소 검색에는 Qdrant 코어 BM25, 각 prefetch에서 200개의 후보를 사용했어요. 지속적 upsert와 옵티마이저 병합으로 형성된 그래프는 다른 랭킹을 돌려줄 수 있어요. 지연 시간 중앙값은 유휴 노트북의 Qdrant 컨테이너 하나에서 한 번에 요청 하나씩 측정한 것이므로, 여러분의 p95 예산·동시성·샤드 팬아웃 아래에서 다시 측정해 보세요. 라벨링된 집합 구축 문서에서 승리한 설정을 어떻게 보류된 쿼리로 재확인했는지 설명합니다.

Over the Better One은 기본 RRF의 nDCG@10에서 더 나은 개별 prefetch를 뺀 값이에요. Second Prefetch Cost는 두 번째 prefetch가 밀집 prefetch 단독에 비해 추가하는 중앙값 지연 시간이에요.

Dataset Dense Alone Sparse Alone Both, RRF (k=2) Over the Better One Second Prefetch Cost
SciFact 0.6239 0.6886 0.7175 +0.0289 +0.73 ms
ArguAna 0.4905 0.4224 0.5216 +0.0311 +1.47 ms
WANDS 0.6921 0.7098 0.7254 +0.0156 +0.60 ms
CodeSearchNet 0.6299 0.5126 0.6555 +0.0256 +0.68 ms
DBPedia-entity 0.4677 0.3857 0.4638 -0.0039 +0.64 ms

네 개 데이터셋에서 퓨전이 두 prefetch를 모두 이겼고, 각 이득의 95% 구간은 0을 제외해요. DBPedia-entity가 예외인데, 퓨전이 밀집 검색보다 0.0039 뒤지고 구간이 0을 가로질러요.

두 번째 prefetch는 또한 포인트당 두 번째 인덱스와 두 번째 벡터를 필요로 해요. 여러분 자신의 라벨에서 관련성을 개선할 때만 유지하세요.

RRF와 DBSF는 서로 다른 신호를 사용해요

Reciprocal Rank Fusion(RRF)은 각 prefetch에서 후보의 위치만 사용해요. 1위 문서는 2위를 큰 차이로 이겼든 좁은 차이로 이겼든 같은 점수를 받아요. Distribution-based score fusion(DBSF)은 각 쿼리에 대해 두 목록을 하나의 스케일 위에 놓는데, 각 목록의 평균 점수와 점수 분포(퍼짐)를 사용해요. 재조정된 두 점수를 더하면 리드(앞서는 차이)의 크기가 퓨전된 랭킹으로 전달되고, 한 prefetch만이 가져온 문서는 그 단일 재조정 점수를 유지해요.

Two panels of dot plots, RRF on the left and DBSF on the right. Each panel has a dense line, a sparse line, and a fused line holding documents A, B, C, and D. The RRF lines space every document evenly and label the slots 4, 3, 2, 1. The DBSF lines keep the raw score spacing on one shared axis, dense running 0.55 to 0.91 with A far out to the right and B, C, and D clustered, sparse running 12.9 to 14.8. The fused lines put B first under RRF and A first under DBSF.

RRF는 각 문서의 슬롯만 읽으므로 A의 밀집 리드가 한 단계로 평평해지고, 두 prefetch 모두에서 상위권에 있는 B가 이긴다. DBSF는 공유 축 위에 간격을 유지하므로 A의 리드가 합산에서 살아남아 A가 이긴다.

RRF는 점수 스케일을 무시해서, 코사인 유사도와 BM25 점수가 서로를 지배하지 않은 채 결합돼요. DBSF는 점수 간극의 크기가 무언가를 의미한다고 가정하므로, 이상치 하나가 결과를 움직일 수 있어요. 어느 쪽이 이기는지는 데이터에 달려 있으니, 여러분의 라벨로 둘 다 돌려보세요.

RRF와 DBSF를 여러분의 라벨로 비교하기

라벨링된 쿼리 집합을 사용해 같은 prefetch 위에서 RRF와 DBSF를 비교해 보세요. RRF를 k=2, 동일 가중치로 돌린 다음 DBSF를 돌리세요.

두 쿼리는 같은 두 후보 목록을 읽으므로, 한 번 연결하고 prefetch를 한 번만 만들면 돼요. prefetch는 컬렉션이 인덱스된 모델을 사용해야 합니다.

from qdrant_client import QdrantClient, models
from your_embedding_setup import dense_query, sparse_query

client = QdrantClient(
    url="https://YOUR-CLUSTER.cloud.qdrant.io",
    api_key="<your-api-key>",
)

dense_prefetch = models.Prefetch(query=dense_query, using="dense", limit=200)
sparse_prefetch = models.Prefetch(query=sparse_query, using="bm25", limit=200)
prefetches = [dense_prefetch, sparse_prefetch]

RrfQuery는 RRF 설정 두 가지, k와 가중치 쌍을 담아요. 여기선 기본값으로 보여 줍니다. Qdrant v1.17 이상과 호환되는 qdrant-client 릴리스가 필요해요.

rrf_response = client.query_points(
    collection_name="products",
    prefetch=prefetches,
    query=models.RrfQuery(rrf=models.Rrf(k=2, weights=[1.0, 1.0])),
    limit=10,
)

DBSF 쿼리는 퓨전 단계에서만 다릅니다.

dbsf_response = client.query_points(
    collection_name="products",
    prefetch=prefetches,
    query=models.FusionQuery(fusion=models.Fusion.DBSF),
    limit=10,
)

멀티샤드 컬렉션에서는 각 샤드가 자기만의 prefetch limit을 적용해요. 루트 레벨 퓨전에서는 Qdrant가 그 후보들을 샤드 전체에서 결합해요. 더 큰 limit은 퓨전에 더 많은 후보를 노출시킬 수 있지만, 검색 작업량과 다운스트림 리랭커의 후보도 늘려요. prefetch 안에 중첩된 퓨전은 샤드별로 실행되고, DBSF는 각 샤드 자체 후보의 점수 분포에 맞춰 재조정됩니다. Candidate depth 가이드에서 limit을 설정하는 법을 설명해요.

이 다섯 데이터셋 중 세 개에서 DBSF가 기본 RRF보다 높게 점수 냈고, 그 차이의 95% 구간은 0을 제외해요. SciFact의 0.0148 이득과 ArguAna의 0.0045 손실은 모두 0을 가로지르므로, 이 두 데이터셋은 결론을 내릴 수 없어요.

Dataset DBSF Over Default RRF
ArguAna 0.5171 -0.0045
CodeSearchNet 0.6716 +0.0161
SciFact 0.7323 +0.0148
DBPedia-entity 0.4822 +0.0184
WANDS 0.7637 +0.0383

DBSF는 파라미터가 없어요. k와 가중치 쌍은 RRF 설정이며, 공개 API는 그것들을 RrfQuery에서만 받아들여요. 그래서 DBSF가 여러분의 라벨에서 이긴다면, 다음 두 섹션은 건너뛰고 보류(held-out) 체크로 가면 돼요.

라벨로 k 범위 고르기

Qdrant는 한 prefetch의 pos 위치에 있는 문서를 1 / ((pos + 1) / weight + k - 1)로 점수 내고, prefetch들을 합산해요. 동일 가중치면 1 / (pos + k)로 줄어들고, k 하나가 목록의 헤드가 테일을 얼마나 가파르게 이기느냐를 결정해요.

Grouped bar chart comparing the share of a retrieval prefetch's top-10 score mass at each rank, for k equal to 2 and k equal to 61. At k=2 rank 1 takes 24.8 percent and rank 10 takes 4.5 percent. At k=61 the shares are nearly flat, 10.7 percent at rank 1 and 9.3 percent at rank 10.

Qdrant의 기본값인 k=2에서 1위는 10위보다 점수 가중치가 5.50배다. k=61에서는 1.15배이므로, prefetch에 후보가 '존재한다는 사실'이 그 위치만큼이나 중요해진다.

k=5에서는 1위가 10위를 2.80배, k=20에서는 1.45배 이기므로, 대부분의 움직임은 k=20 아래에 있어요. 5씩 짝수 스텝으로 훑는 방식은 곡선이 더 이상 움직이지 않는 지점을 지난 뒤에 대부분의 실행을 쓰게 돼요.

k를 1, 2, 5, 20, 61에 걸쳐 훑어 보세요. models.Rrf에서 k만 바꾸고 동일 가중치를 유지하면 돼요. 낮은 값은 한 prefetch가 높게 랭크한 문서를 선호하고, 높은 값은 두 prefetch 모두가 가져온 문서에 더 많은 점수를 줍니다.

표는 동일 가중치에서 다섯 개의 k 값에 대한 nDCG@10을 보여 줘요. k=2가 기본 RRF예요. 별표는 각 행에서 최고의 k를 표시합니다.

Dataset Queries Relevant per Query k=1 k=2 k=5 k=20 k=61
ArguAna 1,401 1.0 0.5171 0.5216 0.5304* 0.5269 0.5207
CodeSearchNet 1,000 1.0 0.6501 0.6555 0.6580* 0.6511 0.6258
SciFact 300 1.1 0.7117 0.7175* 0.7154 0.7122 0.7067
DBPedia-entity 400 38.2 0.4625 0.4638 0.4641 0.4682* 0.4606
WANDS 480 358.9 0.7232 0.7254 0.7336 0.7571 0.7614*

WANDS에서는 k=2k=61이 쿼리의 42%에서 서로 다른 최상위 결과를 골랐고, nDCG@10은 0.0360 올랐어요. 작은 종합 이득도 사용자가 처음 보는 결과를 바꿀 수 있어요.

이 다섯 데이터셋은 방향을 시사해 줘요. 쿼리당 관련 문서가 약 하나일 때는 최적 k가 2나 5였고, 수십~수백 개일 때는 20이나 61이었어요. 여러분의 라벨링된 쿼리 집합에서 쿼리당 관련 문서 수를 세어 보고, 그 범위 부분부터 먼저 시도해 보세요.

다른 시스템에서 RRF 설정을 이식한다면, Qdrant는 0 기반 위치를 사용한다는 점을 기억하세요. Cormack et al.의 1 / (rank + 60) 관례를 1 기반 랭크로 재현하려면 k=61을 쓰세요.

낮은 k에서 동점 점수가 더 흔해져요. SciFact 쿼리를 평균하면 기본 RRF의 상위 10 결과 중 12.5%가 옆 결과와 점수를 공유하는 반면, k=61에서는 2.8%, DBSF에서는 없어요. 퓨전은 점수만으로 정렬하므로, 동점 그룹은 저장소가 만들어 낸 순서대로 반환되고, 같은 쿼리가 10위 자리에 다른 문서를 돌려줄 수 있어요. 10개보다 더 많이 요청하고, 클라이언트에서 점수 내림차순·ID 오름차순으로 정렬한 뒤 처음 10개를 유지하세요. 10위의 점수가 마지막으로 반환된 결과와 여전히 같다면 더 요청하세요.

가중치는 마지막에 튜닝하기

가중치 쌍은 쿼리에 나타나는 prefetch 순서대로 각 prefetch에 하나씩의 승수를 줘요. 이 쌍은 절대적이라서 (1, 2)(2, 4)는 서로 다른 두 설정이에요. 수식이 위치를 가중치로 나누기 때문에 두 가중치를 모두 스케일링하면 모든 점수가 바뀌죠. WANDS의 k=5에서 (1, 2)는 0.7390, (2, 4)는 0.7508을 기록해요.

쌍은 테스트한 k에서만 유효하므로 먼저 k를 확정하세요. WANDS에서는 (2, 4)k=5에서 동일 가중치를 이겨요. 그 데이터셋의 최적값인 k=61에서는 동일 가중치가 이깁니다: 0.7614 대 0.7567.

그다음 몇 개의 쌍을 훑고 여러분의 라벨이 승자를 고르게 하세요. prefetch 자신의 점수는 어느 쪽으로 기울어야 하는지 말해 주지 않아요. 가중치는 각 목록 안의 위치에 작용하므로, 다른 하나가 놓친 쿼리에서 어떤 prefetch가 관련 문서를 높게 랭크하느냐가 쌍을 결정해요.

DBPedia-entity에서 밀집 검색은 0.4677, 희소 검색은 0.3857인데도, 승리한 쌍 (1, 3)은 희소에 밀집의 세 배 가중치를 주고 0.0060을 얻어요. CodeSearchNet은 반대 방향으로 기울어 (2, 1)에서 0.0096을 얻죠. 두 구간 모두 0을 제외해요.

동일 가중치도 실제로 나올 수 있는 결과예요. 각 데이터셋의 최적 k에서 여섯 개의 쌍을 돌렸고, (1, 1)이 다섯 개 중 두 개에서 단독 승리했어요. ArguAna의 최적 쌍은 0.0029를 얻었는데, 그 구간은 0을 가로질러요.

가중치 0.0은 그 prefetch의 모든 문서를 유지하면서 각각 0.0으로 점수 매겨요. 문서들은 사라지지 않고 퓨전된 목록의 맨 아래에 남아요.

보류된 쿼리에서 선택한 설정 확인하기

어떤 설정이 그것을 선택하는 데 쓴 쿼리에서는 최고로 점수 냈어도, 보류된 쿼리에서는 실패할 수 있어요. 사전 튜닝 아티클의 두 체크, 즉 쿼리당 이득의 부트스트랩 구간과 선택·보류 쿼리 사이의 분할을 모두 실행하세요. 설정의 구간이 0을 제외하고 선택된 이득이 보류 절반에서 유지될 때 배포하세요.

SciFact의 300개 쿼리에서는 DBSF의 0.0148 이득을 포함해 우리가 시도한 어떤 것도 95% 구간이 0을 제외하지 못했어요. 200개의 랜덤 스플릿 전체에서, 선택된 퓨전 설정은 보류 쿼리에서 이득의 67%~95%를 유지했어요. 기본값을 유지하는 것도 하나의 실질적인 답이며, 우리의 다섯 데이터셋 중 하나에서는 그게 올바른 선택이었어요.

이런 순서로 튜닝하세요

각 단계는 한 세션 안에서 돌리기에 충분히 가벼워요.

  1. 퓨전이 어느 한쪽 prefetch 단독보다 낫다는 걸 확인한다.
  2. 라벨 위에서 RRF 또는 DBSF를 고른다.
  3. 쿼리당 관련 문서 수에서 k를 정한다.
  4. k에서 가중치 쌍 몇 개를 훑는다.
  5. 배포 전에 보류 쿼리에서 승자를 검증한다.

다음으로, 다운스트림 모델이 검색된 후보의 랭킹을 개선할 수 있다면 리랭커가 그 비용만한 가치가 있는지 테스트해 보세요.

더 알아보기 (Learn more)