AI Engineering/RAG

RAG 성능 고도화 — 리랭킹: 검색 결과를 한 번 더 거른다

코드와트 2026. 6. 27. 17:35

RAG 성능 고도화 — 리랭킹: 검색 결과를 한 번 더 거른다

이 글은 RAG 검색 결과의 정확도를 끌어올리는 '리랭킹(문서 후처리)'을 정리한 기록이다. 검색이 1차로 추려준 문서를 더 정교한 기준으로 다시 줄 세우는 단계 — 크로스 인코더와 3단계 깔때기 전략을 다룬다. 책과 Gemini의 도움을 빌려 궁금증을 해소해가며 작성되었다.

검색이 끝이 아니다 — 한 번 더 거르는 후처리

직전 편에서 희소·밀집·앙상블로 문서를 골라내는 검색 단을 정리했다. 그런데 검색기가 뽑아준 결과를 그대로 LLM에 넘기다 보면 한 가지가 계속 걸린다. 상위에 올라온 문서가 생각보다 질문과 안 맞는 경우가 종종 섞여 있다는 점이다. 검색은 통과했는데 정작 답이 되긴 어려운 문서들이다.

이유는 단순하다. 앞 단의 검색 알고리즘은 본질적으로 기계적이다. 희소검색은 단어가 겹치는지만 보고, 밀집검색은 벡터 공간에서 좌표가 가까운지만 계산한다. 둘 다 빠르고 효율적이지만, 질문의 진짜 의도까지 헤아리지는 못한다. 그래서 1차로 걸러낸 문서들을 한 번 더, 이번엔 더 정교한 기준으로 다시 평가하고 순위를 바꿔주는 단계가 필요하다. 이걸 문서 후처리(Post Processing), 그중에서도 핵심인 순위 재조정을 리랭킹(Re-ranking)이라고 부른다.

왜 1차 검색만으로는 부족한가

여기서 한 가지 짚고 넘어갈 게 있다. 희소·밀집검색 과정에는 사실 LLM이 끼어들 자리가 거의 없다. 키워드를 매칭하거나 고차원 벡터의 유사도를 계산하는 건 LLM이 할 일이 아니다. 굳이 LLM이 관여하는 부분을 찾자면 인덱싱 단계에서 임베딩 모델 API를 호출하는 정도다. 검색 자체는 철저히 수학과 통계의 영역이라고 보면 된다.

그럼 "그냥 처음부터 LLM한테 문서들을 던지고 관련도를 매기게 하면 안 되나"라는 생각이 들 수 있다. 가능은 하다. 하지만 대규모 문서 전체를 LLM이 일일이 읽고 관련성을 판단하게 하면 계산 비용이 폭발하고 시간도 오래 걸린다. 수백만 건을 LLM에 통째로 넣는 건 현실적인 선택지가 아니다.

그래서 실전에서 쓰는 방식은 순차 적용이다. 먼저 빠르고 가벼운 초기 검색(희소·밀집)으로 후보를 빠르게 좁힌다. 그다음 좁혀진 후보군만 리랭커가 정밀하게 다시 평가한다. 빠른 1차 필터와 정확한 2차 심사를 역할 분담시키는 것이다. 이 흐름을 그림으로 보면 검색에서 리랭킹으로 어떻게 바통이 넘어가는지가 한눈에 잡힌다.

flowchart TD

A(["사용자 질문"]) --> B

  

B["1단계 Retrieval<br/>BM25 + 벡터 검색<br/>1,000만 개 전체 문서"]

B --> C["후보 ~100개"]

  

C --> D["2단계 Reranking<br/>크로스 인코더<br/>(BGE-Reranker 등)<br/>100개 정밀 재점수"]

D --> E["후보 ~10개"]

  

E --> F{"중요한<br/>질문인가?"}

F -- "아니오" --> G

F -- "예" --> H["3단계 Refining<br/>LLM 최종 평가·선별"]

H --> I["최종 3~5개"]

I --> G

  

G(["최종 문서 반환"])

리랭커는 맥락을 읽는다

그럼 리랭커는 앞 단의 검색기와 뭐가 다른가. 리랭커는 트랜스포머(Transformer) 기반이다. 트랜스포머는 문장 안 단어들의 관계를 통째로 파악하는 신경망 구조인데, 덕분에 단순 키워드 일치나 벡터 거리를 넘어 문서 전체의 맥락과 잠재적 가치까지 평가할 수 있다.

