리랭커(Reranker), 순위 재정렬

AI

목록 보기
5/6

리랭커란,

  • RAG가 생성한 후보 문서들에 대해 질문에 대한 관련성 및 일관성을 판단하여 문서의 우선 순위를 재정렬하는 것
  • 즉, 질문과 관련성 있는 문서들을 컨텍스트의 상위권에 위치시킴으로써, 답변의 정확도를 올림

1. 기존 RAG에서의 문제

  • RAG는 문서에서 의미론적 검색(Semantic search) 과정을 수행함 → 벡터 검색
  • 이 과정에서 발생되는 정보 손실
    1. 문서의 임베딩 벡터 변환 과정에서 손실 : 문서가 긴 경우에 정해진 벡터의 차원으로 표현하기 어려움
    2. 검색 과정에서 손실 : 시간 단축을 위해 ANNs 기술을 사용함 (Approximate Nearest Neighbor search)
      → 이러한 문제를 해결하기 위해 검색 후 반환되는 문서 수를 늘림 (k 증가)
      → but, LLM에게 전달되는 컨텍스트가 늘어나서 비용이 비효율적
      => 컨텍스트 내 존재 유무가 중요한 게 아니라, 순서가 중요함

2. Lost in the Middle

그림 1: Lost in Middle
Lost in Middle: 질문에 대한 관련 문서가 컨텍스트 중간에 위치할 경우, LLM 응답 정확도가 낮아진다.
(출처: AWS 기술 블로그, 한국어 Reranker를 활용한 검색 증강 생성(RAG) 성능 올리기 / 원 출처: Liu et al., 2023)

  • RAG의 정확도는 관련 정보의 컨텍스트 내 존재 유무가 아니라 순서이다.
    → 관련 정보가 컨텍스트 내 상위권에 위치하고 있을 때 좋은 답변을 얻을 수 있다.

3. 리랭커

💡 가장 관련성 높은 문서를 상위권에 배치(순위 재정렬, Reranking)하기

  • 질문과 문서 사이의 유사도를 측정하는 것이 목적
  • Cross-encoder 사용

그림 2
그림 2: Encoder 종류 (a) Bi-encoder (b) Cross-encoder
(출처: AWS 기술 블로그, 한국어 Reranker를 활용한 검색 증강 생성(RAG) 성능 올리기)

  • 질문과 문서를 하나의 input으로 활용 : 동시에 분석(Self-attention)
  • rerank를 사용할 때는 검색 단계에서 상위 k개 문서에 한해서만 순위를 재조정함
  • 유사도를 이용한 검색은 전체 문서에 대해서 빠르게 결과값을 얻을 수 있지만, rerank는 질의와 문서 사이의 의미론적 유사성을 탐색함 → 오래 걸려서 검색을 통해서 추출된 상위 문서에만 한해서 수행

4. 1차 검색(Bi-encoder)과 리랭커(Cross-encoder)의 차이

💡 둘 다 의미를 계산한다. 차이는 "질문과 문서가 모델 안에서 언제 서로를 보느냐"이다.

Bi-encoder (1차 검색)

  • 질문과 문서를 각각 따로 인코더에 넣는다.
    • 질문 → 인코더 → 벡터 q
    • 문서 → 인코더 → 벡터 d
    • 점수 = cos(q, d) (또는 내적)
  • 문서 벡터 d를 만들 때 질문은 입력에 없다. → 문서 벡터는 질문과 상관없이 미리 계산해서 저장해 둘 수 있다 (벡터 DB, 인덱스).
  • 검색 시점에 하는 일: 질문 인코딩 1번 + 저장된 벡터와의 내적 N번 → 빠르다.
  • 한계
    • 문서 벡터를 만들 때 어떤 질문이 올지 모르므로, 문서의 모든 정보를 고정된 차원의 벡터 하나에 미리 압축해야 한다.
    • 질문의 단어와 문서의 단어가 모델 안에서 직접 비교되는 과정이 없다. 비교는 맨 마지막에 벡터 두 개끼리 한 번만 일어난다.
    • 이 방식을 representation-based(표현 기반) 방식이라고 부른다.
    • 참고: late interaction은 ColBERT처럼 토큰 단위 벡터들을 마지막에 비교하는 방식을 가리키는 용어로, Bi-encoder와는 구분된다.

