langchain 공부

BM25Retriever2 : BM25원리 코드로 해석하기

필만이 2024. 11. 20. 00:10

배경

  • rank_bm25.py를 분석해서 진짜 bm25의 원리를 알고자함

이론적 배경

1. BM25 변형 알고리즘의 상세 비교 및 사용 사례

(1) BM25Okapi

  • 설명:
    • BM25의 기본 변형으로 가장 널리 사용되는 알고리즘.
    • 문서 길이와 단어 빈도를 보정하여 검색 점수를 계산.
    • (b) 파라미터로 긴 문서와 짧은 문서 간의 점수 균형을 맞추며, (k1) 파라미터로 단어 빈도(TF)의 민감도를 조정.
  • 공식:
    • IDF(역문서빈도) : **IDF(Inverse Document Frequency)**를 사용하여 특정 단어가 전체 문서에서 얼마나 중요한지를 측정
    • TF : 단어 빈도(해당 단어가 문서에서 나타난 횟수).
    • k1 : 의 기여도를 조정하는 파라미터로, TF에 대해 얼마나 민감하게 반응할지를 결정.
    • b : 문서 길이 보정 파라미터 ((b = 0): 길이 보정 없음, (b = 1): 완전 보정).
    • avgL : 평균길이
  • 장점:
    • 계산이 단순하고 대부분의 데이터에서 안정적으로 작동.
    • 검색 엔진, 텍스트 기반 추천 시스템에 적합.
  • 단점:
    • 짧은 문서가 과도하게 높은 점수를 받을 가능성이 있음.
      • 짧은 문서의 길이 L가 평균 길이 avgL보다 작으면, 문서 점수가 길이에 의해 더 크게 보정됩니다.
      • 반면, 긴 문서는 L 값이 커져 점수가 감소하는 경향이 있습니다.
  • 사용 사례
    • 일반 검색 엔진: Google, Bing과 같은 텍스트 검색 시스템.
    • 기본 정보 검색: 간단한 문서 검색, 제품 추천 등.

(2) BM25L

  • 설명:
    • 짧은 문서가 높은 점수를 받는 문제를 해결하기 위해 설계.
    • (\delta) 파라미터를 추가하여 짧은 문서의 점수를 완화.
    • 뉴스, 트윗 등 짧은 텍스트와 긴 텍스트가 혼재된 데이터에서 특히 유용.
  • 공식:
    • (\delta): 짧은 문서 점수를 조정하는 파라미터.
  • 장점:
    • 짧은 문서와 긴 문서 간의 점수 균형을 유지.
    • 트위터, 블로그 게시물 등 짧은 텍스트에 적합.
  • 단점:
    • (\delta) 값을 적절히 설정하지 않으면 점수 분포 왜곡 가능.
  • 사용 사례:
    • 소셜 미디어 분석: 트윗, 인스타그램 게시물 검색.
    • 뉴스 검색: 뉴스 기사와 헤드라인 검색.

(3) BM25Plus

  • 설명:
    • 모든 문서에 최소 점수를 추가로 부여하여 점수 분포를 확장.
    • (\delta)를 이용해 관련성이 낮은 문서에도 기본 점수를 부여.
    • 검색 결과의 다양성을 확보하려는 시스템에서 유용.
  • 공식:

  • 장점:
    • 관련성이 낮은 문서에도 최소 점수를 제공하여 검색 결과 다양성 향상.
    • 다양한 데이터에서 안정적으로 작동.
  • 단점:
    • (\delta) 값이 크면 비관련 문서가 과대평가될 가능성.
  • 사용 사례:
    • 이커머스 추천 시스템: 구매 가능성이 낮은 제품도 추천 목록에 포함.
    • 대학 논문 검색: 관련 논문뿐만 아니라 비슷한 분야의 논문도 추천.

(4) BM25Adpt

  • 설명:
    • (k1) 값을 문서 상황에 따라 동적으로 조정하여 점수를 계산.
    • 도메인 특화 데이터(예: 의료 기록, 법률 문서)에서 특히 효과적.
  • 공식:

  • 장점:
    • 문서 특성에 맞게 동적 조정 가능.
    • 복잡한 도메인에 적합.
  • 단점:
    • 동적 (k1) 계산 기준이 복잡.

  • 사용 사례:
    • 의료 데이터 분석: 환자 기록 검색.
    • 법률 문서 검색: 계약서, 판례 검색.

 