비유하자면 이렇다. 도서관에서 '투자'라는 키워드가 들어간 책 수십 권을 1차로 추려온 상황을 떠올려보자. 키워드 검색은 여기까지가 한계다. 그런데 금융 전문가가 그 책들을 직접 한 권씩 펼쳐 읽어보고 "이 책이 지금 질문하는 주제에 정말 깊이 맞닿아 있나"를 판단해준다면, 추려진 순서가 완전히 달라질 것이다. 리랭커가 하는 일이 딱 이 전문가의 역할이다.

방법 하나: LLM에게 점수를 매기게 하기

리랭킹을 구현하는 가장 직관적인 방법은 Claude나 GPT 같은 고성능 LLM을 그대로 심판으로 쓰는 것이다. 성능은 확실히 올라간다. 실제로 ChatGPT를 리랭킹에 투입한 연구에서, TREC·BEIR 같은 여러 평가 데이터셋을 기준으로 기존의 BM25, monoBERT, monoT5, Cohere Rerank 같은 모델들을 크게 앞서는 결과가 나왔다.

LLM 리랭킹의 강점은 범용성이다. GPT는 애초에 범용 모델이라 추가 학습 없이도 어느 도메인의 질의든 곧바로 적용할 수 있다. 의료든 법률이든 금융이든, 따로 학습시킬 필요 없이 그냥 던지면 평가해준다. 다만 대가가 있다. 계산 비용이 많이 든다는 점이다. 문서 하나하나에 대해 LLM을 호출해 점수를 받아야 하니, 후보가 늘어날수록 비용과 시간이 그대로 따라 붙는다.

성능은 좋지만 리소스를 많이 먹는다는 이 트레이드오프를, 다음에 볼 크로스 인코더가 한 번 더 영리하게 풀어낸다.

방법 둘: 크로스 인코더로 정밀 심사하기

크로스 인코더(Cross-Encoder)는 리트리버로 쓰는 바이 인코더(Bi-Encoder)보다 훨씬 느리고 비싸다. 그런데 정확도는 압도적으로 높다. 왜 그런지, 내부에서 무슨 일이 벌어지는지를 뜯어보면 이 트레이드오프가 납득이 된다.

바이 인코더와 크로스 인코더는 입력 방식부터 다르다

앞 편에서 다룬 임베딩 방식(FAISS, Chroma 등)이 바로 바이 인코더다. 둘의 결정적 차이는 질문과 문서를 모델에 어떻게 넣느냐에 있다. 바이 인코더는 질문 따로, 문서 따로 넣어 각각을 벡터로 만든다. 그래서 질문 벡터와 문서 벡터는 서로 만나기 전까지 상대가 무슨 단어를 품고 있는지 전혀 모른다. 반면 크로스 인코더는 질문과 문서를 한 쌍으로 묶어 동시에 넣는다. 그래서 질문 단어와 문서 단어의 관계를 전부 맞대어 비교할 수 있다.

이 차이가 속도와 정확도의 차이로 그대로 이어진다.

| 구분 | 바이 인코더 (일반 검색) | 크로스 인코더 (리랭킹) |

|---|---|---|

| 입력 방식 | 질문과 문서를 따로 넣음 | 질문과 문서를 한 쌍으로 동시에 넣음 |

| 상호작용 | 질문·문서 단어끼리 서로 모름 | 질문 단어와 문서 단어의 관계를 전부 비교 |

| 속도 | 매우 빠름 (미리 만든 벡터끼리 계산) | 매우 느림 (매번 모델을 통째로 돌려야 함) |

| 정확도 | 보통 (문맥을 대략 파악) | 매우 높음 (관계를 정밀하게 따짐) |

정확도의 비밀은 어텐션이다

크로스 인코더가 더 정확한 진짜 이유는 어텐션에 있다. 바이 인코더부터 보자. 질문은 질문대로 숫자가 되고, 문서는 문서대로 숫자가 된다. 둘이 만나기 전까지는 서로 안에 무슨 단어가 들었는지 모른 채 "내 좌표는 여기야"라고만 말하는 식이다.