Cross-encoder (리랭커)

  • 질문과 문서를 하나의 시퀀스로 이어 붙여 한 번에 넣는다.
    • 입력: [CLS] 질문 토큰들 [SEP] 문서 토큰들 [SEP]
  • Transformer의 모든 layer에서 self-attention이 시퀀스 안의 모든 토큰 사이에 계산된다. → 질문 토큰과 문서 토큰이 처음부터 서로를 직접 참조한다.
    • 예: 질문의 데뷔 토큰이 문서의 2022년, 데뷔하여 토큰에 직접 attention 할 수 있다.
  • 마지막 layer의 [CLS] 출력 → linear layer → 관련도 점수(실수 1개).
  • 이 방식을 interaction-based(상호작용 기반) 방식이라고 부른다.
  • 한계
    • 점수가 (질문, 문서) 쌍에 대해서만 정의된다. 질문이 들어오기 전에는 아무것도 미리 계산할 수 없다.
    • 문서가 N개면 모델 forward를 N번 해야 한다.

그래서 2단계로 나눈다

  • 1단계: Bi-encoder로 전체 문서에서 상위 k개를 빠르게 추린다.
  • 2단계: Cross-encoder로 그 k개만 정밀하게 다시 점수 매긴다.
  • 비용 예시
    • 후보 문서 N개 전체에 cross-encoder → 검색어 1개당 forward N번
    • 1차 검색 top-50에만 cross-encoder → 검색어 1개당 forward 50번

5. 코드

파이썬: v3.8.16
transformers: v4.41.2

Dongjin-kr/ko-reranker · Hugging Face
BAAI/bge-reranker-large 기반 한국어 데이터에 대한 fine-tuned model

# 1. 모델과 데이터 처리에 필요한 라이브러리 불러오기
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
import numpy as np

# 2. 비교할 문장 쌍 데이터 설정 (질문과 4개의 후보 답변들)
pairs = [
    ['뉴진스는 몇 년도에 데뷔했나요?', '안녕하세요.'],
    ['뉴진스는 몇 년도에 데뷔했나요?', '뉴진스의 데뷔 연도는 2022년입니다.'],
    ['뉴진스는 몇 년도에 데뷔했나요?', '2022년에는 뉴진스, 르세라핌, 엔믹스가 데뷔했습니다.'],
    ['뉴진스는 몇 년도에 데뷔했나요?', '뉴진스는 2022년에 데뷔하여 Hype boy 등 여러 노래를 발표했습니다.'],
]

# 3. 로짓(모델의 원시 출력값)을 확률 분포(0~1)로 변환하는 정규화 함수 정의
def exp_normalize(x):
    b = x.max()        # 수치적 안정성을 위해(오버플로우 방지) 최댓값 추출
    y = np.exp(x - b)  # 각 값에서 최댓값을 뺀 후 자연상수 e의 지수승 계산
    return y / y.sum() # 모든 값의 합으로 나누어 총합이 1이 되는 확률 값으로 반환

# 4. 사전 학습된 한국어 Reranker 모델의 Hugging Face 저장소 경로 지정
model_path = "Dongjin-kr/ko-reranker"

# 5. 지정된 경로에서 텍스트를 처리할 토크나이저와 모델 가중치 불러오기
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForSequenceClassification.from_pretrained(model_path)

# 6. 모델을 추론(평가) 모드로 전환 (학습용 기능인 Dropout 등 비활성화)
model.eval()

# 7. 기울기(Gradient) 계산을 비활성화하여 메모리 사용량을 줄이고 속도 향상
with torch.no_grad():
    # 문장 쌍을 모델이 이해할 수 있는 토큰 형태로 변환 (패딩 및 절단 적용, 최대 512토큰)
    inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors='pt', max_length=512)
    
    # 모델에 토큰화된 입력을 통과시켜 연관성 점수(logits)를 계산하고, 1차원 실수 배열로 변환
    scores = model(**inputs, return_dict=True).logits.view(-1, ).float()
    
    # 텐서를 NumPy 배열로 변환한 뒤, 직접 정의한 함수를 통해 0~1 사이의 확률값으로 정규화
    scores = exp_normalize(scores.numpy())

# 8. 계산된 확률값에 100을 곱해 퍼센트(%)로 바꾸고, 소수점 둘째 자리까지 반올림하여 출력
print(np.round(scores * 100, 2))
  • pairs의 각 원소가 [질문, 문서]이고, tokenizer(pairs, ...)가 이 둘을 하나의 시퀀스로 합친다. → 4개 쌍 = 시퀀스 4개 = cross-encoder 입력 4개.

6. 코드의 점수(logits)와 exp_normalize(softmax)의 의미

코드에서 실제로 일어나는 일

  1. model(**inputs).logits → shape (4, 1). 쌍 하나당 실수 1개.
  2. .view(-1) → shape (4,)로 펴기.
  3. exp_normalize → 4개 값의 합이 1이 되도록 변환. 이 함수는 softmax와 같은 계산이다.

포인트 1: 모델은 쌍마다 독립적으로 점수를 매긴다 (pointwise)

  • 4개 쌍을 batch로 한 번에 넣었지만, self-attention은 각 시퀀스 내부에서만 계산된다.
  • 즉 2번 쌍의 점수를 계산할 때 모델은 1·3·4번 문서를 전혀 보지 않는다.
  • 이처럼 (질문, 문서 1개) → 점수 1개 구조를 pointwise 방식이라고 한다.

