본문 바로가기
HRDI_AI/[인공지능] RAG 검색 성능 최적화와 최신 Retrieval 전략

3. rrf_comparision

by Toddler_AD 2025. 6. 7.

BM25 vs Dense 임베딩 기반 검색과 RRF 결합 성능 비교

이 문서는 BM25와 Dense Retrieval (밀집 임베딩 기반 검색) 방법의 검색 성능을 비교하고, 두 결과를 RRF(Reciprocal Rank Fusion, 순위 역수 융합) 알고리즘으로 결합하여 성능을 향상시킬 수 있는지 실험한 Jupyter 노트북의 내용을 설명합니다. 각 단계별로 코드와 출력 결과를 포함하고, 그 앞에 해당 코드의 목적과 작동 방식, 출력의 의미, 사용된 알고리즘과 평가 지표 등에 대한 상세한 설명을 제공합니다. 이를 통해 전통적인 BM25(단어 빈도 기반 희소 벡터 검색)와 Dense 임베딩(벡터 임베딩 기반 검색) 기법의 차이점을 이해하고, Reciprocal Rank Fusion 기법을 활용한 하이브리드 검색 결합의 효과를 파악합니다.

1. 패키지 설치 및 환경 준비

이 부분에서는 실험에 필요한 파이썬 패키지들을 설치합니다. %pip install ... 명령을 사용하여 .env 파일을 처리하는 python-dotenv, 데이터 처리를 위한 pandas, 한국어 형태소 분석기 eunjeon(Mecab의 Python wrapper), rank_bm25(BM25 구현), pinecone (벡터 데이터베이스 클라이언트), langchain 및 관련 하위 패키지들 (OpenAI 및 Pinecone 연동), scikit-learn (평가 지표 계산에 사용) 등을 설치하거나 이미 설치되어 있는지 확인합니다. 해당 코드를 실행함으로써 이후 코드 실행에 필요한 라이브러리들이 모두 준비되도록 환경을 설정합니다.

%pip install python-dotenv pandas rank_bm25 pinecone langchain langchain-openai langchain-pinecone eunjeon scikit-learn

위 코드 셀을 실행한 출력 결과입니다. 대부분의 패키지가 이미 설치되어 있어 *"Requirement already satisfied"*라는 메시지가 나옵니다. 이는 해당 라이브러리가 현재 환경에 이미 존재함을 의미하며, 마지막에 *"Note: you may need to restart the kernel to use updated packages."*라는 안내가 출력되어 있습니다 (패키지 설치 후 커널 재시작이 필요할 수 있음을 알리는 일반적인 메시지입니다). 아래는 출력의 주요 부분을 발췌한 내용입니다:

Requirement already satisfied: python-dotenv in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (1.1.0)
Requirement already satisfied: pandas in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (2.3.0)
Requirement already satisfied: rank_bm25 in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (0.2.2)
Requirement already satisfied: pinecone in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (7.0.2)
Requirement already satisfied: langchain in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (0.3.25)
Requirement already satisfied: langchain-openai in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (0.3.19)
Requirement already satisfied: langchain-pinecone in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (0.2.8)
Requirement already satisfied: eunjeon in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (0.4.0)
Requirement already satisfied: scikit-learn in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (1.7.0)
Requirement already satisfied: numpy>=1.26.0 in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (from pandas) (2.2.6)
Requirement already satisfied: python-dateutil>=2.8.2 in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (from pandas) (2.9.0.post0)
... (중략: 의존성 패키지 설치 내역) ...
Requirement already satisfied: markdown-it-py>=2.2.0 in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (from rich>=13.8.1->pytest-codspeed->langchain-tests<1.0.0,>=0.3.7->langchain-pinecone) (3.0.0)
Requirement already satisfied: mdurl~=0.1 in c:\users\ssampooh\rag-retrieval\.conda\lib\site-packages (from markdown-it-py>=2.2.0->rich>=13.8.1->pytest-codspeed->langchain-tests<1.0.0,>=0.3.7->langchain-pinecone) (0.1.2)
Note: you may need to restart the kernel to use updated packages.

위 출력에서 알 수 있듯이, 필요한 모든 패키지가 이미 설치되어 있어 추가적인 설치 작업 없이 진행할 수 있습니다.

2. 환경 변수 로드

