AI

BM25 알고리즘 / 검색 엔진은 점수를 어떻게 매길까?

도우 2026. 6. 17. 14:22

검색창에 "주문 취소"를 입력하면 1,500개의 문서 조각 중에서 가장 관련 있는 것을 골라야한다. 어떤 기준이 있을까?

사내 문서 검색 시스템을 만들면서 BM25라는 스코어링 알고리즘을 사용했다. 직접 구현하지는 않고 AI에게 위임했지만, 왜 이 공식이 동작하는지는 이해하고 있어야 한다. 검색 품질을 판단하려면 점수가 어떻게 매겨지는지 알아야 하기 때문이다.

이 글에서는 BM25가 무엇이고, 왜 이렇게 생겼는지를 직관부터 수식까지 단계적으로 설명한다.

 

1. 가장 단순한 방법

"주문 취소"를 검색한다고 하면 가장 먼저 떠오르는 방법은 단어를 세는 것이다.

"주문"과 "취소"가 많이 나오는 문서가 관련 있는 문서 아닌가?

맞다. 이게 TF(Term Frequency)이다. 특정 단어가 문서에 몇 번 나오는지 세는 것이다.

문서 A : "취소"가 5번 등장
문서 B : "취소"가 1번 등장
-> 문서 A가 더 관련 있을 것이다

 

실제 코드에서 이 빈도를 미리 계산해둔다.

// chunker.ts
export function termFrequency(tokens: string[]): Record<string, number> {
  const tf: Record<string, number> = Object.create(null);
  for (const t of tokens) {
    tf[t] = (tf[t] ?? 0) + 1;
  }
  return tf;
}

각 문서 조각(청크)마다 `{ "주문" : 5, "취소" : 3, "api" : 2, ... }` 형태의 빈도 맵을 만들어 인덱스에 저장한다.

하지만 단어 빈도만으로는 부족하다. 두 가지 문제가 있다.

 


 

2. 첫 번째 문제 

"주문"이라는 단어는 1,500개 청크 중 300개에 나온다. "취소"는 50개에만 나온다. 너무 흔한 단어라서 문제인 것이다. 

둘 다 검색어에 포함되어 있지만, 변별력이 다르다. "주문"은 너무 흔해서 이 단어가 나온다고 해서 관련 문서라고 보기 어렵다. 반면 "취소"가 나오는 문서는 진짜 취소에 대한 문서일 가능성이 높다.

이걸 보정하는게 IDF(Inverse Document Frequency)이다.

IDF 핵심 : 많은 문서에 나오는 단어일수록 가치가 낮다

코드에서 이 값을 구하기 위해, 먼저 각 단어가 몇 개 청크에 등장하는지 세어둔다.

// bm25.ts — buildBM25Index()
for (const chunk of chunks) {
  for (const term of Object.keys(chunk.terms)) {
    df[term] = (df[term] ?? 0) + 1;  // "주문": 300, "취소": 50, ...
  }
}

검색할 때는 이 df 값으로 IDF를 계산한다.

// bm25.ts — scoreBM25()
const idf = Math.log((totalDocs - termDf + 0.5) / (termDf + 0.5) + 1);

 

이 공식이 복잡해 보이지만, 핵심은 분수 부분이다.

(totalDocs - termDf + 0.5) / (termDf + 0.5)
 ─────────────────────────   ──────────────
 이 단어가 "없는" 청크 수      이 단어가 "있는" 청크 수

"취소"가 50개 청크에만 나온다면, 없는 쪽이 1,450개, 있는 쪽이 50개. 비율이 29배. 이 단어는 희귀하다 -> IDF가 높다.

나머지는 보정 값이다.

- `+0.5` : 0으로 나누는 걸 방지하는 스무딩
- `+1` : 결과가 음수가 되지 않게 하는 보정
- `log` : 값이 너무 커지지 않게 압축

숫자를 넣어보면 더 이해하기 쉽다.

전체 1,500개 청크에서:

"주문" (300개 청크에 등장)
IDF = log((1500 - 300 + 0.5) / (300 + 0.5) + 1) = log(4.99) ≈ 1.61

"취소" (50개 청크에 등장)
IDF = log((1500 - 50 + 0.5) / (50 + 0.5) + 1) = log(29.72) ≈ 3.39

"the" (1,200개 청크에 등장)
IDF = log((1500 - 1200 + 0.5) / (1200 + 0.5) + 1) = log(1.25) ≈ 0.22

"취소"의 IDF(3.39)가 "주문"의 IDF(1.61)보다 2배 이상 높다. 흔한 단어일수록 IDF가 낮아지고, 드문 단어일수록 높아진다. 결과적으로 "취소"가 매칭되는 청크가 더 높은 점수를 받는다.

 


 

