Published on

Milvus·Pinecone 하이브리드 검색 리랭크 튜닝

Authors

서치 품질을 올리는 가장 빠른 길은 “더 좋은 임베딩”만이 아닙니다. 실제 서비스에서는 벡터 검색만으로는 최신성, 정확한 키워드 매칭, 도메인 규칙(정책 문서 우선 등)을 반영하기 어렵고, 반대로 키워드 검색만으로는 의미 기반 유사도를 놓치기 쉽습니다. 그래서 실무에서는 하이브리드 검색(벡터+키워드)으로 후보군을 넓히고, 마지막 단계에서 리랭크로 정밀도를 끌어올리는 패턴이 안정적으로 먹힙니다.

이 글은 Milvus와 Pinecone를 “후보 검색기(candidate retriever)”로 두고, 그 위에 리랭크 계층을 얹어 품질과 지연시간을 동시에 튜닝하는 방법을 다룹니다. 특히 다음을 목표로 합니다.

  • Topk 정확도(예: NDCG@10, MRR@10) 상승
  • 동일한 리랭커로도 후보군 설계만 바꿔서 성능 향상
  • 점수 스케일 차이로 인한 하이브리드 결합 실패 방지
  • 온라인 A/B에서 재현 가능한 튜닝 루프 구축

또한 운영 관점에서의 지연시간, 타임아웃, 재시도 정책은 마이크로서비스 장애 양상과도 연결됩니다. 예를 들어 리랭크 호출이 외부 모델 서버를 거친다면 데드라인 전파가 깨질 때 tail latency가 폭증할 수 있으니, 관련 진단은 gRPC 데드라인 전파 실패, 원인과 진단법도 함께 참고하면 좋습니다.

전체 아키텍처: 후보 검색과 리랭크의 역할 분리

하이브리드 검색 리랭크를 설계할 때 핵심은 “각 단계가 잘하는 일만 하게” 만드는 것입니다.

  • 1단계 후보 검색: 빠르게 넓게 가져오기(Recall 중심)
    • Milvus: 벡터 ANN 검색에 강점, 필터링/파티션/인덱스 튜닝이 중요
    • Pinecone: 관리형 벡터DB로 운영 편의, 네임스페이스/메타데이터 필터 조합이 중요
    • 키워드(BM25 등): 정확한 토큰 매칭, 희귀 키워드, 코드/에러 메시지에 강함
  • 2단계 리랭크: 적은 후보를 비싸게 재정렬(Precision 중심)
    • Cross-encoder, ColBERT류 late interaction, 또는 LLM 기반 스코어링
    • 도메인 규칙(신뢰도, 최신성, 권한)도 여기서 함께 반영 가능

중요한 실무 포인트는 “리랭커가 아무리 좋아도 후보군에 정답이 없으면 끝”이라는 점입니다. 그래서 튜닝 순서는 보통 아래가 안전합니다.

  1. 후보군 recall 확보(벡터k, BM25k, 필터 전략)
  2. 하이브리드 결합 스코어 안정화(정규화, 가중치)
  3. 리랭커 교체/튜닝(모델, 입력 길이, 특징)
  4. 온라인 지표로 검증(A/B, 가드레일)

후보군 설계: Milvus와 Pinecone에서 공통으로 먹히는 전략

후보군 크기(k)를 “리랭크 예산”으로 역산

리랭크는 보통 비용이 큽니다. 따라서 리랭커가 처리할 문서 수를 먼저 정하고, 그에 맞춰 후보군을 구성합니다.

  • 리랭커 입력 후보: 보통 20~200개 사이에서 타협
  • 벡터 후보: 50~500
  • 키워드 후보: 20~200

실무 팁:

  • Top10 품질이 목표라면 리랭크 후보 50개부터 시작해도 충분한 경우가 많습니다.
  • tail latency가 문제면 “리랭크 후보 수를 줄이되, 후보 생성에서 정답 포함률을 높이는” 방향이 더 효과적입니다.

필터는 후보 생성 단계에서 최대한 적용

권한, 테넌트, 언어, 문서 타입 같은 필터는 리랭크 전에 반드시 적용해야 비용이 줄고, 잘못된 문서가 상위로 올라오는 사고도 줄어듭니다.

  • Milvus: scalar 필터를 인덱스/세그먼트 구조와 함께 고려
  • Pinecone: metadata filter를 적극 활용