이 단계에서는 .env 파일에 저장된 환경 변수들을 불러옵니다. python-dotenv 패키지의 load_dotenv() 함수를 이용하여 .env 파일에 정의된 값들을 현재 환경 변수로 로드하며, 이후 os.getenv() 함수를 통해 API 키 등의 민감한 설정 값을 변수에 가져옵니다. 이렇게 함으로써 Pinecone API 키, OpenAI API 키 및 모델명 등 외부 서비스 이용에 필요한 설정을 코드에 직접 노출하지 않고 안전하게 사용할 수 있습니다. 마지막 줄에서는 환경 변수가 정상적으로 로드되었는지 확인하기 위해 간단한 완료 메시지를 출력합니다.

import os
from dotenv import load_dotenv

# .env 파일 로드
load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_EMBEDDING_MODEL = os.getenv("OPENAI_EMBEDDING_MODEL")
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
PINECONE_INDEX_REGION = os.getenv("PINECONE_INDEX_REGION")
PINECONE_INDEX_CLOUD = os.getenv("PINECONE_INDEX_CLOUD")
PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME")
PINECONE_INDEX_METRIC = os.getenv("PINECONE_INDEX_METRIC")
PINECONE_INDEX_DIMENSION = int(os.getenv("PINECONE_INDEX_DIMENSION"))

print("환경 변수 로딩 완료")

위 코드 실행 결과로 환경 변수들이 정상적으로 로드되었음을 알리는 메시지가 출력됩니다. 이 메시지를 통해 .env 파일에서 필요한 설정 값들이 모두 읽혀졌음을 확인할 수 있습니다.

환경 변수 로딩 완료

(출력 설명: .env 파일의 내용을 성공적으로 불러왔음을 나타냅니다.)

3. 실험 데이터 불러오기

다음으로, 검색 실험에 사용할 문서 데이터와 질의(Query) 데이터를 불러옵니다. pandas 라이브러리를 이용하여 documents.csv와 queries.csv 파일을 읽어와 각각 documents_df와 queries_df라는 데이터프레임에 저장합니다. 이 데이터셋은 예를 들어 제주도 관광 관련 문서들과 사용자 질의들로 이루어져 있다고 가정할 수 있습니다. 파일을 읽은 후에는 len() 함수를 사용하여 문서와 질의의 개수를 세어 출력합니다. 이를 통해 데이터가 제대로 로드되었는지 및 데이터 규모(문서 수, 질의 수)를 확인할 수 있습니다.

import pandas as pd

# 문서 및 질의 데이터 로드
documents_df = pd.read_csv("documents.csv")
queries_df = pd.read_csv("queries.csv")

print(f"문서 수: {len(documents_df)}")
print(f"질의 수: {len(queries_df)}")

출력 결과로 문서의 개수와 질의의 개수가 나타납니다. 아래 출력에서 문서 수 30개와 질의 수 30개임을 알 수 있습니다. 이는 본 실험에 30개의 문서와 30개의 검색 질의가 사용되고 있음을 의미합니다.

문서 수: 30
질의 수: 30

(출력 설명: 준비된 검색 말뭉치에 30개의 문서(documents_df)와 30개의 테스트 질의(queries_df)가 포함되어 있습니다.)

4. BM25 기반 검색 및 예시

이 부분에서는 BM25 알고리즘을 사용하여 문서를 검색하는 함수를 구성하고, 예시 질의에 대한 BM25 결과 상위 5개를 확인합니다. BM25는 전통적인 문서 빈도 기반의 가중치를 사용하는 단어 중심 검색 모델로서, 질의와 문서 사이의 단어 매치 정도를 점수화합니다. 한국어 처리를 위해 Mecab 형태소 분석기 (eunjeon.Mecab)를 사용하여 문서와 질의를 형태소 단위 토큰화합니다. 토큰화된 문서 리스트를 바탕으로 BM25Okapi 객체를 생성하고, 이후 질의를 입력하면 다음과 같은 절차로 상위 결과를 얻습니다:

  • 질의 문장 -> Mecab 형태소 분석 -> 질의 토큰 리스트
  • 각 문서에 대해 BM25 점수 계산 (bm25.get_scores)
  • 점수에 따라 문서 인덱스를 정렬하여 상위 top_k개의 문서 선택

코드에서는 이러한 과정을 bm25_search(query, top_k=20) 함수로 구현하였습니다. 마지막으로 *"제주도 관광 명소"*라는 예시 질의를 입력하여 BM25 상위 5개 결과 문서의 ID 목록을 출력합니다.

from eunjeon import Mecab
from rank_bm25 import BM25Okapi

# Mecab 형태소 분석기 초기화
mecab = Mecab()
# 문서 토큰화
tokenized_docs = [mecab.morphs(content) for content in documents_df['content']]
bm25 = BM25Okapi(tokenized_docs)