3. 두 번째 문제

같은 단어가 1번 등장하더라도, 100단어짜리 문서에서 1번 나온 것과 10,000단어짜리 문서에서 1번 나온 것은 다르다.

문서 A (100 토큰): "취소" 1번 → 1%가 이 단어
문서 B (10,000 토큰): "취소" 1번 → 0.01%가 이 단어

직관적으로 문서 A가 더 관련 있다. 짧은 문서에서의 매칭이 더 의미 있다.

이걸 보정하기 위해 평균 문서 길이(avgDl)를 미리 계산해둔다.

// bm25.ts — buildBM25Index()
for (const chunk of chunks) {
  const docLen = Object.values(chunk.terms).reduce((a, b) => a + b, 0);
  totalLength += docLen;
}
return {
  avgDl: totalLength / chunks.length,  // 전체 청크의 평균 토큰 수
  df,
};

그리고 검색할 때 이 평균 길이와 비교해서 보정한다.

 

// bm25.ts — scoreBM25()
const tfNorm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (docLen / avgDl)));

docLen / avgDl 이게 핵심이다.
- 평균보다 긴 문서 -> 이 값이 1보다 큼 -> 분모가 커짐 -> 점수가 깎임
- 평균보다 짧은 문서 -> 이 값이 1보다 작음 -> 분모가 작아짐 -> 점수가 올라감

 


 

4. TF도 포화시킨다

단어 빈도(TF)를 그대로 쓰면 또 다른 문제가 있다. "취소"가 3번 나오는 문서는 1번 나오는 문서보다 관련 있지만, 100번 나오는 문서가 1번 나오는 문서보다 100배 관련 있지는 않다. 일정 수준을 넘으면 추가 등장의 가치가 줄어든다.

BM25는 TF에 포화 곡선을 적용한다.

TF 정규화 = (tf × (K1 + 1)) / (tf + K1 × ...)

tf가 아무리 커져도 분자의 tf와 분모의 tf가 함께 커지기 때문에, 결과값이 `k1 + 1`을 넘지 못한다. 이게 포화이다.

tf=1  → tfNorm ≈ 1.0
tf=3  → tfNorm ≈ 1.5
tf=10 → tfNorm ≈ 1.8
tf=100 → tfNorm ≈ 2.1

3번 나오면 1번보다 1.5배 정도 높지만, 100번 나와도 2.1배에서 멈춘다.

 


 

5. K1과 B

const K1 = 1.2;
const B = 0.75;

이 두 상수가 BM25의 동작을 조절한다.

K1 = 1.2 -> TF 포화 속도

K1이 클수록 TF의 영향이 더 오래 지속된다. 작을수록 빨리 포화된다.

K1 = 0.5일 때: tf=2면 거의 포화
K1 = 2.0일 때: tf=5까지도 점수가 계속 올라감
K1 = 1.2 (기본값): 적당한 중간

 

B = 0.75 -> 문서 길이 보정 강도

B = 0: 문서 길이를 무시 (긴 문서도 짧은 문서도 동등)
B = 1: 문서 길이를 강하게 보정 (긴 문서에 큰 페널티)
B = 0.75 (기본값): 적당히 보정

1.2와 0.75는 학술 논문에서 다양한 데이터셋으로 실험하여 검증된 표준 값이다. 대부분의 검색 시스템에서 이 기본값으로 충분하다.

 


 

6. 전체 공식

지금 까지 설명한 세 가지를 합치면 BM25 스코어가 된다.

각 검색어 토큰에 대해 정리하면 다음과 같다.
1. IDF : 이 단어가 얼마나 희귀한가 ( 흔하면 낮은 점수 )
2. TF_norm : 이 문서에 얼마나 자주 나오는가 ( 많으면 높은 점수, 단 포화 + 길이 보정)

두 값을 곱해서 더한다. 코드로 보면 다음과 같다.

// bm25.ts — scoreBM25()
export function scoreBM25(
  queryTerms: string[],
  chunk: Chunk,
  index: BM25Index,
  totalDocs: number,
): number {
  const { avgDl, df } = index;
  const docLen = Object.values(chunk.terms).reduce((a, b) => a + b, 0);
  let score = 0;

  for (const term of queryTerms) {
    const termDf = df[term] ?? 0;
    if (termDf === 0) continue;           // 인덱스에 없는 단어 → 스킵

    const tf = chunk.terms[term] ?? 0;
    if (tf === 0) continue;               // 이 청크에 없는 단어 → 스킵

    // 1. IDF — 희귀할수록 높음
    const idf = Math.log((totalDocs - termDf + 0.5) / (termDf + 0.5) + 1);

    // 2. TF 정규화 — 빈도 높을수록 높되 포화, 문서 길면 깎임
    const tfNorm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (docLen / avgDl)));

    score += idf * tfNorm;
  }
  return score;
}

 


 