필터를 리랭크 이후에 적용하면 “상위 결과가 대거 탈락”하면서 사용자 입장에서는 품질이 급락한 것처럼 보입니다.

최신성(Recency) 편향은 리랭크에서 다루되, 후보에서도 최소한 보장

최신 문서 우선이 중요한 도메인이라면 후보군에 최신 문서가 일정 비율 포함되도록 하는 편이 안전합니다.

  • 전략 A: 후보군을 2개 풀로 나눔
    • 풀1: 일반 벡터/키워드 검색
    • 풀2: 최근 N일 문서에 한정한 검색
    • 두 풀을 합쳐 리랭크

이렇게 하면 리랭커가 최신성 신호를 반영하기 전에 “최신 문서가 후보에 없는” 문제를 줄일 수 있습니다.

하이브리드 결합: 점수 정규화가 80%를 결정한다

Milvus/Pinecone 벡터 유사도 점수와 BM25 점수는 스케일이 다릅니다. 그대로 더하면 특정 쪽이 항상 이겨서 하이브리드가 무력화됩니다. 따라서 결합 전에 정규화가 필수입니다.

가장 안전한 기본: rank 기반 결합(RRF)

RRF(Reciprocal Rank Fusion)는 점수 스케일에 둔감하고, 구현이 간단하며, 튜닝이 쉬워 실무에서 자주 쓰입니다.

  • 각 검색기 결과의 순위만 사용
  • score = sum(1 / (k0 + rank))

아래는 Python 예시입니다.

from collections import defaultdict

def rrf_fusion(rank_lists, k0=60):
    """rank_lists: [{doc_id: rank(1-based)}, ...]"""
    fused = defaultdict(float)
    for ranks in rank_lists:
        for doc_id, r in ranks.items():
            fused[doc_id] += 1.0 / (k0 + r)
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)

# 예: 벡터 검색 rank, BM25 rank
vector_ranks = {"d1": 1, "d3": 2, "d2": 3}
bm25_ranks   = {"d2": 1, "d4": 2, "d1": 3}

print(rrf_fusion([vector_ranks, bm25_ranks], k0=60)[:3])

튜닝 포인트:

  • k0가 작을수록 상위 rank에 더 큰 가중치가 실립니다.
  • 보통 30~100 범위에서 실험하면 됩니다.

점수 기반 결합이 필요하면: z-score 또는 min-max 정규화

점수 기반으로 가중치를 주고 싶다면, 최소한 검색기별 점수 분포를 맞춰야 합니다.

  • min-max: 쿼리별로 점수를 0~1로 스케일
  • z-score: 평균 0, 표준편차 1로 스케일(분포가 안정적일 때 유리)

쿼리별 min-max 예시:

def minmax(scores):
    # scores: list of (doc_id, score)
    vals = [s for _, s in scores]
    lo, hi = min(vals), max(vals)
    if hi == lo:
        return {doc_id: 0.0 for doc_id, _ in scores}
    return {doc_id: (s - lo) / (hi - lo) for doc_id, s in scores}

def weighted_merge(vec_scores, kw_scores, alpha=0.6):
    v = minmax(vec_scores)
    k = minmax(kw_scores)
    doc_ids = set(v) | set(k)
    merged = []
    for d in doc_ids:
        merged.append((d, alpha * v.get(d, 0.0) + (1 - alpha) * k.get(d, 0.0)))
    return sorted(merged, key=lambda x: x[1], reverse=True)

튜닝 포인트:

  • alpha는 0.5부터 시작해 로그 기반 실험으로 최적값을 찾습니다.
  • 특정 쿼리군(짧은 질의, 코드/에러 질의, 고유명사)에서 BM25 비중이 더 커야 할 때가 많습니다.

리랭커 선택: Cross-encoder가 기본, 비용이 문제면 2단계로

1) Cross-encoder 리랭크

가장 흔한 형태는 querydocument를 함께 넣고 relevance score를 출력하는 cross-encoder입니다.

  • 장점: 품질이 잘 나옴, 구현 단순
  • 단점: 후보 수만큼 모델 호출이 필요해 비용과 지연이 큼

실무에서는 리랭크 후보를 50개로 제한하고, 문서 본문은 앞부분 일부만 잘라 넣는 식으로 최적화합니다.

  • 입력 길이 제한: 예를 들어 512~1024 토큰
  • 문서 스니펫: 제목 + 요약 + 첫 N자 + 하이라이트 구간

