포스트

2026-04-07 강의 정리

주요 언어 모델 공급자 및 활용 (Claude, Gemini, Groq, Ollama, Huggingface)

2026-04-07 강의 정리

리랭킹은 검색 후보를 다시 정렬하는 단계이므로 후보를 얼마나 잘 모았는지와 구분해서 평가해야 한다. 키워드·BM25·LLM 기반 점수화를 비교하면서 순위 개선과 추가 처리 비용을 함께 본다. 점수의 의미와 반환 형식을 맞추는 것도 후속 필터 연결의 전제다.

목차

  1. 사용 라이브러리 및 기본 설정
  2. 리랭킹 개념 및 파이프라인
  3. Bi-encoder vs Cross-encoder
  4. 키워드 리랭킹 (Keyword Rerank)
  5. BM25 리랭킹 (BM25Reranker)
  6. LLM 리랭킹 — 문서별 점수 일괄 평가 (llm_rerank)
  7. Hybrid 리랭킹
  8. Listwise 리랭킹 (llm_listwise_rerank)
  9. ScoreFilter (Threshold 필터링)
  10. 자주 나오는 실수 / 주의사항
  11. [보충] 리랭킹 기법 비교
  12. 순위 개선을 설명할 수 있는 비교 만들기

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 Pointwise1개씩 관련성 점수화독립 평가, 병렬화 가능문서별 호출 비용, 다른 후보와 직접 비교하지 않음
LLM 일괄 점수 평가 (6절)여러 문서를 함께 입력해 문서별 점수 반환한 번의 호출로 후보 점수화컨텍스트 제한, 다른 후보와 입력 순서의 영향
LLM ListwiseN개 한번에 정렬문서 간 비교비용, 컨텍스트 제한
HybridBM25 + 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 기반 방식은 호출 시간과 입력 길이까지 포함해 판단한다. 순위가 비슷하다면 더 단순한 방식을 유지할 수 있고, 특정 유형에서만 개선된다면 해당 요청에 한정해 적용하는 선택도 가능하다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그