def bm25_search(query, top_k=20):
    tokens = mecab.morphs(query)
    scores = bm25.get_scores(tokens)
    ranked_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
    return [documents_df['doc_id'].iloc[i] for i in ranked_idx[:top_k]]

# BM25 상위 5개 예시
print("BM25 예시 검색 상위 5:", bm25_search("제주도 관광 명소", top_k=5))

위 코드 실행을 통해 BM25 방식으로 예시 질의를 검색한 결과 상위 5개 문서의 ID 목록이 출력됩니다. 출력 형식은 ['D1', 'D12', 'D2', 'D3', 'D4']처럼 문서 ID의 리스트로 나타납니다. 여기서 D1, D12 등은 각 문서를 식별하는 ID이며, BM25 점수 기준 가장 관련성이 높다고 판단된 문서부터 순서대로 나열되어 있습니다.

BM25 예시 검색 상위 5: ['D1', 'D12', 'D2', 'D3', 'D4']

(출력 설명: 예시 질의 "제주도 관광 명소"에 대해 BM25 알고리즘이 반환한 상위 5개의 문서 ID입니다. 이 결과는 Mecab으로 질의를 형태소 단위로 분해한 후 BM25 점수를 계산하여 얻은 것입니다.)

노트: 실행 중 eunjeon 패키지에서 pkg_resources 관련 경고가 발생할 수 있으나, 이는 패키지 내부의 Deprecation Warning으로 실험 결과에는 영향이 없습니다. 이러한 경고는 무시해도 무방합니다.

5. Dense 임베딩 기반 검색 설정

다음으로 Dense Retrieval 환경을 설정합니다. Dense Retrieval은 문장 임베딩을 활용한 검색 방식으로, 질의와 문서 내용을 각각 벡터로 변환한 후 벡터 유사도(예: 코사인 유사도)를 기반으로 관련 문서를 찾습니다. 여기서는 OpenAI 임베딩 모델을 사용하여 텍스트를 임베딩하고, Pinecone 벡터 데이터베이스에 사전 구축된 인덱스를 활용합니다.

코드에서는 Pinecone 라이브러리를 통해 Pinecone 서비스에 연결하고(Pinecone(api_key=...)), 미리 생성된 인덱스를 불러옵니다 (pc.Index(PINECONE_INDEX_NAME)). 그런 다음 OpenAIEmbeddings 객체를 생성하여 지정한 OPENAI_EMBEDDING_MODEL (예를 들어 ada-002 등)로 임베딩을 계산할 준비를 합니다. 마지막으로 PineconeVectorStore를 이용해 Pinecone 인덱스를 감싼 벡터 검색 객체를 만듭니다. 이 객체의 similarity_search(query, k) 메서드를 호출하면, 주어진 질의에 대해 벡터 임베딩 공간에서 가장 가까운 k개의 문서를 찾아줄 것입니다.

from pinecone import Pinecone
from langchain_openai import OpenAIEmbeddings
from langchain_pinecone import PineconeVectorStore

# Pinecone 클라이언트 연결 (이미 생성된 ir 인덱스 사용)
pc = Pinecone(api_key=PINECONE_API_KEY)
index = pc.Index(PINECONE_INDEX_NAME)

# 임베딩 모델
embedding_model = OpenAIEmbeddings(model=OPENAI_EMBEDDING_MODEL, openai_api_key=OPENAI_API_KEY)
# Dense Retrieval 벡터 스토어
vector_store = PineconeVectorStore(index_name=PINECONE_INDEX_NAME, embedding=embedding_model)

print("Dense Retrieval 설정 완료")

위 코드 셀을 실행하면 Dense 임베딩 기반 검색 환경 설정 완료 메시지가 출력됩니다. 이로써 Pinecone에 저장된 문서 임베딩을 활용하여 질의에 대한 벡터 유사도 검색을 수행할 준비가 된 것입니다.

Dense Retrieval 설정 완료

(출력 설명: Pinecone 벡터스토어와 OpenAI 임베딩 모델을 사용한 밀집 벡터 검색 구성이 완료되었음을 나타냅니다.)

노트: 환경에 ipywidgets 등이 설치되어 있지 않다면 tqdm 진행 표시줄 관련 경고(IProgress not found...)가 나올 수 있지만, 이는 단순 경고이므로 무시해도 됩니다.

6. 검색 성능 평가 지표 정의