2) 2단계 리랭크(cheap rerank 후 expensive rerank)

트래픽이 많거나 지연시간 예산이 빡빡하면 2단계가 효과적입니다.

  • 1차: cheap re-ranker(예: 경량 bi-encoder score, 간단한 feature 모델)
  • 2차: expensive cross-encoder를 TopM에만 적용

예:

  • 후보 200개 생성
  • cheap로 200개를 50개로 축소
  • cross-encoder로 50개 최종 정렬

이 구조는 품질을 크게 잃지 않으면서 비용을 잘 통제합니다.

3) LLM 기반 리랭크는 “규칙+설명 가능성”이 필요할 때만

LLM으로 “이 문서가 왜 관련 있는지” 근거를 생성하면서 점수화할 수 있지만, 비용과 변동성이 큽니다. 운영에서는 다음 조건일 때만 추천합니다.

  • 도메인 규정/정책 기반의 복합 판단이 필요
  • 근거 문장 추출이 매우 중요
  • 강력한 캐시와 타임아웃, 폴백 전략이 있음

LLM 호출이 들어가면 429나 Throttling이 현실 이슈가 됩니다. 모델 호출 안정화는 AWS Bedrock Claude InvokeModel 429·Throttling 해결 같은 패턴(지수 백오프, 동시성 제한, 캐시)이 그대로 적용됩니다.

리랭크 튜닝 포인트: “모델”보다 “입력”이 더 잘 먹힐 때가 많다

문서 입력 구성: 제목, 섹션, 메타데이터를 구조화

리랭커 입력을 단순히 본문만 넣으면, 중요한 신호(문서 타입, 작성일, 제품명, 태그)를 놓칩니다. 다음처럼 템플릿을 고정하는 게 좋습니다.

  • title: 짧고 강한 신호
  • metadata: 제품/버전/언어/권한
  • body_snippet: 너무 길지 않게

예시 템플릿(인라인 구분자는 텍스트로):

[TITLE] ...
[TYPE] ...
[TAGS] ...
[UPDATED_AT] ...
[BODY] ...

이렇게 하면 리랭커가 “문서 성격”을 빨리 파악해 오답 상위를 줄이는 경우가 많습니다.

쿼리 리라이트와 리랭크를 분리

사용자 질의가 짧거나 모호하면 리랭커가 힘을 못 씁니다. 이때는 쿼리 리라이트를 먼저 하고, 리랭크는 리라이트된 쿼리로 수행합니다.

  • 리라이트: 동의어 확장, 제품명 표준화, 오타 교정
  • 리랭크: 최종 relevance 판단

리라이트를 리랭커 프롬프트에 섞어버리면 디버깅이 어려워지고, 품질 회귀 원인도 찾기 힘듭니다.

하드 네거티브를 로그로 수집해 오프라인 평가셋을 만든다

리랭커 튜닝에서 가장 중요한 데이터는 “비슷해 보이지만 틀린 문서”입니다.

  • 벡터 Top50에는 자주 뜨지만 클릭/채택이 거의 없는 문서
  • BM25 Top50에는 뜨지만 실제 답과 무관한 문서

이들을 네거티브로 축적하면, 리랭커가 헷갈리는 경계를 빠르게 학습하거나(파인튜닝 시), 최소한 평가셋이 좋아져 튜닝이 빨라집니다.

Milvus 튜닝 체크리스트: 인덱스와 검색 파라미터

Milvus는 인덱스 타입과 검색 파라미터가 recall/latency를 크게 좌우합니다.

  • 인덱스: HNSW, IVF_FLAT, IVF_PQ 등
  • 검색 파라미터 예: HNSW의 ef, IVF의 nprobe

원칙:

  • 후보군 recall이 부족하면 ef 또는 nprobe를 올리되, p95 지연을 같이 봅니다.
  • 필터가 많은 쿼리에서는 인덱스 성능이 급격히 흔들릴 수 있어, 컬렉션/파티션 설계가 중요합니다.

Milvus 검색 호출 예시(pymilvus, 개념 코드):

# 주의: 실제 필드명/파라미터는 스키마에 맞게 조정
search_params = {
    "metric_type": "COSINE",
    "params": {"ef": 128}
}

results = collection.search(
    data=[query_vector],
    anns_field="embedding",
    param=search_params,
    limit=200,
    expr="tenant_id == 42 and lang == 'ko'"
)

