Published on

Qdrant+BM25 하이브리드 RAG 점수 폭주 디버깅

Authors

서로 다른 스코어 체계를 섞는 하이브리드 RAG는 검색 품질을 크게 끌어올릴 수 있지만, 운영에 들어가면 종종 점수 폭주(score explosion) 혹은 한쪽 신호의 독주를 만납니다. 예를 들어 특정 쿼리에서 BM25 점수가 수백~수천까지 튀어 벡터 유사도를 압도하거나, 반대로 벡터 스코어가 거의 1.0 근처로 몰리면서 텍스트 신호가 무력화되는 식입니다.

이 글은 Qdrant를 벡터 스토어로 쓰고, BM25(예: Elasticsearch/OpenSearch, Tantivy, Postgres ts_rank, 혹은 자체 구현)를 결합하는 구조에서 점수 폭주를 재현하고, 원인을 분해해 찾고, 안전하게 고치는 절차를 실무 관점으로 정리합니다.

중간중간 “점수가 왜 이렇게 커졌지?”를 숫자로 납득할 수 있도록 계측 포인트와 코드 예제를 포함합니다.

하이브리드 스코어 폭주의 전형적 증상

다음 중 하나라도 보이면 “합산/가중합/리랭크” 단계의 점수 체계가 깨졌을 가능성이 큽니다.

  • 특정 쿼리에서 상위 k 결과의 점수가 비정상적으로 큼(예: BM25 1200, 벡터 0.82인데 합산 결과가 1200대)
  • 동일한 쿼리에서 상위 결과가 항상 텍스트 검색 결과만 반복(벡터 의미 검색이 사실상 무시)
  • 반대로 항상 벡터 결과만 반복(BM25가 거의 영향 없음)
  • 배포/인덱스 리빌드 이후 갑자기 랭킹이 뒤집힘(스코어 분포 변화)
  • 쿼리 길이, 따옴표, 특수문자 포함 여부에 따라 스코어가 급변

먼저 구조를 분해하자: “스코어”는 같은 단위가 아니다

하이브리드 RAG에서 가장 흔한 착각은 “둘 다 숫자니까 더하면 된다”입니다.

  • Qdrant 벡터 유사도는 거리(metric)에 따라 의미가 달라집니다.
    • Cosine 유사도는 보통 -1..1 또는 0..1 근처로 모이도록 정규화해서 쓰는 경우가 많습니다.
    • Dot product는 임베딩 노름(norm)에 따라 스케일이 커질 수 있습니다.
    • Euclidean 거리 기반이면 “작을수록 좋음”인데 이를 그대로 더하면 방향이 뒤집힙니다.
  • BM25 점수는 코퍼스 통계(idf), 문서 길이 정규화, 파라미터 k1, b에 따라 스케일이 크게 달라집니다. 같은 데이터라도 토크나이저/스톱워드/필드 부스팅에 따라 분포가 바뀝니다.

즉, 폭주 디버깅의 핵심은 “버그를 잡는다”라기보다 서로 다른 스코어를 비교 가능한 공간으로 사상(mapping)한다에 가깝습니다.

디버깅 0단계: 재현 가능한 로그 스키마 만들기

점수 문제는 “그때 그 쿼리”를 다시 못 만들면 끝입니다. 아래 항목을 한 줄 JSON 로그로 남기면 원인 추적 속도가 확 올라갑니다.

  • 쿼리 원문, 정규화된 쿼리(소문자화/공백 정리 등)
  • BM25 상위 k 결과의 (doc_id, bm25_score)
  • Qdrant 상위 k 결과의 (doc_id, vector_score)
  • 합성 방식(가중합, RRF, min-max, z-score 등)과 파라미터
  • 최종 상위 k(doc_id, final_score)
  • 임베딩 모델 버전, Qdrant 컬렉션/스냅샷 버전, BM25 인덱스 버전

아래는 Node.js/TypeScript에서 “두 검색 결과를 합치기 직전”에 남기는 예시입니다.

type Hit = { id: string; score: number };

function logHybridDebug(params: {
  query: string;
  normalizedQuery: string;
  bm25TopK: Hit[];
  vectorTopK: Hit[];
  fusion: { method: string; alpha?: number; k?: number };
  finalTopK: Hit[];
  meta: Record<string, unknown>;
}) {
  const payload = {
    ts: new Date().toISOString(),
    type: "hybrid_rag_debug",
    query: params.query,
    normalizedQuery: params.normalizedQuery,
    bm25: params.bm25TopK,
    vector: params.vectorTopK,
    fusion: params.fusion,
    final: params.finalTopK,
    meta: params.meta,
  };
  console.log(JSON.stringify(payload));
}