이제 검색 성능을 평가할 지표들을 계산하는 함수를 정의합니다. 정보 검색(IR) 분야에서는 **정답 문서(relevant documents)**에 대한 검색 성능을 측정하기 위해 다양한 지표를 사용합니다. 이 코드에서는 다음과 같은 지표들을 top-k 결과 기준으로 계산합니다:

  • Precision@K (정밀도@K): 상위 K개의 검색 결과 중 정답 문서가 얼마나 포함되어 있는지를 비율로 나타냅니다. 예를 들어 P@5 = 상위 5개 결과 중 정답 문서의 개수 / 5.
  • Recall@K (재현율@K): 전체 정답 문서들 중 상위 K개의 결과 내에 몇 퍼센트가 포함되었는지를 나타냅니다. 예를 들어 R@5 = 상위 5개 결과 중 정답 문서 개수 / 해당 질의의 총 정답 문서 개수.
  • MRR (Mean Reciprocal Rank, 평균 역순위): 각 질의마다 첫 번째로 등장하는 정답 문서의 순위를 역수로 변환한 값(Reciprocal Rank)을 계산하고 평균낸 값입니다. 예를 들어 어떤 질의에서 첫 정답 문서가 검색 결과 1위에 있다면 RR=1/1=1, 3위에 있다면 RR=1/3≈0.333입니다. MRR은 모든 질의에 대한 RR의 평균으로, 정답을 얼마나 상위에 랭크시키는지를 평가합니다.
  • MAP (Mean Average Precision, 평균 평균정밀도): 각 질의에 대해 **평균정밀도(AP)**를 계산한 후 그 값을 평균낸 지표입니다. AP는 랭킹 리스트를 순차적으로 살펴보면서 정답을 만나게 될 때의 Precision 값을 누적 평균한 값으로, 정답 문서들의 순위 품질을 종합적으로 반영합니다. MAP는 모든 질의에 대한 AP의 평균으로 전체 검색 성능을 나타냅니다.

주어진 코드에서는 우선 parse_relevant() 함수를 정의하여, queries.csv 내에 질의별 정답 문서 목록을 문자열로 표시한 데이터를 파싱합니다. (예를 들어 'D1=3;D4=1'과 같은 문자열을 {'D1':3, 'D4':1} 형태의 딕셔너리로 변환합니다. 여기서 숫자는 **정답 등급(relevance grade)**을 뜻하지만, compute_metrics에서는 해당 값들은 사용되지 않고 문서 ID 존재 여부로만 정답 판단을 합니다.)

그 다음 compute_metrics(predicted, relevant_dict, k) 함수는 한 질의에 대한 검색 결과(predicted 문서 ID 리스트)와 정답 문서 딕셔너리(relevant_dict)를 받아 위 설명한 P@K, R@K, RR, AP를 계산합니다. 마지막으로 evaluate_all(method_results, queries_df, k) 함수는 모든 질의에 대해 특정 검색 방법의 결과를 평가하여, 평균 P@K, R@K, MRR, MAP 값을 딕셔너리로 반환합니다. 이 함수는 각 질의별로 compute_metrics를 호출하고 그 결과를 집계합니다.

import numpy as np
from sklearn.metrics import precision_score, recall_score

def parse_relevant(relevant_str):
    pairs = relevant_str.split(';')
    rel_dict = {}
    for pair in pairs:
        doc_id, grade = pair.split('=')
        rel_dict[doc_id] = int(grade)
    return rel_dict

def compute_metrics(predicted, relevant_dict, k=5):
    hits = sum(1 for doc in predicted[:k] if doc in relevant_dict)
    precision = hits / k
    total_relevant = len(relevant_dict)
    recall = hits / total_relevant if total_relevant > 0 else 0
    rr = 0
    for idx, doc in enumerate(predicted):
        if doc in relevant_dict:
            rr = 1 / (idx + 1)
            break
    num_correct = 0
    precisions = []
    for i, doc in enumerate(predicted[:k]):
        if doc in relevant_dict:
            num_correct += 1
            precisions.append(num_correct / (i + 1))
    ap = np.mean(precisions) if precisions else 0
    return precision, recall, rr, ap

def evaluate_all(method_results, queries_df, k=5):
    prec_list, rec_list, rr_list, ap_list = [], [], [], []
    for idx, row in queries_df.iterrows():
        qid = row['query_id']
        relevant_dict = parse_relevant(row['relevant_doc_ids'])
        predicted = method_results[qid]
        p, r, rr, ap = compute_metrics(predicted, relevant_dict, k)
        prec_list.append(p)
        rec_list.append(r)
        rr_list.append(rr)
        ap_list.append(ap)
    return {
        'P@5': np.mean(prec_list),
        'R@5': np.mean(rec_list),
        'MRR': np.mean(rr_list),
        'MAP': np.mean(ap_list)
    }