여기서 limit을 무작정 올리기보다, 리랭크 예산에 맞춰 100~300 사이에서 튜닝하는 편이 좋습니다.

Pinecone 튜닝 체크리스트: 네임스페이스, 필터, TopK 전략

Pinecone에서는 다음이 품질과 비용에 직결됩니다.

  • namespace로 테넌트/도메인을 분리해 검색 공간을 줄이기
  • metadata filter로 후보를 초기에 정제
  • Topk를 리랭크 예산에 맞춰 제한

개념 코드 예시:

# pinecone client 버전에 따라 API는 달라질 수 있음
res = index.query(
    namespace="tenant-42",
    vector=query_vector,
    top_k=200,
    include_metadata=True,
    filter={"lang": "ko", "doc_type": {"$in": ["guide", "faq"]}}
)

실무 팁:

  • 필터 조건이 복잡해질수록 “필터로 인해 후보가 너무 줄어드는” 문제가 생깁니다. 이때는 필터를 완화한 보조 쿼리를 하나 더 날려서 후보를 합치는 방식도 고려합니다.

온라인 튜닝 루프: 지표, 가드레일, 장애 대비

리랭크는 품질을 올리지만, 시스템 복잡도도 올립니다. 그래서 온라인 실험에서는 품질 지표만큼 안정성 지표가 중요합니다.

  • 품질: CTR, long click, answer accept rate, NDCG@10(오프라인)
  • 안정성: p50/p95/p99 latency, 타임아웃 비율, 폴백 발생률

권장 가드레일:

  • 리랭커 타임아웃 시 즉시 폴백: 하이브리드 결과를 그대로 반환
  • 서킷 브레이커: 오류율 상승 시 리랭크 비활성화
  • 캐시: 동일 쿼리 상위 후보에 대한 리랭크 결과 캐시

마이크로서비스에서 타임아웃/데드라인이 계층적으로 전파되지 않으면 상위 서비스가 이미 포기했는데 하위 호출이 계속 살아서 리소스를 태우는 상황이 생깁니다. 이런 경우는 gRPC 데드라인 전파 실패, 원인과 진단법에서 말하는 패턴으로 재현되는 경우가 많습니다.

또한 리랭커를 GPU 서빙으로 운영한다면 배포 방식이 품질과 장애율에 영향을 줍니다. 카나리와 A/B를 결합해 점진적으로 트래픽을 올리는 방식은 KServe로 GPU 모델 무중단 배포 - Canary+A/B도 함께 보면 바로 적용할 수 있습니다.

추천 튜닝 레시피(현업에서 가장 재현성 좋았던 순서)

  1. 후보군 안정화
    • 벡터 Topk 200, 키워드 Topk 100으로 시작
    • 필터는 후보 단계에서 강하게 적용
  2. 결합은 RRF로 시작
    • 점수 정규화 이슈를 우회하고 빠르게 베이스라인 확보
  3. 리랭크 후보는 50으로 제한
    • 입력 템플릿을 고정하고 문서 스니펫을 최적화
  4. 쿼리군별 분리 튜닝
    • 짧은 질의: BM25 비중 상향 또는 키워드 후보 확대
    • 고유명사/에러코드: 키워드 강제 포함 규칙 추가
  5. 온라인 실험
    • 타임아웃 폴백, 캐시, 서킷 브레이커를 먼저 넣고 A/B

마무리

Milvus와 Pinecone는 후보 검색기로서 모두 강력하지만, 하이브리드 결합과 리랭크를 제대로 설계하지 않으면 “두 개를 붙였는데도 품질이 안 오르는” 상황이 쉽게 나옵니다. 이때 가장 먼저 의심해야 할 것은 리랭커 성능이 아니라 다음 두 가지입니다.

  • 후보군에 정답이 들어오는지(Recall)
  • 벡터 점수와 키워드 점수가 공정하게 결합되는지(정규화/결합)

그 위에 리랭커 입력 템플릿과 후보 수 예산을 잡아주면, 같은 모델로도 체감 품질이 크게 오르는 경우가 많습니다.

원하면 다음 단계로, “쿼리 유형 분류 기반 동적 가중치(예: 짧은 질의면 BM25 가중치 증가)”나 “도메인 피처를 포함한 학습형 랭커(LTR)로의 확장”까지 이어서 정리해 드릴 수 있습니다.