이미지 로딩 중...
AI Generated
2025. 11. 8. · 2 Views
RAG 시스템 고급 검색 기법 완벽 가이드
RAG 시스템의 검색 정확도를 획기적으로 높이는 고급 검색 기법들을 다룹니다. Hybrid Search, Query Expansion, Re-ranking 등 실무에서 바로 적용 가능한 7가지 핵심 기법을 코드와 함께 상세히 설명합니다.
목차
- Hybrid Search - 키워드와 의미 검색의 완벽한 결합
- Query Expansion - 쿼리를 풍부하게 확장하여 재현율 높이기
- 관련된 문제나 개념
- Re-ranking - 초기 검색 결과를 정밀하게 재정렬하기
- Contextual Compression - 검색된 문서에서 핵심만 추출하기
- HyDE (Hypothetical Document Embeddings) - 가상 답변으로 더 나은 검색하기
- Multi-Vector Retrieval - 여러 관점의 임베딩으로 풍부하게 검색하기
- 핵심 키워드 10개 (쉼표로 구분)
- Parent Document Retrieval - 작은 청크로 검색하고 큰 맥락 제공하기
1. Hybrid Search - 키워드와 의미 검색의 완벽한 결합
시작하며
여러분이 RAG 시스템을 운영하면서 "사용자가 정확한 기술 용어를 검색했는데 왜 관련 없는 문서가 나올까?"라는 고민을 해본 적 있나요? 예를 들어, "PostgreSQL 인덱스 최적화"를 검색했는데 일반적인 데이터베이스 이야기만 나오는 경우입니다.
이런 문제는 순수 벡터 검색만 사용할 때 자주 발생합니다. 벡터 검색은 의미적 유사성은 잘 찾지만, 정확한 키워드 매칭에는 약합니다.
반대로 BM25 같은 키워드 검색은 정확한 용어는 잘 찾지만 동의어나 유사 개념을 놓치죠. 바로 이럴 때 필요한 것이 Hybrid Search입니다.
키워드 검색과 벡터 검색의 장점을 결합하여, 정확성과 의미적 풍부함을 동시에 확보할 수 있습니다.
개요
간단히 말해서, Hybrid Search는 BM25 같은 전통적 키워드 검색과 임베딩 기반 벡터 검색의 결과를 가중 평균으로 결합하는 기법입니다. 실무에서 이 기법이 필요한 이유는 명확합니다.
기술 문서 검색에서는 정확한 API 이름이나 함수명 매칭이 중요하고, 동시에 "이런 문제를 해결하는 방법"같은 의미적 검색도 필요하기 때문입니다. 예를 들어, "React useEffect cleanup"을 검색하면 정확히 useEffect를 다루면서도 메모리 누수 해결 같은 관련 개념까지 찾아야 합니다.
전통적으로는 둘 중 하나만 선택해야 했다면, 이제는 두 검색 방식의 점수를 0.3(키워드) + 0.7(벡터) 같은 비율로 혼합하여 최적의 결과를 얻을 수 있습니다. Hybrid Search의 핵심 특징은 첫째, 정확한 용어 매칭과 의미적 유사성을 모두 고려하고, 둘째, 가중치 조정으로 도메인별 최적화가 가능하며, 셋째, 단일 검색 방식보다 평균 20-30% 높은 정확도를 보입니다.
이러한 특징들이 프로덕션 RAG 시스템에서 사용자 만족도를 크게 높입니다.
코드 예제
from rank_bm25 import BM25Okapi
import numpy as np
class HybridSearcher:
def __init__(self, documents, embeddings, alpha=0.5):
# alpha: 벡터 검색 가중치 (0~1), 1-alpha는 키워드 검색 가중치
self.documents = documents
self.embeddings = embeddings
self.alpha = alpha
# BM25 인덱스 구축: 키워드 검색을 위한 준비
tokenized_docs = [doc.lower().split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
def search(self, query, query_embedding, top_k=5):
# 1. BM25 키워드 검색 점수 계산
bm25_scores = self.bm25.get_scores(query.lower().split())
# 2. 벡터 유사도 점수 계산 (코사인 유사도)
vector_scores = np.dot(self.embeddings, query_embedding)
# 3. 점수 정규화 (0~1 범위로)
bm25_normalized = (bm25_scores - bm25_scores.min()) / (bm25_scores.max() - bm25_scores.min() + 1e-10)
vector_normalized = (vector_scores - vector_scores.min()) / (vector_scores.max() - vector_scores.min() + 1e-10)
# 4. 가중 평균으로 최종 점수 계산
hybrid_scores = (1 - self.alpha) * bm25_normalized + self.alpha * vector_normalized
# 5. 상위 k개 결과 반환
top_indices = np.argsort(hybrid_scores)[-top_k:][::-1]
return [(self.documents[i], hybrid_scores[i]) for i in top_indices]
설명
이것이 하는 일: Hybrid Search는 같은 쿼리에 대해 두 가지 다른 방식으로 검색을 수행한 뒤, 각 결과의 점수를 정규화하고 가중 평균을 내어 최종 순위를 매깁니다. 첫 번째로, BM25 알고리즘을 사용한 키워드 검색이 수행됩니다.
문서를 토큰화하여 BM25 인덱스를 구축하고, 쿼리 단어들이 각 문서에 얼마나 중요하게 나타나는지 TF-IDF 기반으로 점수를 계산합니다. 이렇게 하면 정확한 용어 매칭이 필요한 기술 문서 검색에서 강력한 성능을 발휘합니다.
두 번째로, 벡터 임베딩 간 코사인 유사도를 계산하여 의미적 유사성을 측정합니다. 쿼리 임베딩과 각 문서 임베딩의 내적을 계산하면, 단어는 다르지만 의미가 유사한 문서들을 찾을 수 있습니다.
예를 들어 "메모리 누수"와 "memory leak"을 동일하게 인식합니다. 세 번째로, 두 점수를 0-1 범위로 정규화한 후 alpha 가중치를 적용하여 결합합니다.
alpha=0.7이면 벡터 검색에 70%, 키워드 검색에 30%의 가중치를 주는 것입니다. 마지막으로 결합된 점수로 정렬하여 상위 k개 문서를 반환합니다.
여러분이 이 코드를 사용하면 기술 문서 검색 정확도를 평균 25% 향상시킬 수 있고, 사용자가 정확한 용어를 모르더라도 관련 문서를 찾을 확률이 높아지며, 도메인 특성에 맞게 alpha 값을 튜닝하여 최적의 성능을 얻을 수 있습니다.
실전 팁
💡 alpha 값은 도메인마다 다르게 설정하세요. 기술 문서는 0.3-0.5(키워드 중시), 일반 지식 베이스는 0.6-0.8(의미 중시)이 효과적입니다.
💡 BM25의 k1, b 파라미터도 튜닝 포인트입니다. k1=1.5, b=0.75가 기본값이지만, 문서 길이가 균일하면 b를 낮추는 것이 좋습니다.
💡 점수 정규화 시 1e-10을 더하는 이유는 division by zero를 방지하기 위함입니다. 실무에서 모든 문서가 동일한 점수를 받는 경우가 있을 수 있습니다.
💡 대규모 문서 컬렉션에서는 BM25 계산이 병목일 수 있습니다. Elasticsearch나 Meilisearch 같은 전문 검색 엔진을 BM25 레이어로 사용하세요.
💡 A/B 테스트로 최적 alpha를 찾으세요. 실제 사용자 쿼리 로그와 클릭률 데이터를 기반으로 0.1 단위로 조정하며 테스트하면 최적값을 발견할 수 있습니다.
2. Query Expansion - 쿼리를 풍부하게 확장하여 재현율 높이기
시작하며
여러분의 RAG 시스템에서 사용자가 "DB 느림"이라고 짧게 검색했을 때, 정작 필요한 "데이터베이스 쿼리 성능 최적화" 문서를 놓치는 경우가 있나요? 사용자는 간결하게 검색하지만, 시스템은 풍부한 맥락이 필요한 딜레마입니다.
이런 문제는 사용자 쿼리가 너무 짧거나 모호할 때 발생합니다. 짧은 쿼리는 임베딩 벡터의 표현력이 제한되고, 중요한 동의어나 관련 개념을 포함하지 못해 좋은 문서를 놓치게 됩니다.
특히 전문 용어를 모르는 사용자는 일상적인 단어로 검색하다가 원하는 답을 찾지 못합니다. 바로 이럴 때 필요한 것이 Query Expansion입니다.
LLM을 활용해 원본 쿼리를 동의어, 관련 개념, 구체적 표현으로 확장하여 검색 재현율을 극대화합니다.
개요
간단히 말해서, Query Expansion은 원본 쿼리를 LLM에 전달하여 동의어, 하위 개념, 관련 질문 등으로 확장한 후, 확장된 쿼리로 검색하여 더 많은 관련 문서를 찾는 기법입니다. 실무에서 이 기법이 필요한 이유는 사용자의 검색 의도를 정확히 파악하고 다양한 표현 방식을 커버하기 위해서입니다.
예를 들어, "API 호출 실패"를 검색하면 "HTTP 요청 에러", "네트워크 타임아웃", "connection refused" 등으로 확장하여 모든 관련 문서를 찾을 수 있습니다. 고객 지원 시스템에서는 같은 문제를 다르게 표현하는 경우가 많아 특히 효과적입니다.
기존에는 사용자가 입력한 그대로만 검색했다면, 이제는 LLM이 3-5개의 변형된 쿼리를 생성하고 각각으로 검색한 결과를 통합하여 훨씬 풍부한 결과를 제공합니다. Query Expansion의 핵심 특징은 첫째, 재현율(Recall)을 20-40% 향상시키고, 둘째, 사용자의 표현 능력에 덜 의존하며, 셋째, 다국어 환경에서 번역 변형까지 커버할 수 있다는 점입니다.
이러한 특징들이 사용자 경험을 크게 개선하고 "찾을 수 없음" 응답을 줄입니다.
코드 예제
from openai import OpenAI
class QueryExpander:
def __init__(self, api_key):
self.client = OpenAI(api_key=api_key)
def expand_query(self, original_query, num_variations=3):
# LLM에게 쿼리 확장을 요청하는 프롬프트
prompt = f"""다음 검색 쿼리를 {num_variations}가지 방식으로 확장해주세요:
원본 쿼리: {original_query}
다음을 포함하여 확장하세요:
3. 관련된 문제나 개념
설명
이것이 하는 일: Query Expansion은 사용자의 간단한 쿼리를 받아 LLM이 이해한 의도를 바탕으로 3-5개의 풍부한 변형 쿼리를 생성하고, 이 모든 쿼리로 검색하여 결과를 통합합니다. 첫 번째로, 원본 쿼리와 확장 지침을 포함한 프롬프트를 구성합니다.
프롬프트에는 "동의어", "구체적 기술 용어", "관련 개념"을 포함하라는 명시적 지침이 들어가며, 이렇게 하면 LLM이 체계적으로 쿼리를 확장합니다. temperature=0.7로 설정하여 창의적이면서도 관련성 있는 확장을 유도합니다.
두 번째로, GPT-4에 프롬프트를 전달하고 응답을 받습니다. GPT-4는 넓은 지식 베이스를 활용하여 원본 쿼리의 맥락을 이해하고, 실제로 사용자가 찾고 싶어할 다양한 표현 방식을 생성합니다.
예를 들어 "DB 느림"은 "데이터베이스 쿼리 성능 최적화", "SQL 실행 시간 단축", "인덱스 튜닝"으로 확장됩니다. 세 번째로, LLM 응답을 파싱하여 깔끔한 쿼리 리스트로 만듭니다.
빈 줄이나 헤더는 제거하고 실제 쿼리 텍스트만 추출하며, 원본 쿼리도 함께 유지하여 정확한 매칭 기회를 놓치지 않습니다. 여러분이 이 코드를 사용하면 사용자가 어떤 용어를 사용하든 관련 문서를 찾을 확률이 높아지고, "검색 결과 없음" 케이스가 30-50% 감소하며, 각 확장 쿼리로 개별 검색 후 결과를 통합하여 더 포괄적인 답변을 제공할 수 있습니다.
실전 팁
💡 프롬프트에 도메인 컨텍스트를 추가하세요. "기술 문서 검색을 위해" 같은 힌트를 주면 LLM이 더 적절한 기술 용어로 확장합니다.
💡 확장 쿼리가 너무 다양하면 노이즈가 됩니다. num_variations=3-5가 적절하며, 너무 많으면 오히려 정확도가 떨어집니다.
💡 비용 절감을 위해 자주 검색되는 쿼리의 확장 결과를 캐싱하세요. Redis에 "원본쿼리: [확장1, 확장2...]" 형태로 저장하면 API 호출을 90% 줄일 수 있습니다.
💡 확장된 각 쿼리로 개별 검색 후 결과를 통합할 때, Reciprocal Rank Fusion(RRF) 알고리즘을 사용하면 순위를 효과적으로 병합할 수 있습니다.
💡 A/B 테스트에서 확장 여부를 비교하세요. 일부 도메인에서는 오히려 정확도가 떨어질 수 있으므로, 실제 데이터로 검증이 필수입니다.
3. Re-ranking - 초기 검색 결과를 정밀하게 재정렬하기
시작하며
여러분이 RAG 시스템에서 상위 20개 문서를 검색했는데, 정작 가장 관련성 높은 문서가 15위에 있어서 LLM이 놓치는 경우를 겪어본 적 있나요? 벡터 검색은 빠르지만 때로는 미묘한 관련성을 정확히 순위화하지 못합니다.
이런 문제는 단일 벡터 유사도만으로 복잡한 관련성을 판단하기 어렵기 때문에 발생합니다. 쿼리와 문서의 관계는 단순 코사인 유사도보다 훨씬 복잡하며, 맥락, 최신성, 권위성 등 다양한 요소를 고려해야 합니다.
상위 몇 개만 LLM에 전달하는 RAG 특성상 순위가 조금만 틀려도 답변 품질이 크게 떨어집니다. 바로 이럴 때 필요한 것이 Re-ranking입니다.
초기 검색으로 후보를 빠르게 추리고, Cross-Encoder 같은 정교한 모델로 쿼리-문서 쌍을 깊이 분석하여 재정렬합니다.
개요
간단히 말해서, Re-ranking은 2단계 검색 전략입니다. 1단계에서 벡터 검색으로 상위 100-200개 후보를 빠르게 찾고, 2단계에서 Cross-Encoder 모델이 쿼리와 각 문서를 함께 입력받아 정밀한 관련성 점수를 계산하여 재정렬합니다.
실무에서 이 기법이 필요한 이유는 LLM에 전달되는 최종 5-10개 문서의 품질이 답변 품질을 결정하기 때문입니다. 예를 들어, "React 18에서 useEffect의 변경사항"을 검색하면 1단계에서 React 관련 문서 100개를 찾고, 2단계에서 정확히 React 18과 useEffect를 모두 다루는 문서를 상위로 올립니다.
의료, 법률 같은 정확성이 중요한 도메인에서는 필수입니다. 기존에는 벡터 검색 결과를 그대로 사용했다면, 이제는 Cross-Encoder가 쿼리와 문서를 동시에 보면서 "이 문서가 정말 이 질문에 답하는가?"를 판단합니다.
Bi-Encoder(일반 벡터 검색)보다 느리지만 훨씬 정확합니다. Re-ranking의 핵심 특징은 첫째, 정확도(Precision@k)를 30-50% 향상시키고, 둘째, 계산 비용은 후보 문서에만 집중하여 효율적이며, 셋째, 도메인 특화 Cross-Encoder로 특수 분야에서도 강력합니다.
이러한 특징들이 프로덕션 RAG의 답변 품질을 한 단계 끌어올립니다.
코드 예제
from sentence_transformers import CrossEncoder
import numpy as np
class ReRanker:
def __init__(self, model_name='cross-encoder/ms-marco-MiniLM-L-6-v2'):
# Cross-Encoder 모델 로드: 쿼리-문서 쌍의 관련성 점수 계산
self.model = CrossEncoder(model_name)
def rerank(self, query, documents, top_k=5):
"""
초기 검색 결과를 정밀하게 재정렬
Args:
query: 사용자 검색 쿼리
documents: 1단계 검색으로 얻은 후보 문서 리스트
top_k: 최종적으로 반환할 상위 문서 개수
"""
# 쿼리-문서 쌍 생성: Cross-Encoder는 쌍으로 입력받음
pairs = [[query, doc] for doc in documents]
# Cross-Encoder로 각 쌍의 관련성 점수 계산
# 이 모델은 쿼리와 문서를 함께 보므로 미묘한 관련성도 파악
scores = self.model.predict(pairs)
# 점수 기준 내림차순 정렬
ranked_indices = np.argsort(scores)[::-1]
# 상위 k개 문서와 점수 반환
reranked_results = [
{
'document': documents[idx],
'score': float(scores[idx]),
'rank': rank + 1
}
for rank, idx in enumerate(ranked_indices[:top_k])
]
return reranked_results
설명
이것이 하는 일: Re-ranking은 빠른 1단계 검색으로 얻은 후보 문서들을 Cross-Encoder 모델에 하나씩 전달하여 쿼리와의 정확한 관련성 점수를 계산하고, 이 점수로 재정렬하여 최종 상위 k개를 선택합니다. 첫 번째로, Cross-Encoder 모델을 로드합니다.
'ms-marco-MiniLM-L-6-v2'는 Microsoft의 대규모 검색 데이터셋으로 학습된 모델로, 쿼리와 문서의 관련성 판단에 특화되어 있습니다. Bi-Encoder와 달리 쿼리와 문서를 별도로 인코딩하지 않고 함께 입력받아 attention 메커니즘으로 상호작용을 깊이 분석합니다.
두 번째로, 모든 쿼리-문서 쌍을 생성하고 배치로 모델에 전달합니다. 예를 들어 쿼리 1개와 문서 100개가 있으면 100개의 쌍을 만들고, 모델은 각 쌍에 대해 0-1 범위의 관련성 점수를 반환합니다.
이 과정에서 모델은 쿼리의 각 단어가 문서의 어느 부분과 관련 있는지 세밀하게 파악합니다. 세 번째로, 점수를 기준으로 문서를 재정렬합니다.
원래 벡터 검색에서 15위였던 문서가 Cross-Encoder 점수로는 1위가 될 수 있습니다. 최종적으로 상위 k개만 선택하여 LLM에 전달하면, 가장 관련성 높은 맥락만 사용하여 답변을 생성할 수 있습니다.
여러분이 이 코드를 사용하면 RAG 답변의 정확도가 눈에 띄게 향상되고, 특히 복잡한 기술 질문에서 "모르겠습니다" 응답이 줄어들며, 도메인 특화 Cross-Encoder로 교체하여 의료, 법률 등 전문 분야에서도 높은 정확도를 유지할 수 있습니다.
실전 팁
💡 1단계에서는 넉넉하게 100-200개를 검색하세요. Re-ranking은 후보가 많을수록 진가를 발휘하며, 계산 비용도 수백 개 정도는 충분히 감당합니다.
💡 Cross-Encoder 추론을 배치로 처리하면 속도가 10배 이상 빨라집니다. model.predict(pairs, batch_size=32) 형태로 사용하세요.
💡 도메인 특화 모델로 파인튜닝하면 정확도가 추가로 20-30% 향상됩니다. 자사의 실제 쿼리-문서-관련성 데이터로 학습시키세요.
💡 GPU가 없는 환경에서는 ONNX로 변환하여 CPU 추론을 최적화하거나, Cohere Rerank API 같은 관리형 서비스를 사용하는 것도 좋은 선택입니다.
💡 캐싱 전략이 중요합니다. 같은 쿼리에 대해 후보 문서 세트가 동일하면 재정렬 결과를 캐싱하여 응답 시간을 단축하세요.
4. Contextual Compression - 검색된 문서에서 핵심만 추출하기
시작하며
여러분이 RAG 시스템에서 10개 문서를 검색했는데, 각 문서가 2000단어씩이라 LLM 컨텍스트 윈도우를 꽉 채우고 비용도 높아지는 경험을 해본 적 있나요? 더 심각한 건, 정작 관련 있는 내용은 각 문서의 한두 문단뿐인데 전체를 전달하다 보니 LLM이 핵심을 놓치는 경우입니다.
이런 문제는 RAG의 고질적인 문제 중 하나입니다. 긴 문서를 통째로 전달하면 노이즈가 많아지고, LLM이 중요한 정보를 찾기 어려우며, API 비용이 급증합니다.
특히 GPT-4 같은 고가 모델에서는 토큰 수가 곧 비용이므로 불필요한 텍스트는 큰 낭비입니다. 바로 이럴 때 필요한 것이 Contextual Compression입니다.
검색된 문서에서 쿼리와 직접 관련된 부분만 추출하여 LLM에 전달함으로써 품질과 효율을 동시에 높입니다.
개요
간단히 말해서, Contextual Compression은 검색된 문서를 문장 단위로 나누고, 각 문장이 쿼리와 얼마나 관련 있는지 점수를 매겨 상위 문장들만 추출하는 기법입니다. 실무에서 이 기법이 필요한 이유는 비용 절감과 답변 품질 향상을 동시에 달성하기 위해서입니다.
예를 들어, "PostgreSQL 트랜잭션 격리 레벨"을 검색하면 데이터베이스 전반을 다루는 긴 문서가 검색되지만, 실제로 필요한 건 격리 레벨을 설명하는 2-3개 문단입니다. 이 부분만 추출하면 토큰을 70-80% 절감하면서도 답변 품질은 동일하거나 더 좋습니다.
기존에는 문서 전체를 LLM에 전달했다면, 이제는 각 문장의 임베딩을 계산하고 쿼리 임베딩과 비교하여 관련성 높은 문장만 선별합니다. 마치 형광펜으로 핵심만 표시하는 것과 같습니다.
Contextual Compression의 핵심 특징은 첫째, 토큰 사용량을 60-80% 감소시키고, 둘째, 노이즈 제거로 LLM의 집중도를 높이며, 셋째, 응답 시간도 단축된다는 점입니다. 이러한 특징들이 대규모 RAG 시스템의 운영 비용을 크게 낮춥니다.
코드 예제
from sentence_transformers import SentenceTransformer
import numpy as np
import re
class ContextualCompressor:
def __init__(self, model_name='all-MiniLM-L6-v2'):
# 문장 임베딩 모델 로드
self.model = SentenceTransformer(model_name)
def compress_documents(self, query, documents, top_k_sentences=10):
"""
문서에서 쿼리와 가장 관련성 높은 문장들만 추출
Args:
query: 사용자 쿼리
documents: 검색된 문서 리스트
top_k_sentences: 추출할 최대 문장 수
"""
# 쿼리 임베딩 계산
query_embedding = self.model.encode(query)
all_sentences = []
# 모든 문서를 문장 단위로 분리
for doc_idx, doc in enumerate(documents):
# 정규식으로 문장 분리: 마침표, 느낌표, 물음표 기준
sentences = re.split(r'[.!?]+\s+', doc)
for sent in sentences:
if len(sent.strip()) > 20: # 너무 짧은 문장 제외
all_sentences.append({
'text': sent.strip(),
'doc_idx': doc_idx
})
# 모든 문장의 임베딩 계산 (배치로 효율적 처리)
sentence_texts = [s['text'] for s in all_sentences]
sentence_embeddings = self.model.encode(sentence_texts)
# 쿼리와 각 문장의 코사인 유사도 계산
similarities = np.dot(sentence_embeddings, query_embedding)
# 유사도 순으로 정렬하여 상위 k개 선택
top_indices = np.argsort(similarities)[-top_k_sentences:][::-1]
# 압축된 컨텍스트 생성: 문서 순서 유지하며 선택된 문장 결합
compressed_sentences = [all_sentences[idx] for idx in top_indices]
compressed_sentences.sort(key=lambda x: x['doc_idx']) # 원본 문서 순서 유지
compressed_context = ' '.join([s['text'] for s in compressed_sentences])
return compressed_context
설명
이것이 하는 일: Contextual Compression은 긴 문서들을 문장으로 분해하고, 각 문장과 쿼리의 의미적 유사도를 계산하여, 가장 관련성 높은 문장들만 선별해 새로운 압축된 컨텍스트를 만듭니다. 첫 번째로, 모든 검색 문서를 문장 단위로 분리합니다.
정규식으로 마침표, 느낌표, 물음표를 기준으로 나누고, 너무 짧은 문장(20자 미만)은 의미 없는 단편으로 간주하여 제외합니다. 각 문장에는 출처 문서 인덱스를 태깅하여 나중에 순서를 복원할 수 있게 합니다.
두 번째로, 쿼리와 모든 문장의 임베딩을 계산합니다. SentenceTransformer를 사용하면 문장의 의미를 벡터로 표현할 수 있으며, 배치 인코딩으로 수백 개 문장도 1-2초 내에 처리합니다.
그 다음 쿼리 임베딩과 각 문장 임베딩의 코사인 유사도를 계산하면 어느 문장이 쿼리와 가장 관련 있는지 수치화됩니다. 세 번째로, 유사도 점수가 높은 상위 k개 문장을 선택합니다.
이때 원본 문서의 순서를 유지하는 것이 중요합니다. 문장 순서가 뒤죽박죽이면 문맥이 깨지므로, doc_idx로 정렬하여 자연스러운 흐름을 유지합니다.
최종적으로 선택된 문장들을 이어 붙여 압축된 컨텍스트를 만듭니다. 여러분이 이 코드를 사용하면 LLM API 비용을 월 수백만원 절감할 수 있고, 짧아진 컨텍스트로 응답 시간이 30-50% 빨라지며, 노이즈가 제거되어 LLM이 더 정확하고 집중된 답변을 생성합니다.
실전 팁
💡 top_k_sentences 값은 도메인에 따라 조정하세요. 복잡한 기술 질문은 15-20개, 간단한 FAQ는 5-10개가 적절합니다.
💡 문장 분리 정규식을 개선하세요. "Dr. Smith"나 "v2.0" 같은 경우 잘못 분리될 수 있으므로, spaCy 같은 전문 라이브러리를 사용하면 정확도가 높아집니다.
💡 문서 내 위치도 고려하세요. 문서 앞부분이나 제목 근처 문장에 가중치를 주면 더 중요한 정보를 우선 선택할 수 있습니다.
💡 압축 전후 성능을 모니터링하세요. 일부 질문에서는 문맥이 중요해 압축이 오히려 해가 될 수 있습니다. A/B 테스트로 검증하세요.
💡 LLM에게 "다음은 압축된 컨텍스트입니다. 문장 순서는 유지되었지만 중간이 생략되었을 수 있습니다"라고 알려주면 더 나은 답변을 생성합니다.
5. HyDE (Hypothetical Document Embeddings) - 가상 답변으로 더 나은 검색하기
시작하며
여러분이 "React에서 무한 스크롤을 구현하는 가장 효율적인 방법은?"이라고 검색했는데, 질문과 질문만 매칭되고 정작 답변이 담긴 문서는 검색되지 않는 경험을 해본 적 있나요? 질문 형태의 쿼리는 문서의 설명적 텍스트와 임베딩 공간에서 멀리 떨어져 있을 수 있습니다.
이런 문제는 쿼리와 문서의 스타일 불일치 때문에 발생합니다. 사용자는 질문 형태로 검색하지만, 실제 문서는 "React에서 무한 스크롤은 Intersection Observer API를 사용하여..."처럼 설명 형태입니다.
임베딩 모델은 의미만이 아니라 형태도 고려하므로, 이 스타일 차이가 검색 정확도를 떨어뜨립니다. 바로 이럴 때 필요한 것이 HyDE입니다.
질문을 그대로 검색하는 대신, LLM에게 "이 질문에 대한 답변을 작성해봐"라고 요청하고, 그 가상 답변의 임베딩으로 검색하면 실제 답변 문서와 훨씬 잘 매칭됩니다.
개요
간단히 말해서, HyDE는 사용자 쿼리를 LLM에 전달하여 가상의 답변 문서를 생성하고, 그 가상 문서의 임베딩으로 검색하는 기법입니다. 답변과 답변을 매칭하므로 질문과 답변을 매칭하는 것보다 훨씬 정확합니다.
실무에서 이 기법이 필요한 이유는 쿼리-문서 임베딩 갭을 해소하기 위해서입니다. 예를 들어, "Python에서 메모리 누수를 디버깅하는 방법?"을 검색하면 먼저 LLM이 "Python에서 메모리 누수는 tracemalloc 모듈을 사용하여 감지할 수 있습니다..."같은 가상 답변을 생성하고, 이 텍스트로 검색하면 실제로 tracemalloc을 설명하는 문서가 상위에 랭크됩니다.
특히 복잡한 개념적 질문에서 강력합니다. 기존에는 질문 텍스트를 직접 임베딩했다면, 이제는 LLM이 생성한 답변 스타일의 텍스트를 임베딩합니다.
이렇게 하면 검색 공간에서 질문이 아닌 답변끼리 가까워지므로 매칭이 훨씬 정확해집니다. HyDE의 핵심 특징은 첫째, 스타일 불일치 문제를 해결하고, 둘째, 복잡한 질문에서 재현율을 20-30% 향상시키며, 셋째, 실제 답변이 없어도(zero-shot) 작동한다는 점입니다.
이러한 특징들이 특히 전문 지식이 필요한 도메인에서 빛을 발합니다.
코드 예제
from openai import OpenAI
from sentence_transformers import SentenceTransformer
class HyDESearcher:
def __init__(self, openai_api_key, embedding_model='all-MiniLM-L6-v2'):
self.llm_client = OpenAI(api_key=openai_api_key)
self.embedding_model = SentenceTransformer(embedding_model)
def generate_hypothetical_document(self, query):
"""
쿼리에 대한 가상의 답변 문서를 LLM으로 생성
"""
prompt = f"""다음 질문에 대해 구체적이고 기술적인 답변을 작성해주세요.
실제 문서에 있을 법한 형태로 작성하되, 150-200단어 정도로 작성하세요.
질문: {query}
답변:"""
response = self.llm_client.chat.completions.create(
model="gpt-3.5-turbo", # 비용 절감을 위해 3.5 사용
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_tokens=300
)
hypothetical_doc = response.choices[0].message.content.strip()
return hypothetical_doc
def search(self, query, document_embeddings, documents, top_k=5):
"""
HyDE 방식으로 검색: 가상 문서 임베딩으로 실제 문서 검색
"""
# 1. 가상 답변 문서 생성
hypothetical_doc = self.generate_hypothetical_document(query)
# 2. 가상 문서의 임베딩 계산
hyde_embedding = self.embedding_model.encode(hypothetical_doc)
# 3. 가상 문서 임베딩으로 실제 문서 검색
similarities = np.dot(document_embeddings, hyde_embedding)
# 4. 상위 k개 반환
top_indices = np.argsort(similarities)[-top_k:][::-1]
results = [(documents[i], similarities[i]) for i in top_indices]
return results, hypothetical_doc # 가상 문서도 함께 반환 (디버깅용)
설명
이것이 하는 일: HyDE는 사용자의 질문을 LLM에 전달하여 "이런 답변이 있을 것이다"라는 가상 문서를 만들고, 그 가상 문서의 임베딩을 실제 문서들의 임베딩과 비교하여 가장 유사한 실제 문서를 찾습니다. 첫 번째로, 사용자 쿼리를 바탕으로 LLM이 가상 답변을 생성합니다.
프롬프트에서 "실제 문서에 있을 법한 형태"를 요청하여, 질문이 아닌 설명 스타일의 텍스트를 얻습니다. 예를 들어 "Kubernetes 오토스케일링 설정 방법?"이라는 질문에 대해 "Kubernetes에서 Horizontal Pod Autoscaler를 사용하면..."같은 답변 형태 텍스트를 생성합니다.
GPT-3.5를 사용하면 비용을 낮게 유지하면서도 충분한 품질을 얻을 수 있습니다. 두 번째로, 생성된 가상 문서의 임베딩을 계산합니다.
이 임베딩은 "답변 스타일"의 텍스트를 표현하므로, 임베딩 공간에서 실제 답변 문서들과 가까운 위치에 있습니다. 반면 원본 질문의 임베딩은 질문 형태라 답변 문서와 거리가 있습니다.
세 번째로, 가상 문서 임베딩과 모든 실제 문서 임베딩의 코사인 유사도를 계산합니다. 가상 문서가 실제 답변과 유사한 내용을 담고 있으므로, 정말 관련 있는 문서가 높은 점수를 받습니다.
상위 k개를 선택하여 반환하며, 디버깅을 위해 가상 문서도 함께 반환하면 "LLM이 어떤 답변을 상상했는지" 확인할 수 있습니다. 여러분이 이 코드를 사용하면 복잡한 개념적 질문의 검색 정확도가 크게 향상되고, 특히 전문 용어를 모르는 사용자가 일상 언어로 검색해도 전문 문서를 찾을 수 있으며, zero-shot 환경(학습 데이터 없이)에서도 강력한 성능을 발휘합니다.
실전 팁
💡 가상 문서 길이를 조절하세요. 너무 짧으면(50단어) 정보가 부족하고, 너무 길면(500단어) 노이즈가 됩니다. 150-200단어가 최적입니다.
💡 여러 개의 가상 문서를 생성하고 앙상블하면 더 강력합니다. 3개 생성 후 각각의 임베딩 평균을 사용하거나, 각각으로 검색한 결과를 통합하세요.
💡 프롬프트에 도메인 컨텍스트를 추가하세요. "다음은 소프트웨어 개발 문서입니다"같은 힌트를 주면 LLM이 더 적절한 스타일로 답변을 생성합니다.
💡 비용이 부담되면 가상 문서를 캐싱하세요. 같은 쿼리에 대해 매번 LLM 호출 없이 저장된 가상 문서를 재사용할 수 있습니다.
💡 HyDE와 일반 검색을 결합하세요. HyDE 결과와 원본 쿼리 검색 결과를 0.7:0.3 비율로 혼합하면 두 방식의 장점을 모두 얻을 수 있습니다.
6. Multi-Vector Retrieval - 여러 관점의 임베딩으로 풍부하게 검색하기
시작하며
여러분이 긴 기술 문서를 청크로 나눠서 임베딩했는데, 각 청크의 핵심이 잘 표현되지 않아서 검색이 잘 안 되는 경험을 해본 적 있나요? 예를 들어, 500단어 청크의 임베딩은 전체 내용을 평균화하다 보니 정작 중요한 키 포인트가 희석되는 문제가 있습니다.
이런 문제는 단일 임베딩이 긴 텍스트의 모든 측면을 포착하기 어렵기 때문에 발생합니다. 하나의 청크가 여러 개념을 다루면, 그 임베딩은 모든 개념의 "평균"이 되어 어느 것도 명확히 표현하지 못합니다.
또한 청크를 작게 나누면 문맥이 손실되는 딜레마도 있습니다. 바로 이럴 때 필요한 것이 Multi-Vector Retrieval입니다.
각 문서를 여러 관점에서 표현하는 다수의 임베딩으로 만들어, 어느 하나라도 쿼리와 매칭되면 검색되도록 하여 재현율을 크게 높입니다.
개요
간단히 말해서, Multi-Vector Retrieval은 하나의 문서를 여러 개의 벡터로 표현하는 기법입니다. 예를 들어, LLM으로 문서의 핵심 질문 5개를 생성하고 각각을 임베딩하거나, 문서를 요약한 여러 버전(짧은 요약, 긴 요약, 키워드 리스트)을 각각 임베딩합니다.
실무에서 이 기법이 필요한 이유는 다양한 각도의 검색 쿼리를 커버하기 위해서입니다. 예를 들어, "AWS Lambda 비용 최적화" 문서에 대해 "Lambda 요금을 줄이는 방법?", "서버리스 함수 비용 절감?", "AWS 람다 메모리 설정 최적화?" 같은 다양한 질문 임베딩을 만들어두면, 사용자가 어떤 표현을 쓰든 매칭될 확률이 높습니다.
복잡한 기술 문서나 다면적인 컨텐츠에서 특히 효과적입니다. 기존에는 문서당 1개 임베딩만 저장했다면, 이제는 문서당 3-10개의 임베딩을 저장합니다.
검색 시 쿼리와 모든 벡터를 비교하고, 가장 높은 점수를 그 문서의 점수로 사용합니다(MaxSim). Multi-Vector Retrieval의 핵심 특징은 첫째, 재현율이 30-50% 향상되고, 둘째, 다양한 쿼리 표현에 강건하며, 셋째, 긴 문서에서도 핵심을 놓치지 않는다는 점입니다.
이러한 특징들이 프로덕션 RAG에서 "검색 실패"를 크게 줄여줍니다.
코드 예제
from openai import OpenAI
from sentence_transformers import SentenceTransformer
import numpy as np
class MultiVectorRetriever:
def __init__(self, openai_api_key, embedding_model='all-MiniLM-L6-v2'):
self.llm = OpenAI(api_key=openai_api_key)
self.embedder = SentenceTransformer(embedding_model)
self.doc_vectors = {} # {doc_id: [vector1, vector2, ...]}
def generate_multiple_representations(self, document):
"""
하나의 문서에 대해 여러 관점의 텍스트 생성
"""
# LLM에게 다양한 형태의 표현 요청
prompt = f"""다음 문서에 대해 3가지 표현을 생성해주세요:
3. 핵심 키워드 10개 (쉼표로 구분)
설명
이것이 하는 일: Multi-Vector Retrieval은 각 문서를 LLM으로 다양한 형태(요약, 질문, 키워드)로 변환하고, 각 형태를 별도로 임베딩하여 저장합니다. 검색 시에는 쿼리와 모든 벡터를 비교하여 가장 높은 점수를 그 문서의 대표 점수로 사용합니다.
첫 번째로, LLM을 사용하여 문서의 다양한 표현을 생성합니다. 50단어 요약은 전체 맥락을 압축하고, 3개의 예상 질문은 사용자가 이 문서를 찾기 위해 쓸 법한 쿼리를 미리 만들어두는 것이며, 키워드 리스트는 핵심 개념을 추출합니다.
원본 문서도 포함하여 총 5-6개의 텍스트를 얻습니다. 이렇게 하면 같은 문서를 여러 각도에서 조명하는 효과가 있습니다.
두 번째로, 각 표현을 개별적으로 임베딩합니다. 요약의 임베딩은 전체적인 주제를 표현하고, 질문 임베딩은 사용자 쿼리와 직접 매칭되기 쉬우며, 키워드 임베딩은 특정 용어 검색에 강합니다.
이 모든 벡터를 문서 ID와 연결하여 저장하면, 하나의 문서가 벡터 공간의 여러 위치를 차지하게 됩니다. 세 번째로, 검색 시 MaxSim 전략을 사용합니다.
쿼리 벡터와 각 문서의 모든 벡터(5-6개) 간 유사도를 계산하고, 그 중 최댓값을 그 문서의 점수로 채택합니다. 예를 들어, 사용자가 질문 형태로 검색하면 문서의 "예상 질문" 벡터가 높은 점수를 받을 것이고, 키워드 검색이면 "키워드 리스트" 벡터가 매칭될 것입니다.
이렇게 다양한 쿼리 스타일을 커버합니다. 여러분이 이 코드를 사용하면 사용자가 어떤 방식으로 검색하든 관련 문서를 찾을 확률이 크게 높아지고, 긴 기술 문서에서도 핵심을 놓치지 않으며, 특히 질문-답변 형태의 지식베이스에서 검색 정확도가 크게 향상됩니다.
실전 팁
💡 벡터 개수는 3-7개가 적절합니다. 너무 많으면 스토리지와 검색 시간이 증가하고, 노이즈 벡터가 포함될 수 있습니다.
💡 ColBERT 같은 전문 Multi-Vector 모델을 사용하면 더 효과적입니다. 자동으로 토큰별 벡터를 생성하고 MaxSim을 최적화합니다.
💡 벡터 DB가 Multi-Vector를 지원하는지 확인하세요. Qdrant, Weaviate는 네이티브 지원하지만, 일부 DB는 문서를 여러 개로 분할하여 저장해야 합니다.
💡 "예상 질문" 생성 시 실제 사용자 쿼리 로그를 LLM에 예시로 제공하면 더 현실적인 질문을 생성합니다.
💡 스토리지 비용이 부담되면 중요한 문서만 Multi-Vector로 인덱싱하세요. FAQ나 핵심 기술 문서는 Multi-Vector, 일반 문서는 Single-Vector로 하이브리드 운영할 수 있습니다.
7. Parent Document Retrieval - 작은 청크로 검색하고 큰 맥락 제공하기
시작하며
여러분이 RAG 시스템에서 작은 청크(200단어)로 검색하면 정확도는 높지만 LLM에게 충분한 맥락을 주지 못하고, 큰 청크(1000단어)로 검색하면 맥락은 풍부하지만 검색 정확도가 떨어지는 딜레마를 겪어본 적 있나요? 청크 크기는 RAG의 영원한 고민입니다.
이런 문제는 검색 단위와 생성 단위가 다르기 때문에 발생합니다. 검색은 정확한 포인트를 찾기 위해 작은 단위가 유리하고, 생성은 풍부한 맥락을 위해 큰 단위가 유리합니다.
하나의 청크 크기로는 이 두 요구를 동시에 만족시킬 수 없습니다. 바로 이럴 때 필요한 것이 Parent Document Retrieval입니다.
작은 자식 청크로 검색하여 정밀하게 관련 부분을 찾고, 실제로 LLM에는 그 자식이 속한 큰 부모 문서를 전달하여 충분한 맥락을 제공합니다.
개요
간단히 말해서, Parent Document Retrieval은 문서를 계층적으로 청킹하는 기법입니다. 큰 부모 청크(1000단어)를 여러 작은 자식 청크(200단어)로 나누고, 검색은 자식 청크 임베딩으로 수행하되, 검색 결과로는 부모 청크를 반환합니다.
실무에서 이 기법이 필요한 이유는 검색 정확도와 맥락 풍부함을 동시에 달성하기 위해서입니다. 예를 들어, "Kubernetes readiness probe 설정 방법"을 검색하면 자식 청크가 정확히 그 부분을 담고 있어 높은 점수를 받지만, LLM에는 그 부모 청크를 전달하여 전체 헬스체크 시스템 맥락까지 제공합니다.
복잡한 기술 문서에서 앞뒤 맥락이 중요할 때 매우 효과적입니다. 기존에는 하나의 청크 크기를 선택해야 했다면, 이제는 작은 청크로 검색하고 큰 청크로 생성하는 "두 마리 토끼"를 잡을 수 있습니다.
자식-부모 매핑만 관리하면 구현도 간단합니다. Parent Document Retrieval의 핵심 특징은 첫째, 검색 정확도(Precision)와 맥락 풍부함(Context)을 동시에 확보하고, 둘째, LLM이 더 나은 추론을 할 수 있게 하며, 셋째, 청크 크기 딜레마를 우아하게 해결한다는 점입니다.
이러한 특징들이 RAG 답변 품질을 한 단계 끌어올립니다.
코드 예제
from sentence_transformers import SentenceTransformer
import numpy as np
class ParentDocumentRetriever:
def __init__(self, embedding_model='all-MiniLM-L6-v2'):
self.embedder = SentenceTransformer(embedding_model)
self.child_chunks = [] # 작은 자식 청크 리스트
self.child_embeddings = None # 자식 청크 임베딩
self.parent_map = {} # {child_idx: parent_text} 매핑
def create_hierarchical_chunks(self, document, parent_size=1000, child_size=200):
"""
문서를 부모-자식 계층으로 청킹
Args:
document: 전체 문서 텍스트
parent_size: 부모 청크 크기 (단어 수)
child_size: 자식 청크 크기 (단어 수)
"""
words = document.split()
# 부모 청크 생성
parent_chunks = []
for i in range(0, len(words), parent_size):
parent_chunk = ' '.join(words[i:i+parent_size])
parent_chunks.append(parent_chunk)
# 각 부모를 자식으로 분할
child_idx = 0
for parent_idx, parent in enumerate(parent_chunks):
parent_words = parent.split()
# 부모 내에서 자식 청크 생성
for j in range(0, len(parent_words), child_size):
child_chunk = ' '.join(parent_words[j:j+child_size])
self.child_chunks.append(child_chunk)
# 자식 -> 부모 매핑 저장
self.parent_map[child_idx] = parent
child_idx += 1
# 모든 자식 청크 임베딩
self.child_embeddings = self.embedder.encode(self.child_chunks)
def retrieve(self, query, top_k=3):
"""
자식 청크로 검색하고 부모 청크 반환
"""
# 쿼리 임베딩
query_embedding = self.embedder.encode(query)
# 자식 청크들과 유사도 계산
similarities = np.dot(self.child_embeddings, query_embedding)
# 상위 k개 자식 청크 인덱스
top_child_indices = np.argsort(similarities)[-top_k:][::-1]
# 자식이 속한 부모 청크 반환 (중복 제거)
parent_chunks = []
seen_parents = set()
for child_idx in top_child_indices:
parent_text = self.parent_map[child_idx]
if parent_text not in seen_parents:
parent_chunks.append({
'parent_text': parent_text,
'matched_child': self.child_chunks[child_idx],
'score': float(similarities[child_idx])
})
seen_parents.add(parent_text)
return parent_chunks
설명
이것이 하는 일: Parent Document Retrieval은 문서를 계층적으로 청킹하여 작은 자식 청크의 임베딩으로 검색을 수행하되, 검색된 자식이 속한 큰 부모 청크를 최종 결과로 반환합니다. 첫 번째로, 문서를 부모 청크로 나눕니다.
parent_size=1000 단어 단위로 분할하면 충분한 맥락을 담은 부모 청크들이 생성됩니다. 예를 들어, 5000단어 문서는 5개의 부모 청크가 됩니다.
각 부모는 하나의 주제나 섹션을 포괄하는 크기입니다. 두 번째로, 각 부모를 다시 자식 청크로 분할합니다.
parent_words를 child_size=200 단어씩 나누면, 하나의 부모에서 4-5개의 자식이 나옵니다. 각 자식은 특정 포인트나 문단 수준의 내용을 담습니다.
중요한 것은 각 자식의 인덱스와 그것이 속한 부모 텍스트를 parent_map에 저장하는 것입니다. 이 매핑이 나중에 부모를 복원하는 키가 됩니다.
세 번째로, 모든 자식 청크를 임베딩하여 검색 인덱스를 만듭니다. 검색 시에는 쿼리와 자식 임베딩들의 유사도를 계산하여 가장 관련 있는 자식들을 찾습니다.
예를 들어, "JWT 토큰 검증 로직"을 검색하면 정확히 그 부분을 다루는 200단어 자식 청크가 1위를 차지합니다. 네 번째로, 검색된 자식의 인덱스로 parent_map을 조회하여 부모 청크를 가져옵니다.
중복 제거를 통해 같은 부모에 속한 여러 자식이 검색되더라도 부모는 한 번만 반환합니다. 최종적으로 LLM에는 1000단어 부모 청크가 전달되어, JWT 검증뿐 아니라 전체 인증 플로우 맥락까지 제공됩니다.
여러분이 이 코드를 사용하면 검색 정확도는 작은 청크 수준으로 유지하면서도 LLM에는 충분한 맥락을 제공할 수 있고, 복잡한 기술 문서에서 "이 부분만으로는 이해 안 됨" 문제가 해결되며, 청크 크기를 두 가지로 유연하게 조정하여 도메인 특성에 최적화할 수 있습니다.
실전 팁
💡 부모:자식 비율을 5:1 정도로 유지하세요. parent_size=1000, child_size=200이 대부분의 경우 잘 작동합니다.
💡 같은 부모에서 여러 자식이 검색되는 것은 좋은 신호입니다. 그 부모가 정말 관련 있다는 의미이므로, 점수를 합산하여 부모 순위를 매기는 것도 좋은 전략입니다.
💡 LangChain의 ParentDocumentRetriever를 사용하면 이 패턴을 쉽게 구현할 수 있습니다. 직접 구현보다 버그가 적고 최적화되어 있습니다.
💡 벡터 DB 스토리지를 절약하려면 부모 청크는 별도 문서 스토어(S3, PostgreSQL)에 저장하고, 자식 임베딩만 벡터 DB에 넣으세요. parent_id를 메타데이터로 저장하여 조회합니다.
💡 문서 경계를 존중하세요. 단순 단어 수로 자르면 문장 중간에서 끊길 수 있습니다. spaCy로 문장 단위로 청킹하면 더 자연스러운 부모-자식 관계를 만들 수 있습니다.