위 코드 셀은 함수 정의만을 포함하고 있으며, 실행해도 별도의 출력은 나타나지 않습니다. 이 단계까지 정의된 함수들을 요약하면:

  • parse_relevant: 질의의 정답 문서 문자열을 파싱하여 딕셔너리 반환.
  • compute_metrics: 한 질의에 대한 P@5, R@5, RR, AP 계산.
  • evaluate_all: 모든 질의에 대해 주어진 검색 결과(method_results)의 평균 P@5, R@5, MRR, MAP 계산.

이러한 함수들은 이후 단계에서 BM25, Dense, RRF 각 방법의 결과에 대해 호출되어 성능 비교에 활용됩니다.

7. BM25와 Dense 결과의 RRF 결합

이 부분에서는 RRF(Reciprocal Rank Fusion) 알고리즘을 사용하여 BM25 결과와 Dense 결과를 결합합니다. RRF는 서로 다른 검색 기법의 결과 리스트를 합성하여 성능을 높이기 위한 랭크 결합 기법입니다. 각 문서에 대해 각 랭킹에서의 순위에 따라 점수를 부여하며, 점수 공식은 다음과 같습니다:

RRF-점수=∑각 랭킹 리스트1k+rank

여기서 rank는 해당 문서가 그 리스트에서 차지한 순위(1위일 때 1, 2위일 때 2, ...)이고, k는 임의의 상수입니다. 실무적으로는 $k$ 값을 60 등 상대적으로 큰 값으로 설정하여 순위에 따른 점수 편차를 완만하게 만듭니다. 이렇게 하면 각 리스트에서 상위에 있는 문서들은 낮은 rank 값으로 인해 높은 점수(1/(작은수))를 받고, 여러 리스트에서 고루 상위권에 등장하는 문서들이 총합 점수가 높아지도록 설계됩니다.

코드에서는 rrf_rank(bm25_list, dense_list, k=60) 함수를 정의하여 두 리스트(BM25 top20, Dense top20)의 문서 ID를 입력받아 RRF 방식으로 결합된 최종 정렬 리스트를 반환합니다. k=60으로 설정하여 각 순위 점수에 1/61, 1/62, ... 식의 가중치를 주고 있음을 확인할 수 있습니다. 그 후, 모든 질의에 대해 BM25 상위 20개와 Dense 상위 20개 검색 결과를 구하고, 이를 RRF로 합칩니다.

구체적으로 반복문에서 queries_df의 각 질의에 대해:

  • bm25_top20 = bm25_search(query_text, top_k=20) : BM25로 상위 20개 문서 검색
  • dense_top20 = vector_store.similarity_search(query_text, k=20) : Dense 임베딩으로 상위 20개 문서 검색 (결과 객체에서 문서 ID 추출)
  • 그런 다음 rrf_list = rrf_rank(bm25_top20, dense_top20, k=60)으로 두 결과를 융합하여 결합된 랭킹 리스트를 얻습니다.
  • 최종적으로 RRF 결합 결과 중 상위 5개를 rrf_results[qid]에 저장합니다 (비교를 P@5 기준으로 할 것이기 때문입니다). BM25와 Dense의 개별 결과도 추후 사용을 위해 각각 bm25_candidates[qid], dense_candidates[qid]에 저장합니다.

마지막으로 모든 질의에 대한 처리가 끝나면 "RRF 결합 완료 (상위 5개 저장됨)" 이라고 출력하여 프로세스 완료를 알리고, **예시로 첫 번째 질의(Q1)**에 대한 RRF 상위 5개 결과를 출력합니다.

# RRF 결합 함수
def rrf_rank(bm25_list, dense_list, k=60):
    # bm25_list, dense_list: 상위 20개 문서 ID 리스트
    candidate_scores = {}
    for rank, doc in enumerate(bm25_list):
        candidate_scores[doc] = candidate_scores.get(doc, 0) + 1 / (rank + 1 + k)
    for rank, doc in enumerate(dense_list):
        candidate_scores[doc] = candidate_scores.get(doc, 0) + 1 / (rank + 1 + k)
    # 점수 정렬
    ranked = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in ranked]

# BM25 상위 20, Dense 상위 20, RRF 상위 5 결과 생성
bm25_candidates = {}
dense_candidates = {}
rrf_results = {}
for idx, row in queries_df.iterrows():
    qid = row['query_id']
    query_text = row['query_text']
    bm25_top20 = bm25_search(query_text, top_k=20)
    dense_top20 = [doc.metadata['doc_id'] for doc in vector_store.similarity_search(query_text, k=20)]
    bm25_candidates[qid] = bm25_top20
    dense_candidates[qid] = dense_top20
    rrf_list = rrf_rank(bm25_top20, dense_top20, k=60)
    rrf_results[qid] = rrf_list[:5]

