Published on

Pinecone 하이브리드 검색 - BM25+벡터 가중치 튜닝

Authors

서치 품질을 올리려면 결국 두 가지를 동시에 만족해야 합니다. 사용자가 입력한 문자 그대로의 단서(키워드) 를 놓치지 않으면서, 표현이 달라도 같은 의미를 찾는 의미 기반 매칭(벡터) 도 강해야 합니다. Pinecone의 하이브리드 검색은 이 두 축을 한 번의 쿼리로 합칠 수 있지만, 기본값만으로는 도메인에 따라 쉽게 삐끗합니다. 특히 BM25 점수 분포벡터 유사도 분포 의 스케일이 다르기 때문에, 단순 가중치 조합은 의도치 않게 한쪽이 다른 쪽을 압도하기 쉽습니다.

이 글에서는 Pinecone 하이브리드 검색에서 BM25+벡터 가중치(알파) 튜닝 을 어떻게 접근하면 재현 가능하고 빠르게 수렴하는지, 그리고 운영 중 어떤 지표로 되돌림 없이 개선하는지까지 다룹니다.

관련해서 LLM 호출을 붙이는 경우가 많으니, 실서비스에서 흔한 재시도/백오프 패턴은 LangChain에서 OpenAI 429·타임아웃 재시도 백오프도 함께 참고하면 좋습니다.

하이브리드 검색이 필요한 대표 증상

다음 중 하나라도 해당하면 하이브리드가 거의 정답입니다.

  • 제품명, 에러코드, 약어처럼 정확한 토큰 매칭 이 중요한데 벡터 검색만 쓰면 누락된다
  • 동의어, 문장형 질의에서 의미 매칭 이 필요해서 BM25만으로는 상위가 엉킨다
  • 문서가 길고 다양한 주제를 포함해, 키워드만으로는 주제 중심 을 잡기 어렵다
  • 한국어처럼 형태소/띄어쓰기 변형이 많아, 키워드 기반만으로는 리콜이 흔들린다

핵심은 “정확히 일치해야 하는 신호”와 “의미적으로 가까워야 하는 신호”를 분리해 각각 최적화한 뒤, 마지막에 결합하는 것입니다.

Pinecone 하이브리드 검색의 점수 결합 개념

Pinecone 하이브리드 검색은 보통 다음 형태로 이해하면 튜닝이 쉬워집니다.

  • bm25_score: sparse(희소) 벡터 기반 키워드 점수
  • vector_score: dense(밀집) 벡터 유사도 점수
  • 최종 점수는 대략 alpha 로 두 신호를 섞는 형태

여기서 가장 중요한 사실은 두 점수의 스케일이 다르다 는 점입니다.

  • BM25 계열은 문서 길이, 용어 빈도, 질의 길이에 따라 점수 범위가 크게 변합니다.
  • 벡터 유사도(코사인/내적)는 임베딩 모델과 정규화 여부에 따라 분포가 바뀝니다.

따라서 alpha=0.5 가 “공평한 절반”을 의미하지 않습니다. 실제로는 한쪽이 거의 지배할 수 있습니다.

설계 1: sparse는 무엇을 넣을지부터 결정하라

하이브리드에서 sparse는 단순히 “원문 전체 토큰”을 넣는다고 끝나지 않습니다. 어떤 토큰을 sparse에 넣느냐가 BM25의 성격을 바꿉니다.

권장 패턴:

  • 제목, 제품명, 에러코드, 핵심 키워드 는 sparse에 반드시 포함
  • 본문 전체를 sparse에 넣을지 여부는 도메인에 따라 결정
    • FAQ/짧은 문서: 본문 포함해도 안정적
    • 긴 기술 문서: 본문 전체 sparse는 노이즈가 늘 수 있음
  • 형태소 분석이나 n-gram을 쓸 경우, 토큰 폭발로 BM25가 과도하게 강해질 수 있어 주의

