[RAG] Cross-Encoder를 이용한 Re-ranking 구현과 원리 (RAG 성능 최적화)
RAG 파이프라인에서의 Context 문제
RAG 파이프라인을 구축할 때, LLM에게 전달할 참고 문서 (컨텍스트; context)의 수는 적을수록 좋다.
문서가 많아질수록(==컨텍스트가 길어질 수록) 다음과 같은 문제가 발생하기 때문.
-
품질 저하 문제 (Lost in the Middle & Hallucination)
가장 큰 문제는 LLM이 참고할 정보가 많아질수록 정답을 놓치거나 잘못된 정보를 생성할 확률이 커진다는 것이다.
- Lost in the Middle: LLM은 문서가 너무 길면 중간에 있는 핵심 내용을 무시하거나 잊어버리는 경향이 있다.
- 노이즈 (Noise)로 인한 환각 (Hallucination): 질문과 무관한 정보가 많이 섞일수록 LLM이 정답을 찾는데 방해를 받아 이상한 대답을 할 확률이 높아진다.
-
효율성 및 비용 문제 (Token & sLM의 한계)
문서 수가 증가하면 효율성 면에서도 문제가 생긴다.
- 입력 토큰 수 증가로 인한 비용 상승
- 특히 SLM (소형 언어 모델)의 경우 프롬프트 처리 한계 (Context Window) 초과
하지만 그렇다고 문서 수를 무조건 줄이면 정답이 포함된 핵심 문서까지 잘려 나갈 위험이 커진다.
그래서 Retrieval (검색) 단계에서는 문서를 넉넉히 가져오면서, LLM에게 넘겨주기 전에 가져온 문서들 중 가장 적절하고 정확도가 높은 것만 주자 라는 전략이 필요하다.
이때 사용하는 것이 바로 Cross-Encoder!
RAG 파이프라인에서의 Cross-Encoder
Cross-Encoder는 전체 파이프라인 중 Re-ranking (재순위화) 단계에서 사용한다.
-
Retrieval (벡터 검색): 빠르게 후보 문서를 가져옴 (정확도보단 속도와 재현율 중심)
-
Reranking (재순위): Cross-Encoder로 후보 문서들과의 유사도 Top K를 선정 (정밀도)
-
Generation (생성): 선정된 Top K만 LLM에게 전달하여 답변 생성
LLM 평가를 하지 않는 이유
Cross-Encoder는 기본적인 RAG 구조에 재순위라는 한 단계를 추가한 것이다.
따라서 이 단계에서는 정확도를 높이면서도 속도를 유지하는 것이 중요하다.
물론 또 다른 LLM을 사용해서 문서를 평가할 수는 있겠지만, LLM 기반 평가는 가성비와 속도 측면에서 여러 단점이 있다.
비교를 해보자면
| 구분 | Cross-Encoder (re-ranker) | LLM eval |
|---|---|---|
| 모델 크기 | 비교적 작음 (BERT Base 수준) | 매우 큼 (수십-수백억 파라미터 이상) |
| 평가 속도 | 매우 빠름 | 느림 (토큰 생성 과정 필요) |
| 평가 방식 | 점수 예측 (회귀) | 텍스트 생성 (e.g. ”0~10점 점수 매겨줘”) |
| 비용 효율성 | 매우 높음 | 매우 낮음 |
Cross-Encoder를 이용한 평가 방법
Cross-Encoder는 두 개의 텍스트(Query, Document)를 쌍으로 입력받아, 그 사이의 연관성을 연속적인 수치 값 (score)로 예측한다.
이러한 Cross-Encoder 구현에는 대부분 HuggingFace의 transformer 라이브러리에서 제공하는 AutoModelForSequenceClassification 모델을 사용한다.
이 모델은 원래 분류 전용이라 이름에 Classification (분류)가 들어가 있긴 하지만, 모델은 내부적으로 다음 질문을 던지면서 아래와 같은 계산을 수행한다.
“이 쿼리 (Query)와 이 문서 (Document)는 얼마나 밀접하게 관련되어 있는가?”
이때 예측되는 결과값이 바로 유사도 (score)이다.
모델의 이름과 달리 분류가 아닌 일종의 NLP 회귀 (Regression) 문제로 접근하는 것이라고 볼 수 있다.
회귀인데 AutoModelForSequenceClassification 모델을 사용하는 이유
여기까지 보다보면 “이름이 Classification (분류)</samll>인데 왜 Regression (회귀) 계산을 하지?”라는 의문이 드는데,
그 이유는 num_labels 파라미터에 있다.
num_labels란 모델이 최종적으로 분류하고자 하는 레이블 (Class)의 개수를 의미하며, Transformers 라이브러리는 이 설정값에 따라 작동 방식을 다르게 처리한다.
num_labels >= 2→ 분류 (Classification) 문제로 인식 (e.g. 긍정/부정)num_labels = 1→ 회귀 (Regression) 문제로 인식 (e.g. 유사도 점수)
그러니까 num_labels를 1로 설정하면 모델은 특정 클래스를 고르는 게 아니라 유사도 점수 (score) 자체를 예측하게 되고, 우린 이 점수를 기준으로 문서를 Re-Ranking하게 된다.
1
2
3
4
5
6
7
# Cross-Encoder 모델 로드
model = AutoModelForSequenceClassification.from_pretrained(
settings.MODEL_PATH
# num_labels=1
# (일반적인 Cross-Encoder 모델의 config.json 기본값이 1이라 생략 가능)
).to(device)
model.eval()
num_labels에 따른 모델 output 차이 비교
| 구분 | num_labels = 1 (Cross-Encoder) |
num_labels >= 2 (일반 분류) |
|---|---|---|
| 모델 출력 | logits, (*loss) | logits, (*loss) |
| Logits 형태 | [batch_size, 1] |
[batch_size, num_classes] |
| Logits 범위 | -∞ ~ +∞ (범위 제한 없음) | -∞ ~ +∞ (범위 제한 없음) |
| Logits의 의미 | 배치 내 각 텍스트 쌍의 유사도 점수 | 각 클래스에 속할 확률적 점수 |
| 내부 loss 함수 | MSELoss | CrossEntropyLoss |
| 해석 방법 | Sigmoid 함수를 통해 0~1 사이의 확률값으로 변환 | Argmax를 통해 가장 높은 클래스 인덱스 선택 |
| 최종 결과 | 유사도 (Similarity) | 분류 라벨 (Class Label) |
*loss 값은 모델 학습 시 labels 파라미터를 전달했을 때만 계산됨. 추론(Inference) 단계에서는 일반적으로 loss를 계산하지 않음
Cross-Encoder (num_labels=1)의 점수 계산 방식
아래 코드는 PyTorch를 사용해서 Cross-Encoder가 최종 유사도 점수를 얻는 과정이다.
1
2
3
4
5
6
7
8
9
10
# 1) 추론 환경 설정
with torch.no_grad():
logits = model(**features).logits
# logits e.g. [[4.5], [-2.3], ...]
# 2) sigmoid를 사용하여 0-1 사이의 확률값으로 변환
sub_scores = torch.sigmoid(logits).squeeze(-1).cpu().tolist()
if isinstance(sub_scores, float):
sub_scores = [sub_scores]
scores.extend(sub_scores)
-
모델 추론 시에는 ‘학습’이 아닌 ‘문서간 유사도 비교’가 목적이다. 따라서 역전파에 필요한 미분값(Gradient)을 계산할 필요가 없다.
미분 계산 과정을 생략하면 메모리가 절약되고 속도가 빨라지므로
with torch.no_grad():를 사용한다.
-
AutoModelForSequenceClassification의 출력인logits는 범위가 정해져 있지 않은 실수(-∞ ~ +∞) 형태다.따라서 이 값을 우리가 이해할 수 있는 0과 1 사이의 확률값(유사도 점수)로 변환하기 위해
torch.sigmoid()함수를 적용한다
이렇게 얻은 확률값이 곧 유사도(Score)이며, 이 점수를 기준으로 후보 문서들에 대해 re-ranking을 수행하게 된다.
추가) 모델이 어떤 labels을 가지고 있는지 확인하는 방법
num_labels >= 2일 때 분류를 수행한다고 했는데, 이때 모델이 분류 기준으로 삼는 라벨의 이름을 확인하기 위한 속성은 id2label이다.
이 속성을 통해 모델을 학습시킨 사람이 0번 인덱스에 무엇으로 정의했는지(e.g. 긍정/부정) 사전 학습된 값(pre-trained value)이 아닌 학습 시 사용된 매핑 정보를 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
from transformers import AutoModelForSequenceClassification
# 모델 이름 정의 (암거나 확인하고 싶은 모델)
model_name = "roberta-large-mnli"
# config 정보만 로드
config = AutoConfig.from_pretrained(model_name)
# 라벨 확인
print(config.id2label)
출력 결과 예시
1
2
3
4
5
6
7
8
9
10
11
12
# num_labels=2인 모델
{
0: 'POSITIVE',
1: 'NEGATIVE'
}
# num_labels=3인 모델
{
0: 'CONTRADICTION',
1: 'NEUTRAL',
2: 'ENTAILMENT'
}