이 정도만 있어도 “BM25가 폭주했다”인지 “벡터가 포화됐다”인지, 아니면 “합성식이 뒤집혔다”인지가 바로 보입니다.

1단계: Qdrant 스코어의 의미부터 확정하기

Qdrant에서 반환되는 score는 설정한 distance/metric에 따라 해석이 다릅니다. 특히 다음 케이스가 폭주/독주의 원인이 됩니다.

(1) Dot product를 쓰는데 임베딩 노름이 들쭉날쭉

Dot product는 임베딩 벡터 크기에 민감합니다. 모델/전처리/정규화 여부가 바뀌면 분포가 크게 흔들립니다.

  • 해결: 임베딩을 저장하기 전에 L2 정규화하거나, cosine metric을 쓰는 편이 운영 안정성이 높습니다.

Python에서 임베딩 정규화 예시:

import numpy as np

def l2_normalize(vec: list[float]) -> list[float]:
    v = np.asarray(vec, dtype=np.float32)
    n = np.linalg.norm(v)
    if n == 0:
        return vec
    return (v / n).tolist()

(2) Euclidean 거리인데 “클수록 좋다”로 합산함

거리 기반은 작을수록 유사합니다. 이를 그대로 더하면 최악의 문서가 상위로 가거나, 변환 과정에서 음수/역수 처리로 폭주가 납니다.

  • 해결: 거리 d를 유사도 s로 바꾸는 단조 변환을 명시적으로 적용합니다.
    • 예: s = 1 / (1 + d) 또는 s = exp(-d)
function euclideanDistanceToSimilarity(d: number) {
  // d가 0에 가까울수록 1에 가깝게
  return 1 / (1 + d);
}

(3) 스코어가 0.7..0.9에 몰리는 “포화”

코사인 유사도는 많은 쿼리에서 상위권이 비슷한 값으로 뭉칠 수 있습니다. 이때 BM25를 조금만 섞어도 랭킹이 급격히 바뀌거나, 반대로 BM25가 너무 약하면 벡터가 고정됩니다.

  • 해결: 벡터 스코어를 그대로 쓰지 말고, 쿼리별 정규화(min-max, z-score)나 rank 기반 결합(RRF)을 고려합니다.

2단계: BM25 점수 폭주의 흔한 원인 6가지

BM25가 “갑자기 수백~수천”으로 튀는 건 이상 현상이라기보다, 아래 중 하나일 가능성이 큽니다.

(1) 필드 부스팅(boost) 또는 함수 스코어가 숨어 있음

검색 엔진 설정에서 title 필드에 boost=10 같은 게 걸려 있으면 점수 스케일이 커집니다. 여기에 function score(최신 문서 가산 등)까지 있으면 합성 시 사실상 BM25가 독주합니다.

  • 점검: 쿼리 DSL 또는 랭킹 스크립트에서 가중치가 있는지 확인

(2) 쿼리 토큰이 “희귀 토큰”으로만 구성됨

idf가 큰 토큰(예: 내부 코드명, UUID 일부, 에러 코드)이 들어가면 BM25가 크게 튈 수 있습니다.

  • 해결: 희귀 토큰을 별도 필드로 분리하거나, 특정 패턴(예: ERR1234)은 exact match로 우선 처리 후 하이브리드에서 제외하는 전략이 안정적입니다.

(3) 토크나이저 불일치(인덱싱과 검색 시 분석기가 다름)

인덱스는 형태소 분석, 검색은 n-gram 등으로 다르면 스코어 분포가 뒤틀립니다.

  • 해결: 인덱스/쿼리 분석기 일치, 또는 멀티필드 설계 후 필드별 가중치 재조정

(4) 문서 길이 정규화가 깨짐

BM25는 문서 길이의 영향을 받습니다. HTML 원문, 로그 원문을 그대로 넣어 길이가 급증하면 특정 문서가 불리/유리해질 수 있습니다.

  • 해결: 본문 정제(boilerplate 제거), 필드 분리(제목/요약/본문), 길이 상한 적용

(5) 중복 문서/중복 필드로 토큰이 과다 반복

같은 텍스트가 여러 필드에 중복 저장되거나, 파이프라인 버그로 본문이 두 번 붙으면 term frequency가 비정상적으로 커집니다.

  • 해결: 인덱싱 파이프라인에서 중복 제거, 필드별 저장 정책 점검

(6) BM25 파라미터 k1, b 튜닝이 과격