(5) BM25T

  • 설명:
    • 단어별로 서로 다른 (k1) 값을 계산하여 점수를 반영.
    • 단어별 중요도를 반영하는 고도화된 검색 시스템에 적합.
  • 공식:

  • 장점:
    • 단어별 중요도를 세밀히 조정 가능.
    • 특정 키워드 중심 검색에서 높은 성능 발휘.
  • 단점:
    • 계산 비용 증가.
    • 단어별 (k1) 설정이 까다로움.
      예>
  • 사용 사례:
    • 과학 논문 검색: 특정 주제나 용어 중심의 검색.
    • 기술 문서 분석: 특정 기술 용어가 포함된 문서 검색.

 


2. 알고리즘 비교 표 (세부설명 추가)

특징/알고리즘 BM25Okapi BM25L BM25Plus BM25Adpt BM25T
주요 목표 기본 BM25 알고리즘 짧은 문서의 점수 과대평가 방지 문서 간 최소 점수 제공 특정 상황에서 (k1) 값 동적 조정 단어별로 중요도를 다르게 반영
점수 보정 방식 문서 길이와 단어 빈도를 보정 문서 길이 보정 + 짧은 문서 점수 완화 문서 길이 보정 + 모든 문서에 일정 점수 추가 문서 특성을 반영해 (k1) 값 동적으로 조정 단어별 (k1) 값을 계산하여 점수에 반영
추가 파라미터 (k1, b) (k1, b, \delta) (k1, b, \delta) (k1, b, \delta) (k1, b, \delta)
복잡성 낮음 중간 중간 중간 높음
문서 길이 반영 (b)를 사용한 문서 길이 보정 (b)와 (\delta)를 활용하여 점수 균형 조정 (b) 보정 후 모든 문서에 (\delta) 추가 문서 길이와 빈도를 기반으로 점수를 세밀히 조정 문서 길이와 빈도를 단어별로 조정
장점 간단하고 빠르며 안정적 짧은 문서와 긴 문서 간의 점수 균형을 맞춤 비관련 문서에도 최소 점수를 부여 가능 점수를 상황에 따라 동적으로 조정 가능 단어별 중요도를 세밀히 반영
단점 짧은 문서가 과대 평가될 수 있음 파라미터 조정이 어려움 (\delta) 값이 크면 비관련 문서도 높은 점수 동적 조정 기준이 복잡 계산 비용 증가 및 단어별 기준 설정 필요
적합한 데이터 일반 텍스트 데이터 (검색 엔진, 문서 요약 등) 뉴스 기사, 트윗 등 짧은 문서와 긴 문서 혼재 모든 문서가 일정 수준 점수를 가져야 하는 경우 도메인 특화 문서 (법률, 의료, 학술 논문 등) 키워드 중심의 고도화된 검색 시스템

3. 결론

선택 기준

  1. 일반적인 검색:
    • BM25Okapi가 적합 (간단하고 효율적).
  2. 짧은 텍스트와 긴 텍스트가 혼재된 데이터:
    • BM25L을 추천.
  3. 검색 결과 다양성을 강조:
    • BM25Plus가 적합.
  4. 도메인 특화 데이터:
    • BM25Adpt로 동적 조정을 활용.
  5. 단어 중요도를 강조:
    • BM25T로 세밀한 조정 가능.

 

더 자세한 상황별 적용 사례 : https://makenow90.tistory.com/103

코드

import math
import numpy as np
from multiprocessing import Pool, cpu_count

"""
이 코드는 Trotman et al., "Improvements to BM25 and Language Models Examined" 논문에서 제안된 BM25 알고리즘과 
그 변형(BM25Okapi, BM25L, BM25Plus)을 구현합니다. BM25 알고리즘은 문서와 쿼리 간의 관련성을 계산하기 위해 
사용됩니다. 각 변형은 검색 성능 향상을 목표로 추가적인 개선점을 제공합니다.
"""


