후보 깊이: 어느 정도의 검색이면 충분할까?

후보 깊이: 어느 정도의 검색이면 충분할까? (Candidate Depth: How Much Retrieval Is Enough?)

후보 깊이를 튜닝하기 전에, 튜닝 전 확인 사항을 사용해 인덱스 상태를 검증하고 라벨링된 기준선을 세워 두세요. 아래의 모든 것은 그 기준선에 대해 측정돼요.

**후보 깊이(candidate depth)**는 검색 단계가 이후의 랭킹 단계에 넘겨주는 후보의 수예요. 오직 이후 단계가 추가 후보를 사용할 수 있을 때만 중요해요. 하이브리드 검색에서는 모든 prefetch가 자신의 limit을 가지며, 한 prefetch 안에 다른 prefetch를 중첩시키는 다단계 쿼리(multi-stage query)는 각 레벨에서 깊이를 정해요. dense 전용 또는 sparse 전용 검색에서는, 랭커나 다른 하위 단계로 넘겨주는 후보의 수예요.

참고: 이 글의 측정값은 코퍼스 크기, 문서·쿼리 형태, 관련성 작업이 다양하도록 선택된 다섯 개의 공개 데이터셋을 사용해요. 그래서 이 깊이 곡선들은 방향성 참고로 읽어 주세요. 5,183에서 100,000 문서까지 다양하며, 각각 노트북 Docker 컨테이너의 단일 샤드에서 비양자화 상태로 all-MiniLM-L6-v2와 Qdrant의 코어 BM25를 사용해 실행됐어요.

출처: 공식문서

짧게 요약하면

  1. 하위 랭킹 단계를 위해 limit을 100과 200에서 테스트하세요. 그 값을 생산 기본값이 아니라 출발점으로 취급하세요: limit은 샤드별로 적용되고, 랭커는 모든 후보를 점수화해요.
  2. hnsw_ef를 올리기 전에, 근사 검색 recall을 정확 검색과 비교하세요. recall이 이미 평탄해졌다면, 더 큰 값은 recall을 개선하지 않고 지연시간만 추가해요.
  3. RAM이 제약이라면, 후보 깊이를 줄이기 전에 양자화를 테스트하세요. 양자화가 컬렉션 설정을 다뤄요.

후보가 많을수록 달성 가능한 최고 점수가 올라갈 수 있어요

먼저 검색한 후보와 파이프라인이 반환하는 순서 사이의 간격을 측정하세요. 라벨링된 쿼리 집합을 사용해 후보 집합이 완벽하게 정렬된 것처럼 점수화하세요. 그것이 그 후보들의 어떤 이후 랭킹도 도달할 수 있는 최고 점수예요. 그것을 같은 쿼리의 현재 점수와 비교하세요. 이 하이브리드 측정에서 현재 점수는 같은 후보들에 대한 fusion의 nDCG@10이에요. nDCG@10은 상위 10개 결과를 채점하고 위쪽 근처의 관련 문서에 더 많은 점수를 줘요.

쿼리가 관련 문서 세 개를 검색하고, fusion이 그것들을 4, 30, 180위로 랭킹한다고 가정해 봐요. 현재 점수는 4위의 것 하나만 보는데, 다른 둘은 채점하는 상위 10개 밖에 있으니까요. 최고 가능 점수는 같은 후보들을 재정렬해 세 개를 모두 맨 위에 놓아요. 어떤 이후 랭킹 단계도 검색된 후보들로 그보다 더 잘할 수 없어요.

하이브리드 검색에서는 dense와 sparse prefetch의 합집합을 점수화하세요. 단일 prefetch 파이프라인에서는 하위 단계로 넘겨지는 후보들을 점수화하세요.

각 값은 limit=10에서 500까지의 nDCG@10 변화예요.

데이터셋 최고 가능 점수 변화 현재 점수 변화
SciFact +0.103 +0.008
ArguAna +0.121 +0.002
WANDS +0.124 +0.007
CodeSearchNet +0.149 +0.010
DBPedia-entity +0.282 +0.003

표 뒤의 전체 스윕. 최고 가능 점수는 모든 데이터셋의 모든 깊이 단계에서 오르는데, fusion이 반환하는 점수는 거의 평평하게 유지돼요.

최고 가능 점수 변화는 이 다섯 개에서 코퍼스 크기에 따라 오르는데, SciFact의 5,183 문서에서 DBPedia-entity의 100,000 문서까지예요. 반면 현재 점수 변화는 평평하게 유지돼요. 여기서 크기와 도메인이 함께 움직이므로, 컬렉션이 커질 때 그 간격을 다시 측정하세요.

Qdrant의 기본 RRF에서는 각 prefetch의 상위 순위가 꼬리보다 fusion된 점수에 훨씬 더 기여해요. limit을 올리면 상위 10개를 바꾸지 않고 후보를 추가하거나, 더 관련성 높은 결과를 대체할 수 있어요. Fusion된 점수가 깊이가 깊을수록 항상 높은 건 아니에요: CodeSearchNet은 limit=200에서 정점을 찍고 500에서 더 낮으며, DBPedia-entity는 50에서 정점을 찍어요. 다른 fusion 방식은 그 후보들을 다르게 랭킹할 수 있어요. Fusion 튜닝이 라벨에서 그것들을 테스트하는 방법을 보여줘요.

