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가 과도하게 강해질 수 있어 정규화/랭크 융합이 특히 유효.
답글 남기기