운영 팁:

  • sparse 입력을 “검색용 필드”로 분리하세요. 예: search_text = title + tags + error_codes + summary
  • 원문은 메타데이터로 보관하고, 검색은 정제된 텍스트로 수행하는 것이 튜닝을 단순화합니다.

설계 2: dense는 어떤 임베딩이든 같지 않다

dense는 임베딩 모델 선택과 전처리에 따라 성능이 크게 갈립니다.

  • 짧은 질의가 많으면, 질의 임베딩이 지나치게 일반화되어 벡터 검색이 넓게 퍼질 수 있습니다.
  • 문서가 길면, 문서 임베딩이 “평균”이 되어 핵심을 잃습니다.

권장 패턴:

  • 문서를 청크로 나누고(예: 300자~800자) 각 청크를 벡터화
  • 제목/헤더를 청크 앞에 붙여 의미를 보강
  • 쿼리도 필요한 경우 “검색용 쿼리 리라이트”를 별도로 두되, 비용과 지연을 고려

LLM을 리라이트에 쓰는 경우 트래픽 급증 시 429/타임아웃이 품질을 흔들 수 있으니, 재시도 전략은 LangChain에서 OpenAI 429·타임아웃 재시도 백오프를 같이 보세요.

가중치 튜닝의 본질: alpha 가 아니라 “분포 정렬”

많은 팀이 alpha 를 0.2, 0.5, 0.8로 돌려보고 감으로 고르는데, 이 방식은 재현성이 낮습니다. 더 안정적인 접근은 다음 순서입니다.

  1. 대표 쿼리 세트(최소 100개)를 만든다
  2. 각 쿼리에서
    • BM25만의 상위 k 결과
    • 벡터만의 상위 k 결과 를 각각 뽑아 품질을 확인한다
  3. 하이브리드로 섞되, alpha 를 바꾸면서 품질 지표를 측정한다
  4. 결과를 보면 “alpha 문제가 아니라 sparse 구성/청킹/필터링 문제”가 드러나는 경우가 많다

여기서 중요한 포인트는 각 단일 신호가 최소한의 품질을 갖추어야 하이브리드가 의미가 있다는 것입니다. BM25만으로도 전혀 못 찾고, 벡터만으로도 전혀 못 찾으면 alpha 로 해결되지 않습니다.

실전 튜닝 절차: 1시간 내 1차 수렴시키기

1) 평가 데이터 만들기(수작업 최소화)

이상적인 건 쿼리별 정답 문서 라벨이지만, 처음부터 완벽할 필요는 없습니다.

  • 상위 10개 결과 중 “정답 여부”만 체크하는 약식 라벨로 시작
  • 쿼리 유형을 섞기
    • 정확 일치형: 에러코드, 함수명, 제품 SKU
    • 의미형: 자연어 질문, 요약형 질의
    • 혼합형: 키워드+자연어

지표는 단순하게 시작하세요.

  • Recall@10: 정답이 10개 안에 있나
  • MRR@10: 정답이 얼마나 위에 있나

2) alpha 그리드 서치

alpha 를 0.0부터 1.0까지 0.1 간격으로 돌려도 되고, 더 빠르게 하려면 0.2, 0.5, 0.8부터 시작해 구간을 좁히면 됩니다.

아래는 Python으로 “그리드 서치 + MRR 계산”을 하는 예시입니다. Pinecone SDK 버전과 하이브리드 API 형태는 환경마다 다를 수 있으니, 핵심은 알파를 바꿔가며 동일 쿼리셋을 반복 평가 하는 구조입니다.

from dataclasses import dataclass
from typing import List, Dict, Optional

@dataclass
class LabeledQuery:
    qid: str
    query: str
    relevant_ids: List[str]


def mrr_at_k(results: List[str], relevant: set, k: int = 10) -> float:
    for i, doc_id in enumerate(results[:k], start=1):
        if doc_id in relevant:
            return 1.0 / i
    return 0.0