# BM25의 기본 클래스
class BM25:
    def __init__(self, corpus, tokenizer=None):
        """
        BM25 클래스 초기화.
        Args:
            corpus: 말뭉치(문서 리스트). 각 문서는 단어 리스트로 구성됨.
            tokenizer: 선택적 토크나이저 함수. 문서를 토큰으로 변환하는 함수.
        """
        self.corpus_size = 0  # 전체 문서의 개수
        self.avgdl = 0  # 문서의 평균 길이
        self.doc_freqs = []  # 각 문서에서 단어 빈도를 저장하는 리스트
        self.idf = {}  # 각 단어의 역문서빈도(IDF) 값
        self.doc_len = []  # 각 문서의 길이를 저장하는 리스트
        self.tokenizer = tokenizer  # 선택적 토크나이저 함수

        # 토크나이저가 제공되었으면, 말뭉치를 토큰화
        if tokenizer:
            corpus = self._tokenize_corpus(corpus)

        # 초기화 및 IDF 계산
        nd = self._initialize(corpus)
        self._calc_idf(nd)

    def _initialize(self, corpus):
        """
        말뭉치를 초기화하고, 문서 빈도와 길이를 계산.
        Args:
            corpus: 말뭉치(문서 리스트).
        Returns:
            nd: 각 단어가 등장한 문서의 수를 저장하는 딕셔너리.
        """
        nd = {}  # 각 단어가 등장한 문서의 수
        num_doc = 0  # 전체 단어 수

        for document in corpus:
            # 각 문서의 길이를 저장
            self.doc_len.append(len(document))
            num_doc += len(document)

            # 단어 빈도 계산
            frequencies = {}
            for word in document:
                if word not in frequencies:
                    frequencies[word] = 0
                frequencies[word] += 1

            # 문서 빈도 리스트에 추가
            self.doc_freqs.append(frequencies)

            # 각 단어가 등장한 문서 수(nd) 계산
            for word, freq in frequencies.items():
                try:
                    nd[word] += 1
                except KeyError:
                    nd[word] = 1

            # 전체 문서 수 증가
            self.corpus_size += 1

        # 문서의 평균 길이 계산
        self.avgdl = num_doc / self.corpus_size
        return nd

    def _tokenize_corpus(self, corpus):
        """
        말뭉치를 병렬로 토큰화.
        Args:
            corpus: 말뭉치(문서 리스트).
        Returns:
            tokenized_corpus: 토큰화된 문서 리스트.
        """
        pool = Pool(cpu_count())  # CPU 코어 수만큼 병렬 처리
        tokenized_corpus = pool.map(self.tokenizer, corpus)  # 토크나이저를 각 문서에 적용
        return tokenized_corpus

    def _calc_idf(self, nd):
        """
        IDF 계산. 하위 클래스에서 구현.
        """
        raise NotImplementedError()

    def get_scores(self, query):
        """
        쿼리에 대한 점수 계산. 하위 클래스에서 구현.
        """
        raise NotImplementedError()

    def get_batch_scores(self, query, doc_ids):
        """
        특정 문서 집합에 대한 점수 계산. 하위 클래스에서 구현.
        """
        raise NotImplementedError()

    def get_top_n(self, query, documents, n=5):
        """
        상위 n개의 문서를 반환.
        Args:
            query: 검색할 쿼리(단어 리스트).
            documents: 전체 문서 리스트.
            n: 반환할 문서의 수.
        Returns:
            상위 n개의 문서 리스트.
        """
        assert self.corpus_size == len(documents), "문서 리스트가 초기화된 말뭉치와 일치하지 않습니다!"

        # 쿼리에 대한 점수를 계산하고, 상위 n개의 문서를 반환
        scores = self.get_scores(query)
        top_n = np.argsort(scores)[::-1][:n]  # 점수를 기준으로 내림차순 정렬
        return [documents[i] for i in top_n]


