BM25 vs Dense Retrieval 성능 비교 실험
1. 라이브러리 설치
첫 번째 코드 셀에서는 실험에 필요한 파이썬 라이브러리들을 설치합니다. %pip install 매직 명령을 사용하여 python-dotenv (환경변수 로드), pandas (데이터 처리), matplotlib (시각화), rank_bm25 (BM25 알고리즘), pinecone (벡터 DB 서비스), langchain 및 관련 패키지들, sentence-transformers (문장 임베딩 모델), scikit-learn (평가지표 계산) 그리고 한국어 형태소 분석기 eunjeon 등을 설치합니다. 특히 eunjeon 패키지는 Mecab 형태소 분석기의 파이썬 래퍼로, 한국어 문서를 토큰화하기 위해 필요합니다. 이 명령을 실행하면 현재 환경에 해당 패키지들이 **이미 설치되어 있다면 “Requirement already satisfied”**라는 메시지가 출력되고, 없는 경우 자동으로 다운로드 및 설치를 진행합니다.
%pip install python-dotenv pandas matplotlib rank_bm25 pinecone langchain langchain-openai langchain-pinecone sentence-transformers scikit-learn eunjeon
위 코드 셀을 실행하면, pip가 나열된 라이브러리들의 설치 상태를 확인하고 필요한 경우 설치를 수행합니다. 아래 출력에서 대부분의 패키지는 이미 설치되어 있어 Requirement already satisfied 메시지가 보입니다. 한편 **eunjeon**은 설치가 안 된 상태였기에 다운로드 및 설치 과정을 진행하며, 완료 후 성공적으로 설치되었다는 메시지가 나타납니다:
Requirement already satisfied: python-dotenv in c:\users\...\site-packages (1.1.0)
Requirement already satisfied: pandas in c:\users\...\site-packages (2.3.0)
Requirement already satisfied: matplotlib in c:\users\...\site-packages (3.10.3)
Requirement already satisfied: rank_bm25 in c:\users\...\site-packages (0.2.2)
Requirement already satisfied: pinecone in c:\users\...\site-packages (7.0.2)
Requirement already satisfied: langchain in c:\users\...\site-packages (0.3.25)
Requirement already satisfied: langchain-openai in c:\users\...\site-packages (0.3.19)
Requirement already satisfied: langchain-pinecone in c:\users\...\site-packages (0.2.8)
Requirement already satisfied: sentence-transformers in c:\users\...\site-packages (4.1.0)
Requirement already satisfied: scikit-learn in c:\users\...\site-packages (1.7.0)
Collecting eunjeon
Downloading eunjeon-0.4.0.tar.gz (34.7 MB)
... (설치 진행 로그 생략) ...
Successfully installed eunjeon-0.4.0
Note: you may need to restart the kernel to use updated packages.
설치 출력의 마지막 부분에서는 eunjeon-0.4.0이 성공적으로 설치되었음을 알리고 있습니다. 또한 *"updated packages를 사용하려면 커널을 재시작해야 할 수 있다"*는 안내는, 새 패키지 사용을 위해 가끔 노트북의 커널 재시작이 필요할 수 있음을 알려주는 일반적인 메시지입니다.
2. 환경 변수 로드 및 설정
두 번째 셀에서는 외부 서비스 연동을 위한 환경 변수들을 불러오는 작업을 수행합니다. dotenv 패키지의 load_dotenv() 함수를 이용해 현재 작업 디렉터리의 .env 파일을 읽어 환경 변수로 로드합니다. 이렇게 함으로써 OpenAI API 키, Pinecone API 키 등이 코드 내에서 사용 가능한 변수로 설정됩니다. os.getenv("KEY_NAME")을 통해 필요한 키들을 가져와 OPENAI_API_KEY, PINECONE_API_KEY 등의 파이썬 변수에 저장하고 있으며, int(os.getenv(...))처럼 숫자형 변수(예: 임베딩 차원)도 정수로 변환하여 불러옵니다. 마지막 줄의 print("환경 변수 로딩 완료")는 모든 환경 변수가 성공적으로 로드되었음을 확인시켜주는 용도입니다.
import os
from dotenv import load_dotenv
# .env 파일 로드
load_dotenv()
# 환경 변수 가져오기
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_LLM_MODEL = os.getenv("OPENAI_LLM_MODEL")
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 파일에서 불러온 API 키와 설정값들이 변수에 저장되고 환경 변수 로드 작업이 완료됩니다. 출력으로 아래와 같이 환경 변수 로딩 완료라는 메시지가 표시되는데, 이는 모든 필요한 키 값들이 제대로 불러와졌음을 의미합니다:
환경 변수 로딩 완료
따라서 이제 OpenAI와 Pinecone 등의 서비스 사용을 위한 인증 정보와 설정값이 준비 완료된 상태입니다.
3. 데이터 불러오기
세 번째 셀에서는 실험에 사용할 문서와 질의 데이터를 불러옵니다. pandas 라이브러리를 이용해 documents.csv와 queries.csv 파일을 읽어 각각 documents_df와 queries_df라는 데이터프레임에 로드합니다. documents_df에는 검색 대상 문서들이, queries_df에는 평가에 사용할 질의와 정답 정보(관련 문서 리스트)가 담겨 있을 것으로 예상됩니다. print 함수를 사용해 문서 수와 질의 수를 출력하여, 데이터가 제대로 로드되었고 그 규모를 쉽게 파악할 수 있게 합니다. 이를 통해 실험에 몇 개의 문서와 질의가 사용되는지 확인할 수 있습니다.
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개의 검색 질의가 사용됩니다. 이 정도 규모의 데이터로 BM25와 Dense 임베딩 기반 검색의 성능을 비교하게 됩니다.
4. BM25 기반 검색기 준비
네 번째 셀에서는 BM25 알고리즘을 이용한 전통적인 문자열 기반 검색 엔진을 설정합니다. 이를 위해 한국어 형태소 분석기 Mecab (eunjeon 패키지 제공)을 사용하여 문서들의 내용을 토큰화합니다. 한국어는 띄어쓰기만으로 단어 경계를 구분하기 어려운 경우가 많고, 조사/어미 등 변형이 있기 때문에 정확한 단어 단위로 비교하기 위해서는 형태소 분석을 통한 토큰화가 유용합니다. 코드에서는 Mecab() 객체를 생성한 후, documents_df['content'] 내 모든 문서에 대해 mecab.morphs(content)를 적용하여 각 문서를 단어 리스트로 변환합니다. 그런 다음, rank_bm25 라이브러리의 BM25Okapi 클래스를 이용해 BM25 인덱스를 생성합니다. BM25는 단어의 빈도와 희소성(tf-idf 계열) 등을 고려하여 문서의 점수를 계산하는 전통적인 정보 검색 모델입니다.
이 셀에는 BM25 기반 검색 함수도 정의되어 있습니다. bm25_search_mecab(query, top_k=5) 함수는 입력 질의를 Mecab으로 토큰화한 뒤, bm25.get_scores()를 사용해 모든 문서에 대한 BM25 점수를 계산합니다. 그런 다음 점수가 높은 상위 top_k개 문서의 인덱스를 추려서, 해당 문서들의 ID 목록을 반환합니다. 마지막 줄의 print는 예시 질의로 "제주도 관광 명소"를 검색하여 BM25가 반환한 상위 5개 문서의 ID를 출력합니다. 이를 통해 BM25 검색이 동작하는지 간단히 확인해봅니다.
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_mecab(query, top_k=5):
query_tokens = mecab.morphs(query)
scores = bm25.get_scores(query_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]]
print("Mecab 기반 BM25 검색:", bm25_search_mecab("제주도 관광 명소", top_k=5))
위 코드 셀을 실행하면, 우선 eunjeon 초기화 과정에서 경고 메시지가 나타날 수 있습니다. 이는 pkg_resources 모듈 사용에 대한 경고로, 기능에는 영향이 없는 내부 패키지의 안내입니다. 이어서 print문의 결과로 BM25 검색 예시 출력이 나타납니다. 출력 결과를 해석하면, "제주도 관광 명소"라는 예시 질의에 대해 BM25 알고리즘이 가장 연관성이 높다고 판단한 문서 5개의 ID를 ['D1', 'D12', 'D2', 'D3', 'D4']처럼 리스트 형태로 보여줍니다:
c:\Users\ssampooh\RAG-Retrieval\.conda\Lib\site-packages\eunjeon\__init__.py:11: UserWarning: pkg_resources is deprecated as an API. ...
import pkg_resources
Mecab 기반 BM25 검색: ['D1', 'D12', 'D2', 'D3', 'D4']
위 예시 출력에서 경고 메시지는 무시해도 좋으며, 핵심은 BM25 검색 함수가 잘 작동하여 상위 5개 문서의 ID 리스트를 반환했다는 것입니다. 이로써 토큰화 기반 BM25 검색엔진이 준비되었습니다.
5. Dense 임베딩 기반 검색기 준비
다섯 번째 셀에서는 Dense Retrieval로 불리는 문서 임베딩 기반의 검색 시스템을 설정합니다. Dense Retrieval은 전통적인 BM25와 달리, 문장/문서 의미를 벡터로 표현한 임베딩을 활용하여 의미적으로 유사한 문서를 검색하는 방법입니다. 여기서는 Pinecone이라는 벡터 데이터베이스를 사용하여 미리 임베딩된 문서 벡터들을 저장하고 검색합니다.
코드에서 Pinecone(api_key=...)를 통해 Pinecone 서비스에 API 키로 인증하고, pc.Index(PINECONE_INDEX_NAME)로 이미 생성된 인덱스(여기서는 이름이 "ir")에 연결합니다. 즉, 해당 인덱스에는 실험에 사용할 문서들의 임베딩 벡터가 미리 올라가 있다고 가정합니다. 그 다음 OpenAIEmbeddings 클래스를 이용해 OpenAI의 임베딩 모델을 불러옵니다. OPENAI_EMBEDDING_MODEL 환경변수로 지정된 임베딩 모델 (예: text-embedding-ada-002)을 사용하며, OpenAI API 키도 함께 설정됩니다. 마지막으로 PineconeVectorStore를 생성하는데, 이는 Pinecone 인덱스와 임베딩 모델을 결합한 래퍼로, vector_store.similarity_search(query, k) 메서드를 호출하면 질의를 임베딩하여 Pinecone에서 유사도가 가장 높은 상위 k개 문서를 찾아주는 객체입니다.
from pinecone import Pinecone
from langchain_openai import OpenAIEmbeddings
from langchain_pinecone import PineconeVectorStore
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
)
vector_store = PineconeVectorStore(
index_name=PINECONE_INDEX_NAME,
embedding=embedding_model
)
print("기존 'ir' 인덱스에 연결하여 Dense Retrieval 설정 완료")
이 셀을 실행하면 Pinecone 및 임베딩 설정이 완료되고, Dense 임베딩 검색을 수행할 준비가 되었다는 메시지를 출력합니다. 실행 시 Jupyter 환경에 tqdm 관련 경고가 나타날 수 있는데, 이는 진행 표시바 UI에 대한 경고로 무시해도 됩니다. 중요한 것은 마지막 줄의 출력으로, Pinecone 상의 'ir' 인덱스에 성공적으로 연결되었고 Dense Retrieval 설정이 완료되었음을 나타냅니다:
c:\Users\ssampooh\RAG-Retrieval\.conda\Lib\site-packages\tqdm\auto.py:21: TqdmWarning: IProgress not found. ...
from .autonotebook import tqdm as notebook_tqdm
기존 'ir' 인덱스에 연결하여 Dense Retrieval 설정 완료
이로써 BM25와 더불어 임베딩 기반의 벡터 검색 엔진도 사용할 수 있게 되었으며, 이제 두 검색 방법을 모두 준비한 상태입니다.
6. 검색 성능 평가 지표 함수 정의
여섯 번째 셀에서는 **검색 결과를 평가하기 위한 다양한 성능 지표(metrics)**를 계산하는 함수를 정의합니다. 정보검색 실험에서는 검색된 결과의 정확도와 효율을 측정하기 위해 여러 평가 척도를 사용합니다. 이 코드 셀은 그러한 척도를 직접 계산하는 함수들을 구현합니다.
- 먼저 parse_relevant(relevant_str) 함수는 각 질의에 대한 정답 문서들과 그 **관련도 등급(relevance grade)**을 파싱하는 헬퍼 함수입니다. 예를 들어 relevant_str가 "D6=3;D14=2;D26=1"처럼 주어지면, 이는 해당 질의의 정답 문서로 D6, D14, D26이 있으며 각각 3, 2, 1의 관련도 점수를 가진다는 의미입니다. 함수를 통해 이 문자열을 {'D6': 3, 'D14': 2, 'D26': 1} 형태의 딕셔너리로 변환합니다. (일반적으로 관련도 등급이 1 이상이면 해당 문서를 관련 문서(relevant)로 취급합니다.)
- 다음으로 compute_metrics(predicted, relevant_dict, k=5) 함수는 하나의 질의에 대해 검색 시스템이 반환한 결과(predicted 리스트)와 정답 문서 집합(relevant_dict)을 받아, 여러 평가 지표를 계산합니다. 여기에서는 정밀도(Precision@k), 재현율(Recall@k), MRR (Mean Reciprocal Rank), 그리고 **AP (Average Precision)**를 구합니다:
- Precision@k (정밀도@k): 상위 k개의 검색 결과 중 관련 문서의 비율을 나타냅니다. 예를 들어 어떤 질의에 대해 5개 결과를 제시했을 때, 그 중 관련 문서가 2개 있다면 Precision@5 = 2/5 = 0.4 (40%)가 됩니다. 이 값이 높을수록, 검색 결과에 **불필요한 문서(비관련 문서)**가 적고 정확한 결과가 많다는 뜻입니다.
- Recall@k (재현율@k): 해당 질의에 존재하는 전체 관련 문서 중에서 상위 k개 결과 내에 포함된 관련 문서의 비율을 의미합니다. 예를 들어 어떤 질의의 전체 관련 문서가 4개인데, 검색 시스템이 그 중 3개를 상위 5개 결과에 포함시켰다면 Recall@5 = 3/4 = 0.75 (75%)가 됩니다. 재현율이 높을수록, 찾아야 할 정답들을 많이 찾아냈다는 것을 의미합니다.
- MRR (Mean Reciprocal Rank, 평균 역순위): 여러 질의에 대해 첫 번째 관련 문서가 나타난 순위의 역수를 평균낸 값입니다. 우선 Reciprocal Rank(RR, 역순위)는 하나의 질의에서 첫 번째로 등장한 관련 문서의 순위에 대한 역수입니다. 예를 들어 어떤 질의에서 첫 관련 문서가 순위 1위라면 RR = 1/1 = 1, 3위에 처음 나왔다면 RR = 1/3 ≈ 0.33이 됩니다. MRR은 이렇게 구한 각 질의별 RR 값을 다시 평균낸 것으로, 사용자가 원하는 정보를 얼마나 상위에 제공하는지 보여줍니다. 예를 들어 3개의 질의에 대해 첫 관련 문서의 순위가 각각 2위, 3위, 1위라면 각 RR은 0.5, 0.333..., 1이며 MRR은 (0.5 + 0.333... + 1) / 3 ≈ 0.611 (61.1%)이 됩니다. MRR이 1에 가까울수록 대부분의 질의에서 첫 번째 결과가 정답임을 의미하고, 값이 낮아질수록 정답이 리스트에서 하위에 위치하거나 놓치는 경우가 있다는 뜻입니다.
- AP (Average Precision, 평균 정밀도): 하나의 질의에 대해 여러 관련 문서의 검색 순위를 모두 고려한 지표입니다. 검색 결과 리스트를 순차적으로 훑어가며 관련 문서를 만날 때마다의 Precision 값을 평균낸 것이 AP입니다. 예를 들어 어떤 질의의 관련 문서가 총 3개이고, 검색 결과에서 이들이 2위, 4위, 5위에 위치했다면, 해당 질의의 AP 계산은 다음과 같습니다. 2위에서 첫 관련 문서를 만났을 때 Precision=1/2 (50%), 4위에서 두 번째 관련 문서를 만났을 때 Precision=2/4 (50%), 5위에서 세 번째 관련 문서를 찾았을 때 Precision=3/5 (60%). 이 세 Precision 값을 평균낸 값이 이 질의의 AP가 됩니다 (≈ 0.533). AP는 검색 결과 전체의 품질을 평가하며, 순위가 높은 곳에 관련 문서들이 많을수록 높은 값을 가집니다.
- 마지막으로 evaluate_all(method_results, queries_df, k=5) 함수는 모든 질의에 대해 위에서 정의한 지표들을 계산하여 평균 성능을 출력합니다. queries_df의 각 질의를 반복하면서, 정답 문자열을 parse_relevant로 파싱하고 예측 결과 리스트(method_results[qid])를 가져와 compute_metrics로 Precision, Recall, RR, AP를 구합니다. 그런 다음 모든 질의에 대한 Precision 리스트, Recall 리스트 등을 만들어 평균값을 계산합니다. 이 함수는 최종적으로 딕셔너리를 반환하며, 키로 'P@k', 'R@k', 'MRR', 'MAP'를 사용하고 값으로 해당 지표들의 평균을 담고 있습니다. 여기서 **MAP (Mean Average Precision)**은 모든 질의에 대한 AP 값들의 평균으로, 전체 질의 세트에 대한 평균적인 정밀도를 나타냅니다.
import numpy as np
from sklearn.metrics import precision_score, recall_score
# 다중 정답 및 등급을 처리하기 위한 헬퍼 함수
def parse_relevant(relevant_str):
# 'D6=3;D14=2;D26=1' 형태
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):
# Precision@k: 상위 k 중 관련(grade>=1) 문서 비율
hits = sum([1 for doc in predicted[:k] if doc in relevant_dict])
precision = hits / k
# Recall@k: 관련 문서 총 개수 대비 상위 k 중 회수된 관련 개수
total_relevant = len(relevant_dict)
recall = hits / total_relevant if total_relevant > 0 else 0
# MRR: 첫 번째 관련 문서 위치 기반
rr = 0
for idx, doc in enumerate(predicted):
if doc in relevant_dict:
rr = 1 / (idx + 1)
break
# 단일 AP 계산 (MAP를 위해)
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@k': np.mean(prec_list),
'R@k': np.mean(rec_list),
'MRR': np.mean(rr_list),
'MAP': np.mean(ap_list)
}
이 셀은 함수를 정의하는 부분이므로 실행해도 눈에 보이는 출력은 없습니다. 내부적으로 precision_score나 recall_score 등을 임포트하지만, 우리가 직접 정의한 함수들을 사용하고 있으므로 scikit-learn의 메트릭 함수들은 실제 사용되지는 않았습니다. 위에서 정의된 함수들은 다음 단계에서 BM25와 Dense 검색 결과를 평가하는 데 활용될 것입니다. (이 셀은 함수 정의만 수행하며, 별도의 출력은 없습니다.)
7. BM25와 Dense 결과 수집 및 성능 평가
일곱 번째 셀에서는 앞서 준비한 BM25와 Dense 검색기를 사용하여 모든 질의에 대한 검색 결과를 생성하고, 이에 대한 평가 지표를 계산합니다.
첫 번째 부분에서는 queries_df의 각 질의에 대해 BM25 검색을 수행합니다. queries_df.iterrows()로 질의 데이터프레임을 순회하면서, 각 query_text에 대해 bm25_search_mecab 함수를 호출하여 상위 5개 문서의 ID 리스트를 얻습니다. 이렇게 얻은 결과를 bm25_results 딕셔너리에 query_id를 키로, 문서 ID 리스트를 값으로 저장합니다. 동일한 방식으로 Dense 임베딩 검색 결과도 구합니다. 각 질의에 대해 vector_store.similarity_search(query_text, k=5)를 호출하면 Pinecone를 통해 임베딩 유사도 Top-5 문서를 반환합니다. 그 결과에서 문서 ID만 추출하여 dense_results 딕셔너리에 저장합니다. 두 가지 방법 모두 5개씩 결과를 수집하는 이유는, 평가를 Precision@5, Recall@5 등 상위 5개 기준으로 할 것이기 때문입니다. 모든 질의에 대한 결과를 수집한 후에는 print를 통해 **"BM25 & Dense Retrieval 결과 수집 완료"**라는 완료 메시지를 출력합니다.
그 다음, 수집된 결과를 토대로 평가 지표를 산출합니다. 앞서 정의한 evaluate_all 함수를 사용하여, bm25_results와 dense_results 각각에 대해 Precision@5, Recall@5, MRR, MAP의 평균값 딕셔너리를 얻습니다. 이를 bm25_metrics와 dense_metrics에 저장합니다. 마지막으로 이 지표들을 보기 좋게 비교하기 위해 pandas.DataFrame을 생성합니다. 데이터프레임 df_metrics는 Metric 이름과 BM25, Dense 각각의 평균 점수를 열로 가지며, df_metrics를 셀의 마지막에 두었으므로 표 형태로 결과가 출력됩니다.
# BM25 결과 저장: {query_id: [doc_ids...]}
bm25_results = {}
for idx, row in queries_df.iterrows():
qid = row['query_id']
query_text = row['query_text']
bm25_results[qid] = bm25_search_mecab(query_text, top_k=5)
# Dense Retrieval 결과 저장
dense_results = {}
for idx, row in queries_df.iterrows():
qid = row['query_id']
query_text = row['query_text']
docs = vector_store.similarity_search(query_text, k=5)
dense_results[qid] = [doc.metadata['doc_id'] for doc in docs]
print("BM25 & Dense Retrieval 결과 수집 완료")
위 코드가 실행되면, 모든 질의에 대한 BM25와 Dense 검색 결과가 수집되고, 완료 메시지가 출력됩니다:
BM25 & Dense Retrieval 결과 수집 완료
이제 두 방법의 결과가 준비되었으므로, 곧바로 성능 평가를 진행합니다.
# BM25 평가
bm25_metrics = evaluate_all(bm25_results, queries_df, k=5)
# Dense 평가
dense_metrics = evaluate_all(dense_results, queries_df, k=5)
import pandas as pd
df_metrics = pd.DataFrame({
'Metric': ['P@5', 'R@5', 'MRR', 'MAP'],
'BM25': [bm25_metrics['P@k'], bm25_metrics['R@k'], bm25_metrics['MRR'], bm25_metrics['MAP']],
'Dense': [dense_metrics['P@k'], dense_metrics['R@k'], dense_metrics['MRR'], dense_metrics['MAP']]
})
df_metrics
이 부분을 실행하면, BM25와 Dense의 평균 성능 지표들이 데이터프레임 형태로 출력됩니다. 결과 표에는 우리가 관심을 갖는 네 가지 지표 (P@5, R@5, MRR, MAP)에 대해 BM25와 Dense 각각의 값이 정리되어 있습니다:
MetricBM25Dense
| P@5 | 0.253333 | 0.260000 |
| R@5 | 0.894444 | 0.916667 |
| MRR | 0.944444 | 0.983333 |
| MAP | 0.937963 | 0.959444 |
표를 보면 Dense 임베딩 검색이 모든 지표에서 BM25보다 약간씩 높은 값을 보이고 있습니다. 각 지표의 의미를 해석하면 다음과 같습니다:
- 정밀도 P@5: BM25는 약 0.2533, Dense는 0.26으로, Dense가 근소하게 더 높습니다. 정밀도 약 0.25란 상위 5개 결과 중 평균적으로 1.25개 정도가 관련 문서임을 뜻합니다. 두 방법 모두 비슷한 정밀도를 보이지만, Dense 쪽이 약간 더 많은 관련 문서를 상위 결과에 포함하고 있습니다 (즉, 불필요한 문서가 약간 적음).
- 재현율 R@5: BM25는 약 0.8944, Dense는 0.9167로, Dense가 약간 더 높은 재현율을 달성했습니다. 재현율 약 0.9란 전체 관련 문서의 90% 정도를 상위 5개 안에 찾아냈다는 의미입니다. 이는 대부분의 질의에서 관련 문서를 빠짐없이 찾아냈음을 보여주며, Dense 방식이 BM25보다 놓치는 관련 문서가 더 적음을 나타냅니다.
- MRR (평균 역순위): BM25는 0.9444, Dense는 0.9833으로 둘 다 매우 높은 값이지만 Dense가 더욱 높습니다. MRR이 이처럼 1에 가깝다는 것은 거의 모든 질의에 대해 첫 번째 검색 결과가 정답을 포함한다는 뜻입니다. 특히 Dense의 MRR 0.9833은 대부분의 질의에서 1순위 결과가 정답 문서였음을 시사하며, BM25도 우수하지만 Dense가 특히 정답을 최상위에 배치하는 능력이 좋음을 알 수 있습니다.
- MAP (평균 정밀도): BM25는 약 0.9380, Dense는 약 0.9594로, Dense의 MAP가 조금 높습니다. 두 값 모두 0.9 이상으로 매우 높기 때문에, 전체적인 검색 순위 품질이 우수함을 알 수 있습니다. Dense의 더 높은 MAP는 관련 문서들을 BM25보다 전반적으로 더 높은 순위에 배열했음을 의미합니다. (MAP는 여러 관련 문서의 순위를 모두 고려한 지표이므로, Dense가 관련 문서를 상위에 더 잘 모아놓았다는 해석이 가능합니다.)
요약하면, 이번 실험 결과에서는 Dense 임베딩 기반 검색이 BM25 대비 약간 더 높은 성능을 보였습니다. 특히 MRR과 MAP에서의 개선은 Dense 방법이 원하는 정답을 최상단에 제시하고, 전반적인 순위 품질도 좋다는 것을 보여줍니다. 다만 정밀도와 재현율 수치가 BM25와 크게 차이나지 않는 것으로 보아, 두 방법 모두 대부분의 관련 문서를 찾아내는 데 성공했고, 차이는 주로 순위 배열의 최적화 측면에서 나타난 것으로 해석할 수 있습니다.
8. BM25 vs Dense 성능 비교 시각화
여덟 번째 (마지막) 셀에서는 앞서 계산한 성능 지표들을 시각화합니다. matplotlib를 사용하여 BM25와 Dense의 지표 값을 한 눈에 비교하는 라인 차트를 그립니다. 먼저 한글 폰트 설정을 위해 Malgun Gothic 폰트를 지정하고, 마이너스 기호 깨짐 현상을 방지하도록 설정합니다. 그런 다음 methods = ['BM25', 'Dense']와 metrics = ['P@5', 'R@5', 'MRR', 'MAP'] 리스트를 정의하여 x축 눈금을 지표 이름으로 사용합니다. bm25_vals와 dense_vals 리스트에는 각각 BM25와 Dense의 지표 값들을 순서대로 담았습니다. plt.plot 함수를 두 번 호출해 BM25의 점수와 Dense의 점수를 각각 선으로 연결합니다. marker 옵션을 주어 각 점에 표시가 되도록 했으며, label로 범례를 추가합니다. plt.xticks(x, metrics)로 x축에 지표 이름 레이블을 달고, plt.ylim(0,1)로 y축 범위를 0부터 1까지로 설정하여 모든 지표 값이 0~1 사이에서 비교되도록 했습니다. 마지막으로 제목과 축 레이블, 격자, 범례를 설정한 뒤 plt.show()로 그래프를 출력합니다.
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']
metrics = ['P@5', 'R@5', 'MRR', 'MAP']
bm25_vals = [bm25_metrics['P@k'], bm25_metrics['R@k'], bm25_metrics['MRR'], bm25_metrics['MAP']]
dense_vals = [dense_metrics['P@k'], dense_metrics['R@k'], dense_metrics['MRR'], dense_metrics['MAP']]
x = range(len(metrics))
plt.figure(figsize=(8,4))
plt.plot(x, bm25_vals, marker='o', label='BM25')
plt.plot(x, dense_vals, marker='s', label='Dense')
plt.xticks(x, metrics)
plt.ylim(0,1)
plt.xlabel('Metric')
plt.ylabel('Score')
plt.title('BM25 vs Dense Retrieval 성능 비교')
plt.legend()
plt.grid(True)
plt.show()
그림: BM25와 Dense Retrieval의 각 성능 지표를 비교한 그래프. 파란 선은 BM25, 주황 선은 Dense 임베딩 검색의 성능을 나타냅니다. X축은 네 가지 평가 지표이고 Y축은 해당 지표 값(0~1 사이)입니다. 그래프를 보면 모든 지표에서 주황색 선(Dense)이 파란색 선(BM25)보다 약간 위에 위치한 것을 확인할 수 있습니다. 이는 앞서 표로 확인한 것처럼 Dense 방식이 BM25보다 조금 더 나은 성능 수치를 보였기 때문입니다. 특히 MRR 지표 부분에서 두 방법의 차이가 가장 두드러지는데, Dense의 점이 BM25보다 위에 있어 Dense가 첫 번째 정답을 더 잘 맞혔음을 알 수 있습니다. 그러나 전반적으로 두 방법의 성능 차이는 크지 않으며, 둘 다 높은 재현율과 MRR을 달성한 점도 그래프에서 알 수 있습니다 (두 선 모두 R@5와 MRR 부근에서 0.9 이상에 위치). 이와 같이 시각화를 통해 BM25 대비 Dense 임베딩 검색의 이점을 한눈에 파악할 수 있으며, 동시에 전통적 방법도 어느 정도 유효함을 확인할 수 있습니다.
'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 |
| 3. rrf_comparision (0) | 2025.06.07 |
| 1. indexing (0) | 2025.06.07 |