Qdrant 컬렉션 튜닝 전에 반드시 확인할 것들
Qdrant 컬렉션 튜닝 전에 반드시 확인할 것들 (What to Check Before Tuning a Qdrant Collection)
설정을 바꾸기 전에, 먼저 여러분의 워크로드에서 "더 나은 검색(retrieval)"이 정확히 무엇을 뜻하는지 정해 두어야 해요. 상위 1위에 올바른 문서가 오는 것, 랭커를 위한 더 많은 후보, 더 낮은 지연시간, 더 작은 메모리 사용량 — 각각이 서로 다른 설정을 선호하거든요. 그래서 목표를 먼저 고르는 게 중요해요. 라벨링된 쿼리(labels)가 여러분이 쫓는 개선을 감지하지 못한다면, 변경이 도움이 됐는지조차 알 수 없게 돼요.
또 일부 설정은 성능을 튜닝하기 위한 게 아니라 정확성을 검증하기 위한 것들이에요. 벡터가 인덱싱되지 않았거나, sparse 벡터에 IDF 수식자가 없거나, BM25 평균 길이가 잘못돼 있으면 결과 자체가 무효해져요. 그 뒤에 돌리는 어떤 벤치마크나 비교도 잘못된 구성을 반영하게 되죠. 이 글에서는 각 설정을 어떻게 확인하고, 올바른 상태가 어떤 모습인지 보여드릴게요.
출처: 공식문서
튜닝 대상인 검색 파이프라인
모든 쿼리는 먼저 후보를 검색한 다음 랭킹을 매겨요. dense 전용 검색에서는 벡터 검색 하나가 두 가지를 모두 해요. 하이브리드 검색은 정확한 용어를 위한 sparse prefetch를 추가하고, 그다음 fusion이 dense와 sparse 후보 목록을 결합해요. 랭커(reranker)가 있다면 상위 후보를 한 번 더 점수화해요.
dense prefetch(limit, hnsw_ef 설정)와 sparse prefetch(limit, Modifier.IDF 설정)가 각각 fusion 단계(RRF k, 가중치, DBSF 설정)로 들어가고, 그 뒤에 선택적인 랭커(후보 수, 모델 설정)가 이어지는 하이브리드 파이프라인
dense 전용 검색만 돌리고 있고 결과에서 정확한 키워드가 빠져 있다면, 하이브리드 검색이 가장 먼저 테스트할 변경이에요. 하이브리드 검색 튜닝은 요청 형태, 두 번째 prefetch의 비용, fusion이 각 prefetch 단독보다 라벨에서 더 나은지 확인하는 방법을 다뤄요.
튜닝하기 전에 다음을 확인하세요:
- 벡터가 인덱싱됐는지, 필터에 사용하는 모든 필드에 페이로드 인덱스가 있는지 확인하세요. 컬렉션 상세 정보와 페이로드 인덱싱에서 무엇을 확인해야 하는지 보여줘요.
- 라벨링된 쿼리 집합을 만들고 제품 경험과 일치하는 메트릭을 고르세요. 라벨링된 쿼리는 실제 사용자 쿼리와 반환되어야 하는 문서를 짝지은 것이에요. 검색 관련성 측정이 설정 과정을 안내해요.
증상이 어디서부터 시작할지 알려 줘요
설정 레퍼런스가 아니라 실패 모드(failure mode)부터 시작하세요. 아래 표는 각 증상을 첫 번째로 유용한 확인과 그 글에 매핑해요.
| 보이는 것 | 첫 번째 확인 | 다음에 읽기 |
|---|---|---|
| 개선이 노이즈와 구분되지 않음 | 라벨링된 쿼리를 만들고, 메트릭을 고르고, 구간을 계산하세요 | 이 글 |
| 관련 문서가 나타나지 않음 | 후보 깊이(candidate depth)가 recall을 제한하는지 측정하세요 | Candidate Depth: How Much Retrieval Is Enough? |
| 키워드, 식별자, SKU, 에러 코드가 일치하지 않음 | sparse prefetch를 추가하고 fusion을 각 prefetch 단독과 비교 측정하세요 | How to Tune Hybrid Search in Qdrant |
| 관련 문서는 있지만 순서가 잘못됨 | 하이브리드 검색이면 fusion을 튜닝하세요. 후보 목록에 다른 랭킹 단계가 필요하면 랭커를 테스트하세요 | How to Tune Hybrid Search in Qdrant, When Is a Reranker Worth It? |
| 결과가 근사 중복을 반복함 | 최대 한계 관련성(maximal marginal relevance)을 테스트하세요. 한 문서의 청크가 페이지를 채우면 그룹핑(grouping)을 사용하세요 | When Is a Reranker Worth It? |
| 검색이 p95 목표를 놓침 | 다른 검색 단계를 추가하기 전에 후보 깊이의 비용을 측정하세요 | Candidate Depth: How Much Retrieval Is Enough? |
| 컬렉션이 더 이상 RAM에 맞지 않음 | 메모리 배치와 리스코어링을 테스트하세요 | When Your Collection Outgrows RAM |
이 측정값들을 읽는 방법
절차는 이식 가능해요: 제품 경험과 일치하는 메트릭을 고르고, 라벨링된 쿼리에서 설정을 비교하고, 승자를 새 쿼리로 검증하세요.
참고: 이 글의 측정값은 코퍼스 크기, 문서·쿼리 형태, 관련성 작업이 다양하도록 선택된 다섯 개의 공개 데이터셋을 사용해요. 5,183에서 100,000 문서까지 다양하며, 각각 노트북 Docker 컨테이너의 단일 샤드에서 비양자화 상태로
all-MiniLM-L6-v2와 Qdrant의 코어 BM25를 사용해 실행됐어요.
Qdrant의 API와 알고리즘 메커니즘은 컬렉션에 걸쳐 이어져요. 파라미터 스윕의 결과는 임베딩 모델, 데이터셋, 쿼리 구성, 필터, 인덱스 상태, 샤드 레이아웃, 배포에 따라 달라져요. 각 결과를 여러분 컬렉션의 테스트를 고르는 데 사용하고, 라벨이 지지하는 설정만 유지하세요.
조용히 품질을 깨뜨리는 설정들
다른 무엇보다 먼저 실행하는 단계들을 확인하세요. 각 사전 조건은 주어진 컬렉션에 대해 올바른 상태가 있고, 에러 없이 실패할 수 있어요. 벤치마크하거나 설정을 비교하기 전에 이것들을 고치세요. 그렇지 않으면 트레이드오프가 아니라 구성 오류를 측정하고 있는 거예요.
Dense 검색과 인덱싱
벡터가 인덱싱됨 GET /collections/{collection_name}을 호출하고 indexed_vectors_count를 points_count와 비교하세요. dense 전용 컬렉션에서는 인덱싱이 완료되면 두 수가 일치해야 해요. 각 포인트에 dense 벡터 하나와 sparse 벡터 하나가 있는 하이브리드 컬렉션에서는, Qdrant가 각 벡터를 따로 세기 때문에 indexed_vectors_count가 points_count의 두 배여야 해요.
인덱싱된 수가 더 적다면, 인덱싱이 여전히 진행 중이거나 멈췄거나, 일부 세그먼트가 기본 indexing_threshold인 10,000KB보다 작은 것일 수 있어요. 인덱싱 옵티마이저 문서를 참조하세요. Qdrant는 세그먼트가 indexing_threshold에 도달한 후에만 HNSW 그래프를 만들어요. 그 전에는 HNSW 없이 세그먼트를 검색하므로 hnsw_ef를 바꿔도 효과가 없어요.
full_scan_threshold dense와 sparse 벡터는 서로 다른 단위의 별도 임계값을 가져요. 그래서 둘 사이에 값을 복사하면 의도한 크기와 전혀 다른 곳에 도달해요. Dense 임계값은 세그먼트의 벡터 킬로바이트를 세며, 기본값이 10,000이에요. 세그먼트가 그보다 적은 벡터를 갖거나, 필터가 그보다 적은 포인트와 일치하면 검색을 그래프 대신 정확 스캔(exact scan)으로 보내요.
Sparse 임계값은 벡터를 세며 기본값이 5,000이고, 필터가 있을 때만 적용돼요.
Sparse 검색
이 설정들은 컬렉션이 수천 개의 문서를 갖든 수십억 개를 갖든 적용돼요.
Modifier.IDF BM25 또는 miniCOIL의 sparse 벡터에는 이 수식자를 사용하세요. 둘 다 역문서 빈도(IDF)를 Qdrant에 맡겨요. Qdrant는 각 샤드에서 쿼리 용어마다 IDF를 계산하고 그 용어에 가중치를 주죠. SPLADE는 이미 코퍼스 수준 용어 가중치를 포함하므로, 수식자를 적용하면 희귀성이 두 번 세어져요.
BM25 avg_len: BM25가 단어를 표제어화하고 불용어를 제거한 후, 필드의 평균 토큰 수로 avg_len을 설정하세요. BM25는 이 값을 사용해 문서 길이를 조정해요. 원시 단어 수로 추정하지 마세요. 여기서 테스트한 다섯 데이터셋에서 표제어화한 수는 15%에서 43% 더 낮았어요. 올바른 값은 기본값 256과 비교해 35.3에서 151.4까지 다양했어요. 컬렉션과 같은 스테머와 불용어 설정을 사용해 측정하세요.
하이브리드 검색
샤딩된 컬렉션에서 fusion의 배치는 중요해요. score_threshold는 요청이 단일 벡터 검색에서 fusion으로 이동할 때 어떤 규모에서도 위험이에요.
Fusion 배치 쿼리의 루트에서 fusion은 각 샤드가 후보를 반환한 후 한 번 실행돼요. prefetch 안에서는 fusion이 각 샤드에서 실행돼요. 각 샤드는 자신의 후보만 fusion하고, 바깥쪽 쿼리는 그 샤드 로컬 fusion 점수로 순위를 매겨요. 결과는 샤드 수와 포인트 분포 방식에 따라 달라지며, 어떤 에러도 그것을 알려주지 않아요. 중첩 fusion은 바깥쪽 단계가 자신의 출력을 리스코어할 때 의도적인 것이에요. 단일 샤드 컬렉션에서는 두 배치가 같은 랭킹을 만들어요.
score_threshold 결과를 반환하는 단계에 대해 측정된 최소 허용 점수가 있을 때만 score_threshold를 사용하세요. dense 전용 검색에서 복사한 임계값은 루트 수준 RRF 또는 DBSF 쿼리에서는 안전하지 않아요. Qdrant는 그것을 dense/sparse 점수가 아니라 fusion된 점수와 비교해요. 결과 목록을 조용히 잘라내거나 아예 결과를 반환하지 않을 수 있어요. 라벨링된 쿼리로 검증하거나, 설정하지 않은 채로 두세요.
필터링된 검색
필터에서 사용하는 모든 필드를 인덱싱하세요. 하나를 건너뛰는 비용은 컬렉션 크기와 쿼리 동시성에 따라 커져요.
페이로드 인덱스 건강한 컬렉션은 필터에 사용되는 모든 필드에 페이로드 인덱스가 있어요. 이 인덱스들을 수집 전에 만드세요. 나중에 추가하면 Qdrant가 필터 인식 HNSW 엣지를 자동으로 추가하지 않아요. HNSW 인덱스를 재구축해야 해요. Qdrant Cloud의 strict 모드는 인덱싱되지 않은 필드로 필터링하는 쿼리를 거부해요. 올바른 인덱스가 있어도 엄격한 필터는 recall을 줄일 수 있어요. What ACORN fixes, and what fixes ACORN은 100만 포인트에서 이 효과를 측정해요.
비용 순서대로 변경하기
컬렉션을 재구축하거나 검색 단계를 추가하지 않는 변경부터 시작하세요. 더 저렴한 옵션이 증상을 해결하지 못할 때만 더 높은 비용 단계로 이동하세요.
| 단계 | 무엇 | 적용 대상 | 비용 |
|---|---|---|---|
| 새 검색 작업 없음 | Fusion 방식, RRF k, 가중치 |
하이브리드 검색 | 이미 검색한 목록을 재정렬해요. 재구축이나 추가 검색 단계 없음 |
| 확장된 검색 | hnsw_ef |
Dense 검색 | 검색 폭과 쿼리 시간을 늘려요 |
| 확장된 검색 | Prefetch limit |
하위 단계가 있는 모든 파이프라인 | 더 많은 후보를 검색해 쿼리 시간을 늘려요 |
| 확장된 검색 | full_scan_threshold |
Dense 검색, 특히 필터링된 검색 | 더 큰 후보 풀에 정확 스캔을 사용해 쿼리 시간을 늘릴 수 있어요 |
| 새 단계 | Sparse prefetch | Dense 전용 검색 | 두 번째 인덱스, 포인트당 두 번째 벡터, 단일 샤드에서 쿼리 시간 0.6~1.5ms |
| 새 단계 | 랭커(Reranker) | 모든 파이프라인 | 후보당 모델 호출 한 번 |
| 재구축 | 임베딩 모델, m |
모든 컬렉션 | 컬렉션 재인덱싱. 임베딩 모델을 바꾸면 모든 포인트에 새 벡터를 생성하는 것도 의미해요 |
| 재구축 | 양자화 | 메모리에 제한된 컬렉션 | 재인덱싱 + 모든 벡터의 압축 사본. 이후 랭킹 품질을 유지하려면 리스코어링에 의존 |
모델 수준 재구축은 측정된 제약을 해결할 때만 고려하세요. 새 임베딩 모델은 모든 포인트를 다시 임베딩하는 것을 의미하니까요. 임베딩 모델 고르는 방법이 이 결정을 다뤄요. 메모리가 제약일 때, Matryoshka 모델의 mrl 파라미터는 벡터 자체를 줄여요. 이는 양자화로 압축하는 것과는 다른 트레이드오프예요.
튜닝 전에 메트릭 고르기
설정을 비교하기 전에 메트릭을 고르세요. 메트릭이 승자를 결정하니까요. 우리 테스트에서 nDCG@10, MRR@10, Recall@100은 각각 서로 다른 최적 설정을 말했고, Recall@100은 다섯 데이터셋 중 네 개에서 nDCG@10과 의견이 달랐어요.
nDCG@k 위쪽 근처의 관련 결과에 보상을 주고, 라벨이 등급화됐을 때 추가 점수를 주며, 각 쿼리를 완벽한 랭킹에 대해 정규화해요. 여러 결과 사이의 순위 순서가 중요할 때 사용하세요.
MRR@k 첫 번째 관련 결과의 순위의 역수의 평균이에요. 좋은 것에 얼마나 빨리 도달했는지를 묻죠. 쿼리에 정답이 하나일 때 사용하세요.
Recall@k 관련 문서 전체 중 상위 k에 들어간 비율이에요. 다른 무엇을 먹이는 첫 단계를 측정할 때 사용하세요. 관련 문서 수에 따라 쿼리별로 상한이 있어요: 관련 문서가 359개인 쿼리는 Recall@100에서 0.28을 넘을 수 없어요. 100개만 들어갈 수 있으니까요. 관련 문서가 적은 쿼리는 그 상한에 묶이지 않으므로, 쿼리 전체 평균은 더 높아질 수 있어요. 우리 테스트에서 한 데이터셋은 쿼리당 평균 358.9개의 관련 문서를 가졌고, 최고 Recall@100은 0.3877이었어요. k를 고르기 전에 쿼리당 관련 문서 수를 세세요.
라벨이 개선을 감지할 수 있는지 확인하기
검색 관련성이 라벨링된 집합을 만드는 것을 다뤄요. 그 크기가 어떤 검색 튜닝을 여러분에게 보이게 하는지 결정해요.
라벨링된 집합은 신경 쓰는 개선을 일반적인 쿼리 간 변동과 구분할 수 있을 만큼 클 때 충분해요. 크기만으로 대표성이 없는 집합을 살리진 못해요. 제품이 보는 구성 전체에 걸친 쿼리를 끌어오고, 중요한 쿼리 타입과 필터를 포함하며, 라벨 샘플을 직접 스팟체크하세요.
아래의 모든 확인은 비교하는 각 설정에 대해 쿼리당 점수 하나를 취해요. 서비스가 이미 보내는 Qdrant 요청을 사용하세요. 파이프라인이 dense 전용, 하이브리드 fusion, 랭커 중 무엇을 돌리든 점수 방식은 같아요.
스코어링은 메트릭 자체에서 시작해요. dcg는 순위에 따라 커지는 할인과 함께 등급화된 관련성을 합산해요. ndcg_at_k는 반환된 것에 대해 그 합을 실행한 다음, 쿼리의 라벨이 허용하는 최상의 순서에 대한 같은 합으로 나눠요.
import math
def dcg(gains):
"""Relevance summed with a discount that grows with rank."""
return sum(gain / math.log2(rank + 2) for rank, gain in enumerate(gains))
def ndcg_at_k(doc_ids, relevance, k=10):
"""One query's ranking against the best ranking its labels allow."""
returned = [relevance.get(doc_id, 0) for doc_id in doc_ids[:k]]
ideal = sorted(relevance.values(), reverse=True)[:k]
return dcg(returned) / dcg(ideal) if any(ideal) else 0.0
그런 다음 라벨링된 쿼리를 두 설정 모두로 실행해요. 여러분이 search를 작성하는데, 이것은 한 설정을 서비스가 이미 보내는 요청에 적용하고 서버가 랭킹한 대로 포인트를 반환해요. 그 요청에 with_payload=["doc_id"]를 추가해 모든 포인트가 여러분의 라벨이 사용하는 ID를 갖게 하거나, 포인트 ID가 이미 문서 ID라면 point.id를 읽으세요. score는 각 목록을 숫자 하나로 바꾸고, 각 쿼리에 대해 두 점수를 빼면 쿼리별 이득이 나와요.
# Relevance keyed by the document IDs your labels already use.
qrels = {"q1": {"doc-41": 1, "doc-77": 2}}
# Your labeled queries. Each value is what search sends to Qdrant: text or a vector.
queries = {"q1": [...]}
# The one parameter under test, in whatever form your search applies it.
current_setting = {"hnsw_ef": 64}
candidate_setting = {"hnsw_ef": 256}
def search(query_id, query, setting):
"""You write this: your own Qdrant request, with setting applied.
Return the points in the order the server ranked them, each carrying doc_id.
"""
raise NotImplementedError
def score(queries, qrels, search, setting):
"""One nDCG@10 per query, for one setting."""
return {
query_id: ndcg_at_k(
[point.payload["doc_id"] for point in search(query_id, query, setting)],
qrels.get(query_id, {}),
)
for query_id, query in queries.items()
}
candidate = score(queries, qrels, search, candidate_setting)
current = score(queries, qrels, search, current_setting)
per_query_gain = [candidate[q] - current[q] for q in sorted(queries)]
두 호출은 정확히 하나의 설정에서만 달라야 해요. 필터, 쿼리 형태, 후보 한도는 동일하게 유지해요. MRR@10과 Recall@100의 경우, pytrec_eval이 같은 qrels에서 둘 다 계산해요.
다른 쿼리 집합을 뽑았다면 평균 이득이 얼마나 움직였을지 추정하기 위해 쿼리별 이득을 교체로 리샘플링하세요. 그 결과 95% 구간은 그 샘플링 변동과 일치하는 범위를 보여줘요. 구간이 0을 포함한다면, 라벨로는 품질 이득을 확립할 수 없어요.
import numpy as np
def interval(per_query_gain, resamples=1000, seed=42):
"""95% interval for the mean per-query gain of one setting over another."""
gains = np.asarray(per_query_gain, dtype=float)
rng = np.random.default_rng(seed)
draws = rng.integers(0, len(gains), size=(resamples, len(gains)))
return np.percentile(gains[draws].mean(axis=1), [2.5, 97.5])
평가하는 라벨링된 쿼리가 많을수록 측정된 이득이 더 정밀해져요. 데이터셋 전체에서 95% 구간은 보통 nDCG@10 이득의 이만큼 위아래로 뻗었어요:
| 라벨링된 쿼리 | 구간, 이득의 양쪽 |
|---|---|
| 25 | 0.047 |
| 50 | 0.035 |
| 100 | 0.025 |
| 200 | 0.018 |
| 300 | 0.015 |
필요한 라벨 수는 주로 효과 크기와 쿼리 간 변동에 달려 있어요. 컬렉션 크기만으로는 결정되지 않아요.
우리 측정에서 fusion 설정은 nDCG@10을 0.012에서 0.038까지 움직였어요. 검색 파이프라인을 재구축하는 게 아니라 이미 동작하는 컬렉션을 튜닝한 이득이죠.
라벨링된 쿼리 50개면 더 큰 이득에는 충분했어요: 0.038 이득은 추출의 93%에서 0을 제외하는 구간을 가졌고, 0.02 미만의 이득은 7%에서 38%에서 그 기준을 넘었어요. 작은 움직임은 측정할 라벨이 생길 때까지 미해결로 취급하세요.
새 쿼리에서 승자 확인하기
같은 쿼리에서 선택하고 평가한 설정은 새 쿼리에서의 성능보다 더 좋아 보일 거예요. 라벨링된 쿼리를 반으로 나누세요: 한쪽에서 승자를 선택하고, 다른 쪽에서 그 이득을 측정하세요. 데이터셋당 이 분할을 200번 반복했어요.
선택된 설정은 보통 이식돼요. 새 절반에서 30개 설정을 모두 다시 랭킹했을 때, 우리의 선택은 보통 상위 네 개에 들었고, 분할의 0%에서 6%에서 기본값보다 뒤처졌어요. 이득은 줄어들지만, 선택이 보고한 것의 67%에서 95%를 유지했어요. 그러니 새 쿼리의 숫자를 보고하세요.
별도로 재구축한 인덱스를 비교한다면, 작은 nDCG@10 차이를 튜닝 이득으로 취급하기 전에 두 빌드 간의 top-10 일치를 확인하세요. 깨끗한 재구축 테스트에서 쿼리 샘플링이 그래프 변동보다 nDCG@10을 더 많이 움직였어요.
한 번에 하나의 변경으로 시작하기
대표적인 쿼리 집합에 대한 현재 관련성 메트릭과 p95 지연시간을 기록하세요. 증상 표에서 저비용 변경 하나를 고르고, 새 쿼리로 검증하고, 이득이 남는 경우에만 유지하세요. 그 기준선이 생기면, Candidate Depth: How Much Retrieval Is Enough?가 검색 깊이가 제약인지 테스트하는 방법을 보여줘요.