print("RRF 결합 완료 (상위 5개 저장됨)")
# 예시 확인
print("질의 Q1 RRF 상위 5:", rrf_results[queries_df.loc[0, 'query_id']])

위 코드 실행 결과로, 모든 질의에 대한 RRF 결합 과정이 완료되었다는 메시지와 함께, 첫 번째 질의(Q1)에 대한 RRF 결합 상위 5개 문서 ID 리스트가 출력됩니다. 이 리스트는 앞서 BM25나 Dense의 결과와 비교했을 때 두 방법의 장점을 모두 반영한 결과일 것으로 기대됩니다. 예를 들어 Q1의 BM25 상위 결과와 Dense 상위 결과에 모두 등장한 문서들이 높은 순위를 차지하거나, 한쪽 방법에서 놓친 중요한 문서가 다른 방법으로 보완되어 목록에 포함되었을 수 있습니다.

RRF 결합 완료 (상위 5개 저장됨)
질의 Q1 RRF 상위 5: ['D1', 'D2', 'D8', 'D12', 'D9']

(출력 설명: RRF 알고리즘을 이용하여 BM25와 Dense 결과를 융합한 최종 상위 5개 결과를 질의 Q1에 대해 보여줍니다. 예시를 보면 문서 D1과 D2 등이 상위에 있으며, BM25 결과와 Dense 결과를 조합하여 나온 순위입니다.)

8. 검색 성능 평가 및 비교

이제 앞서 정의한 evaluate_all 함수를 활용하여 BM25DenseRRF 세 가지 방법의 검색 성능을 계산하고 비교합니다. 각 방법에 대해 상위 5개 결과를 기준으로 P@5, R@5, MRR, MAP를 산출합니다.

우선, RRF의 경우 애초에 rrf_results에 상위 5개 결과만 저장해 두었으므로 그대로 사용하면 되고, BM25와 Dense의 경우에도 RRF와 동일한 기준으로 평가하기 위해 상위 5개 결과만 추출합니다 (bm25_results_5, dense_results_5). 이렇게 얻은 딕셔너리들을 evaluate_all(…, k=5)에 전달하여 각 방법의 평균 성능 지표를 얻습니다. 마지막으로 pandas.DataFrame을 이용해 세 방법의 성능 지표를 표 형태로 정리합니다. 데이터프레임 df_metrics은 다음과 같은 구조를 가집니다:

  • 행(index): Metric 이름 (P@5, R@5, MRR, MAP)
  • 열(columns): 방법 이름 (BM25, Dense, RRF)
  • 값(values): 해당 방법의 해당 지표 값 (실수, 소수점 6자리 정도로 표시)
import pandas as pd

# BM25 상위 5 리스트 생성
bm25_results_5 = {qid: lst[:5] for qid, lst in bm25_candidates.items()}
# Dense 상위 5 리스트 생성
dense_results_5 = {qid: lst[:5] for qid, lst in dense_candidates.items()}

# 평가
bm25_metrics = evaluate_all(bm25_results_5, queries_df, k=5)
dense_metrics = evaluate_all(dense_results_5, queries_df, k=5)
rrf_metrics = evaluate_all(rrf_results, queries_df, k=5)

# 결과 테이블
df_metrics = pd.DataFrame({
    'Metric': ['P@5', 'R@5', 'MRR', 'MAP'],
    'BM25': [bm25_metrics['P@5'], bm25_metrics['R@5'], bm25_metrics['MRR'], bm25_metrics['MAP']],
    'Dense': [dense_metrics['P@5'], dense_metrics['R@5'], dense_metrics['MRR'], dense_metrics['MAP']],
    'RRF': [rrf_metrics['P@5'], rrf_metrics['R@5'], rrf_metrics['MRR'], rrf_metrics['MAP']]
})
df_metrics

위 코드 실행 결과, 세 방법의 성능 지표를 나란히 비교한 표가 출력됩니다. 데이터프레임이 출력되면서 Jupyter Notebook 상에서는 HTML 테이블로 나타나지만, 여기서는 텍스트 형태로 그 내용을 표현하면 다음과 같습니다.

  Metric      BM25     Dense       RRF
0    P@5  0.253333  0.260000  0.260000
1    R@5  0.894444  0.916667  0.911111
2    MRR  0.944444  0.983333  0.950000
3    MAP  0.937963  0.959444  0.930185