limit을 100에서 200 정도로 시작한 다음, 자신의 라벨에서 더 큰 값을 테스트하세요. 랭커는 추가된 후보를 사용할 수 있고, 공식 쿼리(Formula Query)는 페이로드 필드에서 같은 후보들을 리스코어할 수 있어요.

limit을 올리면 검색 작업이 추가돼요. 랭커가 따라오면, 랭커가 점수화하는 후보 수도 늘어나요. 단일 샤드 테스트에서 limit을 10에서 500으로 올리면 중앙 지연시간이 37%에서 43% 증가했어요. 이 결과는 방향을 확립하는 것이지, 이식 가능한 비율이 아니에요. 여러분의 p95 예산, 동시성, 샤드 팬아웃 아래에서 변화를 측정하세요.

깊이는 샤드별로 적용돼요. 각 샤드는 자신의 limit을 받고 자신의 데이터를 검색하므로, 12개 샤드에서 limit=200은 컬렉션 수준 단계(fusion 또는 하위 랭커)가 최대 2,400개 후보를 받을 수 있다는 뜻이에요. 루트 수준 fusion은 컬렉션 수준에서 한 번 실행되며, prefetch 안에 중첩된 fusion만 샤드별로 실행돼요.

recall이 아직 오르고 있을 때만 hnsw_ef 올리기

dense 벡터의 경우 limit은 dense 단계가 반환하는 후보 수를 결정하고, hnsw_ef는 HNSW 그래프 탐색이 후보를 찾기 위해 얼마나 넓게 검색하는지 결정해요. 근사 검색 recall과 지연시간을 맞바꾸는 셈이죠. 하위 단계가 더 많은 후보를 사용할 수 있을 때는 limit을 라벨로 측정하고, hnsw_ef는 정확 검색과 비교해 탐색이 여전히 이웃을 놓치는지 보세요.

hnsw_ef는 검색이 방문하는 노드 집합을 넓히는 것이지, 결과 수를 늘리는 게 아니에요. 여기서 더 넓은 탐색이 좁은 탐색이 놓친 이웃에 도달하고, 가장 약한 결과를 대체해요.

자신의 데이터에서 limit을 dense 전용 단계나 dense prefetch가 사용하는 값으로 설정하고 같은 확인을 실행하세요.

import time

from qdrant_client import QdrantClient, models

client = QdrantClient(
    url="https://YOUR-CLUSTER.cloud.qdrant.io",
    api_key="<your-api-key>",
)
# Your own query vectors, embedded with the model the collection was built with.
queries = [...]
# The limit your dense-only stage or dense prefetch uses.
LIMIT = 100


def top_ids(vector, **search_params):
    return {point.id for point in client.query_points(
        collection_name="products", query=vector, using="dense",
        limit=LIMIT, search_params=models.SearchParams(**search_params),
    ).points}


# The full scan is the ground truth, and it runs once: it does not depend on hnsw_ef.
truth = [top_ids(vector, exact=True) for vector in queries]

for ef in (16, 64, 128, 256, 512):
    found = 0
    started = time.perf_counter()
    for vector, wanted in zip(queries, truth):
        found += len(top_ids(vector, hnsw_ef=ef) & wanted)
    elapsed_ms = (time.perf_counter() - started) / len(queries) * 1000
    print(ef, found / (LIMIT * len(queries)), elapsed_ms)

exact=True는 전체 스캔을 실행해요. 아래 두 열은 단일 샤드 SciFact 컬렉션에 대한 그 루프에서, 50개 쿼리로, 클라이언트에서 시간을 재서 네트워크 왕복이 숫자 안에 들어간 결과예요:

hnsw_ef 정확 검색 대비 Recall 쿼리당 밀리초
16 0.986 1.98
64 0.993 1.98
128 0.999 2.18
256 1.000 2.45
512 1.000 2.25

이 다섯 데이터셋에서 깊이 200으로 hnsw_ef를 16, 64, 128, 512를 통해 올리면 fusion된 nDCG@10이 최대 0.0022 이동했어요. 후보 합집합의 관련 문서 recall은 최대 0.0040 이동했어요. 중앙 지연시간은 prefetch limit=200에서 다섯 개 하이브리드 요청에 걸쳐 4%에서 49% 사이로 올랐어요. 그래프가 이미 포화됐을 때, 더 넓은 검색 예산은 순수 비용에 가까워요.

