CPN 한국어 자습서 · 러닝패스 2 / 4 — Building with the Claude API
RAG
BM25 lexical search
시맨틱 검색은 강력하지만 코너케이스가 있습니다. 드물고 정확한 식별자(예: incident 2023)를 물으면 엉뚱한 섹션이 끼어들죠. 해법은 고전적 어휘 검색(lexical search)을 나란히 돌리는 것 — 그 표준 알고리즘이 BM25(Best Match 25)입니다.
Stephen Grider · Anthropic 기술 스태프
RAG 파이프라인의 첫 버전을 다 만들었습니다. 지금은 잘 도는 것 같지만, 곧 검색 결과가 늘 최선은 아니라는 걸 알게 됩니다. 예를 보겠습니다. report.md의 소프트웨어 공학 섹션을 보면 INC(Incident의 약자) 2023 Q4-011 이라는 표현이 그 문단 안에 세 번 나옵니다. 문서를 더 내려가면 Section 10 사이버보안 분석에도 등장합니다 — 헤더에 한 번, 문단 안에 한 번.
이 식별자 "incident 2023 Q4-011"로 검색하면 시맨틱 검색이 무엇을 돌려줄까요? 노트북의 사용자 질의를 "what happened with incident 2023"으로 바꾸고 전체 셀을 다시 돌립니다. 결과가 조금 뜻밖입니다. 첫 결과는 Section 10 — 이 incident를 다루는 섹션이니 좋습니다. 그런데 그다음 결과가 놀랍게도 Section 3 재무분석입니다. Section 3을 열어 보면 그 incident는 어디에도 언급되지 않습니다. 우리가 원한 건 Section 10 다음 Section 2(소프트웨어 공학)였는데, 받은 건 Section 10 다음 엉뚱한 Section 3이었습니다.
시맨틱 검색 기법은 대부분 아주 잘 동작하지만, 이렇게 기대대로 안 되는 코너케이스가 있습니다. 그래서 결과를 개선할 기법을 봅니다. 전략은 이렇습니다. 사용자가 질문하면 그 질문을 시맨틱 검색(임베딩+벡터DB)에 넣는 동시에, 병렬로 별도의 어휘 검색 시스템도 돌립니다. 어휘 검색은 고전적 텍스트 검색에 가깝습니다 — 질문을 개별 단어로 쪼개고, 그 단어들이 든 청크를 찾습니다. 두 시스템의 결과를 모은 뒤 병합하면, 의미적 측면과 평문 검색 측면이 한 결과 집합에 균형 있게 담깁니다.
어휘 검색을 구현하는 방법은 무수히 많지만, 우리가 만드는 것 같은 RAG 파이프라인에서 아주 흔히 보이는 방법이 BM25입니다 — Best Match 25의 약자죠. 알고리즘을 고수준으로 훑어보겠습니다(작은 단계 몇 개는 단순화를 위해 생략).
모든 것은 사용자 질의에서 시작합니다. "a incident 2023 Q4-011" 같은 검색 문자열을 넣었다고 합시다. 1단계 토큰화: 질의를 개별 조각으로 쪼갭니다. 지금은 아주 단순한 방법 — 구두점을 제거하고 공백으로 나눕니다. 그러면 "a", "incident", "2023" 같은 항이 나옵니다. 2단계 빈도: 각 항이 전체 청크들에 얼마나 자주 나오는지 셉니다. 청크가 둘뿐이라 하면, "a"는 다 합쳐 다섯 번, "incident 2023"은 한 번 같은 식으로요.
3단계 상대적 중요도: 사용 빈도로 각 항에 중요도를 줍니다. "a"는 여기저기 다섯 번 쓰여 별로 중요하지 않다고 봅니다. 반면 "incident 2023"은 아주 드물게 쓰여 검색 중요도가 더 큽니다. 4단계: 높은 가중치를 받은 항을 더 많이 쓴 청크를 상위로 올립니다.
노트북 004_bm25에서는 BM25Index 클래스가 제공됩니다. 단계는 시맨틱과 같습니다 — 섹션으로 청킹하고, 청크를 스토어에 추가하고, 검색합니다. 같은 질의로 검색하면 이번엔 소프트웨어 공학 → 사이버보안 → 방법론 순서로 나옵니다. 드문 핵심어 "incident 2023"을 가장 많이 쓴 섹션을 제대로 우선합니다. "what happened with" 같은 흔한 항은 원문 곳곳에 쓰이니 그만큼 가중하지 않습니다.
이제 두 검색 시스템이 생겼습니다 — 시맨틱 검색과, 좀 더 고전적인 텍스트 검색. 두 스토어의 구현은 API가 거의 같습니다 — 둘 다 add_document와 search 함수를 가집니다. 다음 레슨에서 이 둘을 병합합니다. 사용자가 질의를 내면 두 시스템 모두에 전달해 결과를 받고, 그 둘을 합쳐 양쪽의 장점을 모두 취합니다.
이 장에서 배우는 것What you'll learn
약 7분시맨틱 검색의 코너케이스 — 드문 식별자에 엉뚱한 섹션이 끼어듦
해법: 어휘 검색을 병렬로 — 질문을 단어로 쪼개 그 단어가 든 청크를 찾음
BM25(Best Match 25) — RAG에서 가장 흔한 어휘 검색 알고리즘
4단계: 토큰화 → 빈도 → 희소어 가중↑ → 고가중어 많은 청크 우선
BM25Index는 VectorIndex와 같은 API(add_document·search)
같은 질의 결과: 소프트웨어 공학 → 사이버보안 → 방법론
incident 2023처럼 희소한 항이 검색을 좌우하게 한다.k1=빈도 포화, b=문서 길이 정규화 정도. 기본 1.5 · 0.75.소프트웨어 공학 섹션과 사이버보안 섹션 모두 식별자 INC-2023-Q4-011을 언급합니다. 이걸 시맨틱 검색에 물으면 Section 10은 잘 찾지만, 다음 자리에 엉뚱하게 Section 3 재무분석이 끼어듭니다 — 그 incident가 한 번도 안 나오는 섹션인데도요.
시맨틱은 Section 3 재무분석을 잘못 끼워 넣습니다 — 거기엔 그 incident가 한 번도 안 나오는데도요. BM25는 드문 식별자 incident 2023을 실제로 쓴 섹션을 우선합니다.
질문을 시맨틱 검색에 넣는 동시에, 별도의 어휘 검색도 돌립니다. 어휘 검색은 질문을 단어로 쪼개 그 단어가 든 청크를 찾습니다. 두 결과를 병합하면 의미적 측면과 평문 검색 측면이 한데 모입니다. 표준 알고리즘이 BM25입니다.
① 토큰화 구두점 제거·공백 분리 → ② 빈도 각 항이 전체 청크에 몇 번 나오나 → ③ 희소어 가중↑ 드문 항일수록 중요 → ④ 우선순위 고가중어를 많이 쓴 청크를 상위로.
한 항을 클릭하면 그 항이 어느 청크를 끌어올리는지 보입니다. 고가중어를 더 많이 쓴 청크 B가 상위로 올라갑니다.
노트북에서 BM25Index 클래스가 제공됩니다. 핵심은 토큰화기와 _calculate_idf — 희소어일수록 큰 가중치를 줍니다. 공개 API는 시맨틱 스토어와 똑같이 add_document·search입니다.
import re, math class BM25Index: def __init__(self, k1=1.5, b=0.75, tokenizer=None): self.k1 = k1 # 단어 빈도 포화 정도 self.b = b # 문서 길이 정규화 정도 self.tokenizer = tokenizer or self._default_tokenizer self.documents = [] def _default_tokenizer(self, text): text = text.lower() # 소문자화 tokens = re.split(r"\W+", text) # 구두점 제거 · 공백 분리 return [t for t in tokens if t] def _calculate_idf(self, term, N, df): # 희소어일수록 idf↑ — 드문 단어가 검색을 좌우 return math.log(((N - df + 0.5) / (df + 0.5)) + 1) def add_document(self, document): # 시맨틱 스토어와 동일 API ... def search(self, query_text, k=1): # BM25 점수로 상위 k ...
사용 단계도 시맨틱과 같습니다 — 섹션으로 청킹하고, 청크를 스토어에 추가하고, 시맨틱이 틀렸던 바로 그 질의로 검색합니다.
with open("./report.md", "r") as f: text = f.read() # 1. 섹션으로 청킹 chunks = chunk_by_section(text) # 2. BM25 스토어 생성 + 청크 추가 store = BM25Index() for chunk in chunks: store.add_document({"content": chunk}) # 3. 검색 — 시맨틱이 틀렸던 바로 그 질의 results = store.search("what happened with incident 2023 Q4-011", 3) for doc, distance in results: print(distance) print(doc["content"][:200]) print("\n----\n")
## Section 2: Software Engineering — Project Phoenix The engineering team resolved incident INC-2023-Q4-011 after three regression cycles, restoring the payment service ... ---- ## Section 10: Cybersecurity Analysis Incident INC-2023-Q4-011 originated from an exposed staging endpoint; containment completed within 4 hours ... ---- ## Methodology Each domain team followed a shared incident-review protocol ...
BM25Index는 VectorIndex와 같은 API(add_document·search) — 다음 레슨에서 병합한다.Q1시맨틱 검색의 코너케이스란 어떤 상황인가요?
의미는 비슷하지만 정확한 식별자가 안 든 섹션(예: 재무분석)이 끼어들 수 있습니다. 어휘 검색이 이를 보완합니다.
Q2BM25에서 드문 단어(예: incident 2023)는 어떻게 다뤄지나요?
IDF가 희소어에 큰 가중치를 줍니다. "a"·"what" 같은 흔한 항은 가중치가 낮습니다.
Q3BM25Index를 VectorIndex와 같은 API로 만든 이유는?
둘 다 add_document·search를 가지므로 다음 레슨에서 Retriever 한 클래스로 감쌀 수 있습니다.
두 검색 시스템이 같은 API로 준비됐습니다. 이제 둘을 Retriever 한 클래스로 묶고, RRF(상호 순위 융합)로 결과를 병합합니다. → 멀티 인덱스 RAG 파이프라인
전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 76개 레슨을 끝까지 읽을 수 있습니다.
이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.