고급 RAG(Advanced RAG): 청킹, 재정렬, 하이브리드 검색
기본 RAG는 가장 유사한 상위 k개 청크(chunk)를 검색합니다. 단순한 질문에는 이 방식으로 충분합니다. 하지만 다중 홉 추론(multi-hop reasoning), 모호한 질의, 대규모 코퍼스(corpus)에서는 무너집니다. 고급 RAG는 10개 문서에서 동작하는 데모와 1,000만 개 문서에서 동작하는 시스템 사이의 차이를 만들어 줍니다.
유형: Build
언어: Python
선수 지식: Phase 11, Lesson 06 (RAG)
예상 시간: 약 90분
관련: Phase 5 · 23 (Chunking Strategies for RAG)에서는 recursive, semantic, sentence, parent-document, late chunking, contextual retrieval 등 여섯 가지 청킹(chunking) 알고리즘과 Vectara/Anthropic 벤치마크를 다룹니다. 이 lesson은 그 위에 하이브리드 검색(hybrid search), 재정렬(reranking), 질의 변환(query transformation)을 쌓아 올립니다.
학습 목표
- 문서 구조와 문맥을 보존하는 고급 청킹 전략(semantic, recursive, parent-child)을 구현합니다.
- BM25 키워드 매칭, 의미 기반 벡터 검색(semantic vector search), 교차 인코더(cross-encoder) 재정렬기를 결합한 하이브리드 검색 파이프라인을 만듭니다.
- 모호하거나 복잡한 질문에서 검색 품질을 끌어올리기 위해 HyDE, 다중 질의(multi-query), 한 발 물러서기(step-back) 같은 질의 변환 기법을 적용합니다.
- 잘못된 청크가 검색되는 경우, 답이 문맥에 포함되지 않는 경우, 다중 홉 추론이 무너지는 경우 등 흔한 RAG 실패 유형을 진단하고 고칩니다.
문제
Lesson 06에서 기본 RAG 파이프라인을 만들어 보았습니다. 작은 코퍼스에서 단순한 질문에는 잘 동작합니다. 이제 다음과 같은 질의를 시도해봅시다.
모호한 질의(ambiguous query): "What was revenue last quarter?"라고 물으면, 의미 기반 검색은 매출 전략(revenue strategy), 매출 전망(revenue projection), CFO가 말한 매출 성장(revenue growth)에 대한 청크를 돌려줍니다. 모두 "revenue"라는 단어와 의미적으로 가깝지만, 실제 숫자는 들어 있지 않습니다. 정답이 들어 있는 청크는 "$47.2M in Q3 2025"이지만 "revenue" 대신 "earnings"라는 단어를 씁니다. 임베딩 모델은 "Q3 earnings were $47.2M"보다 "revenue strategy"가 질의에 더 가깝다고 판단해 버립니다.
다중 홉 질문(multi-hop question): "Which team had the highest customer satisfaction score improvement?"라는 질문은 각 팀의 만족도 점수를 찾아내고, 서로 비교한 뒤, 최댓값을 가진 팀을 찾아내야 합니다. 단 하나의 청크에 답이 들어 있지 않고, 정보가 여러 팀 보고서에 흩어져 있습니다.
대규모 코퍼스 문제(large corpus problem): 청크가 200만 개라고 가정해 봅시다. 정답은 청크 #1,847,293에 있습니다. 상위 5개 검색이 #14, #89,201, #1,200,000, #44, #901,333을 꺼내옵니다. 임베딩 공간(embedding space)에서는 가까운 청크들이지만, 정답을 담고 있는 청크는 아닙니다. 이 규모에서는 근사 최근접 이웃 탐색(approximate nearest neighbor search)이 가진 오차만으로도 관련 결과가 상위 k 밖으로 밀려나기 시작합니다.
기본 RAG가 실패하는 근본 원인은 벡터 유사도(vector similarity)가 곧 관련성(relevance)이 아니라는 데 있습니다. 어떤 청크가 질의와 의미적으로 가깝다고 해서 답을 만드는 데 유용하다고 보장되지는 않습니다. 고급 RAG는 이 문제를 네 가지 기법으로 풀어냅니다. 키워드 매칭을 더하는 하이브리드 검색, 후보를 더 신중하게 점수화하는 재정렬, 검색 전에 질의 자체를 다듬는 질의 변환, 적절한 단위에서 청크를 검색할 수 있도록 만드는 더 나은 청킹입니다.
개념
하이브리드 검색(Hybrid Search): 의미 기반 + 키워드
의미 기반 검색(semantic search)은 벡터 유사도를 사용해 의미를 이해하는 데 강합니다. "How do I cancel my subscription?"이라는 질의는 단어가 전혀 겹치지 않더라도 "Steps to terminate your plan"과 매칭됩니다. 하지만 정확 일치(exact match)는 놓치기 쉽습니다. 임베딩 모델이 "Error code E-4021" 같은 문자열을 잡음(noise)으로 취급해 버리면, "E-4021"이 들어 있는 청크와도 매칭되지 않을 수 있습니다.
키워드 검색(keyword search)은 정반대 성격을 가집니다. BM25 같은 알고리즘은 정확 일치에 강합니다. "E-4021"은 그대로 매칭됩니다. 하지만 문서가 "terminate your plan"이라고만 표현하고 있다면 "cancel my subscription"이라는 질의는 결과가 한 건도 나오지 않을 수 있습니다.
하이브리드 검색은 둘을 모두 실행한 뒤 결과를 합칩니다.
BM25(Best Matching 25)는 표준 키워드 검색 알고리즘으로, 1990년대부터 검색 엔진의 중추 역할을 해왔습니다. 수식은 다음과 같습니다.
BM25(q, d) = sum over terms t in q:
IDF(t) * (tf(t,d) * (k1 + 1)) / (tf(t,d) + k1 * (1 - b + b * |d| / avgdl))
여기서 tf(t,d)는 문서 d에서 단어 t가 등장하는 단어 빈도(term frequency), IDF(t)는 역문서 빈도(inverse document frequency), |d|는 문서 길이, avgdl은 평균 문서 길이를 뜻합니다. k1은 단어 빈도가 점수에 미치는 영향을 얼마나 빠르게 포화시킬지를 조절하는 파라미터(기본값 1.2)이고, b는 문서 길이 보정(length normalization)의 강도를 조절하는 파라미터(기본값 0.75)입니다.
평이하게 말하면 BM25는 질의에 포함된 단어, 특히 희귀한 단어를 많이 가진 문서에 높은 점수를 줍니다. 다만 같은 단어가 반복될 때는 점점 점수 증가폭이 줄어듭니다. "revenue"가 50번 들어 있는 문서가 1번 들어 있는 문서보다 50배 더 관련 있다고 간주하지 않습니다.
상호 순위 융합(Reciprocal Rank Fusion; RRF)
벡터 검색의 순위 리스트와 BM25의 순위 리스트가 있습니다. 이 둘을 어떻게 합칠 수 있을까요? 표준적인 방법이 상호 순위 융합(Reciprocal Rank Fusion; RRF)입니다.
RRF_score(d) = sum over rankings R:
1 / (k + rank_R(d))
여기서 k는 1위 결과가 점수를 지나치게 지배하지 않도록 막아주는 상수로, 보통 60을 사용합니다.
벡터 검색에서 1위, BM25에서 5위인 문서는 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318을 받습니다.
벡터 검색에서 3위, BM25에서 2위인 문서는 1/(60+3) + 1/(60+2) = 0.0159 + 0.0161 = 0.0320을 받습니다.
RRF는 두 신호를 자연스럽게 균형 잡습니다. 두 리스트 모두에서 높은 순위를 받은 문서가 가장 좋은 점수를 받고, 한 리스트에서는 1위지만 다른 리스트에는 없는 문서는 적당한 점수를 받습니다. 점수가 아니라 순위만 사용하기 때문에, 두 시스템 사이의 점수 분포 차이에도 견고하게 동작합니다.
재정렬(Reranking)
검색(벡터, 키워드, 하이브리드 어떤 방식이든)은 빠르지만 정밀도가 떨어지는 경향이 있습니다. 검색 단계에서는 보통 이중 인코더(bi-encoder)를 사용합니다. 질의와 각 문서를 독립적으로 임베딩한 뒤 비교하는 방식입니다. 임베딩은 한 번 계산해 캐시(cache)해 두면 되므로 수백만 건의 문서로도 확장됩니다.
재정렬에서는 교차 인코더(cross-encoder)를 사용합니다. 질의와 후보 문서를 함께 모델에 넣어 관련도 점수를 출력하는 방식입니다. 모델은 두 텍스트를 동시에 보면서 세밀한 상호작용까지 포착할 수 있습니다. 이중 인코더가 놓친 연결까지도, 교차 인코더는 "What were Q3 earnings?"가 "$47.2M in Q3"를 포함한 청크와 매우 관련 있음을 이해할 수 있습니다.
대신 트레이드오프가 있습니다. 교차 인코더는 질의-문서 쌍을 함께 처리해야 하기 때문에 이중 인코더보다 100~1,000배 느립니다. 따라서 수백만 건의 문서 전체에 교차 인코더 점수를 미리 계산해 둘 수는 없습니다. 해결책은 하이브리드 검색으로 더 넓은 후보 집합(예: 상위 50개)을 가져온 뒤, 그 안에서만 교차 인코더로 재정렬해 최종 상위 5개를 뽑는 것입니다.
graph LR
Q["Query"] --> H["Hybrid Search"]
H --> C50["Top 50 candidates"]
C50 --> RR["Cross-Encoder Reranker"]
RR --> C5["Top 5 final results"]
C5 --> P["Build prompt"]
P --> LLM["Generate answer"]
대표적인 재정렬 모델(2026년 기준)은 다음과 같습니다.
- Cohere Rerank 3.5: 매니지드(managed) API, 다국어 지원, 혼합 코퍼스에서 가장 큰 재현율(recall) 향상을 보여줍니다.
- Voyage rerank-2.5: 매니지드 API, 호스팅(hosted) 옵션 중 지연 시간(latency)이 가장 낮은 편입니다.
- Jina-Reranker-v2 Multilingual: 공개 가중치(open-weight), 100개 이상 언어를 지원합니다.
- bge-reranker-v2-m3: 공개 가중치, 견고한 기준선(baseline) 성능을 보여줍니다.
- cross-encoder/ms-marco-MiniLM-L-6-v2: 공개 가중치, 프로토타입 단계에서는 CPU에서도 실행 가능합니다.
- ColBERTv2 / Jina-ColBERT-v2: 후기 상호작용(late-interaction) 기반 다중 벡터 재정렬기로, 점수 계산 시간이 문서 수가 아니라 토큰 수에 비례합니다(O(tokens) 수준).
문제가 검색이 아니라 질의 자체에 있는 경우도 있습니다. "What was that thing about the new policy change?" 같은 질의는 검색용으로 매우 좋지 않습니다. 구체적인 단어가 없고, 임베딩도 모호합니다. 어떤 검색 시스템에 넣어도 적절한 문서를 찾기 어렵습니다.
질의 재작성(query rewriting): 사용자의 질의를 더 좋은 검색 질의로 바꿉니다. LLM이 이 일을 잘 합니다.
User: "What was that thing about the new policy change?"
Rewritten: "Recent policy changes and updates"
HyDE(Hypothetical Document Embeddings): 질의로 직접 검색하는 대신, 가상의 답변(hypothetical answer)을 만들어내고 그것을 임베딩해, 그와 유사한 실제 문서를 검색합니다.
Query: "What is the refund policy for enterprise?"
Hypothetical answer: "Enterprise customers are eligible for a full refund
within 60 days of purchase. Refunds are pro-rated based on the remaining
subscription period and processed within 5-7 business days."
가상의 답변을 임베딩한 뒤, 그와 유사한 실제 문서를 검색합니다. 직관적으로는, 가상의 답변이 원래 질문보다 실제 정답 문서에 임베딩 공간에서 더 가깝다는 점을 이용한 것입니다. 질문 문장과 답변 문장은 언어적 구조가 다릅니다. 가상의 답변을 만들어내는 행위는 "질문 공간"과 "답변 공간" 사이의 간격을 좁혀 줍니다.
HyDE는 검색 전에 LLM 호출(call)을 한 번 추가합니다. 그 결과로 지연 시간이 500~2,000ms 늘어납니다. 원본 질의의 검색 품질이 낮을 때라면 충분히 감수할 만한 비용입니다.
부모-자식 청킹(Parent-Child Chunking)
기본 청킹 방식은 트레이드오프를 강요합니다. 정밀한 검색을 위해서는 청크를 작게 잘라야 하고, 충분한 문맥을 위해서는 청크를 크게 잘라야 합니다. 부모-자식 청킹은 이 트레이드오프를 없애줍니다.
검색용으로는 작은 청크(예: 128토큰)를 인덱싱(index)합니다. 작은 청크가 검색되면, 그 청크가 속한 부모 청크(예: 512토큰)를 프롬프트(prompt)에 넣어 줍니다. 작은 청크는 질의와 정밀하게 매칭되고, 부모 청크는 LLM이 좋은 답을 생성하기에 충분한 문맥을 제공합니다.
graph TD
P["Parent chunk (512 tokens)<br/>refund policy 전체 섹션"]
C1["Child chunk (128 tokens)<br/>Standard plan: 30-day refund"]
C2["Child chunk (128 tokens)<br/>Enterprise: 60-day pro-rated"]
C3["Child chunk (128 tokens)<br/>Processing time: 5-7 days"]
C4["Child chunk (128 tokens)<br/>How to submit a request"]
P --> C1
P --> C2
P --> C3
P --> C4
Q["Query: enterprise refund?"] -.->|"matches child"| C2
C2 -.->|"return parent"| P
"enterprise refund?"라는 질의는 자식 청크 C2와 정밀하게 매칭됩니다. 하지만 프롬프트에는 처리 기간과 신청 방법까지 포함한 부모 청크 P가 통째로 전달됩니다.
벡터 검색을 실행하기 전에, 코퍼스를 메타데이터(metadata)로 한 번 거르면 좋습니다. 작성일, 출처, 카테고리, 작성자, 언어 등이 메타데이터가 될 수 있습니다. 이렇게 하면 검색 범위가 줄고 관련 없는 결과도 줄어듭니다.
"What changed in the security policy last month?"라는 질의는 최근 30일 이내이면서 보안 카테고리인 문서만 검색하면 충분합니다. 메타데이터 필터링이 없으면 전체 코퍼스를 뒤지다가, 의미적으로만 가까운 2년 전 보안 문서를 끌어올 수도 있습니다.
운영 환경의 RAG 시스템은 각 청크 옆에 출처 문서, 작성일, 카테고리, 작성자, 버전 같은 메타데이터를 함께 저장합니다. 벡터 데이터베이스(vector database)는 유사도 검색 전에 메타데이터로 사전 필터링(pre-filtering)하는 기능을 지원하며, 규모가 커질수록 이 기능이 성능에 결정적인 역할을 합니다.
평가(Evaluation)
RAG 시스템을 만들었다고 했을 때, 잘 동작하는지 어떻게 알 수 있을까요? 다음 세 가지 지표를 사용합니다.
검색 관련성(Recall@k): 정답이 어떤 문서에 들어 있는지 알고 있는 테스트 질문 집합을 두고, 그 정답 문서가 상위 k개 결과 안에 들어오는 비율을 잽니다. 어떤 질문의 답이 청크 #47에 있다면, 청크 #47이 상위 5개 안에 들어오는지 보는 것입니다.
충실성(Faithfulness): 생성된 답이 검색된 문서에 근거(grounded)하고 있는지 평가합니다. 검색된 청크에는 "60-day refund window"라고 적혀 있는데 모델이 "90-day refund window"라고 답했다면 충실성 실패입니다. 올바른 문맥이 주어졌는데도 모델이 환각(hallucination)을 일으킨 경우입니다.
답변 정확도(Answer correctness): 생성된 답이 기대 답변과 일치하는지 봅니다. 이것은 끝에서 끝까지의 종단 지표(end-to-end metric)이며, 검색 품질과 생성 품질을 함께 반영합니다.
간단한 충실성 점검 방법은, 생성된 답의 각 주장을 가져와 검색된 청크 안에 내용 차원에서 존재하는지 확인하는 것입니다. 답에는 들어 있지만 어떤 검색 청크에도 근거가 없는 사실이 있다면 환각일 가능성이 높습니다.
graph TD
subgraph "Evaluation Framework"
Q["Test questions<br/>+ expected answers<br/>+ relevant doc IDs"]
Q --> Ret["Retrieval evaluation<br/>Recall@k: 정답 문서가<br/>검색되었는가?"]
Q --> Faith["Faithfulness evaluation<br/>답이 검색 문서에<br/>근거하고 있는가?"]
Q --> Correct["Correctness evaluation<br/>답이 기대 답변과<br/>일치하는가?"]
end
직접 만들기
Step 1: BM25 구현(BM25 Implementation)
import math
from collections import Counter
class BM25:
def __init__(self, k1=1.2, b=0.75):
self.k1 = k1
self.b = b
self.docs = []
self.doc_lengths = []
self.avg_dl = 0
self.doc_freqs = {}
self.n_docs = 0
def index(self, documents):
self.docs = documents
self.n_docs = len(documents)
self.doc_lengths = []
self.doc_freqs = {}
for doc in documents:
words = doc.lower().split()
self.doc_lengths.append(len(words))
unique_words = set(words)
for word in unique_words:
self.doc_freqs[word] = self.doc_freqs.get(word, 0) + 1
self.avg_dl = sum(self.doc_lengths) / self.n_docs if self.n_docs else 1
def score(self, query, doc_idx):
query_words = query.lower().split()
doc_words = self.docs[doc_idx].lower().split()
doc_len = self.doc_lengths[doc_idx]
word_counts = Counter(doc_words)
score = 0.0
for term in query_words:
if term not in word_counts:
continue
tf = word_counts[term]
df = self.doc_freqs.get(term, 0)
idf = math.log((self.n_docs - df + 0.5) / (df + 0.5) + 1)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_dl)
score += idf * numerator / denominator
return score
def search(self, query, top_k=10):
scores = [(i, self.score(query, i)) for i in range(self.n_docs)]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
Step 2: 상호 순위 융합(Reciprocal Rank Fusion)
def reciprocal_rank_fusion(ranked_lists, k=60):
scores = {}
for ranked_list in ranked_lists:
for rank, (doc_id, _) in enumerate(ranked_list):
if doc_id not in scores:
scores[doc_id] = 0.0
scores[doc_id] += 1.0 / (k + rank + 1)
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return fused
Step 3: 하이브리드 검색 파이프라인(Hybrid Search Pipeline)
def hybrid_search(query, chunks, vector_embeddings, vocab, idf, bm25_index, top_k=5, fusion_k=60):
query_emb = tfidf_embed(query, vocab, idf)
vector_results = search(query_emb, vector_embeddings, top_k=top_k * 3)
bm25_results = bm25_index.search(query, top_k=top_k * 3)
fused = reciprocal_rank_fusion([vector_results, bm25_results], k=fusion_k)
return fused[:top_k]
Step 4: 단순 재정렬기(Simple Reranker)
운영 환경에서는 교차 인코더 모델을 사용합니다. 여기서는 단어 겹침(word overlap), 단어 중요도(term importance), 구절 매칭(phrase matching)을 이용해 질의-문서 관련도를 점수화하는 간단한 재정렬기를 만들어 봅니다.
def rerank(query, candidates, chunks):
query_words = set(query.lower().split())
stop_words = {"the", "a", "an", "is", "are", "was", "were", "what", "how",
"why", "when", "where", "do", "does", "for", "of", "in", "to",
"and", "or", "on", "at", "by", "it", "its", "this", "that",
"with", "from", "be", "has", "have", "had", "not", "but"}
query_terms = query_words - stop_words
scored = []
for doc_id, initial_score in candidates:
chunk = chunks[doc_id].lower()
chunk_words = set(chunk.split())
term_overlap = len(query_terms & chunk_words)
query_bigrams = set()
q_list = [w for w in query.lower().split() if w not in stop_words]
for i in range(len(q_list) - 1):
query_bigrams.add(q_list[i] + " " + q_list[i + 1])
bigram_matches = sum(1 for bg in query_bigrams if bg in chunk)
position_boost = 0
for term in query_terms:
pos = chunk.find(term)
if pos != -1 and pos < len(chunk) // 3:
position_boost += 0.5
rerank_score = (
term_overlap * 1.0
+ bigram_matches * 2.0
+ position_boost
+ initial_score * 5.0
)
scored.append((doc_id, rerank_score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
Step 5: HyDE(Hypothetical Document Embeddings)
def hyde_generate_hypothesis(query):
templates = {
"what": "The answer to '{query}' is as follows: Based on our documentation, {topic} involves specific policies and procedures that define how the process works.",
"how": "To address '{query}': The process involves several steps. First, you need to initiate the request. Then, the system processes it according to the defined rules.",
"default": "Regarding '{query}': Our records indicate specific details and policies related to this topic that provide a comprehensive answer."
}
query_lower = query.lower()
if query_lower.startswith("what"):
template = templates["what"]
elif query_lower.startswith("how"):
template = templates["how"]
else:
template = templates["default"]
topic_words = [w for w in query.lower().split()
if w not in {"what", "is", "the", "how", "do", "does", "a", "an",
"for", "of", "to", "in", "on", "at", "by", "and", "or"}]
topic = " ".join(topic_words) if topic_words else "this topic"
return template.format(query=query, topic=topic)
def hyde_search(query, chunks, vector_embeddings, vocab, idf, top_k=5):
hypothesis = hyde_generate_hypothesis(query)
hypothesis_emb = tfidf_embed(hypothesis, vocab, idf)
results = search(hypothesis_emb, vector_embeddings, top_k)
return results, hypothesis
Step 6: 부모-자식 청킹(Parent-Child Chunking)
def create_parent_child_chunks(text, parent_size=200, child_size=50):
words = text.split()
parents = []
children = []
child_to_parent = {}
parent_idx = 0
start = 0
while start < len(words):
parent_end = min(start + parent_size, len(words))
parent_text = " ".join(words[start:parent_end])
parents.append(parent_text)
child_start = start
while child_start < parent_end:
child_end = min(child_start + child_size, parent_end)
child_text = " ".join(words[child_start:child_end])
child_idx = len(children)
children.append(child_text)
child_to_parent[child_idx] = parent_idx
child_start += child_size
parent_idx += 1
start += parent_size
return parents, children, child_to_parent
Step 7: 충실성 평가(Faithfulness Evaluation)
def evaluate_faithfulness(answer, retrieved_chunks):
answer_sentences = [s.strip() for s in answer.split(".") if len(s.strip()) > 10]
if not answer_sentences:
return 1.0, []
grounded = 0
ungrounded = []
context = " ".join(retrieved_chunks).lower()
for sentence in answer_sentences:
words = set(sentence.lower().split())
stop_words = {"the", "a", "an", "is", "are", "was", "were", "and", "or",
"to", "of", "in", "for", "on", "at", "by", "it", "this", "that"}
content_words = words - stop_words
if not content_words:
grounded += 1
continue
matched = sum(1 for w in content_words if w in context)
ratio = matched / len(content_words) if content_words else 0
if ratio >= 0.5:
grounded += 1
else:
ungrounded.append(sentence)
score = grounded / len(answer_sentences) if answer_sentences else 1.0
return score, ungrounded
def evaluate_retrieval_recall(queries_with_relevant, retrieval_fn, k=5):
total_recall = 0.0
results = []
for query, relevant_indices in queries_with_relevant:
retrieved = retrieval_fn(query, k)
retrieved_indices = set(idx for idx, _ in retrieved)
relevant_set = set(relevant_indices)
hits = len(retrieved_indices & relevant_set)
recall = hits / len(relevant_set) if relevant_set else 1.0
total_recall += recall
results.append({
"query": query,
"recall": recall,
"hits": hits,
"total_relevant": len(relevant_set)
})
avg_recall = total_recall / len(queries_with_relevant) if queries_with_relevant else 0
return avg_recall, results
사용해보기
실제 교차 인코더로 재정렬하려면 다음처럼 사용할 수 있습니다.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank_with_cross_encoder(query, candidates, chunks, top_k=5):
pairs = [(query, chunks[doc_id]) for doc_id, _ in candidates]
scores = reranker.predict(pairs)
scored = list(zip([doc_id for doc_id, _ in candidates], scores))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
Cohere의 매니지드 재정렬기를 쓸 수도 있습니다.
import cohere
co = cohere.Client()
def rerank_with_cohere(query, candidates, chunks, top_k=5):
docs = [chunks[doc_id] for doc_id, _ in candidates]
response = co.rerank(
model="rerank-english-v3.0",
query=query,
documents=docs,
top_n=top_k
)
return [(candidates[r.index][0], r.relevance_score) for r in response.results]
실제 LLM을 사용한 HyDE는 다음과 같습니다.
import anthropic
client = anthropic.Anthropic()
def hyde_with_llm(query):
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=256,
messages=[{
"role": "user",
"content": f"Write a short paragraph that would be a good answer to this question. Do not say you don't know. Just write what the answer would look like.\n\nQuestion: {query}"
}]
)
return response.content[0].text
Weaviate로 운영 환경의 하이브리드 검색을 구성할 수도 있습니다.
import weaviate
client = weaviate.connect_to_local()
collection = client.collections.get("Documents")
response = collection.query.hybrid(
query="enterprise refund policy",
alpha=0.5,
limit=10
)
alpha 파라미터는 두 신호 사이의 균형을 조절합니다. 0.0은 순수 키워드(BM25), 1.0은 순수 벡터, 0.5는 양쪽을 동등하게 둔다는 뜻입니다. 운영 환경에서는 보통 0.3에서 0.7 사이의 alpha 값을 사용합니다.
산출물 만들기
이 lesson은 다음 산출물을 만듭니다.
outputs/prompt-advanced-rag-debugger.md -- 검색, 생성, 평가 전반에 걸친 RAG 품질 문제를 진단하고 고치기 위한 prompt
outputs/skill-advanced-rag.md -- 하이브리드 검색과 재정렬을 갖춘 운영 등급 RAG를 만들기 위한 skill
연습문제
-
(쉬움) 샘플 문서 위에서 BM25, 벡터 검색, 하이브리드 검색을 비교하세요. 5개 테스트 질의 각각에 대해, 어떤 접근법이 1위 자리에 가장 관련 있는 청크를 올려놓는지 기록하세요. 하이브리드 검색은 5개 중 최소 3개에서 이겨야 합니다.
-
(중간) 메타데이터 필터를 구현하세요. 각 문서에 category 필드(security, billing, api, product)를 추가합니다. 벡터 검색을 실행하기 전에 관련 카테고리 청크만 남도록 필터링하세요. "What encryption is used?"로 테스트해, security 카테고리 청크만 검색하는지 확인하세요.
-
(중간) Lesson 06에서 만든 단순 생성 함수를 사용해 전체 HyDE 파이프라인을 만드세요. 5개 테스트 질의 전체에서 원본 질의로 검색한 결과와 HyDE로 검색한 결과의 상위 3개 관련성을 비교하세요. HyDE는 모호한 질의에서 결과를 개선해야 합니다.
-
(중간) 샘플 문서 위에서 부모-자식 청킹 전략을 구현하세요. child_size=30, parent_size=100을 사용합니다. 자식 청크로 검색하되 프롬프트에는 부모 청크를 전달하세요. chunk_size=50인 표준 청킹과 생성된 답을 비교하세요.
-
(어려움) 평가용 데이터셋을 만드세요. 정답 청크가 알려진 10개 질문을 준비합니다. (a) 벡터 검색만, (b) BM25만, (c) 하이브리드 검색, (d) 하이브리드 + 재정렬 네 가지에 대해 Recall@3, Recall@5, Recall@10을 측정하세요. 결과를 그래프로 그려서 재정렬이 가장 도움이 되는 지점을 찾아보세요.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| BM25 | "키워드 검색" | 단어 빈도(term frequency), 역문서 빈도(inverse document frequency), 문서 길이 보정(length normalization)을 결합해 문서에 점수를 매기는 확률적 랭킹 알고리즘이다. |
| 하이브리드 검색(Hybrid search) | "두 방식의 장점을 모두" | 의미 기반(vector) 검색과 키워드(BM25) 검색을 병렬로 실행한 뒤, 순위 융합(rank fusion)으로 합치는 방식이다. |
| 상호 순위 융합(Reciprocal Rank Fusion) | "여러 순위 리스트 합치기" | 각 문서에 대해 모든 리스트의 1/(k + rank)를 합산해 여러 순위 리스트를 결합하는 방법이다. |
| 재정렬(Reranking) | "두 번째 점수 계산 단계" | 1차 검색 결과 후보 집합을 더 비싼 교차 인코더 모델로 다시 점수화하는 방식이다. |
| 교차 인코더(Cross-encoder) | "질의-문서 공동 모델" | 질의와 문서를 하나의 입력으로 받아 관련도 점수를 출력하는 모델이다. 이중 인코더보다 정확하지만 전체 코퍼스 검색에는 너무 느리다. |
| 이중 인코더(Bi-encoder) | "독립 임베딩 모델" | 질의와 문서를 독립적으로 임베딩하는 모델이다. 임베딩을 미리 계산할 수 있어 빠르지만 교차 인코더보다는 정확도가 떨어진다. |
| HyDE | "가짜 답변으로 검색하기" | 질의에 대한 가상의 답변을 생성해 임베딩한 뒤, 그와 유사한 실제 문서를 검색하는 방식이다. |
| 부모-자식 청킹(Parent-child chunking) | "작게 검색, 크게 문맥 제공" | 정밀한 검색을 위해 작은 청크를 인덱싱하되, 충분한 문맥을 위해 더 큰 부모 청크를 반환하는 방식이다. |
| 메타데이터 필터링(Metadata filtering) | "검색 전에 범위 좁히기" | 벡터 검색 전에 작성일, 출처, 카테고리 같은 속성으로 문서를 걸러 검색 범위를 줄이는 방식이다. |
| 충실성(Faithfulness) | "근거를 지켰는가" | 생성된 답이 모델의 학습 데이터에서 환각된 것이 아니라 검색된 문서에 의해 뒷받침되는지 여부를 가리킨다. |
더 읽을거리
- Robertson & Zaragoza, "The Probabilistic Relevance Framework: BM25 and Beyond" (2009) -- BM25 수식의 확률적 토대를 설명하는 표준 참고 문헌입니다.
- Cormack et al., "Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods" (2009) -- RRF가 더 복잡한 융합 방법을 이긴다는 점을 보인 원조 논문입니다.
- Gao et al., "Precise Zero-Shot Dense Retrieval without Relevance Labels" (2022) -- 가상의 답변 임베딩이 학습 데이터 없이도 검색 품질을 개선한다는 점을 보여준 HyDE 논문입니다.
- Nogueira & Cho, "Passage Re-ranking with BERT" (2019) -- BM25 위에 교차 인코더 재정렬을 얹으면 검색 품질이 크게 향상됨을 보였습니다.
- Khattab et al., "DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines" (2023) -- 프롬프트 구성과 가중치 선택을 검색 파이프라인 위에서의 최적화 문제로 다룹니다. "프롬프트하는 LLM"이 아니라 "프로그래밍하는 LLM"의 관점을 익히기에 좋습니다.
- Edge et al., "From Local to Global: A Graph RAG Approach to Query-Focused Summarization" (Microsoft Research 2024) -- 개체-관계 추출과 Leiden 커뮤니티 탐지를 결합한 GraphRAG 논문으로, 전역(global) 대 지역(local) 검색의 구분이 핵심입니다.
- Asai et al., "Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection" (ICLR 2024) -- 반성 토큰(reflection token)을 활용한 자기 평가형 RAG로, 정적인 "검색 후 생성" 구조를 넘어선 에이전트형(agentic) 지향점을 보여줍니다.
- LangChain Query Construction blog -- 자연어 질의를 Text-to-SQL이나 Cypher 같은 구조화된 데이터베이스 질의로 바꾸는 사전 검색(pre-retrieval) 단계를 다룹니다.