표를 해석하면 다음과 같습니다:

  • Precision@5(P@5): BM25 약 0.2533, Dense 약 0.2600, RRF 약 0.2600으로, Dense와 RRF가 BM25보다 약간 더 높은 정밀도를 보입니다. P@5 값 자체는 0.25~0.26 정도로, 상위 5개 중 평균 1.3개 정도가 정답이라는 뜻입니다. Dense와 RRF 모두 **5개 중 평균 1.30개(26%)**가 정답이고, BM25는 **1.27개(25.33%)**가 정답인 셈입니다.
  • Recall@5(R@5): BM25 0.8944, Dense 0.9167, RRF 0.9111로, Dense가 가장 높은 재현율을 보이고 BM25가 가장 낮습니다. 예를 들어 R@5 = 0.9167이라면, 각 질의의 전체 정답 문서 중 약 **91.67%**를 상위 5개 안에 포함시켰다는 의미입니다. Dense 검색이 약간 더 많은 정답을 top5 내에 포괄하고 있으며, RRF는 Dense보다는 약간 낮지만 BM25보다는 높습니다. (RRF 0.9111 vs BM25 0.8944)
  • MRR (Mean Reciprocal Rank): BM25 0.9444, Dense 0.9833, RRF 0.9500입니다. MRR은 최대값이 1.0이며 높을수록 첫 번째 정답의 순위가 높다는 것을 뜻합니다. Dense의 MRR 0.9833은 거의 모든 질의에서 첫 번째 결과가 정답일 정도로 높은 수치입니다. BM25도 0.94로 높지만 Dense에 약간 못 미치고, RRF는 0.95로 BM25보다 약간 높지만 Dense보다는 낮습니다. 이 결과는 Dense 임베딩 방법이 대체로 가장 첫 번째로 관련 문서를 잘 찾아주는 경향을 보임을 나타냅니다.
  • MAP (Mean Average Precision): BM25 약 0.93796, Dense 0.95944, RRF 0.93019로, Dense > BM25 > RRF 순서입니다. MAP는 모든 정답의 순위를 고려한 정밀도의 평균치이므로, 높은 MAP는 관련 문서들을 전반적으로 랭킹 상위에 잘 배치했다는 의미입니다. Dense가 가장 높아 전반적인 랭킹 품질이 우수하고, BM25가 그 다음, RRF가 근소하게 BM25보다 낮습니다.

종합하면, Dense 임베딩 기반 검색이 이 데이터셋에서 가장 좋은 성능을 보였고 BM25가 약간 뒤처지며, RRF 융합 결과는 Dense 단일 방법에 비해 뚜렷한 향상을 보이지 않았다는 해석을 할 수 있습니다. 특히 Precision과 Recall 측면에서는 RRF가 Dense와 거의 비슷한 수준을 유지했으나, MRR과 MAP에서는 Dense보다 약간 낮게 나왔습니다. 이는 Dense 검색이 이미 대부분의 질의에서 최상위 순위를 잘 맞추고 있어서 RRF로 BM25를 섞어도 크게 개선될 여지가 없었거나, 오히려 BM25의 일부 낮은 순위 문서가 섞이면서 MAP 등이 약간 떨어진 것으로 볼 수 있습니다.

물론, RRF 결합의 유용성은 사용되는 두 검색 기법의 상호 보완성에 따라 달라집니다. 만약 BM25와 Dense가 서로 다른 정답을 찾아주는 경우가 많다면 RRF로 결합했을 때 Precision이나 Recall이 상승할 수 있습니다. 이번 실험에서는 Dense 자체의 성능이 워낙 좋아 BM25와 결합해도 큰 차이가 없었던 것으로 보입니다. 그래도 RRF 결과가 BM25 대비 Precision과 Recall을 높인 점에서, BM25의 약점을 어느 정도 보완했음을 알 수 있습니다.

9. 성능 비교 결과 시각화

마지막으로, 세 방법의 성능 지표를 시각화하여 비교합니다. 코드에서는 matplotlib을 사용해 Metric별 점수를 선 그래프로 그렸습니다. 각 Metric (P@5, R@5, MRR, MAP)을 x축에 두고, y축에 성능 점수를 표시하여 BM25(노란 원)Dense(주황 사각)RRF(분홍 삼각) 세 가지 방법의 추이를 한 그림에 나타냈습니다. 그래프를 통해 수치 비교를 직관적으로 할 수 있으며, 특히 어떤 방법이 전반적으로 우수한지 한눈에 볼 수 있습니다.