def evaluate_alpha(
    queries: List[LabeledQuery],
    search_fn,
    alpha: float,
    k: int = 10,
) -> Dict[str, float]:
    mrr_sum = 0.0
    hit_sum = 0.0

    for item in queries:
        # search_fn은 alpha를 받아 하이브리드 검색을 수행하고
        # doc_id 리스트를 반환한다고 가정
        doc_ids = search_fn(item.query, alpha=alpha, top_k=k)
        rel = set(item.relevant_ids)

        mrr = mrr_at_k(doc_ids, rel, k=k)
        mrr_sum += mrr
        hit_sum += 1.0 if mrr > 0 else 0.0

    n = max(len(queries), 1)
    return {
        "alpha": alpha,
        "mrr@10": mrr_sum / n,
        "recall@10": hit_sum / n,
    }


# 사용 예시
alphas = [i / 10 for i in range(0, 11)]
# results = [evaluate_alpha(qset, pinecone_hybrid_search, a) for a in alphas]
# print(sorted(results, key=lambda x: x["mrr@10"], reverse=True)[:3])

이 단계에서 얻고 싶은 것은 “최고 알파”가 아니라, 어느 구간에서 성능이 안정적인지 입니다. 운영에서는 쿼리 분포가 바뀌기 때문에, 아주 뾰족한 최적점은 깨지기 쉽습니다.

3) 쿼리 유형별로 알파를 다르게 적용하기

단일 alpha 로 전체를 커버하려 하면 타협이 커집니다. 실전에서는 쿼리 분류 후 알파를 다르게 주는 것이 효과적입니다.

예시 규칙:

  • 에러코드/ID/버전 문자열 포함 시: sparse 비중 증가
  • 질의 길이가 길고 문장형이면: dense 비중 증가
  • 따옴표, 괄호, 특수 토큰이 많으면: sparse 비중 증가

아래는 간단한 휴리스틱 예시입니다.

import re

def choose_alpha(query: str) -> float:
    q = query.strip()

    # 에러코드, 버전, 식별자 같은 패턴
    if re.search(r"\b([A-Z]{2,}-\d{2,}|\d+\.\d+\.\d+)\b", q):
        return 0.25  # sparse를 더 신뢰

    # 짧은 키워드형
    if len(q) <= 8 and " " not in q:
        return 0.35

    # 문장형
    if len(q) >= 25:
        return 0.70  # dense를 더 신뢰

    return 0.55

이 방식은 “전역 최적 알파”를 찾는 것보다, 체감 품질을 더 빠르게 올려줍니다.

점수 스케일 문제를 다루는 방법

alpha 튜닝이 어려운 가장 큰 이유는 스케일 불일치입니다. 이를 완화하는 실전 방법을 정리합니다.

방법 A: sparse 입력을 줄여 BM25의 폭주를 막기

BM25가 너무 강하면 하이브리드가 사실상 키워드 검색이 됩니다.

  • 본문 전체를 sparse에 넣었다면, 요약/키워드만 남기고 줄여보세요.
  • 태그/카테고리처럼 반복 토큰이 많은 필드는 sparse에서 제외하거나 가중치를 낮추는 방향을 고려하세요.

방법 B: dense 쪽 리콜을 올려 벡터가 경쟁하게 만들기

dense가 약하면 alpha 를 높여도 효과가 없습니다.

  • 청크 크기 재조정
  • 제목/섹션 헤더를 청크에 포함
  • 임베딩 모델 교체 또는 도메인 특화 임베딩 고려

방법 C: 필터를 먼저 정확히 설계하기

하이브리드는 “검색”이지 “권한/테넌트 분리”를 대신하지 않습니다.

  • 테넌트, 언어, 문서 타입, 최신 버전 같은 조건은 메타데이터 필터로 먼저 제한
  • 필터가 부정확하면 sparse와 dense가 서로 다른 코퍼스를 보고 싸우는 상황이 생깁니다