# BM25의 표준 구현 (BM25Okapi)
class BM25Okapi(BM25):
    def __init__(self, corpus, tokenizer=None, k1=1.5, b=0.75, epsilon=0.25):
        """
        BM25Okapi 초기화.
        Args:
            corpus: 말뭉치(문서 리스트).
            tokenizer: 선택적 토크나이저 함수.
            k1: TF에 대한 가중치(기본값: 1.5).
            b: 문서 길이 보정 파라미터(기본값: 0.75).
            epsilon: 음수 IDF 방지를 위한 하한값.
        """
        self.k1 = k1  # TF 가중치
        self.b = b  # 문서 길이 보정 파라미터
        self.epsilon = epsilon  # 음수 IDF 하한값
        super().__init__(corpus, tokenizer)

    def _calc_idf(self, nd):
        """
        IDF(역문서빈도) 계산.
        음수 IDF를 방지하기 위해 epsilon 값을 사용.
        Args:
            nd: 각 단어가 등장한 문서 수.
        """
        idf_sum = 0
        negative_idfs = []  # 음수 IDF를 가진 단어 리스트

        for word, freq in nd.items():
            idf = math.log(self.corpus_size - freq + 0.5) - math.log(freq + 0.5)
            self.idf[word] = idf
            idf_sum += idf

            if idf < 0:
                negative_idfs.append(word)

        # 평균 IDF 계산 및 epsilon 설정
        self.average_idf = idf_sum / len(self.idf)
        eps = self.epsilon * self.average_idf

        # 음수 IDF를 epsilon 값으로 설정
        for word in negative_idfs:
            self.idf[word] = eps

    def get_scores(self, query):
        """
        쿼리에 대한 점수 계산.
        Args:
            query: 검색할 쿼리(단어 리스트).
        Returns:
            각 문서의 점수 배열.
        """
        score = np.zeros(self.corpus_size)  # 문서 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)  # 문서 길이 배열

        for q in query:
            # 각 문서에서 쿼리 단어의 빈도 계산
            q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])

            # BM25 공식에 따라 점수 계산
            score += (self.idf.get(q) or 0) * (q_freq * (self.k1 + 1) /
                                               (q_freq + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)))
        return score

    def get_batch_scores(self, query, doc_ids):
        """
        특정 문서 집합에 대해 쿼리 점수 계산.
        Args:
            query: 검색할 쿼리(단어 리스트).
            doc_ids: 점수를 계산할 문서 ID 리스트.
        Returns:
            각 문서의 점수 리스트.
        """
        assert all(di < len(self.doc_freqs) for di in doc_ids), "문서 ID가 유효하지 않습니다!"

        score = np.zeros(len(doc_ids))  # 문서 ID 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)[doc_ids]  # 선택한 문서의 길이 배열

        for q in query:
            # 선택한 문서에서 쿼리 단어의 빈도 계산
            q_freq = np.array([(self.doc_freqs[di].get(q) or 0) for di in doc_ids])

            # BM25 공식에 따라 점수 계산
            score += (self.idf.get(q) or 0) * (q_freq * (self.k1 + 1) /
                                               (q_freq + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)))
        return score.tolist()

    def get_batch_scores(self, query, doc_ids):
        """
        특정 문서 집합에 대해 쿼리 점수 계산.
        Args:
            query: 검색할 쿼리(단어 리스트).
            doc_ids: 점수를 계산할 문서 ID 리스트.
        Returns:
            각 문서의 점수 리스트.
        """
        assert all(di < len(self.doc_freqs) for di in doc_ids), "문서 ID가 유효하지 않습니다!"

        score = np.zeros(len(doc_ids))  # 문서 ID 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)[doc_ids]  # 선택한 문서의 길이 배열

        for q in query:
            # 선택한 문서에서 쿼리 단어의 빈도 계산
            q_freq = np.array([(self.doc_freqs[di].get(q) or 0) for di in doc_ids])

            # BM25 공식에 따라 점수 계산
            score += (self.idf.get(q) or 0) * (q_freq * (self.k1 + 1) /
                                               (q_freq + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)))
        return score.tolist()

