검색 품질 검증
검색 품질 검증 (migration-guidance-search-quality)
두 시스템이 완전히 동일한 벡터를 들고 있어도 인덱싱, 양자화, 스코어링, 필터링 구현 방식이 다르기 때문에 서로 다른 검색 결과를 낼 수 있어요. 이게 마이그레이션 검증에서 아마도 가장 어려운 부분입니다. 이 가이드는 검증을 세 개의 티어(tier) 로 나눠서, 자원과 위험 허용 수준에 맞는 엄격함을 선택할 수 있게 해줘요.
출처: Qdrant 공식문서
세 단계(Tier) 검색 품질 체크
| Tier | 노력 | 무엇을 잡는가 | 언제 사용하는가 |
|---|---|---|---|
| Tier 1: Spot-Check | 15분 | 치명적 실패: 잘못된 메트릭, 고장난 필터, 명백히 잘못된 결과 | 모든 마이그레이션 |
| Tier 2: Statistical Sampling | 1-2시간 | 체계적 리콜 저하, 필터 상호작용 버그, 점수 분포 이동 | 프로덕션 워크로드, >100K 벡터 |
| Tier 3: Gold-Standard Evaluation | 반나절~수일 | 신뢰 구간이 있는 측정 가능한 관련성 변화 | 높은 위험 검색(수익·안전), 규제 산업 |
우리의 권장 사항: 모든 마이그레이션은 Tier 1과 Tier 2를 실행해야 합니다. Tier 3은 라벨링된 평가 데이터가 있거나 만들 수 있는 팀을 위한 것이에요. 지금 라벨링된 데이터가 없다면, Tier 2가 강력한 정량적 기준을 제공하고 이 가이드는 시간이 지남에 따라 Tier 3으로 나아가는 방법을 보여줍니다.
Tier 1: Spot-Check (모든 마이그레이션)
기준 쿼리를 Qdrant에 대해 실행하고 결과를 눈으로 확인해 보세요. 이 단계는 모든 쿼리에 영향을 주는 설정 레벨 오류를 잡아냅니다: 잘못된 거리 메트릭, 누락된 인덱스, 고장난 필터 로직.
from qdrant_client import QdrantClient, models
client = QdrantClient("localhost", port=6333)
def run_baseline_queries(baseline_queries, collection_name):
"""Run pre-recorded baseline queries against Qdrant."""
results = []
for bq in baseline_queries:
qdrant_results = client.query_points(
collection_name=collection_name,
query=bq["query_vector"],
limit=bq["top_k"],
query_filter=build_qdrant_filter(bq["filters"]) if bq.get("filters") else None,
)
results.append({
"query_id": bq["query_id"],
"description": bq["description"],
"source_results": bq["source_results"],
"qdrant_results": [
{"id": hit.id, "score": hit.score, "rank": i + 1}
for i, hit in enumerate(qdrant_results.points)
],
})
return results
무엇을 봐야 하는가
각 쿼리에 대해 소스 결과와 Qdrant 결과를 비교하세요.
def tier1_report(comparison_results):
"""Generate a human-readable spot-check report."""
for result in comparison_results:
source_ids = [r["id"] for r in result["source_results"]]
qdrant_ids = [r["id"] for r in result["qdrant_results"]]
overlap = set(source_ids) & set(qdrant_ids)
overlap_pct = len(overlap) / len(source_ids) * 100
print(f"\nQuery: {result['description']} ({result['query_id']})")
print(f" Result overlap: {len(overlap)}/{len(source_ids)} ({overlap_pct:.0f}%)")
# Check if top result matches
if source_ids and qdrant_ids:
if source_ids[0] == qdrant_ids[0]:
print(f" Top result: ✓ matches")
else:
print(f" Top result: ✗ differs "
f"(source={source_ids[0]}, qdrant={qdrant_ids[0]})")
# Check score distribution
if result["qdrant_results"]:
scores = [r["score"] for r in result["qdrant_results"]]
print(f" Score range: {min(scores):.4f} to {max(scores):.4f}")
Tier 1 통과/실패 기준
- Top-1 일치율 ≥80%: 10개 쿼리 중 8개가 같은 top 결과를 반환
- Top-10 겹침 ≥70%: 같은 문서 중 7개 이상이 top 10에 나타남 (순서는 다를 수 있음)
- 빈 결과 없음: 소스에서 결과를 반환한 쿼리는 Qdrant에서도 결과를 반환해야 함
- 점수 범위가 합리적: 코사인 유사도 점수는 -1과 1 사이여야 함; dot product 점수는 벡터 크기에 따라 달라짐
Tier 1이 실패하면: 멈추세요. 문제는 거의 확실히 설정 문제(거리 메트릭, 누락된 인덱스, 필터 변환 오류)입니다. 추가 체크를 실행하기 전에 불일치 진단으로 가세요.
Tier 2: 통계적 샘플링 (권장)
Tier 2는 recall@k로 검색 품질을 정량화합니다: 소스 시스템 결과 중 Qdrant 결과에도 나타나는 비율이에요. 10개 쿼리를 눈으로 보는 대신 50개 이상에서 리콜을 측정하고 통계를 계산합니다.
Recall@k
Recall@k는 이렇게 측정합니다: "소스 시스템의 top-k 결과 중 Qdrant의 top-k에도 나타나는 비율은?"
def recall_at_k(source_results, qdrant_results, k):
"""Compute recall@k: fraction of source top-k present in Qdrant top-k."""
source_ids = set(r["id"] for r in source_results[:k])
qdrant_ids = set(r["id"] for r in qdrant_results[:k])
if not source_ids:
return 1.0 # No source results = vacuously correct
return len(source_ids & qdrant_ids) / len(source_ids)
평가 실행
import numpy as np
import json
def tier2_evaluation(baseline_queries, collection_name, client, k=10):
"""Run Tier 2 recall evaluation across all baseline queries."""
recalls = []
for bq in baseline_queries:
qdrant_results = client.query_points(
collection_name=collection_name,
query=bq["query_vector"],
limit=k,
query_filter=build_qdrant_filter(bq["filters"]) if bq.get("filters") else None,
)
qdrant_ranked = [
{"id": hit.id, "score": hit.score}
for hit in qdrant_results.points
]
r_at_k = recall_at_k(bq["source_results"], qdrant_ranked, k)
recalls.append({
"query_id": bq["query_id"],
"recall_at_k": r_at_k,
})
# Compute aggregate statistics
recall_values = [r["recall_at_k"] for r in recalls]
stats = {
"num_queries": len(recalls),
"k": k,
"mean_recall": float(np.mean(recall_values)),
"median_recall": float(np.median(recall_values)),
"min_recall": float(np.min(recall_values)),
"p5_recall": float(np.percentile(recall_values, 5)),
"p25_recall": float(np.percentile(recall_values, 25)),
"std_recall": float(np.std(recall_values)),
}
return recalls, stats
Tier 2 결과 해석
def tier2_report(recalls, stats):
"""Print Tier 2 evaluation summary."""
print(f"Recall@{stats['k']} across {stats['num_queries']} queries:")
print(f" Mean: {stats['mean_recall']:.3f}")
print(f" Median: {stats['median_recall']:.3f}")
print(f" Min: {stats['min_recall']:.3f}")
print(f" P5: {stats['p5_recall']:.3f}")
print(f" Std: {stats['std_recall']:.3f}")
# Flag low-recall queries for investigation
low_recall = [r for r in recalls if r["recall_at_k"] < 0.7]
if low_recall:
print(f"\n⚠ {len(low_recall)} queries with recall < 0.7:")
for r in low_recall:
print(f" {r['query_id']}: {r['recall_at_k']:.3f}")
리콜이 1.0이 아닌 이유 (그래도 괜찮다는 뜻)
정확한 마이그레이션이라도 recall@10은 보통 1.0이 아니라 0.85에서 0.95 사이를 보여줘요. 이건 버그가 아니에요. 이유는 다음과 같습니다.
- HNSW는 근사적입니다: 두 시스템 모두 근사 최근접 이웃 알고리즘을 사용해요. HNSW 파라미터(
ef_construction,M, 검색 시ef)가 다르면 약간 다른 탐색 경로를 만들고 약간 다른 이웃을 가져옵니다. - 인덱스 빌드 순서가 중요합니다: HNSW 그래프 구조는 삽입 순서에 의존합니다. 같은 데이터라도 다른 순서로 삽입하면 다른(하지만 통계적으로 동등한) 리콜을 가진 다른 그래프가 만들어져요.
- 양자화는 노이즈를 도입합니다: 어느 시스템이든 양자화를 쓰면 거리 계산 정밀도가 떨어집니다. 양자화 방식이 다른 두 시스템은 경계선상 결과에 대해 다르게 판단할 수 있어요.
- 점수 동률: 여러 벡터가 쿼리와 거의 동일한 거리를 가질 때 동률 해소(tie-breaking)는 임의적이에요. 10번째와 11번째 결과가 시스템 간에 바뀔 수 있습니다.
중요한 것: 개별 쿼리 리콜이 아니라 리콜의 분포입니다. 평균 recall@10 ≥0.85이고 recall <0.5인 쿼리가 없다면 마이그레이션이 올바르게 동작하고 있는 거예요. 시스템들이 명확한 매치가 아니라 경계선상 결과에서만 의견이 다른 겁니다.
Tier 2 통과/실패 기준
| 메트릭 | 통과 | 조사 | 실패 |
|---|---|---|---|
| Mean recall@10 | ≥0.85 | 0.70 to 0.85 | <0.70 |
| Median recall@10 | ≥0.90 | 0.75 to 0.90 | <0.75 |
| Min recall@10 | ≥0.50 | 0.30 to 0.50 | <0.30 |
| P5 recall@10 | ≥0.60 | 0.40 to 0.60 | <0.40 |
Tier 2가 통과했는데 일부 쿼리만 리콜이 낮다면: 이건 정상이에요. 리콜이 낮은 쿼리가 매우 선택적인 필터나 엣지 케이스를 포함하는지 확인하세요. 불일치 진단을 참고하세요.
Tier 2 확장: 점수 상관관계
리콜 너머에, 점수의 상대적 순서가 일관적인지 확인하세요:
from scipy import stats as scipy_stats
def score_correlation(source_results, qdrant_results):
"""Compute rank correlation between source and Qdrant scores for overlapping results."""
# Find overlapping IDs
source_map = {r["id"]: r["score"] for r in source_results}
qdrant_map = {r["id"]: r["score"] for r in qdrant_results}
common_ids = set(source_map.keys()) & set(qdrant_map.keys())
if len(common_ids) < 3:
return None # Not enough overlap to compute correlation
source_scores = [source_map[id] for id in common_ids]
qdrant_scores = [qdrant_map[id] for id in common_ids]
# Spearman rank correlation (order matters more than magnitude)
correlation, p_value = scipy_stats.spearmanr(source_scores, qdrant_scores)
return {"correlation": correlation, "p_value": p_value, "n_common": len(common_ids)}
겹치는 결과에서 Spearman 상관관계 >0.8이면, 정확한 점수는 다를지라도(시스템마다 점수를 다르게 스케일하므로 어차피 다름) 랭킹이 보존된다는 뜻이에요.
Tier 3: 골드-스탠다드 평가 (라벨링된 데이터가 있을 때)
Tier 3는 검색 결과가 관련성 있는지(단순히 소스 시스템과 일치하는지만 보는 것이 아니라) 측정합니다. 이를 위해 라벨링된 관련성 판단이 필요해요: 일련의 쿼리에 대해, 어떤 문서가 관련 있는지 사람이 검증한 라벨이 있는 것.
Tier 3가 중요한 이유
Tier 2는 소스 시스템의 결과가 ground truth라고 가정해요. 하지만 소스 시스템의 검색 품질이 불충분해서 마이그레이션하는 거라면, 그 결과를 완벽히 맞추는 것은 잘못된 목표입니다. Tier 3는 결과가 그랬던 것이 아니라 그래야 하는 것에 대해 측정합니다.
평가 집합 만들기
라벨링된 데이터가 없다면, 만들 수 있는 실용적 접근 방식 세 가지:
- 접근 A: 클릭/전환 로그 (가장 낮은 노력)
- 접근 B: 전문가 라벨링 (중간 노력)
- 접근 C: 합성 평가 (가장 낮은 진입 장벽)
Tier 3 메트릭 계산
라벨링된 데이터가 있으면 표준 정보 검색 메트릭을 계산합니다:
import numpy as np
def ndcg_at_k(retrieved_ids, relevance_map, k):
"""Normalized Discounted Cumulative Gain at k."""
dcg = 0.0
for i, doc_id in enumerate(retrieved_ids[:k]):
rel = relevance_map.get(doc_id, 0)
dcg += (2**rel - 1) / np.log2(i + 2) # i+2 because log2(1) = 0
# Ideal DCG: sort all relevance scores descending
ideal_rels = sorted(relevance_map.values(), reverse=True)[:k]
idcg = sum((2**rel - 1) / np.log2(i + 2) for i, rel in enumerate(ideal_rels))
return dcg / idcg if idcg > 0 else 0.0
def mrr(retrieved_ids, relevant_ids):
"""Mean Reciprocal Rank: how high is the first relevant result?"""
for i, doc_id in enumerate(retrieved_ids):
if doc_id in relevant_ids:
return 1.0 / (i + 1)
return 0.0
def tier3_evaluation(eval_queries, collection_name, client, k=10):
"""Full Tier 3 evaluation with NDCG and MRR."""
ndcgs = []
mrrs = []
for eq in eval_queries:
qdrant_results = client.query_points(
collection_name=collection_name,
query=eq["query_vector"],
limit=k,
query_filter=build_qdrant_filter(eq["filters"]) if eq.get("filters") else None,
)
retrieved_ids = [hit.id for hit in qdrant_results.points]
relevance_map = {d["id"]: d["relevance"] for d in eq["relevant_docs"]}
relevant_ids = set(relevance_map.keys())
ndcgs.append(ndcg_at_k(retrieved_ids, relevance_map, k))
mrrs.append(mrr(retrieved_ids, relevant_ids))
return {
"mean_ndcg": float(np.mean(ndcgs)),
"mean_mrr": float(np.mean(mrrs)),
"median_ndcg": float(np.median(ndcgs)),
"num_queries": len(eval_queries),
}
Tier 3 통과/실패 기준
Tier 3 목표는 도메인 특화적입니다. 여기 시작점이 있어요:
| 메트릭 | 좋음 | 수용 가능 | 조사 |
|---|---|---|---|
| NDCG@10 | ≥0.70 | 0.50 to 0.70 | <0.50 |
| MRR | ≥0.60 | 0.40 to 0.60 | <0.40 |
진짜 테스트: 소스 시스템과 Qdrant 사이의 Tier 3 메트릭을 비교하세요. Qdrant의 NDCG가 소스와 같거나 높으면 마이그레이션이 검색 품질을 개선한 것이에요. 더 낮다면 그 차이가 설정(고칠 수 있음) 때문인지 실제 기능 격차 때문인지 조사하세요.
Tier 3로 점진적으로 나아가기
대부분의 팀은 마이그레이션 당일에 라벨링된 평가 데이터가 없어요. 괜찮습니다. 실용적인 경로가 있어요.
- Day 0 (마이그레이션): Tier 1과 Tier 2를 실행하세요. 이제 정량적 기준을 갖게 됩니다.
- Week 1: 프로덕션에서 검색 쿼리와 사용자 상호작용을 로깅하기 시작하세요.
- Month 1: 클릭 로그로 접근 A 평가 데이터를 만드세요. 그걸로 Tier 3을 실행합니다.
- Quarter 1: 도메인 전문가가 가장 어려운 쿼리(Tier 2 리콜이 가장 낮았던 것)를 라벨링하게 하세요. 이게 골드-스탠다드 집합이 됩니다.
- 지속적으로: 임베딩, 인덱싱 파라미터, 양자화 설정을 바꿀 때마다 Tier 3을 다시 실행하세요.
다음: 불일치 진단(Diagnosing Discrepancies)