k1이 크면 TF에 민감해지고, b가 크면 길이 정규화가 강해집니다. 데이터 성격에 안 맞으면 스코어 분포가 넓어집니다.

  • 해결: 오프라인 평가셋으로 재튜닝, 최소한 분포(평균/표준편차/상위 퍼센타일) 모니터링

3단계: “합성 방식”이 폭주의 진짜 원인인 경우

대부분의 점수 폭주는 검색기 자체가 아니라 합성 방식에서 생깁니다. 아래는 실무에서 안정적으로 쓰는 순서입니다.

선택지 A: Rank 기반 결합(RRF)로 폭주를 원천 차단

Reciprocal Rank Fusion은 스코어 크기를 버리고 “순위”만 쓰므로, BM25가 1200이든 12든 상관이 없습니다.

RRF 공식은 보통 1 / (k + rank) 형태입니다. k는 완만함을 조절합니다.

function rrfFuse(params: {
  bm25: { id: string; rank: number }[];
  vector: { id: string; rank: number }[];
  k: number; // 예: 60
}) {
  const scores = new Map<string, number>();

  for (const h of params.bm25) {
    const s = 1 / (params.k + h.rank);
    scores.set(h.id, (scores.get(h.id) ?? 0) + s);
  }
  for (const h of params.vector) {
    const s = 1 / (params.k + h.rank);
    scores.set(h.id, (scores.get(h.id) ?? 0) + s);
  }

  return [...scores.entries()]
    .map(([id, score]) => ({ id, score }))
    .sort((a, b) => b.score - a.score);
}
  • 장점: 점수 폭주/스케일 문제에 매우 강함
  • 단점: “BM25 점수 차이가 큰 경우” 같은 강한 신호를 활용하기 어렵고, 상위 k를 넉넉히 뽑아야 함

선택지 B: 쿼리별 정규화 후 가중합(가장 흔한 정석)

BM25와 벡터 스코어를 각각 0..1로 맞춘 뒤 alpha로 섞습니다.

주의점은 “전역 정규화”가 아니라 쿼리별 정규화를 먼저 시도해야 한다는 점입니다. 전역 분포는 데이터/인덱스 버전에 따라 흔들립니다.

function minMaxNormalize(scores: number[]) {
  const min = Math.min(...scores);
  const max = Math.max(...scores);
  if (max === min) return scores.map(() => 0.5);
  return scores.map((s) => (s - min) / (max - min));
}

function weightedSumFuse(params: {
  bm25: { id: string; score: number }[];
  vector: { id: string; score: number }[];
  alpha: number; // 0..1, alpha가 클수록 벡터 비중
}) {
  const bm25NormArr = minMaxNormalize(params.bm25.map((x) => x.score));
  const vecNormArr = minMaxNormalize(params.vector.map((x) => x.score));

  const bm25Norm = new Map(params.bm25.map((x, i) => [x.id, bm25NormArr[i]]));
  const vecNorm = new Map(params.vector.map((x, i) => [x.id, vecNormArr[i]]));

  const ids = new Set<string>([...bm25Norm.keys(), ...vecNorm.keys()]);
  const out: { id: string; score: number }[] = [];

  for (const id of ids) {
    const b = bm25Norm.get(id) ?? 0;
    const v = vecNorm.get(id) ?? 0;
    const score = params.alpha * v + (1 - params.alpha) * b;
    out.push({ id, score });
  }

  return out.sort((a, b) => b.score - a.score);
}
  • 장점: 구현이 쉽고 해석 가능
  • 단점: 상위 k 내에서 min-max를 하면 이상치(outlier) 하나가 들어왔을 때 나머지가 눌릴 수 있음

이 단점을 줄이려면 min-max 대신 percentile clipping(예: 상위 95퍼센타일을 max로)이나 z-score를 고려합니다.

선택지 C: “곱” 또는 로지스틱 결합은 조심

final = bm25 * vector 같은 곱셈 결합은 한쪽이 0에 가까우면 전체가 죽고, 반대로 한쪽이 큰 스케일이면 폭주가 납니다. 로지스틱도 파라미터를 잘못 잡으면 포화/절벽이 생깁니다.

운영 안정성이 중요하면 RRF 또는 정규화+가중합이 안전합니다.

4단계: 점수 폭주를 빠르게 찾는 체크리스트