class BM25L(BM25):
    def __init__(self, corpus, tokenizer=None, k1=1.5, b=0.75, delta=0.5):
        """
        BM25L 클래스 초기화
        Args:
            corpus: 말뭉치(문서 리스트)
            tokenizer: 선택적 토크나이저 함수
            k1: TF(단어 빈도)에 대한 가중치 (기본값: 1.5)
            b: 문서 길이 보정을 위한 파라미터 (기본값: 0.75)
            delta: 짧은 문서의 점수를 조정하기 위한 파라미터 (기본값: 0.5)
        """
        self.k1 = k1  # TF 가중치
        self.b = b  # 문서 길이 보정 파라미터
        self.delta = delta  # 짧은 문서 점수를 보정하는 추가 파라미터
        super().__init__(corpus, tokenizer)

    def _calc_idf(self, nd):
        """
        역문서빈도(IDF) 계산
        Args:
            nd: 단어별로 등장한 문서의 수
        """
        for word, freq in nd.items():
            # IDF 계산: 문서의 총 개수와 단어 등장 빈도를 기반으로 로그 값 계산
            idf = math.log(self.corpus_size + 1) - math.log(freq + 0.5)
            self.idf[word] = idf  # 각 단어의 IDF 값을 저장

    def get_scores(self, query):
        """
        쿼리에 대한 BM25L 점수 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
        Returns:
            각 문서의 점수 배열
        """
        score = np.zeros(self.corpus_size)  # 문서 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)  # 각 문서의 길이 배열

        for q in query:
            # 각 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])
            # 문서 길이를 보정한 TF 계산
            ctd = q_freq / (1 - self.b + self.b * doc_len / self.avgdl)
            # BM25L 점수 공식
            score += (self.idf.get(q) or 0) * q_freq * (self.k1 + 1) * (ctd + self.delta) / \
                     (self.k1 + ctd + self.delta)

        return score  # 각 문서의 점수 배열 반환

    def get_batch_scores(self, query, doc_ids):
        """
        특정 문서 집합에 대해 BM25L 점수를 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
            doc_ids: 점수를 계산할 문서 ID 리스트
        Returns:
            각 문서의 점수 리스트
        """
        # 입력된 문서 ID가 유효한지 확인
        assert all(di < len(self.doc_freqs) for di in doc_ids)
        score = np.zeros(len(doc_ids))  # 문서 ID 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)[doc_ids]  # 선택한 문서의 길이 배열

        for q in query:
            # 선택된 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(self.doc_freqs[di].get(q) or 0) for di in doc_ids])
            # 문서 길이를 보정한 TF 계산
            ctd = q_freq / (1 - self.b + self.b * doc_len / self.avgdl)
            # BM25L 점수 공식
            score += (self.idf.get(q) or 0) * q_freq * (self.k1 + 1) * (ctd + self.delta) / \
                     (self.k1 + ctd + self.delta)

        return score.tolist()  # 각 문서의 점수 리스트 반환


class BM25Plus(BM25):
    def __init__(self, corpus, tokenizer=None, k1=1.5, b=0.75, delta=1):
        """
        BM25Plus 클래스 초기화
        Args:
            corpus: 말뭉치(문서 리스트)
            tokenizer: 선택적 토크나이저 함수
            k1: TF(단어 빈도)에 대한 가중치 (기본값: 1.5)
            b: 문서 길이 보정을 위한 파라미터 (기본값: 0.75)
            delta: 추가적인 점수를 부여하기 위한 파라미터 (기본값: 1)
        """
        self.k1 = k1  # TF 가중치
        self.b = b  # 문서 길이 보정 파라미터
        self.delta = delta  # 모든 문서에 일정 점수를 추가하는 파라미터
        super().__init__(corpus, tokenizer)

    def _calc_idf(self, nd):
        """
        역문서빈도(IDF) 계산
        Args:
            nd: 단어별로 등장한 문서의 수
        """
        for word, freq in nd.items():
            # IDF 계산: 말뭉치 내 단어 희소성을 반영
            idf = math.log((self.corpus_size + 1) / freq)
            self.idf[word] = idf  # 각 단어의 IDF 값을 저장

    def get_scores(self, query):
        """
        쿼리에 대한 BM25Plus 점수 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
        Returns:
            각 문서의 점수 배열
        """
        score = np.zeros(self.corpus_size)  # 문서 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)  # 각 문서의 길이 배열

        for q in query:
            # 각 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])
            # BM25Plus 점수 공식
            score += (self.idf.get(q) or 0) * (self.delta + (q_freq * (self.k1 + 1)) /
                                               (self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) + q_freq))

        return score  # 각 문서의 점수 배열 반환

    def get_batch_scores(self, query, doc_ids):
        """
        특정 문서 집합에 대해 BM25Plus 점수를 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
            doc_ids: 점수를 계산할 문서 ID 리스트
        Returns:
            각 문서의 점수 리스트
        """
        # 입력된 문서 ID가 유효한지 확인
        assert all(di < len(self.doc_freqs) for di in doc_ids)
        score = np.zeros(len(doc_ids))  # 문서 ID 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)[doc_ids]  # 선택된 문서의 길이 배열

        for q in query:
            # 선택된 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(self.doc_freqs[di].get(q) or 0) for di in doc_ids])
            # BM25Plus 점수 공식
            score += (self.idf.get(q) or 0) * (self.delta + (q_freq * (self.k1 + 1)) /
                                               (self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) + q_freq))

        return score.tolist()  # 각 문서의 점수 리스트 반환

