기본 RAG를 운영하다 보면 '이 답이 문서에 분명히 있는데 왜 못 찾지?'라는 상황을 맞닥뜨립니다. 벡터 검색은 강력하지만 완벽하지 않습니다. 더 정확한 RAG를 만들려면 검색 방식을 고도화해야 합니다.
- 정확한 고유명사·모델 번호·코드 찾기 실패: 벡터 검색은 의미를 보지만 정확한 철자를 놓칩니다
- 짧은 키워드 검색 부정확: '환불 7일'처럼 짧은 검색어는 의미 벡터가 뭉뚱그려져 엉뚱한 결과가 나옵니다
- 청크 경계에서 맥락 단절: 의미가 뚝 잘린 짧은 청크는 문맥이 부족해 AI가 제대로 답하지 못합니다
개선 1: 하이브리드 검색 (키워드 + 의미)
도서관 검색을 떠올려 보세요. '파이썬'을 찾을 때 제목에 정확히 '파이썬'이 들어간 책과 비슷한 주제의 책을 동시에 찾고 싶습니다. BM25(키워드 검색)는 정확한 단어 빈도를 보고, 벡터 검색은 의미 유사도를 봅니다. 두 결과를 앙상블(ensemble)로 합치면 서로의 약점을 보완합니다.
EnsembleRetriever: BM25와 벡터 검색 합치기
# pip install rank_bm25 langchain-community langchain-chroma
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
# chunks: 이전 레슨에서 만든 Document 청크 리스트라고 가정
# 키워드 검색 리트리버 (BM25 = Okapi BM25 알고리즘)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 3 # 결과 3개 반환
# 벡터 검색 리트리버
vectorstore = Chroma.from_documents(
chunks, OpenAIEmbeddings(model='text-embedding-3-small')
)
vector_retriever = vectorstore.as_retriever(search_kwargs={'k': 3})
# 두 리트리버를 0.5:0.5 가중치로 합칩니다
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.5, 0.5], # 키워드와 의미 검색의 상대적 중요도
)
# 하이브리드 검색: 키워드와 의미 양쪽에서 결과를 잡아냅니다
results = ensemble_retriever.invoke('GPT-4o 토큰 한도')
for doc in results:
print(doc.page_content[:80])
개선 2: 리랭킹 (Cross-Encoder)
1차 검색에서 10개를 가져왔는데 1위가 정답이 아닌 경우가 있습니다. 리랭킹(Re-ranking)은 1차 검색 결과를 더 정밀한 모델로 다시 순위를 매기는 2단계 검색입니다. 면접 1차 서류 통과 후 2차 심층 면접으로 최종 순위를 가리는 것과 같습니다. Cross-Encoder는 질문과 각 문서를 함께 보고 관련성 점수를 매기기 때문에 훨씬 정밀합니다.
CrossEncoderReranker: 검색 결과 재정렬
# pip install sentence-transformers
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
# 크로스인코더 모델 로딩 (첫 실행 시 허깅페이스에서 자동 다운로드)
encoder = HuggingFaceCrossEncoder(
model_name='cross-encoder/ms-marco-MiniLM-L-6-v2'
)
# 리랭킹 압축기: 재정렬 후 상위 3개만 남깁니다
reranker = CrossEncoderReranker(model=encoder, top_n=3)
# 기존 리트리버(앙상블) 위에 리랭킹 레이어를 씌웁니다
compression_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=ensemble_retriever, # 앞서 만든 하이브리드 리트리버
)
# 1차에서 6개 가져온 뒤 정밀 재정렬해 3개만 반환합니다
reranked = compression_retriever.invoke('환불 정책 7일')
for i, doc in enumerate(reranked):
print(f'{i + 1}위: {doc.page_content[:80]}')
개선 3: 부모-자식 청크 전략
작은 청크는 정밀하게 검색되지만 맥락이 부족하고, 큰 청크는 맥락은 풍부하지만 검색 정확도가 떨어집니다. ParentDocumentRetriever는 두 장점을 동시에 취합니다. 작은 자식 청크로 검색하고, 해당 청크가 속한 큰 부모 청크를 AI에게 전달합니다. 도서관에서 색인 카드로 위치를 찾고 실제로는 책 한 챕터 전체를 읽어 주는 것과 같습니다.
ParentDocumentRetriever: 작게 찾고 크게 반환
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
# 자식 청크: 작게 잘라 정확하게 검색합니다 (인덱싱용)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 부모 청크: 크게 잘라 맥락을 보존합니다 (AI 에게 전달)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
# 부모 문서를 보관하는 저장소 (실서비스에서는 Redis 나 DB 로 교체)
docstore = InMemoryStore()
vectorstore = Chroma(
collection_name='child_chunks',
embedding_function=OpenAIEmbeddings(model='text-embedding-3-small'),
)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 문서 추가: 내부적으로 부모와 자식을 분리해 저장합니다
retriever.add_documents(chunks)
# 검색: 자식 청크(200자)로 찾지만 부모 청크(1000자)를 반환합니다
results = retriever.invoke('환불 신청 방법')
print(f'반환된 청크 길이: {len(results[0].page_content)}자') # 1000자 내외
| 전략 | 해결하는 문제 | 복잡도 |
|---|---|---|
| 하이브리드 검색(EnsembleRetriever) | 정확한 키워드를 의미 검색이 놓칠 때 | 낮음 — BM25는 추가 비용 없음 |
| 리랭킹(CrossEncoderReranker) | 1차 검색 결과 순위가 부정확할 때 | 중간 — 추가 모델 실행 필요 |
| 부모-자식 청크(ParentDocumentRetriever) | 짧은 청크로 맥락이 부족할 때 | 낮음 — 저장소만 추가 |
| 세 가지 조합 | 높은 정확도가 필요한 운영 서비스 | 높음 — 단계별 적용 권장 |
RAG 개선은 처음부터 세 가지를 동시에 넣지 말고 단계적으로 적용하세요. 먼저 하이브리드 검색으로 기본을 다지고, LangSmith로 검색 실패 사례를 수집한 뒤, 리랭킹과 부모-자식 청크를 순서대로 추가하면 각 개선의 효과를 수치로 명확히 측정할 수 있습니다.
CrossEncoderReranker는 검색 결과 문서마다 크로스인코더 모델을 실행하므로 응답 시간이 늘어납니다. base_retriever의 k를 6~10으로 넉넉히 잡고, top_n은 3으로 줄여 정확도와 속도의 균형을 맞추세요. 운영 서비스에서는 Cohere Rerank API 같은 클라우드 리랭커도 좋은 대안입니다.