실무 Multi-Agent 오케스트레이션 15장 · 검색 품질 개선 2 / 7 ← 이전목차다음 → TechLead Cro

15장. 검색 품질 개선

키워드 검색과 BM25 — 단어로 찾는 검색을 다시 꺼낸다

한 줄 요약

BM25는 단어가 얼마나 겹치는지로 점수를 매기는 키워드 검색 방법입니다. 13장에서 단어 검사의 한계를 봤지만, 벡터 검색과 약점이 달라서 함께 쓰면 서로를 메워 줍니다. 다만 한국어에서는 단어를 어떻게 자르느냐에 따라 성능이 크게 달라집니다.


1. BM25가 점수를 매기는 방법

BM25는 오래전부터 검색 엔진이 써 온 점수 계산법입니다. 두 가지 생각을 식으로 옮긴 것입니다.

생각 뜻
자주 나올수록 관련 있다 질문의 단어가 그 청크에 많이 나오면 점수가 올라갑니다
드문 단어가 더 값지다 모든 청크에 다 나오는 단어("반품")가 겹친 것보다, 한두 청크에만 나오는 단어("리셀러")가 겹친 것에 점수를 더 줍니다

임베딩 모델도 API 호출도 필요 없습니다. 내 컴퓨터에서 단어를 세어 계산합니다.


2. 코드에서는 이렇게 쓴다

from rank_bm25 import BM25Okapi

texts = [c["text"] for c in chunks]
bm25 = BM25Okapi([t.split() for t in texts])      # 청크마다 단어 목록으로

scores = bm25.get_scores(query.split())           # 청크 12개의 점수
코드 뜻
t.split() 글을 공백 기준으로 잘라 단어 목록을 만듭니다. 이렇게 자르는 일을 토큰화(tokenization) 라고 합니다
BM25Okapi(단어 목록들) 청크 12개의 단어 통계를 미리 계산해 둡니다
get_scores(질문의 단어 목록) 청크마다 점수를 하나씩 돌려줍니다. 클수록 관련이 높습니다

벡터 검색의 거리는 작을수록 좋았는데, BM25 점수는 클수록 좋습니다. 방향이 반대입니다.

검색 함수는 점수가 높은 순서로 k개를 고르되, 점수가 0인 청크는 버립니다.

def keyword_search(query: str, k: int = 4) -> list[str]:
    scores = bm25.get_scores(query.split())
    top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
    return [with_source(chunks[i]["metadata"], texts[i]) for i in top if scores[i] > 0]

with_source는 청크 본문 앞에 출처 꼬리표 한 줄을 붙여 주는 함수입니다. 점수를 매길 때는 본문만 쓰고, 돌려줄 때 꼬리표를 붙입니다. 「따라하기」에서 다시 봅니다.

벡터 검색과 다른 점이 여기 있습니다. 벡터 검색은 항상 k개를 돌려줬습니다. 키워드 검색은 겹치는 단어가 없으면 한 건도 돌려주지 않습니다.


3. 두 검색은 실패하는 자리가 다르다

같은 청크 12개에 두 검색을 돌려 본 결과입니다. 숫자는 그 말이 실제로 들어 있는 청크가 몇 위에 나왔는지입니다.

검색어 벡터 검색 키워드 검색 무슨 일이 있었나
리셀러 3위 1위 문서에 딱 한 번 나오는 드문 단어. 키워드 검색이 정확히 짚었습니다
하루페이 2위 0건 문서에는 간편결제(하루페이 로 붙어 있어 공백으로 자르면 다른 단어가 됩니다
소비자분쟁해결기준 1위 0건 문서에는 「소비자분쟁해결기준」을 로 적혀 있습니다. 괄호와 조사가 붙었습니다
벡터 검색 키워드 검색
강한 곳 단어가 달라도 뜻이 같은 질문, 구어체 드문 단어, 고유한 이름, 문서 번호처럼 정확히 그 글자를 찾을 때
약한 곳 드문 단어 하나를 정확히 짚는 일 단어가 조금만 달라도 0건

서로의 약한 곳을 서로의 강한 곳이 덮습니다. 이런 관계를 상보적이라고 합니다. 그래서 둘 중 하나를 고르지 않고 합칩니다.


4. 한국어에서는 공백으로 자르면 약하다

위 표의 "0건" 두 개는 모두 자르는 방법 때문입니다.

한국어는 단어 뒤에 조사가 붙습니다. "청약철회란", "청약철회에", "청약철회는"은 공백으로 자르면 서로 다른 세 단어입니다. 질문의 "청약철회"와는 어느 것도 글자가 같지 않습니다.

문서에 적힌 모양 공백으로 자른 단어 질문의 "청약철회"와
'청약철회'란 관련법에 따라 '청약철회'란 다르다
법률상 청약철회에 해당한다 청약철회에 다르다

단어의 뜻을 가진 가장 작은 조각을 형태소라고 하고, 글을 형태소로 잘라 주는 도구를 형태소 분석기라고 합니다. 형태소 분석기를 쓰면 "청약철회에"가 "청약철회"와 "에"로 나뉘어 제대로 겹칩니다.

이 과정에서는 공백으로만 자릅니다. 설치할 것을 늘리지 않고 원리를 보기 위해서입니다. 그 대신 한계를 분명히 알고 갑니다.

우리 코드의 키워드 검색은 약한 키워드 검색입니다. 조사가 붙지 않은 단어, 문서와 글자가 똑같은 단어에만 반응합니다. 「따라하기」에서 키워드 검색 결과가 기대보다 엉뚱하게 나와도 놀라지 마세요.


5. 같은 청크, 두 개의 색인

눈여겨볼 구조가 하나 있습니다. 같은 청크 12개가 두 곳에 들어 있습니다.

어디에 무엇으로 언제 만드나
chroma_db/ (Chroma) 3,072차원 벡터 13장에서 미리. 파일로 저장되어 있다
bm25 (메모리) 단어 통계 이 파일을 실행할 때마다 새로. 저장하지 않는다

그래서 이번 장의 파일은 chroma_db/를 불러오는 것에 더해, 12장의 load_policy_chunks()로 청크 본문을 다시 가져옵니다. BM25를 만들 원문이 필요하기 때문입니다.

두 색인은 같은 청크로 만들어야 합니다. 12장의 청킹을 바꿨는데 13장을 다시 실행하지 않으면, Chroma에는 옛 청크가, BM25에는 새 청크가 들어가 서로 어긋납니다.


핵심 정리

  • BM25는 단어가 겹치는 정도로 점수를 매깁니다. 자주 나올수록, 드문 단어일수록 점수가 높습니다.
  • BM25 점수는 클수록 좋습니다. 겹치는 단어가 없으면 0건을 돌려줍니다.
  • 벡터 검색은 뜻에 강하고, 키워드 검색은 정확한 글자에 강합니다. 실패하는 자리가 달라 함께 씁니다.
  • 한국어를 공백으로만 자르면 조사 때문에 겹치지 않는 단어가 많습니다. 제대로 하려면 형태소 분석기가 필요합니다.
  • 같은 청크가 Chroma와 BM25 두 곳에 들어갑니다. 둘은 같은 청크로 만들어야 합니다.
← 이전 절왜 검색 품질을 따져야 하는가 — 검색이 못 찾으면 답도 틀린다다음 절 →하이브리드 검색 — 두 검색의 결과를 합친다
오명운 · macro@prag-ai.com