포인트 2: softmax 확률은 "후보들 사이의 상대값"이다

  • softmax는 모델이 계산을 끝낸 뒤에 적용하는 후처리다. 모델이 후보들을 서로 비교한 결과가 아니다.
  • softmax 값은 같이 넣은 후보 집합에 따라 달라진다.
  • 가상의 logit으로 계산한 예:
    • 4개 후보 logit이 [-5, 5, 3, 4]일 때 softmax → 약 [0.0%, 66.5%, 9.0%, 24.5%]
    • 같은 2번 문서를 1번 문서(안녕하세요.)와만 비교하면 logit [-5, 5] → 약 [0.0%, 100.0%]
    • 2번 문서의 logit(5)은 그대로인데, 확률은 66.5% → 100%로 바뀐다.
  • 따라서 softmax 확률은 "이 문서가 절대적으로 얼마나 관련 있는가"를 나타내지 않는다.

포인트 3: 순위만 필요하면 softmax는 없어도 된다

  • softmax는 큰 값이 항상 큰 값으로 남는 변환(단조 증가)이다. → softmax 전후의 순위는 같다.
  • 재정렬 목적이면 logit을 그대로 정렬해도 결과가 같다.
  • "점수 X 이상만 남긴다" 같은 절대 기준이 필요하면 softmax 확률이 아니라 logit 자체나 sigmoid(logit)을 기준으로 삼아야 한다. (어떤 변환을 쓰는지는 모델 카드 확인)

7.End-to-End PipeLine

질문 → 1단계 Bi-encoder로 top-k 후보 추출 → 2단계 Cross-encoder로 후보 재점수 → 상위 n개를 컨텍스트 앞쪽에 배치 → LLM
  • 문서 임베딩은 질문이 들어오기 전에 미리 계산한다. (4장의 Bi-encoder 특징)
  • 예제는 문서 수가 적어서 전체 문서와 내적을 계산했지만, 실제로는 벡터 DB와 ANN 검색을 사용한다.
  • 리랭킹 단계는 softmax 없이 logit을 그대로 정렬한다. (6장 포인트 3)
  • top_k(리랭커에 넘길 후보 수)와 top_n(LLM에 넘길 문서 수)은 다른 값이다.
    • top_k가 클수록 1차 검색에서 놓치는 문서가 줄지만, 리랭커 forward 횟수가 늘어난다.
    • top_n이 작을수록 LLM에 전달되는 컨텍스트가 줄어든다.
from sentence_transformers import SentenceTransformer
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
import numpy as np

# 문서 저장소
documents = [
    '안녕하세요.',
    '뉴진스의 데뷔 연도는 2022년입니다.',
    '2022년에는 뉴진스, 르세라핌, 엔믹스가 데뷔했습니다.',
    '뉴진스는 2022년에 데뷔하여 Hype boy 등 여러 노래를 발표했습니다.',
    '르세라핌은 2022년 5월에 데뷔했습니다.',
    '아이브는 2021년에 데뷔했습니다.',
]
query = '뉴진스는 몇 년도에 데뷔했나요?'

# 1단계: Bi-encoder 1차 검색
embedder = SentenceTransformer('BAAI/bge-m3')
doc_embs = embedder.encode(documents, normalize_embeddings=True)   # 질문과 무관하게 미리 계산
query_emb = embedder.encode(query, normalize_embeddings=True)

sims = doc_embs @ query_emb            # 정규화된 벡터의 내적 = 코사인 유사도
top_k = 4
candidate_idx = np.argsort(-sims)[:top_k]
candidates = [documents[i] for i in candidate_idx]

# 2단계: Cross-encoder 리랭킹
model_path = 'Dongjin-kr/ko-reranker'
tokenizer = AutoTokenizer.from_pretrained(model_path)
reranker = AutoModelForSequenceClassification.from_pretrained(model_path)
reranker.eval()

pairs = [[query, doc] for doc in candidates]
with torch.no_grad():
    inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors='pt', max_length=512)
    logits = reranker(**inputs, return_dict=True).logits.view(-1).float().numpy()

top_n = 2
order = np.argsort(-logits)            # logit 그대로 내림차순 정렬
reranked = [candidates[i] for i in order[:top_n]]

# 3단계: 관련도 높은 문서를 컨텍스트 앞쪽에 배치하여 LLM에 전달
context = '\n'.join(f'[문서 {i + 1}] {doc}' for i, doc in enumerate(reranked))
prompt = f"""다음 문서를 참고하여 질문에 답하세요.

{context}

질문: {query}
답변:"""