아래를 위에서부터 순서대로 보면 보통 30분 안에 “어디가 문제인지” 윤곽이 나옵니다.

  1. Qdrant metric 확인: cosine인지 dot인지 euclidean인지, 점수 방향(클수록 좋은지)
  2. 임베딩 정규화 여부: 저장 시점과 쿼리 시점 모두 동일한지
  3. BM25 쿼리 DSL 출력: boost, function score, 필드 가중치 확인
  4. 분포 로그: 쿼리별로 bm25_top1/top10, vector_top1/top10의 평균/표준편차 기록
  5. 합성식 단위 테스트: 동일 입력에 대해 합성 결과가 직관과 맞는지
  6. 인덱스 버전 차이: 리빌드 전후 동일 쿼리의 상위 문서와 스코어 비교

특히 4번의 “분포 로그”는 데이터가 쌓이면 조기 경보가 됩니다. 갑자기 상위 1퍼센타일이 3배가 됐다면, 인덱싱 파이프라인이나 분석기 변경이 들어갔을 가능성이 큽니다.

5단계: 재현 테스트 만들기(점수 회귀 방지)

점수 폭주는 재발하기 쉽습니다. 그래서 “대표 쿼리 세트”에 대한 회귀 테스트를 만들어두면, 인덱스/모델/파라미터 변경 시 바로 감지할 수 있습니다.

아래는 Python으로 하이브리드 결과의 간단한 회귀 지표를 측정하는 예시입니다.

  • dominance: 최종 상위 10개 중 BM25 출신이 몇 개인지
  • score_ratio: 최종 스코어에서 BM25 기여가 벡터 기여를 얼마나 압도하는지
from dataclasses import dataclass

@dataclass
class Hit:
    id: str
    bm25: float
    vec: float
    final: float

def dominance(topk: list[Hit]) -> float:
    # bm25가 0이 아니면 bm25 후보군에서 왔다고 가정(로그 설계에 맞게 조정)
    bm25_count = sum(1 for h in topk if h.bm25 > 0)
    return bm25_count / max(len(topk), 1)

def score_ratio(topk: list[Hit]) -> float:
    # 간단 지표: bm25 평균 / vec 평균
    bm25_avg = sum(h.bm25 for h in topk) / max(len(topk), 1)
    vec_avg = sum(h.vec for h in topk) / max(len(topk), 1)
    return bm25_avg / (vec_avg + 1e-9)

이런 지표는 정답 라벨이 없어도 “스코어 폭주”를 조기에 잡는 데 유용합니다.

운영 팁: 하이브리드 RAG의 “안전장치” 3가지

  1. 스코어 캡(cap) 또는 클리핑
    • BM25 상위 점수를 퍼센타일 기준으로 잘라내면 이상치가 전체를 망치는 일이 줄어듭니다.
  2. 쿼리 라우팅
    • 코드/에러/식별자 쿼리는 BM25 우선, 자연어 질문은 벡터 우선 등 룰을 두면 안정적입니다.
  3. 리랭커 단계에서 최종 결정
    • 하이브리드는 후보 생성(retrieval)까지만 맡기고, 최종은 cross-encoder나 LLM 리랭킹으로 결정하면 스코어 스케일 문제의 영향이 줄어듭니다. 다만 비용/지연이 늘어납니다.

추론 품질을 올리면서도 안전하게 운영하려면 “검색 점수”뿐 아니라 “추론 단계의 설계”도 함께 봐야 합니다. 리랭킹/추론 제어 관점은 CoT 유출 없이 추론 강화 - SCR·ReAct 실전도 같이 참고하면 연결이 잘 됩니다.

결론: 폭주는 버그가 아니라 스케일링 문제인 경우가 많다

Qdrant+BM25 하이브리드 RAG에서 점수 폭주가 발생하면, 먼저 “누가 폭주했는지”를 로그로 분리하고, 그 다음 “스코어의 의미(거리/유사도/정규화/부스팅)”를 확정한 뒤, 마지막으로 합성 방식을 RRF 또는 쿼리별 정규화+가중합으로 안정화하는 순서가 가장 빠릅니다.

정리하면 실무에서 가장 효과가 좋았던 처방은 아래 조합입니다.

  • 벡터: cosine + 임베딩 L2 정규화
  • 텍스트: 숨은 boost/function score 제거, 분석기 일치
  • 결합: RRF로 1차 안정화 후, 필요하면 정규화+가중합으로 정밀 튜닝
  • 운영: 대표 쿼리 회귀 테스트와 분포 모니터링

점수 폭주 디버깅은 결국 “관측 가능성(observability)” 싸움입니다. 로그 스키마를 먼저 만들고, 분포가 흔들리는 지점을 찾는 것부터 시작하면 대부분의 문제는 재현 가능해지고, 재현 가능해지면 해결은 생각보다 단순해집니다.