크로스 인코더는 다르다. 질문과 문서를 [CLS] 질문 [SEP] 문서 형태로 하나로 합쳐 모델에 넣는다. 그러면 어텐션(Attention) 메커니즘이 작동한다. 어텐션은 문장 안의 각 단어가 다른 단어와 얼마나 관련 있는지를 따지는 장치인데, 이 덕분에 "질문의 '주식'이라는 단어가 문서의 '변동성'이라는 단어와 어떤 논리적 관계인지"를 글자 단위로 꼼꼼하게 대조한다.

이건 마치 서류 전형(바이 인코더) 심층 면접(크로스 인코더)의 차이와 같다. 서류는 빠르게 훑어 추려낼 수 있지만, 사람의 진가는 직접 마주 앉아 대화해봐야 드러난다. 크로스 인코더는 질문과 문서를 직접 대면시켜 대화를 붙이는 셈이다.

코드로 보는 리랭킹: 1차 검색 vs 정밀 재점수

말로 풀어낸 리랭킹을 코드로 직접 확인해보자. 외부 라이브러리나 실제 BERT 없이, 핵심 개념만 추려 '논문 검색'을 예로 짜봤다. 먼저 단순 키워드 빈도로 후보를 빠르게 추리고(1차 검색), 그다음 질문과 문서를 함께 보는 정밀 점수기로 그 후보만 다시 줄 세운다(리랭킹). 일부러 키워드만 잔뜩 겹치는 '노이즈 문서'를 섞어, 1차에서 상위였다가 리랭킹 후 밀려나는 장면을 만들었다.

"""

논문 검색 리랭킹 예제

- 1차 검색: TF(단순 키워드 빈도)로 후보 top-N을 빠르게 추림

- 2차 리랭킹: 크로스 인코더 흉내 — 질문-문서 쌍을 함께 보는 정밀 점수 함수

(실제로는 CrossEncoder 모델 또는 LLM API 호출로 대체)

- 1차 순위 vs 리랭킹 후 순위를 나란히 출력해 순위 변동을 확인

- 노이즈 문서(전기 변압기)가 1차에서 상위권이었다가 리랭킹 후 밀려나는 사례 포함

"""

  

import math

import re

from typing import List, Tuple

  
  

# ──────────────────────────────────────────────

# 데이터: 논문 초록 말뭉치 (노이즈 문서 의도적으로 포함)

# ──────────────────────────────────────────────

PAPERS = [

{

"id": "P1",

"title": "Attention Is All You Need",

"abstract": (

"트랜스포머(Transformer) 아키텍처를 제안한다. "

"어텐션(Attention) 메커니즘만으로 시퀀스 변환 태스크를 수행하며, "

"기계 번역에서 최고 성능을 달성했다. "

"인코더-디코더 구조와 멀티헤드 어텐션을 사용한다."

),

},

{

"id": "P2",

"title": "BERT: Pre-training of Deep Bidirectional Transformers",

"abstract": (

"양방향 트랜스포머를 사전 학습하는 BERT 모델을 제안한다. "

"마스크 언어 모델(MLM)과 다음 문장 예측으로 언어 표현을 학습하며, "

"다양한 NLP 태스크에서 파인튜닝으로 최고 성능을 달성했다. "

"어텐션 기반 문맥 표현을 활용한다."

),

},

{

"id": "P3",

"title": "GPT-3: Language Models are Few-Shot Learners",

"abstract": (

"대규모 언어 모델 GPT-3를 제안한다. "

"퓨샷 학습(Few-shot learning)으로 다양한 NLP 태스크를 수행하며, "

"트랜스포머 디코더 기반의 자기회귀 생성 모델이다. "

"파인튜닝 없이 프롬프트만으로 높은 성능을 달성한다."

),

},

{

"id": "P4",

"title": "RoBERTa: Robustly Optimized BERT Pretraining",

"abstract": (

"BERT 사전 학습을 개선한 RoBERTa를 제안한다. "

"더 많은 데이터, 긴 학습, 동적 마스킹으로 성능을 향상시켰다. "

"트랜스포머 인코더 구조를 유지하면서 다양한 NLP 벤치마크에서 "

"BERT를 능가하는 결과를 달성했다."

),

},

{

# 노이즈: '트랜스포머' 단어를 많이 포함하지만 전기 변압기 주제

# → 1차(TF 점수)에서는 상위권이지만, 문맥을 보면 자연어 처리와 무관

"id": "N1",

"title": "High-Efficiency Power Transformer Design",

"abstract": (

"트랜스포머 트랜스포머 트랜스포머 설계 최적화를 다룬다. "

"트랜스포머 코어 재질과 트랜스포머 권선 구조를 개선하여 "

"전력 변환 트랜스포머의 에너지 손실을 줄이는 방법을 제안한다. "

"산업용 전력 공급 장치의 트랜스포머 효율을 높인다."

),

},

{

"id": "P5",

"title": "T5: Exploring the Limits of Transfer Learning with NLP",

"abstract": (

"텍스트-투-텍스트 트랜스포머(T5) 모델을 제안한다. "

"모든 NLP 태스크를 텍스트 생성 문제로 통일하여 처리한다. "

"대규모 사전 학습 후 다양한 언어 이해 및 생성 태스크에 적용한다. "

"어텐션 메커니즘 기반으로 문맥을 정밀하게 포착한다."

),

},

]

  
  