7. 실제 예시로 계산해보기

"주문 취소"를 검색했을 때, 두 청크의 점수를 직접 계산해보자.

전체
- 전체 청크 수 : 1,500
- 평균 토큰 수(avgDl) : 150
- "주문"의 df : 300 (300개 청크에 등장)
- "취소"의 df : 50 (50개 청크에 등장)

 

청크 A : "주문 취소 API" 문서 (토큰 120개, "주문" 4회, "취소" 6회)

"주문":
  IDF = log((1500 - 300 + 0.5) / (300 + 0.5) + 1) = 1.61
  tfNorm = (4 × 2.2) / (4 + 1.2 × (1 - 0.75 + 0.75 × (120/150)))
         = 8.8 / (4 + 1.2 × 0.85) = 8.8 / 5.02 = 1.75
  기여 = 1.61 × 1.75 = 2.82

"취소":
  IDF = log((1500 - 50 + 0.5) / (50 + 0.5) + 1) = 3.39
  tfNorm = (6 × 2.2) / (6 + 1.2 × (1 - 0.75 + 0.75 × (120/150)))
         = 13.2 / (6 + 1.2 × 0.85) = 13.2 / 7.02 = 1.88
  기여 = 3.39 × 1.88 = 6.37

총점 = 2.82 + 6.37 = 9.19

 

청크 B : "주문 생성 흐름" 문서 (토큰 800개, "주문" 15회, "취소" 0회)

"주문":
  IDF = 1.61 (동일)
  tfNorm = (15 × 2.2) / (15 + 1.2 × (1 - 0.75 + 0.75 × (800/150)))
         = 33 / (15 + 1.2 × 4.25) = 33 / 20.1 = 1.64
  기여 = 1.61 × 1.64 = 2.64

"취소":
  tf = 0 → 기여 = 0

총점 = 2.64

 

청크 A : 9.19점 -> "취소"의 높은 IDF가 점수를 끌어올림
청크 B : 2.64점 -> "주문"만 매칭, 문서도 길이서 보정까지 받음

"취소"라는 단어 하나가 점수를 3배 이상 벌렸다. IDF가 높은(드문) 단어가 매칭될 때 점수가 크게 올라간다는 걸 숫자로 확인할 수 있다.

 


 

8. BM25의 한계

BM25는 키워드 매칭이다. 단어가 일치하는지만 본다.

검색 : "숙소 등록"
기대 : "객실 생성 API" 문서
결과 : 매칭 실패 (단어가 다르므로)

 

같은 의미인데 다른 단어를 쓰면 찾지 못한다. 이 한계를 극복하려면 단어의 의미를 벡터로 변환하는 임베딩 기반 검색을 병행해야 한다.

하지만 API 문서처럼 메소드명, 경로, 파라미터명 같은 고유 키워드가 명확한 도메인에서는 BM25만으로도 충분히 동작한다.

현재 시스템에서 이 한계를 보완하는 방법은 아래 두 가지가 있을 것이다.

1. 쿼리 라우팅 : 검색어에서 "API"라는 단어가 있으면 endpoint 카테고리로 범위를 좁힌다.
2. 토큰화 : `getOrderList`를 `[getorderlist, get, order, list]`로 쪼개서, "order"로도 매칭되게 한다.

완벽한 해결은 아니지만, 규모가 작은 사내 도구 수준에서는 실용적인 선택이었다.

 

정리

BM25는 세 가지 직관을 수학으로 옮긴 것이다.

직관 수학적 표현 코드
이 단어가 이 문서에 많이 나오면 관련이 있다. TF(Term Frequency) chunk.terms[term]
어디서나 나오는 흔한 단어는 가치가 낮다. IDF (Inverse Document Frequency
Math.log((N - df + 0.5) / (df + 0.5) + 1)
짧은 문서에서의 매칭이 더 의미 있다. Length Normalization docLen / avgDl

 

최종 점수 = 각 검색어에 대해 `IDF x TF(포화 + 길이보정)`을 더한 것.

검색 결과가 이상할 때 "왜 이 문서가 높은 점수를 받았지?"를 디버깅하려면 이 세 가지를 확인하면 된다. 
IDF가 낮은 흔한 단어가 끌어올린 건 아닌지, 문서가 짧아서 보정 혜택을 받은 건 아닌지, TF가 비정상적으로 높은 건 아닌지.

728x90