디버깅: 왜 이런 결과가 나왔는지 설명 가능해야 한다

하이브리드를 운영에 넣으면, 반드시 “왜 이 문서가 1등인가”를 설명해야 하는 순간이 옵니다.

추천 체크리스트:

  • 해당 문서가 sparse에서 어떤 토큰으로 매칭되었는가
  • dense에서 유사도가 높은 이유가 무엇인가(청크 내용 확인)
  • 문서가 너무 길어 dense가 평균화된 것은 아닌가
  • 동형이의어 때문에 벡터가 잘못 끌어온 것은 아닌가

실무에서는 “검색 결과의 상위 5개에 대해, 매칭 근거를 로그로 남기기”가 효과적입니다. 예를 들어 결과 문서의 matched_termstop_chunk_preview 같은 필드를 함께 저장해두면, 튜닝 속도가 크게 빨라집니다.

운영 팁: 하이브리드 검색과 RAG를 붙일 때

하이브리드 검색은 RAG의 리트리버로 자주 쓰입니다. 이때는 검색 품질뿐 아니라 지연과 비용 도 같이 최적화해야 합니다.

  • top_k 를 무작정 키우면 LLM 컨텍스트 비용이 폭증합니다
  • 대신 “하이브리드로 top_k=20”을 뽑고, rerank로 top_k=5로 줄이는 구성이 흔합니다
  • LLM 기반 rerank를 쓸 경우 장애/레이트리밋 대응이 필수입니다. 재시도/폴백 설계는 Claude API 529 Overloaded 재시도·폴백 설계도 참고할 만합니다

자주 하는 실수 7가지

  1. alpha=0.5 를 “중간값”으로 믿고 더 이상 보지 않는다
  2. sparse에 원문 전체를 넣어 BM25가 노이즈를 확 키운다
  3. 청크 없이 문서 전체를 한 벡터로 만들어 dense가 약해진다
  4. 필터(테넌트/언어/버전)가 부정확해 관련 없는 코퍼스를 같이 검색한다
  5. 평가 쿼리셋 없이 운영 트래픽에서 감으로 튜닝한다
  6. 쿼리 유형별로 다른 알파를 주지 않고 전역 값 하나로만 해결하려 한다
  7. 디버깅 로그가 없어 “왜 이게 1등인지”를 설명할 수 없다

추천 시작점(치트시트)

도메인별로 다르지만, 빠른 1차 수렴을 위한 출발점은 다음이 무난합니다.

  • 문서 청크: 400자800자, 오버랩 50자150자
  • sparse 텍스트: 제목 + 태그 + 요약 + 식별자(에러코드/제품명)
  • alpha 초기값
    • 기술 문서 QnA: 0.55~0.75
    • 제품 SKU/정확일치 중심: 0.20~0.45
  • top_k: 10~30에서 시작 후 rerank로 축소

마무리: 튜닝은 alpha 가 아니라 “신호를 설계하는 일”

Pinecone 하이브리드 검색의 가중치 튜닝은 겉으로는 alpha 하나처럼 보이지만, 실제로는

  • sparse에 어떤 텍스트를 넣을지
  • dense를 어떤 단위로 벡터화할지(청크)
  • 필터로 코퍼스를 어떻게 제한할지
  • 쿼리 유형별로 어떤 전략을 적용할지

를 조합해 두 신호가 같은 문제를 경쟁적으로 풀 수 있게 만드는 과정 입니다.

먼저 BM25 단독과 벡터 단독을 각각 “쓸 만한 수준”으로 만든 뒤, 평가 쿼리셋으로 alpha 를 그리드 서치하고, 마지막으로 쿼리 유형별 동적 알파로 운영 안정성을 확보하세요. 이 순서대로 가면 감이 아니라 데이터로 수렴합니다.