# ──────────────────────────────────────────────

# 유틸: 텍스트 토큰화 (공백/구두점 기준 분리)

# ──────────────────────────────────────────────

def tokenize(text: str) -> List[str]:

"""소문자 변환 후 단어 단위 토큰화"""

return re.findall(r"[가-힣a-zA-Z0-9]+", text.lower())

  
  

# ──────────────────────────────────────────────

# 1차 검색: TF 기반 키워드 점수 (바이 인코더 역할)

# 질문과 문서를 '따로' 보는 방식 — 빠르지만 문맥 파악 한계

# ──────────────────────────────────────────────

def first_stage_score(query: str, document: str) -> float:

"""

쿼리 토큰이 문서에 등장하는 빈도의 합을 반환.

실제로는 BM25 또는 임베딩 벡터 유사도(코사인)를 사용.

단순 빈도 기반이라 노이즈 문서(키워드 반복)를 구분하지 못한다.

"""

query_tokens = set(tokenize(query))

doc_tokens = tokenize(document)

score = sum(doc_tokens.count(t) for t in query_tokens)

return float(score)

  
  

def first_stage_retrieve(

query: str, papers: List[dict], top_n: int = 4

) -> List[Tuple[dict, float]]:

"""1차 검색으로 후보 top_n 논문을 점수와 함께 반환"""

scored = []

for paper in papers:

search_text = paper["title"] + " " + paper["abstract"]

score = first_stage_score(query, search_text)

scored.append((paper, score))

scored.sort(key=lambda x: x[1], reverse=True)

return scored[:top_n]

  
  

# ──────────────────────────────────────────────

# 2차 리랭킹: 크로스 인코더 흉내 (정밀 점수 함수)

# 질문-문서 쌍을 '함께' 보는 방식

# 실제로는 CrossEncoder 모델(sentence-transformers) 또는 LLM API를 호출

# ──────────────────────────────────────────────

  

# 자연어 처리 분야의 핵심 맥락어 목록

# 실제 크로스 인코더는 이런 규칙 없이 어텐션으로 자동 파악

NLP_CONTEXT_WORDS = {

"nlp", "자연어", "언어", "모델", "학습", "처리",

"bert", "gpt", "t5", "roberta", "태스크", "번역",

"생성", "인코더", "디코더", "파인튜닝", "사전",

}

  
  

def cross_encoder_simulate(query: str, document: str) -> float:

"""

[크로스 인코더 시뮬레이션]

실제로는: model.predict([[query, document]]) 또는 LLM 관련성 점수 반환.

  

두 가지 신호를 결합:

(a) 공통 키워드 IDF 가중 점수 — 희귀 단어 일치에 더 높은 점수

(b) 문맥 적합성 보너스 — NLP 도메인 단어가 문서에 있으면 가산점

두 신호를 합산해 '질문-문서 쌍 관련성 점수'를 산출.

"""

query_tokens = tokenize(query)

doc_tokens = tokenize(document)

doc_set = set(doc_tokens)

  

total_tokens = len(doc_tokens) if doc_tokens else 1

  

# ── (a) IDF 가중 공통 키워드 점수 ──────────────────