아래 코드에서는 한글 폰트(맑은 고딕)를 설정하여 그래프 제목과 축레이블을 표시하고, plt.plot으로 세 방법의 점수 배열을 그림으로 나타냈습니다. plt.show()를 호출하면 Jupyter Notebook 상에서 그래프가 출력됩니다.

import matplotlib.pyplot as plt
from matplotlib import font_manager, rc

font_path = "C:/Windows/Fonts/malgun.ttf"
font_prop = font_manager.FontProperties(fname=font_path).get_name()
rc('font', family=font_prop)
plt.rcParams['axes.unicode_minus'] = False

methods = ['BM25', 'Dense', 'RRF']
metrics = ['P@5', 'R@5', 'MRR', 'MAP']
bm25_vals = [bm25_metrics['P@5'], bm25_metrics['R@5'], bm25_metrics['MRR'], bm25_metrics['MAP']]
dense_vals = [dense_metrics['P@5'], dense_metrics['R@5'], dense_metrics['MRR'], dense_metrics['MAP']]
rrf_vals = [rrf_metrics['P@5'], rrf_metrics['R@5'], rrf_metrics['MRR'], rrf_metrics['MAP']]

x = range(len(metrics))
plt.figure(figsize=(8,5))
plt.plot(x, bm25_vals, marker='o', label='BM25')
plt.plot(x, dense_vals, marker='s', label='Dense')
plt.plot(x, rrf_vals, marker='^', label='RRF')
plt.xticks(x, metrics)
plt.ylim(0,1)
plt.xlabel('Metric')
plt.ylabel('Score')
plt.title('BM25 vs Dense vs RRF 성능 비교')
plt.legend()
plt.grid(True)
plt.show()

그림 1: BM25, Dense, RRF 방법의 주요 성능 지표 비교 그래프. 그래프에서 각 점은 해당 방법의 성능 수치를 나타내며, 선은 방법별 추세를 연결합니다. Dense 방법(주황색 선)이 전 구간에 걸쳐 BM25(노란색 선)와 RRF(분홍색 선)를 상회하거나 유사한 높은 성능을 보여주는 것을 확인할 수 있습니다. 특히 MRR 지표에서 Dense가 1.0에 가까운 점수로 가장 높고, RRF와 BM25가 그보다 약간 낮게 나타난 부분을 볼 수 있습니다. Precision@5와 Recall@5 지표에서는 Dense와 RRF가 거의 동일한 수준이며 BM25가 소폭 낮습니다. MAP 지표의 경우 Dense가 가장 높고 BM25와 RRF가 비슷한데, 이는 RRF 결합이 Dense 대비 MAP 향상을 이루지 못했음을 시사합니다.

Overall, 이 실험의 결과를 통해 다음을 알 수 있습니다:

  • Dense 임베딩 기반 검색은 이 데이터셋에서 매우 우수한 성능을 보이며, 특히 첫 번째 정답 반환(MRR) 면에서 탁월합니다.
  • BM25도 높은 성능을 보이지만 Dense에 비해 약간 열세이며, 주로 일부 질의에서 첫 정답 문서의 순위가 낮은 경우가 있는 것으로 추측됩니다.
  • RRF 융합은 BM25의 Recall을 약간 향상시켰지만, 이미 성능이 좋은 Dense와 결합했을 때 극적인 개선은 나타나지 않았습니다. 그러나 RRF를 통해 BM25와 Dense 결과를 함께 활용함으로써 Precision이나 Recall 측면에서 둘 중 하나만 썼을 때보다 나쁘지 않은, 안정적인 성능을 얻을 수 있음을 확인했습니다.

실무적으로, 하이브리드 검색을 적용할 때 RRF는 구현이 간단하면서도 유용한 방법입니다. 특히 서로 다른 특징을 가진 검색 모델(BM25는 단어 매칭, Dense는 의미 매칭)을 조합하면 사용자의 질의 의도에 따른 다양한 측면의 관련 문서를 놓치지 않고 찾아낼 수 있습니다. 이번 실험에서처럼 한쪽 모델이 이미 매우 강력한 경우에는 결합의 효과가 크지 않을 수 있지만, 서로 보완적인 모델들을 사용할 경우 RRF는 Precision-Recall 균형을 개선하는 데 기여할 수 있습니다.

'HRDI_AI > [인공지능] RAG 검색 성능 최적화와 최신 Retrieval 전략' 카테고리의 다른 글

6. contextual_compression  (1) 2025.06.07
5. cohere_rerank_experiment  (0) 2025.06.07
4. hyde_experiment  (1) 2025.06.07
2. bm25_dense_comparision  (0) 2025.06.07
1. indexing  (0) 2025.06.07