# answer = call_llm(prompt)  # 사용하는 LLM API로 대체
print(prompt)

8. Pointwise / Pairwise / Listwise

  • Pointwise: (질문, 문서 1개) → 점수. 5장의 코드가 이 방식.
  • Pairwise: (질문, 문서 A, 문서 B) → A와 B 중 어느 쪽이 나은가.
  • Listwise: (질문, 문서 여러 개를 한 입력에) → 순서 전체. 모델이 입력 안에서 후보들을 직접 비교한다.
  • 5장 코드의 softmax는 겉보기에 후보를 비교하는 것처럼 보이지만, 비교는 모델 밖에서 사후에 일어난 것이다. Listwise는 비교가 모델 안에서 일어난다는 점이 다르다.
방식평가 메커니즘특징장단점
Pointwise
(포인트와이즈)
하나의 쿼리와 단 하나의 문서를 독립적으로 비교하여 점수를 매김주로 Cross-Encoder 모델 사용➕ 속도가 빠르고 병렬 처리가 쉬움
➖ 다른 문서들과의 상대적 우위를 반영하지 못함
Pairwise
(페어와이즈)
하나의 쿼리에 대해 두 개의 문서를 짝지어 어느 쪽이 더 우수한지 비교토너먼트 형식의 비교 가능➕ 상대적 비교가 가능해짐
➖ 문서 수가 많아질수록 비교 횟수가 제곱에 비례하여 증가
Listwise
(리스트와이즈)
하나의 쿼리와 전체 후보 문서 목록을 동시에 입력받아 최종 순열(Permutation)을 한 번에 출력최신 고성능 모델(LLM 기반)에서 주로 차용➕ 문서 간 중복성, 다양성, 미세한 품질 차이를 가장 잘 포착함
➖ 연산 비용이 크고 실시간 처리 속도(Latency)가 느림

Listwise 관련: ZipRerank

Pointwise와 Listwise의 처리 방식

💡 처리 방식(병렬이냐 한 번이냐)은 결과로 따라오는 특징이다. 본질적인 차이는 "모델이 다른 후보를 보느냐"이다.

Pointwise: 병렬로 처리"해야" 하는 게 아니라, 병렬로 처리"할 수 있다"

  • 후보마다 계산이 독립적이다. → 순서대로 10번 돌려도, 동시에 돌려도 결과가 같다.
  • 실무에서는 속도 때문에 batch로 묶어서 한 번에 처리한다.
    • 5장 코드에서 pairs 4개를 tokenizer(pairs, ...)로 한꺼번에 넣은 것이 batch 처리다.
  • batch로 묶여 있어도 각 쌍은 서로를 보지 않는다. 함께 넣는 것은 GPU를 효율적으로 쓰기 위한 것일 뿐이다.

Listwise: 기본은 한 번, 후보가 많으면 여러 번

  • 후보가 10개 정도면 호출 1번으로 끝난다.
  • 후보가 입력 길이 한도를 넘으면 여러 번 나눠서 호출한다.
    • 예: 후보 50개 → 뒤쪽 20개를 먼저 정렬 → 창을 앞으로 옮기며 다시 정렬 (sliding window)
    • 각 호출이 앞 호출의 결과를 이어받으므로 순서대로 처리해야 한다. 병렬 처리가 안 된다.

정리

  • Pointwise
    • 본질: 후보 하나씩 따로 판단
    • 호출: K번. 서로 독립적이라 병렬 가능
    • 출력: 후보마다 점수
  • Listwise
    • 본질: 후보 여러 개를 같이 보고 판단
    • 호출: 기본 1번. 후보가 많으면 여러 번 순차 처리
    • 출력: 순서

"한 번에 처리" ≠ "빠르다"

  • Listwise는 호출이 1번이어도 느릴 수 있다.
    • 후보 전부가 들어가므로 입력이 길다.
    • 순서를 token 하나씩 생성한다.
  • Pointwise는 호출이 여러 번이어도 병렬로 돌리면 전체 시간이 짧을 수 있다.
  • 어느 쪽이 빠른지는 모델 크기, 후보 수, 서빙 환경에 따라 달라진다.

Listwise와 Lost in the Middle

  • 여러 문서를 한 입력에 넣는 listwise 리랭커 자체도 입력 중간의 후보를 덜 보는 같은 문제를 겪을 수 있다. → 후보 순서를 바꿔 여러 번 돌리거나, 작은 묶음 단위로 나눠 정렬하는 방식으로 대응한다.

마무리

  • 리랭커는 전체 문서를 정밀하게 보는 비용과 1차 검색의 정보 손실 사이에서, 상위 k개만 다시 보는 절충 지점이다.
  • 관련 문서를 컨텍스트 앞쪽으로 옮겨 Lost in the Middle을 피해 간다.

참고 자료

0개의 댓글