class BM25Adpt(BM25):
    def __init__(self, corpus, k1=1.5, b=0.75, delta=1):
        """
        BM25Adpt 초기화
        Args:
            corpus: 말뭉치(문서 리스트)
            k1: TF(단어 빈도)에 대한 가중치의 초기값 (기본값: 1.5)
            b: 문서 길이 보정 파라미터 (기본값: 0.75)
            delta: 추가적인 점수를 부여하기 위한 파라미터 (기본값: 1)
        """
        self.k1 = k1  # 기본 TF 가중치
        self.b = b  # 문서 길이 보정 파라미터
        self.delta = delta  # 추가 점수를 위한 파라미터
        super().__init__(corpus)  # BM25 기본 클래스 초기화

    def _calc_idf(self, nd):
        """
        역문서빈도(IDF) 계산
        Args:
            nd: 단어별로 등장한 문서의 수
        """
        for word, freq in nd.items():
            # IDF 계산: 말뭉치 내 단어 희소성을 반영
            idf = math.log((self.corpus_size + 1) / freq)
            self.idf[word] = idf  # 각 단어의 IDF 값을 저장

    def get_scores(self, query):
        """
        쿼리에 대한 점수 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
        Returns:
            각 문서의 점수 배열
        """
        score = np.zeros(self.corpus_size)  # 문서 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)  # 각 문서의 길이 배열

        for q in query:
            # 각 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])
            # 점수 계산: BM25Adpt는 특정 조건에 따라 \(k1\) 조정 가능
            score += (self.idf.get(q) or 0) * (self.delta + (q_freq * (self.k1 + 1)) /
                                               (self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) + q_freq))

        return score  # 각 문서의 점수 배열 반환

class BM25T(BM25):
    def __init__(self, corpus, k1=1.5, b=0.75, delta=1):
        """
        BM25T 초기화
        Args:
            corpus: 말뭉치(문서 리스트)
            k1: TF(단어 빈도)에 대한 가중치의 초기값 (기본값: 1.5)
            b: 문서 길이 보정 파라미터 (기본값: 0.75)
            delta: 추가적인 점수를 부여하기 위한 파라미터 (기본값: 1)
        """
        self.k1 = k1  # 기본 TF 가중치
        self.b = b  # 문서 길이 보정 파라미터
        self.delta = delta  # 추가 점수를 위한 파라미터
        super().__init__(corpus)  # BM25 기본 클래스 초기화

    def _calc_idf(self, nd):
        """
        역문서빈도(IDF) 계산
        Args:
            nd: 단어별로 등장한 문서의 수
        """
        for word, freq in nd.items():
            # IDF 계산: 말뭉치 내 단어 희소성을 반영
            idf = math.log((self.corpus_size + 1) / freq)
            self.idf[word] = idf  # 각 단어의 IDF 값을 저장

    def get_scores(self, query):
        """
        쿼리에 대한 점수 계산
        Args:
            query: 검색할 쿼리(단어 리스트)
        Returns:
            각 문서의 점수 배열
        """
        score = np.zeros(self.corpus_size)  # 문서 수만큼 점수 배열 초기화
        doc_len = np.array(self.doc_len)  # 각 문서의 길이 배열

        for q in query:
            # 각 문서에서 쿼리 단어의 빈도를 계산
            q_freq = np.array([(doc.get(q) or 0) for doc in self.doc_freqs])
            # 점수 계산: BM25T는 term-specific \(k1\) 값을 계산
            score += (self.idf.get(q) or 0) * (self.delta + (q_freq * (self.k1 + 1)) /
                                               (self.k1 * (1 - self.b + self.b * doc_len / self.avgdl) + q_freq))

        return score  # 각 문서의 점수 배열 반환