앙상블

1) 점수 정규화 후 합치기 (CombSUM의 기본형)

쿼리마다 각 점수를 정규화(min-max, z-score, 혹은 rank→percentile) 한 뒤 가중합합니다.

import numpy as np
from sklearn.preprocessing import minmax_scale
from sklearn.metrics.pairwise import cosine_similarity

def ensemble_search(query, k=3, w_bm25=0.6, w_tfidf=0.4):
    # 1) 개별 점수 계산
    bm25_scores = np.array(bm25.get_scores(query.split()))          # 길이 = N
    cos = cosine_similarity(tfidf.transform([query]), tfidf_mat).ravel()  # 길이 = N, [0,1]

    # 2) 쿼리 단위 정규화 (min-max)
    bm25_n = minmax_scale(bm25_scores)   # -> [0,1]
    cos_n  = minmax_scale(cos)           # -> [0,1]

    # 3) 가중합
    scores = w_bm25 * bm25_n + w_tfidf * cos_n

    # 4) 상위 k
    top_idx = np.argsort(scores)[::-1][:k]
    return [(int(i), float(scores[i]), corpus[i]) for i in top_idx]
  • 장점: 간단하고 효과적.
  • : min-max 대신 z-score(평균/표준편차 표준화)도 자주 씁니다. 분포 꼬리가 긴 BM25에 유리할 때가 있어요.
  • 더 안전하게: min-max는 outlier에 민감 → 랭크 기반 정규화(아래 2번)도 고려.

2) 랭크 융합 (Reciprocal Rank Fusion, RRF)

점수 대신 순위만 사용하므로 스케일 문제를 원천적으로 제거합니다. 최신 랭킹 대회에서도 baseline으로 강력합니다.

def rrf_fusion(ranks_list, k=3, C=60):
    # ranks_list: 각 모델이 준 정렬 인덱스 리스트 [idx_sorted_by_bm25, idx_sorted_by_tfidf, ...]
    # RRF: sum_i 1 / (C + rank_i(d))
    n = len(ranks_list[0])
    rrf = np.zeros(n)
    for ranks in ranks_list:
        pos = np.empty(n, dtype=int)
        pos[ranks] = np.arange(n)  # 문서→순위
        rrf += 1.0 / (C + pos)
    top_idx = np.argsort(rrf)[::-1][:k]
    return top_idx, rrf

def ensemble_search_rrf(query, k=3, C=60):
    bm25_scores = np.array(bm25.get_scores(query.split()))
    cos = cosine_similarity(tfidf.transform([query]), tfidf_mat).ravel()

    bm25_order = np.argsort(bm25_scores)[::-1]
    cos_order  = np.argsort(cos)[::-1]

    top_idx, rrf = rrf_fusion([bm25_order, cos_order], k=k, C=C)
    return [(int(i), float(rrf[i]), corpus[i]) for i in top_idx]
  • 장점: 스케일 문제 없음, 구현 쉬움, 튼튼한 성능.
  • C는 보통 10~100 사이에서 개발셋으로 튜닝.

3) CombMNZ (정규화 + 활성 신호 수 반영)

정규화한 점수의 합을 비영(>0) 모델 수로 곱합니다. 여러 모델이 동의하는 문서를 올려줍니다.

def ensemble_search_mnz(query, k=3, eps=1e-12):
    bm25_scores = np.array(bm25.get_scores(query.split()))
    cos = cosine_similarity(tfidf.transform([query]), tfidf_mat).ravel()

    bm25_n = (bm25_scores - bm25_scores.min()) / (bm25_scores.ptp() + eps)
    cos_n  = (cos - cos.min()) / (cos.ptp() + eps)

    S = bm25_n + cos_n
    active = (bm25_n > 0).astype(int) + (cos_n > 0).astype(int)
    scores = S * active

    top_idx = np.argsort(scores)[::-1][:k]
    return [(int(i), float(scores[i]), corpus[i]) for i in top_idx]

4) 가중치에 대한 팁

  • w_bm25 + w_tfidf = 1로 둘 필요는 없지만, 정규화 후에는 그렇게 두면 해석이 직관적입니다.
  • 최적 가중치는 개발셋에서 그리드 서치로 찾으세요. (예: w_bm25 ∈ {0.2,0.4,0.6,0.8})

5) 구현 체크리스트

  • BM25 토크나이저와 TF-IDF 벡터라이저가 동일한 전처리(소문자화, 형태소·불용어 등)를 공유하는지 확인.
  • 코퍼스가 크면 TF-IDF는 sparse, BM25는 배열: 정렬/인덱싱 길이 일치 확인.
  • 쿼리 길이가 1인 경우 BM25가 과도하게 강해질 수 있어 정규화/랭크 융합이 특히 유효.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다