리랭킹은 검색 후보를 다시 정렬하는 단계이므로 후보를 얼마나 잘 모았는지와 구분해서 평가해야 한다. 키워드·BM25·LLM 기반 점수화를 비교하면서 순위 개선과 추가 처리 비용을 함께 본다. 점수의 의미와 반환 형식을 맞추는 것도 후속 필터 연결의 전제다.
목차
- 사용 라이브러리 및 기본 설정
- 리랭킹 개념 및 파이프라인
- Bi-encoder vs Cross-encoder
- 키워드 리랭킹 (Keyword Rerank)
- BM25 리랭킹 (BM25Reranker)
- LLM 리랭킹 — 문서별 점수 일괄 평가 (llm_rerank)
- Hybrid 리랭킹
- Listwise 리랭킹 (llm_listwise_rerank)
- ScoreFilter (Threshold 필터링)
- 자주 나오는 실수 / 주의사항
- [보충] 리랭킹 기법 비교
- 순위 개선을 설명할 수 있는 비교 만들기
1. 사용 라이브러리 및 기본 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import os, re, time, json, math
import numpy as np
import pandas as pd
from collections import Counter
from scipy import stats
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_classic.retrievers.document_compressors import LLMChainExtractor
from dotenv import load_dotenv
load_dotenv()
MODEL = "gpt-4o-mini"
llm = ChatOpenAI(model=MODEL)
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
|
샘플 문서
1
2
3
4
5
6
7
8
9
10
11
12
13
| documents = [
Document(page_content="트랜스포머는 Self-Attention 메커니즘을 사용하여 시퀀스 데이터를 병렬로 처리하는 딥러닝 아키텍처입니다.", metadata={"id": "d1"}),
Document(page_content="BERT는 양방향 트랜스포머 인코더로 MLM과 NSP 태스크로 사전학습됩니다.", metadata={"id": "d2"}),
Document(page_content="GPT는 단방향 트랜스포머 디코더로 다음 토큰 예측 방식으로 학습합니다.", metadata={"id": "d3"}),
Document(page_content="RAG는 검색 증강 생성 기법으로 외부 지식을 LLM에 결합하여 할루시네이션을 줄입니다.", metadata={"id": "d4"}),
Document(page_content="벡터 데이터베이스는 임베딩 벡터를 저장하고 유사도 기반 검색을 수행합니다. FAISS, Pinecone 등이 있습니다.", metadata={"id": "d5"}),
Document(page_content="파인튜닝은 사전학습된 모델을 특정 도메인 데이터로 추가 학습하는 기법입니다. LoRA, QLoRA가 효율적입니다.", metadata={"id": "d6"}),
Document(page_content="프롬프트 엔지니어링은 LLM에 효과적인 지시를 설계하는 기법입니다. Few-shot, CoT 등이 있습니다.", metadata={"id": "d7"}),
Document(page_content="토큰화는 텍스트를 모델이 처리할 수 있는 단위로 분할하는 과정입니다. BPE, WordPiece 등이 사용됩니다.", metadata={"id": "d8"}),
]
vectorstore = FAISS.from_documents(documents, embeddings_model)
bm25_retriever = BM25Retriever.from_documents(documents, k=5)
|
2. 리랭킹 개념 및 파이프라인
정답 문서가 후보 목록에 없다면 순서만 바꾸어서는 복구할 수 없다. 먼저 후보 집합의 정답 포함 여부를 확인하고, 그다음 정답이 몇 위로 이동했는지 비교하면 검색과 리랭킹의 영향을 나눌 수 있다.
리랭킹은 1차 검색 결과를 더 정확한 기준으로 재정렬하는 과정입니다.
1
2
3
4
5
6
7
8
9
10
11
| [전체 RAG 파이프라인]
사용자 쿼리
↓
[1차 검색] Vector Search / BM25 / Hybrid
↓ 후보 문서 (doc1 ~ doc5)
[리랭킹] Cross-encoder / BM25 / LLM
↓ 재정렬된 문서
[생성] Generator (LLM)
↓
최종 답변
|
3. Bi-encoder vs Cross-encoder
1
2
3
4
5
6
7
8
9
10
11
12
| [Bi-encoder — 일반 벡터 검색]
query ──(emb_model)──► query_emb
doc1 ──(emb_model)──► doc1_emb
──► 코사인 유사도로 비교 (문장 각각 따로 임베딩)
[Cross-encoder — 쌍(pair)으로 입력]
(query + doc1) ──(emb_model)──► 관련성 점수
(query + doc2) ──(emb_model)──► 관련성 점수
──► 쿼리-문서 쌍의 상호작용까지 반영
[LLM 기반]
(query, doc1) ──(LLM)──► score 또는 True/False
|
| 방식 | 계산 단위 | 확인할 점 | 특징 |
|---|
| Bi-encoder | 질문·문서의 개별 벡터 | 후보 회수율과 검색 시간 | 문서 임베딩 사전 계산 가능 |
| Cross-encoder | 질문·문서 쌍 | 후보 수에 따른 처리 시간과 순위 변화 | 두 입력의 상호작용을 반영 |
| LLM reranker | 프롬프트에 넣은 후보 | 호출 수·입력 길이·평가셋 성능 | 작업에 맞춘 평가 지시 가능 |
4. 키워드 리랭킹 (Keyword Rerank)
작동 방식
쿼리 단어가 문서에 몇 개나 포함됐는지 세어 기존 점수에 가산점을 더합니다.
1
2
3
4
5
6
7
8
9
10
11
12
| def keyword_rerank(query, search_results):
query_terms = set(query.lower().split())
reranked = []
for doc, orig_score in search_results:
doc_terms = doc.page_content.lower().split()
keyword_hits = sum(1 for t in doc_terms if t in query_terms)
new_score = orig_score + 0.1 * keyword_hits # 키워드 히트당 +0.1
reranked.append((doc, new_score, orig_score))
reranked.sort(key=lambda x: x[1], reverse=True)
return reranked # (doc, 새 점수, 원래 점수)
|
순위 변화 추적
1
2
3
4
5
6
7
8
9
10
11
12
13
| def rank_change(original_results, reranked_results):
orig_ranks = {doc_.metadata['id']: i+1 for i, (doc_, _) in enumerate(original_results)}
new_ranks = {doc_.metadata['id']: i+1 for i, (doc_, _, _) in enumerate(reranked_results)}
changes = []
for doc_id in orig_ranks:
changes.append({
'doc_id': doc_id,
'before': orig_ranks[doc_id],
'after': new_ranks.get(doc_id, -1),
'change': orig_ranks[doc_id] - new_ranks.get(doc_id, -1)
})
return pd.DataFrame(changes).sort_values('after')
|
5. BM25 리랭킹 (BM25Reranker)
BM25 공식
1
2
3
4
5
6
7
8
| BM25(q, d) = Σ IDF(qt) × [ tf × (k1 + 1) ] / [ tf + k1 × (1 - b + b × |d|/avgDL) ]
- tf : 쿼리 단어의 문서 내 출현 빈도
- IDF : log((N - df + 0.5) / (df + 0.5) + 1)
- |d| : 문서 길이
- avgDL : 평균 문서 길이
- k1=1.5 : 단어 빈도 포화 제어 (높을수록 빈도 효과 강화)
- b=0.75 : 문서 길이 정규화 강도 (0이면 정규화 없음)
|
TF-IDF vs BM25: TF-IDF는 tf를 그대로 사용하지만 BM25는 tf 포화(saturation) 처리로 단어가 많이 반복돼도 점수가 무한정 올라가지 않음.
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| class BM25Reranker:
def __init__(self, k1=1.5, b=0.75):
self.k1 = k1
self.b = b
def rerank(self, query, search_results):
docs = [doc for doc, _ in search_results]
tokenized = [doc.page_content.lower().split() for doc in docs]
avg_dl = np.mean([len(t) for t in tokenized])
df_count = Counter()
for tokens in tokenized:
for t in set(tokens):
df_count[t] += 1
N = len(docs)
query_tokens = query.lower().split()
scored = []
for i, doc in enumerate(docs):
score = 0.0
doc_len = len(tokenized[i])
tf_count = Counter(tokenized[i])
for qt in query_tokens:
tf = tf_count.get(qt, 0)
if tf == 0:
continue
df = df_count.get(qt, 0)
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / avg_dl)
score += idf * numerator / denominator
scored.append((doc, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
|
6. LLM 리랭킹 — 문서별 점수 일괄 평가 (llm_rerank)
개념
Pointwise는 질문과 문서 하나를 기준으로 관련성을 개별 평가하는 방식입니다. 아래 llm_rerank 구현은 모든 후보 문서를 한 프롬프트에 넣고 한 번 호출해 문서별 점수(0.0~1.0)를 받습니다. 문서마다 독립적으로 호출하는 Pointwise 예제와는 호출 방식이 다릅니다.
1
2
3
| [아래 코드의 일괄 점수 평가]
(query + doc1 + doc2 + doc3) ──(LLM 1회)──► 문서 ID별 점수
→ 점수 내림차순으로 재정렬
|
한 번에 전달하는 문서가 많으면 입력 길이가 늘고 다른 후보나 입력 순서가 점수에 영향을 줄 수 있습니다. 점수는 모델의 관련성 판단값이며 정답일 확률로 보정된 수치는 아닙니다.
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| def llm_rerank(query, search_results, top_k=3):
docs_text = '\n'.join(
f"[{doc.metadata['id']}] {doc.page_content}" for doc, _ in search_results
)
score_template = ", ".join(
f'"{doc.metadata["id"]}": 0.0-1.0' for doc, _ in search_results
)
scoring_chain = ChatPromptTemplate.from_messages([
('system', '당신은 scoring 시스템입니다. 항상 json 형태로 출력하세요'),
('human', """다음 쿼리에 대해 각 문서의 관련성을 0.0~1.0으로 평가하세요.
쿼리 : {query}
문서들 : {docs_text}
JSON으로 답하세요:
{{"scores" : {{{score_template}}}}}""")
]) | llm | StrOutputParser()
result = scoring_chain.invoke({
'query': query, 'docs_text': docs_text, 'score_template': score_template
})
cleaned = result.strip()
if cleaned.startswith('```json'):
cleaned = cleaned.replace('```json', '').replace('```', '')
scores = json.loads(cleaned).get('scores', {})
scored = []
for doc, orig in search_results:
rerank_score = scores.get(doc.metadata['id'], 0.0)
scored.append((doc, float(rerank_score), orig))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
|
1
2
3
4
| 실행 예시 (query = "트랜스포머와 BERT의 관계"):
d2 (BERT) → 1.0 ← LLM이 가장 관련 높다고 판단
d1 (트랜스포머) → 0.4
d3 (GPT) → 0.3
|
7. Hybrid 리랭킹
서로 다른 점수를 정규화하면 결합은 가능해지지만 같은 의미의 확률이 되는 것은 아니다. 후보 집합이 달라지면 정규화 결과도 달라질 수 있으므로, 가중치와 필터 기준을 정할 때 실제 질문별 분포를 함께 확인한다.
BM25와 LLM 점수를 정규화 후 가중합으로 결합합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| def hybrid_rerank(query, search_results, bm25_weight=0.3):
bm25 = BM25Reranker()
bm25_scored = bm25.rerank(query, search_results)
bm25_map = {doc.metadata['id']: score for doc, score in bm25_scored}
llm_scored = llm_rerank(query, search_results, top_k=len(search_results))
llm_map = {doc.metadata['id']: score for doc, score, _ in llm_scored}
def normalize(scores_dict):
vals = list(scores_dict.values())
min_, max_ = min(vals), max(vals)
range_ = max_ - min_ if max_ > min_ else 1e-8
return {k: (v - min_) / range_ for k, v in scores_dict.items()}
bm25_norm = normalize(bm25_map)
llm_norm = normalize(llm_map)
combined = []
for doc, _ in search_results:
did = doc.metadata['id']
score = bm25_weight * bm25_norm.get(did, 0) + (1 - bm25_weight) * llm_norm.get(did, 0)
combined.append((doc, score))
combined.sort(key=lambda x: x[1], reverse=True)
return combined
|
1
2
| bm25_weight = 0.3 → LLM 점수 70% + BM25 점수 30%
각 점수를 min-max 정규화한 뒤 가중합 → 스케일 차이 문제 해소
|
8. Listwise 리랭킹 (llm_listwise_rerank)
Pointwise vs Listwise 비교
1
2
3
4
5
6
7
| [Pointwise] : 문서 1개씩 LLM에 입력 → 점수
- 장점: 문서별 독립 평가와 병렬 처리 가능
- 단점: 문서별 호출 비용, 다른 후보와 직접 비교하지 않음
[Listwise] : N개 문서 한번에 입력 → 순서 반환
- 장점: 문서 간 비교 가능
- 단점: 비용, 할루시네이션 / 컨텍스트 길이 제한
|
6절의 일괄 점수 평가는 여러 문서를 함께 입력하되 문서별 점수를 받고, 이 절의 Listwise 구현은 문서 순서를 직접 받습니다. 속도와 품질은 후보 수, 문서 길이, 모델, 병렬 호출 여부에 따라 달라지므로 같은 평가셋에서 비교합니다.
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| def llm_listwise_rerank(query, search_results):
docs_text = '\n'.join(
f"{i+1}. [{doc.metadata['id']}] {doc.page_content[:80]}"
for i, (doc, _) in enumerate(search_results)
)
ranking_chain = ChatPromptTemplate.from_messages([
('system', "당신은 문서 랭킹 시스템입니다"),
('human', """다음 문서들을 쿼리와의 관련성 순서로 정렬하세요.
쿼리 : {query}
문서들 : {docs_text}
가장 관련성 높은 순서대로 문서 번호를 쉼표로 나열하세요 (예: 3,1,5,2,4):""")
]) | llm | StrOutputParser()
result = ranking_chain.invoke({'query': query, 'docs_text': docs_text})
order = [int(x.strip()) for x in result.strip().split(',')]
docs_list = [doc for doc, _ in search_results]
reranked = []
for rank, idx in enumerate(order):
if 1 <= idx <= len(docs_list):
reranked.append((docs_list[idx-1], 1.0 - rank * 0.1))
return reranked
|
9. ScoreFilter (Threshold 필터링)
리랭킹 후 낮은 점수 문서를 제너레이터에 넘기기 전에 걸러냅니다.
1
2
3
4
| RAG 파이프라인에서 필터링 위치:
Retriever → Reranker → [ScoreFilter] → Generator
↑
점수 낮은 문서 차단 (노이즈 감소)
|
필터링 전략 3가지
1
2
3
4
5
6
7
8
9
10
11
12
13
| class ScoreFilter:
@staticmethod
def fixed_threshold(scored_docs, threshold=0.5):
"""고정 임계값: threshold 미만 제거 (기본값 0.5)"""
return [(doc, s) for doc, s in scored_docs if s >= threshold]
@staticmethod
def dynamic_threshold(scored_docs, std_factor=1):
"""동적 임계값: 평균 - N×표준편차 미만 제거"""
scores = [s for _, s in scored_docs]
threshold = np.mean(scores) - std_factor * np.std(scores)
return [(doc, s) for doc, s in scored_docs if s >= threshold]
|
1
2
3
4
5
6
7
| fixed_threshold(threshold=0.5):
입력: [0.9, 0.85, 0.7, 0.5, 0.2]
출력: [0.9, 0.85, 0.7, 0.5] ← 0.2 제거
dynamic_threshold(std_factor=1):
mean=0.63, std=0.25 → threshold = 0.63 - 0.25 = 0.38
출력: [0.9, 0.85, 0.7, 0.5] ← 0.2 제거
|
score_gap 전략 (점수 급락 지점 탐지)은 다음 강의(04_08)에서 구현됩니다.
10. 자주 나오는 실수 / 주의사항
- LLM 일괄 점수 평가 JSON 파싱 오류: LLM이
json 코드블록으로 감싸서 반환하는 경우 있음 → `cleaned.replace('json’, ‘’)` 처리 필수 - BM25 리랭킹 점수 0: 쿼리 토큰이 문서 내에 한 개도 없으면 점수가 0 → 키워드 중심 쿼리에서 유리, 의미 기반 쿼리에서는 불리
- Hybrid 정규화 분모가 0:
range_ = max_ - min_ 이 0이 될 수 있음 → if max_ > min_ else 1e-8 처리 필요 - Listwise 순서 파싱: LLM이 “3,1,5,2,4” 대신 다른 형식으로 반환할 수 있음 → 파싱 오류 예외 처리 추가 권장
- rank_change 함수 인자 형태:
original_results는 (doc, score) 튜플, reranked_results는 (doc, new_score, orig_score) 튜플 — 형태가 다르므로 주의
[보충] 리랭킹 기법 비교
| 기법 | 방식 | 장점 | 단점 |
|---|
| 키워드 리랭크 | 키워드 히트 수로 가산점 | 매우 빠름, 구현 단순 | 의미 고려 불가 |
| BM25 리랭크 | TF-IDF 개선판 공식 | 키워드 기반 정밀도 향상 | 의미적 유사도 반영 안 됨 |
| LLM Pointwise | 1개씩 관련성 점수화 | 독립 평가, 병렬화 가능 | 문서별 호출 비용, 다른 후보와 직접 비교하지 않음 |
| LLM 일괄 점수 평가 (6절) | 여러 문서를 함께 입력해 문서별 점수 반환 | 한 번의 호출로 후보 점수화 | 컨텍스트 제한, 다른 후보와 입력 순서의 영향 |
| LLM Listwise | N개 한번에 정렬 | 문서 간 비교 | 비용, 컨텍스트 제한 |
| Hybrid | BM25 + LLM 가중합 | 균형 잡힌 성능 | 가중치 튜닝 필요 |
리랭킹 파이프라인 전체 흐름
1
2
3
4
5
6
7
8
9
10
11
12
| 쿼리
↓
[Vector Search] (Bi-encoder, FAISS)
↓ 후보 5~20개
[Reranker 선택]
├─ 빠른 응답 필요 → BM25 or Keyword Rerank
├─ 문서별 점수 필요 → LLM 점수 평가
└─ 문서 비교 필요 → LLM Listwise
↓
[ScoreFilter] → 낮은 점수 문서 제거
↓
[Generator] → 최종 답변 생성
|
12. 순위 개선을 설명할 수 있는 비교 만들기
리랭커 비교에서는 원래 후보를 고정하고 변경 전후의 순위를 기록한다. 정답 문서의 순위가 올랐는지, 이미 좋았던 결과를 밀어내지는 않았는지를 함께 봐야 적용할 질문 범위를 정할 수 있다.
LLM 기반 방식은 호출 시간과 입력 길이까지 포함해 판단한다. 순위가 비슷하다면 더 단순한 방식을 유지할 수 있고, 특정 유형에서만 개선된다면 해당 요청에 한정해 적용하는 선택도 가능하다.