컬렉션에서는 지연시간 예산 안에서 recall 목표에 도달하는 가장 낮은 hnsw_ef를 고르세요. 첫 값부터 recall이 평평하다면, hnsw_ef를 그대로 두고 Qdrant가 HNSW 그래프를 만들었는지 확인하세요. Qdrant는 세그먼트가 기본 indexing_threshold를 통과한 후에 그 그래프를 만들며, 더 작은 세그먼트는 hnsw_ef가 효과가 없는 완전 탐색(exhaustive search)을 사용해요. 튜닝 전 확인 사항이 그래프 존재를 확인하는 방법을 보여줘요.

포화는 여러분 그래프 자신의 속성이에요. 이 컬렉션들은 최대 100,000 문서를 한 번에 만들고, 필터 없이 비양자화 상태였어요. 완전한 4,635,922 문서 DBPedia-entity 컬렉션에서 같은 확인을 실행했는데, 정확한 top 10의 0.957을 반환했어요: 진짜 최근접 이웃의 약 4%는 돌아오지 않았죠.

hnsw_ef가 recall 목표에 도달하지 못한다면, m이 그래프의 연결을 늘리고 ef_construct가 그래프 구성 중 검색을 넓혀요. 둘 다 인덱스가 달성할 수 있는 recall을 높이며, 어느 것을 바꾸든 HNSW 인덱스를 재구축해요. 필터는 탐색이 도달하는 것에 대한 또 다른 제한이에요: ACORN 검색 알고리즘은 기본적으로 비활성화돼 있고, 그 enable 플래그는 필터가 그래프 이웃을 제외할 때 검색이 직접 그래프 이웃 너머로 탐색하게 해요. ACORN은 약 2~10배 느리게 실행될 수 있으므로, 여러 개의 엄격한 페이로드 필터가 결합될 때 사용하세요.

RAM이 제약일 때

limit은 쿼리 시점 예산이에요. 그것을 낮추면 검색 작업과 이후 단계가 받는 후보가 줄어들고, 컬렉션의 디스크·RAM 사용량은 그대로 남아요. 양자화는 그 사용량을 옮기므로, 메모리 때문에 limit을 낮추기 전에 라벨로 테스트하세요. Qdrant의 TurboQuant가 저장 클래스를 비교해요.

Int8 스칼라 양자화는 float32 벡터 크기의 4분의 1에 압축 사본을 저장해요. 우리는 dense top-10 일치와 최종 하이브리드 결과에 미치는 영향을 측정하기 위해 그것으로 SciFact와 DBPedia-entity를 재구축했어요.

설정 비양자화와의 Dense Top-10 일치 Fusion된 nDCG@10 변화
리스코어링 없음 0.984 -0.0001 ~ +0.0000
rescore=True 0.997 ~ 1.000 -0.0001 ~ +0.0000
rescore=True, oversampling=4 0.998 ~ 1.000 +0.0000 ~ +0.0001

양자화는 후보 목록을 재정렬해요: 리스코어링이 없으면 dense prefetch의 top 10 중 1.6%가 움직여요. 다만 기본 RRF fusion이 순위를 사용했기 때문에 그 중 거의 아무것도 fusion된 결과에 도달하지 못했어요. rescore는 원본 벡터로 단기 리스트를 리스코어하고, oversampling은 그 단계가 고를 수 있도록 추가 압축 후보를 가져오며, SciFact에서 리스코어링은 비양자화 top 10을 회복했어요.

이 측정은 5,000과 100,000 문서에서 단일 샤드의 int8 스칼라 양자화를 다뤄요. 바이너리 양자화는 훨씬 더 공격적인 트레이드이며 여기서는 테스트하지 않았어요.

양자화를 limit을 자르는 것과 비교해 보세요. 깊이를 500에서 10으로 낮추면 우리 실행에서 중앙 지연시간이 27%에서 30% 제거되고 사용량은 그대로 남았어요. Int8 양자화는 벡터를 4분의 1 크기로 저장하고 fusion된 nDCG@10을 어느 방향으로든 최대 0.0001 이동시켰어요. 둘 중에서, 벡터가 RAM에 필요한 것을 줄이는 건 양자화예요.

컬렉션이 RAM을 넘어서면, 질문은 후보를 몇 개 가져올지가 아니라 어떤 구조가 상주하느냐가 돼요. 메모리 배치와 리스코어링이 460만 벡터에서 그 경계를 측정하고 배치 규칙을 설명해요.

다음에 무엇을 튜닝할까

최고 가능 점수와 현재 점수 사이의 간격이 다음 실험이 랭킹에 집중해야 할지 검색에 집중해야 할지 알려줘요. 큰 간격은 관련 후보가 존재하지만 충분히 높게 랭킹되지 않았다는 뜻이에요. 하이브리드 검색에서는 fusion 설정을 테스트하고, 하위 단계가 있는 파이프라인에서는 랭커가 그 간격을 회복할 수 있는지 테스트하세요. 작은 간격은 랭킹이 후보 집합이 허용하는 최선에 이미 가깝다는 뜻이므로, 대신 후보를 개선하세요.

다음으로, 하이브리드 검색을 쓴다면 이미 검색한 후보들에 대해 fusion을 튜닝하세요.

더 알아보기 (Learn more)