# 단어가 드물수록(문서 내 빈도 낮을수록) 더 중요하다고 간주

# 노이즈 문서는 같은 단어를 반복해 빈도가 높아 IDF 페널티를 받음

idf_score = 0.0

for token in set(query_tokens):

freq = doc_tokens.count(token)

if freq > 0:

# 빈도가 높을수록 log 값이 작아져 점수가 낮아짐 (IDF 역할)

idf_score += math.log(total_tokens / (freq + 1) + 1)

  

# ── (b) 문맥 적합성 보너스: NLP 도메인 단어 존재 여부 ──────

# 크로스 인코더는 어텐션으로 이런 맥락을 자동으로 파악함

context_bonus = sum(0.8 for w in NLP_CONTEXT_WORDS if w in doc_set)

  

return idf_score + context_bonus

  
  

def rerank(

query: str, candidates: List[Tuple[dict, float]]

) -> List[Tuple[dict, float]]:

"""

크로스 인코더 점수로 후보 논문을 재정렬.

실제로는 CrossEncoder.predict() 또는 LLM 관련성 점수를 사용.

"""

reranked = []

for paper, _ in candidates:

text = paper["title"] + " " + paper["abstract"]

score = cross_encoder_simulate(query, text)

reranked.append((paper, score))

reranked.sort(key=lambda x: x[1], reverse=True)

return reranked

  
  

# ──────────────────────────────────────────────

# 메인 실행: 1차 순위 vs 리랭킹 후 순위 비교 출력

# ──────────────────────────────────────────────

def main():

query = "트랜스포머 어텐션 메커니즘을 이용한 자연어 처리 모델"

  

print("=" * 65)

print(f"검색 질문: {query}")

print("=" * 65)

  

# 1단계: 빠른 1차 검색 (BM25/벡터 역할)

top_candidates = first_stage_retrieve(query, PAPERS, top_n=4)

  

print("\n[1차 검색 결과 — 빠른 키워드 빈도 점수 순위]")

print(f"{'순위':<4} {'ID':<5} {'1차점수':>7} 제목")

print("-" * 65)

for rank, (paper, score) in enumerate(top_candidates, 1):

marker = " <- 노이즈" if paper["id"] == "N1" else ""

print(f"{rank:<4} {paper['id']:<5} {score:>7.2f} {paper['title']}{marker}")

  

# 2단계: 크로스 인코더 흉내로 재순위 (후보만 재점수)

reranked = rerank(query, top_candidates)

  

print("\n[2차 리랭킹 결과 — 크로스 인코더(시뮬) 정밀 점수 순위]")

print("(실제로는 CrossEncoder.predict() 또는 LLM 관련성 점수 사용)")

print(f"{'순위':<4} {'ID':<5} {'정밀점수':>8} 제목")

print("-" * 65)

for rank, (paper, score) in enumerate(reranked, 1):

marker = " <- 노이즈" if paper["id"] == "N1" else ""

print(f"{rank:<4} {paper['id']:<5} {score:>8.3f} {paper['title']}{marker}")

  

# 순위 변동 요약

print("\n[순위 변동 요약]")

first_order = [p["id"] for p, _ in top_candidates]

rerank_order = [p["id"] for p, _ in reranked]

for pid in first_order:

r1 = first_order.index(pid) + 1

r2 = rerank_order.index(pid) + 1

delta = r1 - r2 # 양수면 순위 상승

if delta > 0:

arrow = f"▲ {delta}단계 상승"

elif delta < 0:

arrow = f"▼ {abs(delta)}단계 하락"

else:

arrow = "변동 없음"

noise_tag = " [노이즈 문서]" if pid == "N1" else ""

print(f" {pid}{noise_tag}: 1차 {r1}위 -> 리랭킹 {r2}위 ({arrow})")

  
  

if __name__ == "__main__":

main()

