Bi-encoder와 Cross-encoder의 구조적 차이, Elastic Rerank의 DeBERTa v3 모델, 점수 퓨전(RRF), 학습 랭킹(LTR)을 심층적으로 다룹니다.
검색 랭킹에서 가장 중요한 구분은 Bi-encoder와 Cross-encoder의 차이입니다. 두 모델은 같은 Transformer 아키텍처를 기반으로 하지만, 입력을 처리하는 방식이 근본적으로 다릅니다.
Bi-encoder는 쿼리와 문서를 독립적으로 인코딩합니다. 각각이 별도의 벡터로 변환된 뒤, 코사인 유사도나 내적으로 관련성을 측정합니다.
Cross-encoder는 쿼리와 문서를 하나의 입력으로 결합하여 인코더에 통과시킵니다. Transformer의 self-attention 메커니즘이 쿼리 토큰과 문서 토큰 간의 상호작용을 직접 모델링합니다.
from sentence_transformers import CrossEncoder
# Cross-encoder 모델 로드
model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")
query = "파이썬 비동기 프로그래밍"
documents = [
"Python asyncio를 활용한 비동기 웹 서버 구축 가이드",
"JavaScript Promise와 async/await 패턴",
"파이썬 동시성: threading vs multiprocessing 비교",
"Node.js 이벤트 루프의 작동 원리",
]
# 쿼리-문서 쌍의 관련성 점수 계산
pairs = [[query, doc] for doc in documents]
scores = model.predict(pairs)
for doc, score in sorted(zip(documents, scores), key=lambda x: x[1], reverse=True):
print(f"{score:.4f} | {doc}")| 항목 | Bi-encoder | Cross-encoder |
|---|---|---|
| 처리 방식 | 독립 인코딩 | 결합 인코딩 |
| 속도 (1000 문서) | 약 5ms | 약 2000ms |
| 정확도 (NDCG@10) | 0.65-0.75 | 0.80-0.90 |
| 사전 계산 | 가능 (문서 벡터) | 불가능 |
| 적합한 단계 | 1차 검색 (리트리벌) | 2차 정밀 평가 (리랭킹) |
실전에서는 Bi-encoder로 수백만 문서에서 상위 100-200개를 빠르게 추출한 뒤, Cross-encoder로 이 후보들을 정밀 재평가하는 2단계 구조를 사용합니다. 이를 "retrieve-then-rerank" 패턴이라 합니다.
리랭킹(Reranking)은 1차 검색 결과의 순위를 재조정하는 과정입니다. Cross-encoder를 리랭커로 사용하는 전체 파이프라인을 구현해 보겠습니다.
from sentence_transformers import SentenceTransformer, CrossEncoder
import numpy as np
class TwoStageSearchPipeline:
"""2단계 검색 파이프라인: Bi-encoder 리트리벌 + Cross-encoder 리랭킹"""
def __init__(
self,
retriever_model: str = "intfloat/multilingual-e5-large",
reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-12-v2",
):
self.retriever = SentenceTransformer(retriever_model)
self.reranker = CrossEncoder(reranker_model)
self.doc_embeddings = None
self.documents = []
def index(self, documents: list[str]):
"""문서 인덱싱 (Bi-encoder 벡터 생성)"""
self.documents = documents
texts = [f"passage: {doc}" for doc in documents]
self.doc_embeddings = self.retriever.encode(texts, normalize_embeddings=True)
def search(self, query: str, top_k: int = 10, rerank_k: int = 100) -> list[dict]:
"""2단계 검색: 리트리벌 후 리랭킹"""
# 1단계: Bi-encoder 리트리벌
query_vec = self.retriever.encode(
f"query: {query}", normalize_embeddings=True
)
similarities = self.doc_embeddings @ query_vec
top_indices = np.argsort(similarities)[::-1][:rerank_k]
candidates = [(i, self.documents[i]) for i in top_indices]
# 2단계: Cross-encoder 리랭킹
pairs = [[query, doc] for _, doc in candidates]
rerank_scores = self.reranker.predict(pairs)
# 최종 결과 정렬
results = []
for (idx, doc), score in zip(candidates, rerank_scores):
results.append({
"doc_id": idx,
"text": doc,
"retrieval_score": float(similarities[idx]),
"rerank_score": float(score),
})
results.sort(key=lambda x: x["rerank_score"], reverse=True)
return results[:top_k]Elasticsearch 8.14부터 도입된 Elastic Rerank는 DeBERTa v3 기반의 Cross-encoder 리랭킹 모델입니다. Elasticsearch의 Inference API를 통해 별도의 ML 인프라 없이 리랭킹을 적용할 수 있습니다.
Elastic에서 공개한 벤치마크에 따르면 Elastic Rerank는 다음과 같은 성능 향상을 보여줍니다.
{
"service": "elasticsearch",
"service_settings": {
"num_allocations": 1,
"num_threads": 1,
"model_id": ".rerank-v1"
}
}{
"retriever": {
"text_similarity_reranker": {
"retriever": {
"standard": {
"query": {
"match": {
"content": "벡터 데이터베이스 성능 비교"
}
}
}
},
"field": "content",
"inference_id": "my-rerank-model",
"inference_text": "벡터 데이터베이스 성능 비교",
"rank_window_size": 100
}
},
"size": 10
}Elastic Rerank의 rank_window_size는 리랭킹 대상 문서 수를 결정합니다. 값이 클수록 정확하지만 지연 시간이 증가합니다. 일반적으로 50-200 사이의 값이 적절합니다.
**RRF(Reciprocal Rank Fusion)**는 여러 검색 결과 목록을 하나로 병합하는 알고리즘입니다. 각 문서의 순위 역수를 합산하여 최종 점수를 계산합니다. 서로 다른 스케일의 점수를 가진 검색 결과를 안정적으로 결합할 수 있다는 것이 핵심 장점입니다.
각 문서 d의 RRF 점수는 다음과 같이 계산됩니다.
RRF(d) = sum(1 / (k + rank_i(d))) (모든 검색 결과 목록 i에 대해)
여기서 k는 상수(기본값 60)이며, rank_i(d)는 i번째 결과 목록에서 문서 d의 순위입니다.
def reciprocal_rank_fusion(
ranked_lists: list[list[str]],
k: int = 60,
) -> list[tuple[str, float]]:
"""
RRF 점수 퓨전
ranked_lists: 여러 검색 결과 목록 (각 목록은 문서 ID 순서)
k: 스무딩 상수 (기본값 60)
"""
rrf_scores: dict[str, float] = {}
for ranked_list in ranked_lists:
for rank, doc_id in enumerate(ranked_list, start=1):
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
rrf_scores[doc_id] += 1.0 / (k + rank)
# 점수 기준 내림차순 정렬
sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results
# BM25 결과와 시맨틱 검색 결과 병합
bm25_results = ["doc_A", "doc_B", "doc_C", "doc_D", "doc_E"]
semantic_results = ["doc_C", "doc_A", "doc_F", "doc_B", "doc_G"]
fused = reciprocal_rank_fusion([bm25_results, semantic_results])
for doc_id, score in fused[:5]:
print(f"{doc_id}: {score:.6f}")| 방법 | 장점 | 단점 |
|---|---|---|
| RRF | 점수 스케일 무관, 파라미터 적음 | 순위만 사용 (점수 정보 손실) |
| 선형 보간 | 점수 차이 반영 | 스케일 정규화 필요, 가중치 튜닝 |
Elasticsearch 8.x에서는 RRF를 하이브리드 검색의 기본 퓨전 방식으로 제공합니다. 6장에서 Elasticsearch의 RRF 구현을 자세히 다룹니다.
**LTR(Learning to Rank, 학습 랭킹)**은 머신러닝 모델을 학습시켜 검색 결과의 순위를 결정하는 접근법입니다. BM25 점수, 시맨틱 유사도, 문서 인기도, 최신성 등 다양한 특성(feature)을 입력으로 받아 최적의 순위를 예측합니다.
from dataclasses import dataclass
@dataclass
class SearchFeatures:
"""LTR 모델의 입력 특성"""
# 텍스트 매칭 특성
bm25_score: float
semantic_similarity: float
title_match_score: float
# 문서 품질 특성
doc_length: int
doc_freshness_days: int
doc_pagerank: float
# 사용자 행동 특성
historical_ctr: float
avg_dwell_time: float
# 쿼리-문서 상호작용 특성
query_doc_topic_overlap: float
entity_match_count: int
def extract_features(query: str, doc: dict, user_profile: dict) -> SearchFeatures:
"""쿼리-문서 쌍의 LTR 특성 추출"""
return SearchFeatures(
bm25_score=compute_bm25(query, doc["text"]),
semantic_similarity=compute_similarity(query, doc["embedding"]),
title_match_score=compute_title_match(query, doc["title"]),
doc_length=len(doc["text"]),
doc_freshness_days=(today() - doc["published_date"]).days,
doc_pagerank=doc.get("pagerank", 0.0),
historical_ctr=doc.get("ctr", 0.0),
avg_dwell_time=doc.get("avg_dwell_time", 0.0),
query_doc_topic_overlap=compute_topic_overlap(query, doc),
entity_match_count=count_entity_matches(query, doc),
)LTR 모델은 충분한 학습 데이터가 있어야 효과적입니다. 최소 수천 개의 쿼리-문서 쌍에 대한 관련성 레이블이 필요합니다. 데이터가 부족한 초기에는 Cross-encoder 리랭킹이 더 현실적인 선택입니다.
프로젝트의 규모와 상황에 따라 적절한 랭킹 전략이 달라집니다.
| 상황 | 권장 전략 |
|---|---|
| 초기 단계, 데이터 부족 | BM25 + 시맨틱 검색 (RRF 퓨전) |
| 품질 향상 필요 | Cross-encoder 리랭킹 추가 |
| Elasticsearch 사용 중 | Elastic Rerank 도입 |
| 대규모 트래픽, 풍부한 로그 | LTR 모델 학습 |
| 최고 품질 추구 | 다단계: BM25+시맨틱 -> RRF -> Cross-encoder -> LTR |
이번 장에서는 검색 결과의 순위를 결정하는 랭킹 모델을 심층적으로 다루었습니다. Bi-encoder와 Cross-encoder의 구조적 차이와 트레이드오프를 이해했고, 두 모델을 결합한 retrieve-then-rerank 패턴을 구현했습니다. Elastic Rerank의 DeBERTa v3 모델이 가져오는 40-90%의 성능 향상, RRF를 활용한 점수 퓨전, LTR의 세 가지 접근법(Pointwise, Pairwise, Listwise)도 학습했습니다.
다음 장에서는 이러한 이론적 배경을 Elasticsearch에서 실제로 구현하는 방법을 다룹니다. kNN 검색, Inference API, semantic_text 필드, ELSER, 하이브리드 검색(RRF) 등 Elasticsearch의 AI 검색 기능을 실습합니다.
이 글이 도움이 되셨나요?
Elasticsearch의 kNN 검색, Inference API, semantic_text 필드, ELSER, Elastic Rerank, 하이브리드 검색(RRF)을 실습과 함께 다룹니다.
쿼리 분류, 의도 인식, 엔티티 인식부터 LLM 기반 쿼리 확장, HyDE(가상 문서 생성), 다국어 처리까지 쿼리 이해 파이프라인을 다룹니다.
OpenSearch 신경 검색, 재랭킹 파이프라인과 Algolia, Meilisearch, Typesense 등 주요 검색 엔진의 AI 검색 기능을 비교합니다.