CPN 한국어 자습서 · 러닝패스 2 / 4 — Building with the Claude API
RAG
A Multi-Index RAG pipeline
시맨틱(VectorIndex)과 어휘(BM25Index)가 같은 API를 가지니, 둘을 Retriever 한 클래스로 묶습니다. 두 결과를 합칠 땐 RRF(상호 순위 융합)를 씁니다 — 각 청크의 순위에 1/(k+rank)를 더해 점수를 매기고 다시 정렬합니다.
Stephen Grider · Anthropic 기술 스태프
이제 시맨틱 검색 구현과 어휘 검색 구현이 모두 있습니다. 둘을 연결해야 합니다. 먼저 눈에 띄는 점 — 두 검색 기능의 공개 API가 거의 똑같습니다. 왼쪽 VectorIndex 클래스에 add_document·search가 있고, BM25Index에도 거의 동일한 메서드가 있습니다. 그래서 둘을 하나의 검색 파이프라인으로 잇기 위해, 새 클래스 Retriever로 감쌉니다.
Retriever는 사용자 질문을 받아 VectorIndex와 BM25Index의 search로 각각 전달합니다. 그런 다음 두 결과를 받아 어떤 방식으로든 병합합니다. 그런데 이 병합이 좀 까다롭습니다. 그래서 결과 병합 방법을 자세히 보겠습니다. 결합에는 RRF(Reciprocal Rank Fusion, 상호 순위 융합)를 씁니다. 예시로 이해하는 게 가장 쉽습니다.
벡터 인덱스로 검색해 결과 [2, 7, 6]을 얻었다고 합시다. 같은 검색을 BM25로 하면 [6, 2, 7]이 나옵니다. 이 두 결과 목록을 합쳐야 합니다. 모든 결과를 한 표에 모읍니다. 텍스트 청크 2와 6, 7… 각각에 대해 벡터 인덱스 출력에서의 순위(rank)와 BM25 출력에서의 순위를 기록합니다. 순위란 그냥 검색 출력 위치 — 1, 2, 3 — 를 말합니다.
순위를 다 적었으면 공식을 적용합니다. 공식이 무섭게 생겼지만 보기보다 단순합니다. 순위 칼럼마다 항을 하나씩 씁니다. 첫 칼럼 항은 1/(1+그 순위), 둘째 칼럼 항도 1/(1+그 순위). 청크별 점수를 다 계산했으면, 점수가 큰 것부터 표를 정렬합니다.
그러면 Section 2가 가장 관련 높은 결과가 됩니다(순위 1·2). Section 6이 그다음(1·3), Section 7이 가장 낮습니다(2·3). 개별 순위만 봐도 말이 됩니다 — Section 2는 양쪽에서 상위(1·2)라 위로 향하고, Section 7은 양쪽에서 하위(2·3)라 아래로 향합니다.
노트북 005_hybrid에는 VectorIndex·BM25Index·Retriever가 모두 들어 있습니다. Retriever의 add_documents는 받은 문서를 각 인덱스에 그대로 전달하고, search는 질의를 각 인덱스에 전달해 결과를 받아 RRF로 병합합니다. (노트북 구현은 상수 k_rrf=60으로 1/(k_rrf+rank)를 더합니다 — 개념은 같고 상수만 다릅니다.)
이제 테스트입니다. 우리를 이 길로 이끈 건, 벡터DB에서 "what happened with incident 2023"을 검색했을 때 Section 10(좋음) 다음에 뜻밖의 Section 3이 나온 일이었습니다. 우리는 둘째 결과로 소프트웨어 공학(Section 2)을 원했죠. 이제 두 인덱스를 결합한 하이브리드로 같은 질의를 돌리면 — Section 10 → Section 2 → Section 5가 나옵니다. 정확히 원하던 결과입니다.
좋은 점은 각 인덱스를 따로 만들었다는 것입니다 — 별개의 클래스죠. 각 구현이 search·add_document라는 똑같은 API를 갖게 했기에, 더 큰 Retriever로 쉽게 감쌀 수 있었습니다. 원한다면 완전히 다른 방식의 검색 인덱스를 추가해도, 같은 API만 지키면 결과를 만들어 다른 결과들과 함께 병합할 수 있습니다. 성공이지만 끝은 아닙니다 — 정확도를 더 끌어올리는 기법은 아직 더 남아 있습니다.
이 장에서 배우는 것What you'll learn
약 8분두 인덱스가 같은 API → Retriever 한 클래스로 감쌈
Retriever: 질의를 각 인덱스 search로 전달하고 결과를 병합
병합은 RRF(상호 순위 융합) — 순위표를 점수로 바꿔 다시 정렬
공식 1/(k+rank)를 순위 칼럼마다 더함 (노트북 k_rrf=60)
예시 벡터[2,7,6]·BM25[6,2,7] → Section 2 최상
incident 질의 결과: Section 10 → Section 2 → Section 5 (Section 2 회복)
1/(k+rank)를 더해 점수를 매기는 병합 기법.VectorIndex와 BM25Index는 공개 API가 거의 같습니다 — 둘 다 add_document·search. 그래서 둘을 새 클래스 Retriever로 감쌉니다. Retriever는 질문을 두 인덱스의 search로 전달하고, 돌아온 결과를 병합합니다.
두 인덱스가 똑같은 공개 API를 가지므로, Retriever는 어느 인덱스든 같은 방식으로 호출합니다. 새 검색 방식도 같은 API만 지키면 그대로 끼워 넣을 수 있습니다.
두 결과를 합치는 건 까다롭습니다. RRF(상호 순위 융합)는 점수가 아닌 순위를 씁니다. 벡터 결과 [2, 7, 6], BM25 결과 [6, 2, 7]을 한 표에 모으고, 순위 칼럼마다 1/(1+rank)를 더해 점수를 낸 뒤 정렬합니다.
| 청크 | 벡터 순위 | BM25 순위 | 점수 |
|---|
표시는 개념용으로 1/(1+rank)를 씁니다. 노트북 구현은 상수 k_rrf=60으로 1/(60+rank)를 더합니다 — 순서를 정하는 원리는 같습니다.
Section 2는 양쪽에서 상위(순위 1·2)라 합산 점수가 가장 큽니다. Section 7은 양쪽에서 하위(2·3)라 아래로 갑니다. 개별 순위만 봐도 결과가 납득됩니다.
노트북 005_hybrid의 Retriever는 add_documents로 각 인덱스에 위임하고, search로 각 인덱스 결과를 RRF로 병합합니다. (상수 k_rrf=60으로 1/(k_rrf+rank).)
class Retriever: def __init__(self, *indexes): if not indexes: raise ValueError("At least one index must be provided") self._indexes = list(indexes) def add_documents(self, documents): # 각 인덱스에 그대로 위임 for index in self._indexes: index.add_documents(documents) def search(self, query_text, k=1, k_rrf=60): all_results = [index.search(query_text, k=k * 5) for index in self._indexes] doc_ranks = {} for idx, results in enumerate(all_results): for rank, (doc, _) in enumerate(results): doc_id = id(doc) if doc_id not in doc_ranks: doc_ranks[doc_id] = {"doc_obj": doc, "ranks": [float("inf")] * len(self._indexes)} doc_ranks[doc_id]["ranks"][idx] = rank + 1 def calc_rrf_score(ranks): # 핵심 공식 return sum(1.0 / (k_rrf + r) for r in ranks if r != float("inf")) scored = [(v["doc_obj"], calc_rrf_score(v["ranks"])) for v in doc_ranks.values()] scored = [(d, s) for d, s in scored if s > 0] scored.sort(key=lambda x: x[1], reverse=True) return scored[:k]
인덱스 둘로 Retriever를 만들고, 시맨틱이 놓쳤던 바로 그 incident 질의로 검색합니다.
# 인덱스 2개로 Retriever 구성 vector_index = VectorIndex(embedding_fn=generate_embedding) bm25_index = BM25Index() retriever = Retriever(bm25_index, vector_index) # 모든 청크를 Retriever에 추가(내부적으로 두 인덱스 모두에 전달, 벌크) chunks = chunk_by_section(text) retriever.add_documents([{"content": chunk} for chunk in chunks]) # 하이브리드 검색 — 시맨틱이 놓쳤던 그 질의 results = retriever.search("what happened with incident 2023 Q4-011", 3) for doc, score in results: print(score) print(doc["content"][:200]) print("\n----\n")
## Section 10: Cybersecurity Analysis Incident INC-2023-Q4-011 originated from an exposed staging endpoint; containment completed within 4 hours ... ---- ## Section 2: Software Engineering — Project Phoenix The engineering team resolved incident INC-2023-Q4-011 after three regression cycles, restoring the payment service ... ---- ## Section 5: Legal & Compliance A post-incident review confirmed disclosure obligations were ...
시맨틱 단독이 놓쳤던 Section 2가 돌아왔습니다 — Section 10 → Section 2 → Section 5. 각 인덱스를 같은 API로 만든 덕에, 새 검색 방식도 같은 API만 지키면 그대로 끼워 넣을 수 있습니다.
1/(k+rank)를 더해 점수화 후 정렬.하이브리드 검색까지 갖췄지만 끝은 아닙니다. 이어지는 섹션 Claude의 기능에서는 확장된 사고, 이미지·PDF 입력, 프롬프트 캐싱처럼 Claude를 더 깊이 활용하는 기능을 다룹니다.
Q1Retriever로 두 인덱스를 묶을 수 있었던 핵심 이유는?
API가 같으니 Retriever가 어느 인덱스든 똑같이 호출할 수 있습니다. 새 검색 방식도 같은 API만 지키면 추가됩니다.
Q2RRF는 무엇을 기준으로 점수를 매기나요?
RRF는 원점수가 아닌 등수를 씁니다. 그래서 척도가 다른 두 검색을 공정하게 합칠 수 있습니다.
Q3하이브리드 검색으로 incident 질의를 돌린 결과는?
시맨틱 단독이 끼워 넣던 Section 3 대신, 원하던 Section 2(소프트웨어 공학)가 둘째 자리로 돌아왔습니다.
나만의 RAG 파이프라인이 시맨틱·어휘·하이브리드까지 완성됐습니다. 다음 섹션은 Claude의 기능(확장된 사고·이미지·PDF·프롬프트 캐싱…)입니다. → 확장된 사고 — Claude의 기능
전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 76개 레슨을 끝까지 읽을 수 있습니다.
이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.