위 예제의 정밀 점수기는 질문과 문서를 '한 쌍으로 함께 본다'는 크로스 인코더의 핵심만 흉내 낸 것이다. 실제 크로스 인코더(BERT)라면 이 쌍이 들어갔을 때 내부에서 이런 일이 순서대로 벌어진다. 먼저 입력이 [CLS] 질문 [SEP] 문서 [SEP] 형태로 하나로 합쳐진다. 그다음 전체 어텐션이 돌아간다. 질문의 '변동성'이라는 단어가 문서의 '분산 투자'와 어떤 관계인지, '사과 파이' 같은 엉뚱한 문서와는 왜 상관이 없는지를 모든 레이어에서 동시에 비교하는 단계다. 마지막으로 문장 맨 앞의 [CLS] 토큰을 꺼낸다. 이 토큰은 전체 문맥을 요약하는 역할을 하는데, 모델은 이 값을 보고 "이 둘은 95% 확률로 관련 있다"는 식의 최종 판단을 내린다.

그럼 왜 검색이 아니라 리랭킹에만 크로스 인코더를 쓰는가

여기서 자연스럽게 의문이 든다. 그렇게 정확하면 처음부터 검색에 크로스 인코더를 쓰면 되지 않나. 문서가 100만 개인 상황을 떠올려보면 답이 나온다.

바이 인코더는 질문 하나만 임베딩한 뒤 미리 저장해둔 벡터들과 수학적으로 슥 비교하면 끝난다. 0.01초 안팎이다. 반면 크로스 인코더는 질문과 문서 100만 개를 일일이 쌍으로 묶어 BERT를 100만 번 돌려야 한다. 컴퓨터가 폭발할 일이고 몇 시간이 걸린다.

그래서 리랭킹이라는 단계가 필수가 된다. 먼저 상위 10~20개만 빠르게 거르고, 그 소수만 크로스 인코더에 넣어 진짜 1등을 가려내는 것이다. 정리하면 크로스 인코더는 질문과 문서의 상호작용을 극대화해 "이 문서가 이 질문에 진짜 답이 될 확률"을 0~1 사이 점수로 뱉어내는 심판관이다. 대신 연산량이 많아 GPU 자원을 많이 먹고 응답이 느리다. 실무에서는 BGE-Reranker나 Cohere Rerank API 같은 모델이 이 역할로 많이 쓰인다.

결국 답은 3단계 깔때기다

여기까지 정리하면 두 갈래의 리랭킹 방법이 손에 잡힌다. LLM은 가장 정확하지만 비싸고, 크로스 인코더는 그보다 가볍지만 그래도 1차 검색보다는 무겁다. 그럼 "정확하니까 그냥 LLM을 리랭커로 쓰면 되는 거 아니냐"는 결론으로 가고 싶어진다. 하지만 100건, 1000건씩 쌓이는 실전 트래픽을 떠올려보면 곧바로 요금 폭탄과 긴 지연 시간이라는 벽에 부딪힌다.

그래서 현실적인 해법은 단계를 나눠 점진적으로 정제하는 3단계 깔때기(Three-Stage Retrieval) 전략이다. 위로 갈수록 빠르고 싸게 많이 거르고, 아래로 갈수록 느리고 비싸지만 정확하게 추리는 구조다. 깔때기처럼 후보를 단계마다 좁혀 나간다.

1차는 검색(Retrieval) 단계다. BM25와 벡터 검색으로 1,000만 개 중 100개를 빠르게 골라낸다. 가장 가볍고 가장 빠른 그물질이다.

2차는 리랭킹(Reranking) 단계다. 가벼운 크로스 인코더(BGE-Reranker 등)로 그 100개 중 10개를 추린다. 1차보다는 무겁지만 여전히 빠르고 저렴해서 부담 없이 돌릴 수 있다.

3차는 정제(Refining) 단계다. 정말 중요한 질문일 때만 LLM을 투입해 최종 3~5개의 순위를 확정한다. 가장 정확하지만 가장 비싼 카드라, 꼭 필요한 순간에만 아껴 쓰는 것이다.

핵심은 비싼 도구를 맨 마지막에, 그것도 후보가 충분히 좁혀진 다음에만 꺼낸다는 데 있다. 가장 정확한 LLM을 처음부터 1,000만 개에 들이대는 게 아니라, 빠른 검색으로 100개, 크로스 인코더로 10개까지 좁힌 뒤에야 비로소 LLM에게 맡기는 것이다. 결국 리랭킹 설계도 검색 단과 똑같은 결론으로 수렴한다. 정확도와 비용 사이에서 내 서비스의 트래픽과 질문 성격을 보고 어느 단계에 얼마를 쓸지 저울질하는 일이다.