AI RAG 시스템 개발
AI RAG 시스템을 공부 해 볼 예정입니다.
학습 항목
이미지 로딩 중...
RAG 시스템 완벽 가이드 - RAG의 개념과 필요성
전통적인 LLM의 한계를 극복하는 RAG(Retrieval-Augmented Generation) 시스템의 개념과 필요성을 실무 관점에서 배워봅니다. 외부 지식을 활용하여 더 정확하고 최신의 정보를 제공하는 AI 시스템을 구축하는 방법을 단계별로 알아봅니다.
목차
- 전통적인 LLM의 한계 - 왜 RAG가 필요한가
- RAG의 핵심 개념 - 검색과 생성의 결합
- 벡터 임베딩 - 의미를 숫자로 변환하기
- 벡터 데이터베이스 - 고속 유사도 검색의 핵심
- RAG 파이프라인 구축 - 전체 시스템 통합
- 문서 청킹 전략 - 검색 품질을 좌우하는 핵심
- 하이브리드 검색 - 키워드와 의미 검색의 결합
- 프롬프트 엔지니어링 - RAG를 위한 효과적인 지시
- 최종 답변:"""
1. 전통적인 LLM의 한계 - 왜 RAG가 필요한가
시작하며
여러분이 ChatGPT에게 "우리 회사의 최신 제품 가격은 얼마인가요?"라고 물어봤을 때, "죄송하지만 저는 그 정보를 알지 못합니다"라는 답변을 받은 적 있나요? 아니면 "2023년 9월 이후의 정보는 알 수 없습니다"라는 답변을 본 적이 있으신가요?
이런 문제는 실제 개발 현장에서 AI 챗봇이나 고객 서비스 시스템을 구축할 때 자주 발생합니다. LLM은 훈련 시점까지의 데이터만 학습했기 때문에, 최신 정보나 회사 내부 문서, 특정 도메인의 전문 지식에 대해서는 정확한 답변을 할 수 없습니다.
더욱이, 모델이 잘못된 정보를 그럴듯하게 만들어내는 "환각(Hallucination)" 현상도 심각한 문제입니다. 바로 이럴 때 필요한 것이 RAG(Retrieval-Augmented Generation) 시스템입니다.
RAG는 외부 지식 베이스에서 관련 정보를 검색한 후, 그 정보를 바탕으로 LLM이 답변을 생성하도록 하여 이러한 문제들을 효과적으로 해결합니다.
개요
간단히 말해서, 전통적인 LLM은 훈련된 시점의 지식만 가지고 있는 "고정된 두뇌"와 같습니다. 실무에서 AI 시스템을 구축할 때 가장 큰 문제는 세 가지입니다.
첫째, LLM은 훈련 데이터에 포함되지 않은 정보를 알 수 없습니다. 둘째, 시간이 지나면서 정보가 낡아집니다(Knowledge Cutoff 문제).
셋째, 모델이 확신 없는 질문에 대해 그럴듯한 거짓 답변을 생성할 수 있습니다. 예를 들어, 2024년 최신 기술 동향이나 회사 내부 정책 문서에 대한 질문에는 제대로 답변할 수 없습니다.
기존에는 이런 문제를 해결하기 위해 모델을 재훈련하거나 파인튜닝을 해야 했습니다. 하지만 이제는 RAG 시스템을 통해 모델을 다시 훈련하지 않고도 최신 정보와 도메인 전문 지식을 활용할 수 있습니다.
전통적인 LLM의 핵심 한계는 정적인 지식(훈련 시점에 고정), 환각 현상(거짓 정보 생성), 그리고 출처 추적 불가(어떤 정보를 바탕으로 답변했는지 알 수 없음)입니다. 이러한 한계들은 신뢰성 있는 비즈니스 애플리케이션을 구축하는 데 큰 장애물이 됩니다.
코드 예제
# 전통적인 LLM 사용 예시 - 한계를 보여주는 코드
from openai import OpenAI
client = OpenAI(api_key="your-api-key")
# 최신 정보에 대한 질문
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "2024년 12월에 출시된 우리 회사의 신제품 가격은?"}
]
)
# LLM은 학습하지 않은 정보이므로 정확한 답변 불가
print(response.choices[0].message.content)
# 결과: "죄송하지만, 저는 회사의 실시간 제품 정보에 접근할 수 없습니다."
# 또는 더 심각하게, 잘못된 정보를 생성할 수도 있음 (환각 현상)
설명
이것이 하는 일: 위 코드는 전통적인 방식으로 OpenAI의 GPT 모델을 사용하는 예시입니다. 사용자가 회사의 최신 제품 정보에 대해 질문하지만, 모델은 이 정보를 학습하지 않았기 때문에 정확한 답변을 제공할 수 없습니다.
첫 번째로, OpenAI 클라이언트를 초기화하고 API 키를 설정합니다. 이는 GPT 모델과 통신하기 위한 기본 설정입니다.
API 키는 여러분의 계정을 인증하고 사용량을 추적하는 데 사용됩니다. 그 다음으로, chat.completions.create() 메서드를 호출하여 모델에게 질문을 전달합니다.
messages 배열에는 대화 히스토리가 포함되며, 여기서는 단순히 사용자의 질문 하나만 전달합니다. 모델은 이 질문을 받아 자신의 내부 지식(훈련 데이터)만을 바탕으로 답변을 생성하려고 시도합니다.
마지막으로, 모델의 응답을 받아 출력합니다. 하지만 모델은 "2024년 12월에 출시된 특정 회사의 신제품"이라는 정보를 학습한 적이 없기 때문에, 정확한 가격을 알려줄 수 없습니다.
최선의 경우 모르겠다고 답하지만, 최악의 경우 그럴듯한 거짓 정보를 만들어낼 수 있습니다. 여러분이 이 코드를 사용하면 전통적인 LLM의 명확한 한계를 확인할 수 있습니다.
모델 재훈련 없이는 새로운 정보를 추가할 수 없고, 특정 도메인 지식에 대한 정확성을 보장할 수 없으며, 답변의 출처를 추적할 수 없다는 점이 실무에서 큰 제약이 됩니다.
실전 팁
💡 LLM의 Knowledge Cutoff 날짜를 항상 확인하세요. GPT-3.5-turbo는 2021년 9월, GPT-4는 2023년 4월까지의 데이터로 훈련되었습니다. 이후의 정보에 대해서는 신뢰할 수 없습니다.
💡 환각 현상을 감지하려면 모델에게 "확실하지 않으면 '모르겠습니다'라고 답하세요"라는 지시를 프롬프트에 포함시키세요. 이렇게 하면 잘못된 정보 생성을 어느 정도 줄일 수 있습니다.
💡 비즈니스 크리티컬한 정보(가격, 법률, 의료 등)는 전통적인 LLM만으로 처리하지 마세요. 반드시 검증된 데이터 소스와 결합해야 합니다.
💡 모델의 응답에 temperature 파라미터를 낮게 설정(0.0~0.3)하면 더 일관되고 사실적인 답변을 얻을 수 있지만, 창의성은 떨어집니다.
💡 프로덕션 환경에서는 항상 모델의 답변을 로깅하고 모니터링하여 환각 현상이나 부적절한 응답을 추적하세요.
2. RAG의 핵심 개념 - 검색과 생성의 결합
시작하며
여러분이 중요한 시험을 준비할 때, 모든 내용을 외우는 것과 필요할 때 책을 찾아보는 것 중 어느 것이 더 효율적일까요? 대부분의 경우 후자가 훨씬 현실적이고 정확합니다.
AI 시스템도 마찬가지입니다. 모든 정보를 모델 안에 넣으려고 하는 것보다, 필요한 정보를 외부에서 찾아와 활용하는 것이 훨씬 효과적입니다.
특히 정보가 자주 업데이트되거나 방대한 양의 도메인 지식이 필요한 경우에는 더욱 그렇습니다. 바로 이것이 RAG(Retrieval-Augmented Generation)의 핵심 아이디어입니다.
관련 정보를 먼저 검색(Retrieval)한 다음, 그 정보를 바탕으로 답변을 생성(Generation)하는 두 단계 프로세스를 통해 LLM의 한계를 극복합니다.
개요
간단히 말해서, RAG는 "똑똑한 사서와 작가의 협업"과 같습니다. 사서(검색 시스템)가 관련 자료를 찾아오면, 작가(LLM)가 그 자료를 바탕으로 답변을 작성하는 것입니다.
RAG가 필요한 이유는 명확합니다. 첫째, 최신 정보를 실시간으로 활용할 수 있습니다.
둘째, 모델을 재훈련하지 않고도 지식을 업데이트할 수 있습니다. 셋째, 답변의 출처를 명확히 추적할 수 있어 신뢰성이 높아집니다.
예를 들어, 법률 자문 챗봇이나 기술 문서 검색 시스템 같은 경우에 매우 유용합니다. 기존에는 새로운 정보를 추가하려면 모델을 파인튜닝하거나 재훈련해야 했습니다.
이제는 RAG를 통해 단순히 지식 베이스에 문서를 추가하는 것만으로 시스템을 업데이트할 수 있습니다. RAG의 핵심 특징은 모듈성(검색과 생성을 분리), 확장성(지식 베이스를 쉽게 확장 가능), 그리고 투명성(답변의 근거를 명확히 제시)입니다.
이러한 특징들이 RAG를 프로덕션 환경에서 신뢰할 수 있는 AI 시스템으로 만들어줍니다.
코드 예제
# RAG의 기본 개념을 보여주는 의사 코드
def rag_system(user_question):
# 1단계: 검색 (Retrieval)
# 사용자 질문과 관련된 문서들을 벡터 DB에서 검색
relevant_documents = vector_db.search(
query=user_question,
top_k=3 # 가장 관련성 높은 3개 문서
)
# 2단계: 컨텍스트 구성
# 검색된 문서들을 하나의 컨텍스트로 결합
context = "\n\n".join([doc.content for doc in relevant_documents])
# 3단계: 생성 (Generation)
# LLM에게 컨텍스트와 함께 질문 전달
prompt = f"""다음 정보를 바탕으로 질문에 답하세요:
{context}
질문: {user_question}
답변:"""
# LLM이 컨텍스트를 참고하여 답변 생성
answer = llm.generate(prompt)
# 4단계: 출처와 함께 반환
return {
"answer": answer,
"sources": [doc.metadata for doc in relevant_documents]
}
설명
이것이 하는 일: 위 코드는 RAG 시스템의 전체 워크플로우를 보여주는 개념적 예시입니다. 사용자의 질문을 받아 관련 문서를 검색하고, 그 문서를 바탕으로 LLM이 답변을 생성하는 전체 과정을 단계별로 나타냅니다.
첫 번째로, 검색 단계에서는 사용자의 질문을 벡터 데이터베이스에서 검색합니다. 여기서 핵심은 단순한 키워드 매칭이 아니라 의미론적 유사도(semantic similarity)를 기반으로 검색한다는 점입니다.
질문과 가장 관련성 높은 상위 3개 문서를 가져옵니다. top_k 값은 성능과 정확성의 트레이드오프를 조절하는 중요한 파라미터입니다.
그 다음으로, 검색된 문서들을 하나의 컨텍스트로 결합합니다. 각 문서의 내용을 줄바꿈으로 구분하여 연결하면, LLM이 이해할 수 있는 형태의 컨텍스트가 만들어집니다.
이 컨텍스트가 LLM에게 제공될 "참고 자료"가 됩니다. 세 번째 단계에서는 구성된 컨텍스트와 사용자 질문을 결합하여 프롬프트를 만듭니다.
"다음 정보를 바탕으로"라는 지시를 통해 LLM이 자신의 내부 지식이 아닌 제공된 컨텍스트를 우선적으로 참고하도록 유도합니다. 이것이 환각 현상을 크게 줄이는 핵심 메커니즘입니다.
마지막으로, LLM이 생성한 답변과 함께 출처 정보도 반환합니다. 어떤 문서를 참고했는지 메타데이터를 함께 제공함으로써 사용자가 답변의 신뢰성을 검증할 수 있습니다.
이는 특히 법률, 의료, 금융 같은 고신뢰 도메인에서 필수적입니다. 여러분이 이 시스템을 사용하면 전통적인 LLM의 한계를 극복할 수 있습니다.
지식 베이스만 업데이트하면 되므로 유지보수가 쉽고, 답변의 정확성이 높으며, 출처 추적이 가능하여 신뢰성 있는 AI 애플리케이션을 구축할 수 있습니다.
실전 팁
💡 top_k 값은 너무 크면 노이즈가 많아지고, 너무 작으면 중요한 정보를 놓칠 수 있습니다. 실험을 통해 도메인에 맞는 최적값을 찾으세요. 일반적으로 3~5개가 적절합니다.
💡 프롬프트에 "제공된 정보에 없는 내용은 '모르겠습니다'라고 답하세요"를 추가하면 환각 현상을 더욱 줄일 수 있습니다.
💡 검색된 문서의 관련성 점수(similarity score)도 함께 반환하여 모니터링하세요. 점수가 낮으면 검색 품질에 문제가 있을 수 있습니다.
💡 답변과 함께 출처를 UI에 표시하면 사용자 신뢰도가 크게 향상됩니다. "이 정보는 [문서명]에서 가져왔습니다" 형식으로 표시하세요.
💡 컨텍스트의 길이가 LLM의 토큰 제한을 넘지 않도록 주의하세요. GPT-3.5는 4k, GPT-4는 8k~128k 토큰 제한이 있습니다.
3. 벡터 임베딩 - 의미를 숫자로 변환하기
시작하며
여러분이 도서관에서 책을 찾을 때, 단순히 제목에 포함된 단어만 매칭한다면 어떨까요? "머신러닝 입문"이라는 책을 찾기 위해 "AI 기초"로 검색하면 아무것도 찾을 수 없을 겁니다.
하지만 사람이라면 이 두 개념이 관련 있다는 것을 알 수 있습니다. 이런 문제는 검색 시스템을 구축할 때 근본적인 도전 과제입니다.
전통적인 키워드 기반 검색은 정확히 일치하는 단어만 찾을 수 있어서, 의미는 같지만 다르게 표현된 내용을 놓치게 됩니다. 예를 들어, "자동차 수리"와 "차량 정비"는 같은 의미지만 단어가 달라서 매칭되지 않습니다.
바로 이럴 때 필요한 것이 벡터 임베딩(Vector Embedding)입니다. 텍스트의 의미를 고차원 숫자 벡터로 변환하여, 의미론적으로 유사한 텍스트들이 벡터 공간에서 가까이 위치하도록 만듭니다.
이를 통해 진정한 의미 기반 검색이 가능해집니다.
개요
간단히 말해서, 벡터 임베딩은 텍스트를 수백~수천 개의 숫자로 이루어진 벡터로 변환하는 기술입니다. 이 벡터는 텍스트의 의미를 수학적으로 표현한 것입니다.
RAG 시스템에서 벡터 임베딩이 필요한 이유는 세 가지입니다. 첫째, 의미론적 유사도를 계산할 수 있습니다.
"강아지"와 "개"가 유사하다는 것을 시스템이 이해할 수 있습니다. 둘째, 언어의 뉘앙스를 포착합니다.
같은 의미를 다르게 표현한 문장들도 유사한 벡터로 변환됩니다. 셋째, 수학적 연산이 가능해집니다.
코사인 유사도 같은 방법으로 빠르게 관련 문서를 찾을 수 있습니다. 예를 들어, 고객 문의 시스템에서 "환불 받고 싶어요"와 "돈 돌려받을 수 있나요?"를 같은 의도로 인식할 수 있습니다.
기존에는 TF-IDF나 BM25 같은 키워드 기반 방법을 사용했습니다. 이제는 BERT, OpenAI Embeddings 같은 딥러닝 기반 임베딩 모델을 통해 훨씬 더 정교한 의미 이해가 가능합니다.
벡터 임베딩의 핵심 특징은 차원성(보통 384~1536 차원의 벡터), 의미 보존(비슷한 의미는 비슷한 벡터), 그리고 컨텍스트 의존성(같은 단어도 문맥에 따라 다른 벡터)입니다. 이러한 특징들이 RAG 시스템의 검색 품질을 결정짓는 핵심 요소입니다.
코드 예제
# OpenAI의 임베딩 API를 사용한 벡터 변환
from openai import OpenAI
import numpy as np
client = OpenAI(api_key="your-api-key")
# 텍스트를 벡터로 변환하는 함수
def get_embedding(text, model="text-embedding-3-small"):
# OpenAI API를 통해 텍스트를 1536차원 벡터로 변환
response = client.embeddings.create(
input=text,
model=model
)
# 벡터 데이터 추출
return response.data[0].embedding
# 예시: 두 문장의 유사도 계산
text1 = "머신러닝은 AI의 한 분야입니다"
text2 = "인공지능의 하위 영역으로 기계학습이 있습니다"
text3 = "오늘 날씨가 좋네요"
# 각 텍스트를 벡터로 변환
vec1 = get_embedding(text1)
vec2 = get_embedding(text2)
vec3 = get_embedding(text3)
# 코사인 유사도 계산 (두 벡터가 얼마나 유사한지 측정)
def cosine_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
print(f"Text1 vs Text2 유사도: {cosine_similarity(vec1, vec2):.3f}") # 높은 값
print(f"Text1 vs Text3 유사도: {cosine_similarity(vec1, vec3):.3f}") # 낮은 값
설명
이것이 하는 일: 위 코드는 OpenAI의 임베딩 API를 사용하여 텍스트를 벡터로 변환하고, 두 텍스트가 의미적으로 얼마나 유사한지 계산하는 예시입니다. RAG 시스템의 검색 단계에서 핵심적으로 사용되는 기술입니다.
첫 번째로, get_embedding 함수를 정의합니다. 이 함수는 입력받은 텍스트를 OpenAI의 text-embedding-3-small 모델을 통해 1536차원의 벡터로 변환합니다.
각 차원은 텍스트의 특정 의미적 특징을 나타내며, 수백 억 개의 텍스트로 학습된 모델이 만들어낸 의미 공간에서의 좌표입니다. 그 다음으로, 세 개의 서로 다른 문장을 벡터로 변환합니다.
text1과 text2는 표현은 다르지만 같은 개념(머신러닝과 AI의 관계)을 다루고 있고, text3는 완전히 다른 주제(날씨)입니다. 각 문장은 1536개의 실수로 이루어진 벡터로 변환되는데, 이 벡터들은 언어 모델이 학습한 의미 공간에서의 위치를 나타냅니다.
세 번째 단계에서는 코사인 유사도를 계산합니다. 이는 두 벡터 사이의 각도를 측정하는 방법으로, -1에서 1 사이의 값을 가집니다.
1에 가까울수록 의미가 유사하고, 0에 가까울수록 관련이 없습니다. 두 벡터의 내적을 각 벡터의 크기로 나누어 정규화된 유사도를 얻습니다.
마지막으로, 결과를 출력합니다. text1과 text2는 단어는 다르지만 의미가 유사하므로 높은 유사도(보통 0.7~0.9)를 보입니다.
반면 text1과 text3는 완전히 다른 주제이므로 낮은 유사도(보통 0.1~0.3)를 보입니다. 이것이 바로 키워드 매칭으로는 불가능한 의미 기반 검색의 핵심입니다.
여러분이 이 기술을 사용하면 사용자가 어떤 표현을 사용하든 의도를 정확히 파악할 수 있습니다. 검색 품질이 크게 향상되고, 동의어나 유사 표현을 따로 등록할 필요가 없으며, 다국어 지원도 같은 방식으로 가능합니다.
실전 팁
💡 text-embedding-3-small(1536차원)은 비용이 저렴하고 빠르지만, text-embedding-3-large(3072차원)는 더 정확합니다. 프로토타입에서는 small, 프로덕션에서는 large를 고려하세요.
💡 임베딩은 캐싱하세요! 같은 텍스트를 반복해서 임베딩하면 비용과 시간이 낭비됩니다. Redis나 로컬 DB에 저장하여 재사용하세요.
💡 텍스트가 너무 길면(8191 토큰 초과) 잘라야 합니다. 긴 문서는 청크로 나누어 각각 임베딩하는 것이 좋습니다.
💡 코사인 유사도 외에도 유클리드 거리(Euclidean distance)나 내적(dot product)을 사용할 수 있습니다. 벡터 DB마다 지원하는 거리 측정 방법이 다르니 확인하세요.
💡 임베딩 모델을 변경하면 기존 벡터들과 호환되지 않습니다. 모델 업그레이드 시 전체 데이터를 다시 임베딩해야 하므로 신중하게 선택하세요.
4. 벡터 데이터베이스 - 고속 유사도 검색의 핵심
시작하며
여러분이 1억 개의 문서 중에서 사용자 질문과 가장 유사한 5개를 찾아야 한다면 어떻게 하시겠습니까? 모든 문서를 하나씩 비교하면 몇 분이 걸릴 수도 있습니다.
하지만 사용자는 1초 안에 답변을 기대합니다. 이런 문제는 대규모 RAG 시스템을 구축할 때 반드시 해결해야 하는 과제입니다.
수백만 개의 벡터 중에서 쿼리 벡터와 가장 유사한 것들을 빠르게 찾아내려면 특별한 데이터 구조와 알고리즘이 필요합니다. 전통적인 관계형 데이터베이스로는 이런 고차원 벡터 검색을 효율적으로 처리할 수 없습니다.
바로 이럴 때 필요한 것이 벡터 데이터베이스(Vector Database)입니다. HNSW, IVF 같은 근사 최근접 이웃(ANN) 알고리즘을 사용하여 밀리초 단위로 유사한 벡터를 찾아냅니다.
개요
간단히 말해서, 벡터 데이터베이스는 고차원 벡터를 저장하고 유사도 기반으로 빠르게 검색할 수 있도록 최적화된 특수한 데이터베이스입니다. 벡터 DB가 필요한 이유는 명확합니다.
첫째, 속도입니다. 수백만 개의 벡터 중에서 밀리초 안에 결과를 찾습니다.
둘째, 확장성입니다. 데이터가 늘어나도 검색 속도가 크게 느려지지 않습니다.
셋째, 메타데이터 필터링입니다. "2024년에 작성된 문서 중에서 유사한 것 찾기" 같은 복합 쿼리가 가능합니다.
예를 들어, 대규모 고객 지원 시스템에서 수백만 개의 FAQ 중에서 관련 항목을 실시간으로 찾아야 할 때 필수적입니다. 기존에는 PostgreSQL의 pgvector 확장이나 Elasticsearch를 사용했습니다.
이제는 Pinecone, Weaviate, Qdrant, Chroma 같은 전용 벡터 DB가 훨씬 더 나은 성능과 기능을 제공합니다. 벡터 DB의 핵심 특징은 ANN 검색(정확도를 약간 희생하고 속도 확보), 인덱싱 전략(HNSW, IVF-PQ 등), 그리고 하이브리드 검색(벡터 검색과 키워드 검색 결합)입니다.
이러한 특징들이 프로덕션급 RAG 시스템의 성능을 좌우합니다.
코드 예제
# Pinecone을 사용한 벡터 DB 예시
from pinecone import Pinecone, ServerlessSpec
from openai import OpenAI
# 초기화
pc = Pinecone(api_key="your-pinecone-key")
openai_client = OpenAI(api_key="your-openai-key")
# 인덱스 생성 (최초 1회만)
index_name = "rag-knowledge-base"
if index_name not in pc.list_indexes().names():
pc.create_index(
name=index_name,
dimension=1536, # OpenAI embedding 차원
metric="cosine", # 유사도 측정 방법
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
index = pc.Index(index_name)
# 문서를 벡터로 변환하여 저장
def store_document(doc_id, text, metadata):
# 텍스트를 임베딩으로 변환
embedding = openai_client.embeddings.create(
input=text,
model="text-embedding-3-small"
).data[0].embedding
# 벡터 DB에 저장 (id, vector, metadata)
index.upsert(vectors=[(doc_id, embedding, metadata)])
# 예시: 여러 문서 저장
store_document("doc1", "RAG는 검색과 생성을 결합한 시스템입니다",
{"category": "AI", "date": "2024-01"})
store_document("doc2", "벡터 임베딩은 텍스트를 숫자로 변환합니다",
{"category": "AI", "date": "2024-01"})
# 쿼리: 유사한 문서 검색
def search(query, top_k=3):
# 쿼리를 벡터로 변환
query_embedding = openai_client.embeddings.create(
input=query,
model="text-embedding-3-small"
).data[0].embedding
# 유사한 벡터 검색
results = index.query(vector=query_embedding, top_k=top_k,
include_metadata=True)
return results
# 실행
results = search("RAG 시스템이 뭔가요?")
for match in results.matches:
print(f"Score: {match.score}, ID: {match.id}, Metadata: {match.metadata}")
설명
이것이 하는 일: 위 코드는 Pinecone 벡터 데이터베이스를 사용하여 문서를 저장하고 검색하는 완전한 예시입니다. RAG 시스템의 핵심 인프라인 벡터 검색 파이프라인을 구현합니다.
첫 번째로, Pinecone과 OpenAI 클라이언트를 초기화하고 인덱스를 생성합니다. dimension=1536은 OpenAI의 임베딩 벡터 크기에 맞춰진 것이고, metric="cosine"은 코사인 유사도로 검색하겠다는 의미입니다.
ServerlessSpec을 사용하면 인프라 관리 없이 자동으로 확장되는 서비스를 이용할 수 있습니다. 인덱스는 한 번만 생성하면 되므로 조건문으로 중복 생성을 방지합니다.
그 다음으로, store_document 함수를 정의합니다. 이 함수는 문서 텍스트를 받아 OpenAI API로 임베딩 벡터로 변환한 후, Pinecone에 저장합니다.
각 벡터는 고유 ID, 벡터 데이터, 그리고 메타데이터(카테고리, 날짜 등)와 함께 저장됩니다. upsert 메서드는 같은 ID가 있으면 업데이트하고 없으면 삽입합니다.
세 번째 단계에서는 실제로 두 개의 문서를 저장합니다. 각 문서는 RAG와 임베딩에 대한 설명을 담고 있으며, AI 카테고리와 날짜 메타데이터를 포함합니다.
이 메타데이터는 나중에 필터링할 때 유용하게 사용됩니다. 마지막으로, search 함수는 사용자 쿼리를 받아 유사한 문서를 찾습니다.
쿼리를 임베딩으로 변환한 후, index.query()로 벡터 공간에서 가장 가까운 top_k개의 벡터를 찾습니다. 결과에는 유사도 점수(score), 문서 ID, 그리고 메타데이터가 포함됩니다.
예를 들어, "RAG 시스템이 뭔가요?"라는 질문에 대해 doc1이 가장 높은 점수로 반환될 것입니다. 여러분이 이 시스템을 사용하면 수백만 개의 문서를 저장하고도 밀리초 안에 관련 문서를 찾을 수 있습니다.
실시간 검색이 가능하고, 메타데이터 필터링으로 정교한 쿼리를 구현할 수 있으며, 서버리스 아키텍처로 운영 부담이 적습니다.
실전 팁
💡 무료 플랜으로 시작하세요. Pinecone은 100만 벡터까지 무료, Qdrant는 로컬에서 무료로 사용할 수 있습니다. 프로토타입에서는 충분합니다.
💡 배치로 저장하세요. 한 번에 하나씩 저장하는 것보다 100~1000개씩 배치로 저장하면 속도가 10배 이상 빠릅니다. upsert(vectors=[...])의 리스트에 여러 벡터를 넣으세요.
💡 메타데이터 필터링을 적극 활용하세요. filter={"category": "AI"}처럼 특정 조건을 만족하는 문서만 검색하면 정확도가 향상됩니다.
💡 namespace를 사용하여 데이터를 논리적으로 분리하세요. 예를 들어, 고객별로 다른 namespace를 사용하면 멀티테넌트 시스템을 쉽게 구축할 수 있습니다.
💡 주기적으로 인덱스 통계를 확인하세요. index.describe_index_stats()로 저장된 벡터 수와 차원을 모니터링하여 시스템 상태를 파악하세요.
5. RAG 파이프라인 구축 - 전체 시스템 통합
시작하며
여러분이 지금까지 배운 모든 개념들(임베딩, 벡터 DB, LLM)을 어떻게 하나의 작동하는 시스템으로 만들 수 있을까요? 각 부분은 이해했지만 실제로 구현하려면 어디서부터 시작해야 할지 막막할 수 있습니다.
실제 프로덕션 환경에서 RAG 시스템을 구축할 때는 여러 단계를 거쳐야 합니다. 문서 수집, 청크 분할, 임베딩 생성, 벡터 저장, 검색, 그리고 답변 생성까지 이 모든 과정이 유기적으로 연결되어야 합니다.
각 단계마다 최적화 포인트가 있고, 실수하기 쉬운 함정들이 존재합니다. 바로 이제부터 전체 RAG 파이프라인을 처음부터 끝까지 구축하는 방법을 알아보겠습니다.
문서를 준비하고, 벡터 DB에 저장하고, 사용자 질문에 답변하는 완전한 시스템을 단계별로 만들어봅니다.
개요
간단히 말해서, RAG 파이프라인은 문서 처리부터 답변 생성까지의 전체 워크플로우입니다. 완전한 RAG 시스템이 필요한 이유는 각 컴포넌트가 서로 의존하기 때문입니다.
문서를 잘못 청크하면 검색 품질이 떨어지고, 검색이 부정확하면 LLM이 좋은 답변을 만들 수 없습니다. 전체 파이프라인을 이해해야 각 단계를 최적화할 수 있습니다.
예를 들어, 기술 문서 검색 시스템에서는 청크 크기, 오버랩, 메타데이터 추출이 모두 최종 답변의 품질에 영향을 미칩니다. 기존에는 각 단계를 수동으로 연결하고 관리해야 했습니다.
이제는 LangChain, LlamaIndex 같은 프레임워크가 이런 복잡성을 추상화해주지만, 내부 동작을 이해하는 것이 중요합니다. RAG 파이프라인의 핵심 단계는 문서 로딩(PDF, 웹, DB 등에서 수집), 청크 분할(적절한 크기로 나누기), 임베딩 및 저장(벡터 DB에 인덱싱), 그리고 검색 및 생성(쿼리 처리)입니다.
각 단계의 설정이 전체 시스템의 성능을 결정합니다.
코드 예제
# 완전한 RAG 시스템 구현 예시
from openai import OpenAI
from pinecone import Pinecone
import tiktoken
class RAGSystem:
def __init__(self, openai_key, pinecone_key, index_name):
self.openai = OpenAI(api_key=openai_key)
self.pc = Pinecone(api_key=pinecone_key)
self.index = self.pc.Index(index_name)
self.tokenizer = tiktoken.get_encoding("cl100k_base")
# 1단계: 문서를 청크로 분할
def chunk_text(self, text, chunk_size=500, overlap=50):
"""텍스트를 토큰 기반으로 청크 분할"""
tokens = self.tokenizer.encode(text)
chunks = []
for i in range(0, len(tokens), chunk_size - overlap):
chunk_tokens = tokens[i:i + chunk_size]
chunk_text = self.tokenizer.decode(chunk_tokens)
chunks.append(chunk_text)
return chunks
# 2단계: 문서를 벡터 DB에 저장
def ingest_document(self, doc_id, text, metadata=None):
"""문서를 청크로 나누고 벡터 DB에 저장"""
chunks = self.chunk_text(text)
for idx, chunk in enumerate(chunks):
# 각 청크를 임베딩
embedding = self.openai.embeddings.create(
input=chunk,
model="text-embedding-3-small"
).data[0].embedding
# 벡터 DB에 저장
chunk_id = f"{doc_id}_chunk_{idx}"
chunk_metadata = {**(metadata or {}), "chunk_index": idx, "text": chunk}
self.index.upsert(vectors=[(chunk_id, embedding, chunk_metadata)])
return len(chunks)
# 3단계: 질문에 답변 생성
def query(self, question, top_k=3):
"""질문을 받아 관련 문서를 검색하고 답변 생성"""
# 질문을 임베딩
query_embedding = self.openai.embeddings.create(
input=question,
model="text-embedding-3-small"
).data[0].embedding
# 유사한 청크 검색
results = self.index.query(
vector=query_embedding,
top_k=top_k,
include_metadata=True
)
# 컨텍스트 구성
context = "\n\n".join([match.metadata["text"] for match in results.matches])
# LLM으로 답변 생성
response = self.openai.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "제공된 컨텍스트를 바탕으로 정확하게 답변하세요. 컨텍스트에 없는 정보는 '모르겠습니다'라고 답하세요."},
{"role": "user", "content": f"컨텍스트:\n{context}\n\n질문: {question}"}
]
)
return {
"answer": response.choices[0].message.content,
"sources": [{"id": m.id, "score": m.score} for m in results.matches]
}
# 사용 예시
rag = RAGSystem(openai_key="your-key", pinecone_key="your-key", index_name="rag-index")
# 문서 추가
rag.ingest_document("doc1", "RAG는 검색 증강 생성 시스템입니다. 외부 지식을 활용하여 LLM의 한계를 극복합니다.",
{"category": "AI"})
# 질문
result = rag.query("RAG가 뭔가요?")
print(f"답변: {result['answer']}")
print(f"출처: {result['sources']}")
설명
이것이 하는 일: 위 코드는 프로덕션에서 사용할 수 있는 완전한 RAG 시스템의 구현입니다. 문서를 추가하고 질문에 답변하는 전체 프로세스를 클래스로 캡슐화했습니다.
첫 번째로, RAGSystem 클래스를 초기화할 때 OpenAI, Pinecone, 그리고 토크나이저를 설정합니다. 토크나이저(tiktoken)는 텍스트를 토큰 단위로 정확히 분할하기 위해 필요합니다.
OpenAI의 모델은 토큰 기반으로 동작하므로 문자 수가 아닌 토큰 수로 청크를 나누는 것이 정확합니다. 그 다음으로, chunk_text 메서드는 긴 문서를 작은 청크로 나눕니다.
chunk_size=500은 각 청크가 약 500 토큰(약 375 단어)이 되도록 하고, overlap=50은 인접한 청크 간에 50 토큰씩 겹치게 합니다. 오버랩이 중요한 이유는 문장이나 문단이 청크 경계에서 잘리는 것을 방지하기 위해서입니다.
예를 들어, "RAG 시스템은 두 가지 주요 컴포넌트로 구성됩니다"라는 문장이 중간에 잘리면 의미가 손실될 수 있습니다. 세 번째로, ingest_document 메서드는 문서 수집 파이프라인입니다.
텍스트를 청크로 나누고, 각 청크를 임베딩으로 변환한 후, 고유 ID와 메타데이터와 함께 벡터 DB에 저장합니다. chunk_id에 인덱스를 포함시켜 청크의 순서를 추적할 수 있고, 메타데이터에 원본 텍스트를 저장하여 나중에 검색 시 사용합니다.
100페이지 문서라면 수백 개의 청크로 나뉘어 각각 벡터로 저장됩니다. 네 번째로, query 메서드는 사용자 질문 처리 파이프라인입니다.
질문을 임베딩으로 변환하고, 벡터 DB에서 가장 유사한 청크들을 찾고, 그 청크들을 컨텍스트로 결합하여 LLM에게 전달합니다. 시스템 프롬프트에 "컨텍스트에 없는 정보는 모르겠다고 답하세요"를 추가하여 환각을 방지합니다.
최종적으로 답변과 함께 출처 정보(어떤 청크를 참고했는지)도 반환하여 투명성을 확보합니다. 여러분이 이 시스템을 사용하면 몇 줄의 코드로 강력한 RAG 애플리케이션을 구축할 수 있습니다.
회사 문서, 제품 매뉴얼, 기술 문서 등 어떤 지식 베이스든 쉽게 통합할 수 있고, 지속적으로 문서를 추가하여 시스템을 업데이트할 수 있으며, 답변의 근거를 명확히 제시할 수 있습니다.
실전 팁
💡 청크 크기는 도메인에 따라 조정하세요. 기술 문서는 작은 청크(300500토큰), 소설이나 에세이는 큰 청크(10002000토큰)가 적합합니다.
💡 오버랩은 청크 크기의 10~20%가 적절합니다. 너무 크면 중복이 많아지고, 너무 작으면 문맥이 끊깁니다.
💡 메타데이터를 풍부하게 저장하세요. 문서 제목, 작성자, 날짜, 카테고리 등을 저장하면 검색 정확도가 크게 향상됩니다.
💡 배치 처리를 구현하세요. 수천 개의 문서를 처리할 때는 멀티스레딩이나 비동기 처리로 속도를 10배 이상 높일 수 있습니다.
💡 로깅과 모니터링을 추가하세요. 각 쿼리의 검색 점수, 응답 시간, 사용된 토큰 수를 추적하면 시스템을 지속적으로 개선할 수 있습니다.
6. 문서 청킹 전략 - 검색 품질을 좌우하는 핵심
시작하며
여러분이 RAG 시스템을 구축했는데 답변 품질이 기대에 못 미친다면, 가장 먼저 의심해야 할 부분이 어디일까요? 많은 경우 문제는 모델이 아니라 문서를 어떻게 나누었는지에 있습니다.
청크 크기가 너무 작으면 문맥이 부족하여 LLM이 제대로 이해하지 못합니다. 반대로 너무 크면 관련 없는 정보가 많이 포함되어 노이즈가 됩니다.
예를 들어, "Python의 데코레이터는 무엇인가요?"라는 질문에 데코레이터 부분만 담긴 작은 청크를 검색하면 정의만 알려주고, 전체 Python 문법을 담은 큰 청크를 검색하면 불필요한 정보가 너무 많아집니다. 바로 이럴 때 필요한 것이 적절한 청킹 전략입니다.
고정 크기 청킹, 문장 기반 청킹, 의미론적 청킹 등 다양한 방법이 있으며, 각각 장단점이 있습니다. 도메인과 문서 타입에 맞는 전략을 선택하는 것이 RAG 시스템 성공의 열쇠입니다.
개요
간단히 말해서, 문서 청킹은 긴 문서를 검색과 생성에 최적화된 작은 단위로 나누는 기술입니다. 청킹 전략이 중요한 이유는 검색 품질과 직결되기 때문입니다.
잘못된 청킹은 관련 정보를 놓치거나, 불필요한 정보를 포함하거나, 문맥을 깨뜨립니다. 올바른 청킹은 정확한 검색, 효율적인 토큰 사용, 그리고 의미 있는 답변으로 이어집니다.
예를 들어, API 문서에서는 각 엔드포인트를 별도 청크로 만들고, 소설에서는 장면이나 단락 단위로 나누는 것이 효과적입니다. 기존에는 단순히 글자 수나 단어 수로 고정 크기로 나눴습니다.
이제는 토큰 기반, 문장 경계 인식, 의미론적 유사도 기반 청킹 등 훨씬 정교한 방법들이 사용됩니다. 청킹 전략의 핵심 요소는 크기(너무 작지도 크지도 않게), 오버랩(문맥 유지), 그리고 구조 인식(헤더, 리스트, 코드 블록 등 보존)입니다.
이러한 요소들을 균형 있게 조정하는 것이 성능 최적화의 핵심입니다.
코드 예제
# 다양한 청킹 전략 구현
import tiktoken
import re
class DocumentChunker:
def __init__(self):
self.tokenizer = tiktoken.get_encoding("cl100k_base")
# 전략 1: 고정 크기 토큰 기반 청킹 (가장 기본)
def fixed_size_chunking(self, text, chunk_size=500, overlap=50):
"""토큰 기반 고정 크기 청킹"""
tokens = self.tokenizer.encode(text)
chunks = []
for i in range(0, len(tokens), chunk_size - overlap):
chunk_tokens = tokens[i:i + chunk_size]
chunks.append(self.tokenizer.decode(chunk_tokens))
return chunks
# 전략 2: 문장 경계 기반 청킹 (더 자연스러움)
def sentence_based_chunking(self, text, max_tokens=500):
"""문장 단위로 청킹하되 토큰 제한 준수"""
# 문장으로 분할 (간단한 정규식 사용)
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current_chunk = []
current_tokens = 0
for sentence in sentences:
sentence_tokens = len(self.tokenizer.encode(sentence))
# 현재 청크에 추가하면 제한 초과 시 새 청크 시작
if current_tokens + sentence_tokens > max_tokens and current_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = [sentence]
current_tokens = sentence_tokens
else:
current_chunk.append(sentence)
current_tokens += sentence_tokens
# 마지막 청크 추가
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
# 전략 3: 마크다운 구조 인식 청킹 (문서 구조 보존)
def markdown_aware_chunking(self, text, max_tokens=500):
"""마크다운 헤더를 기준으로 청킹"""
# 헤더로 섹션 분할
sections = re.split(r'\n(#{1,6}\s+.+)\n', text)
chunks = []
for i in range(0, len(sections), 2):
# 헤더와 내용 결합
if i + 1 < len(sections):
section_text = sections[i] + "\n" + sections[i+1]
else:
section_text = sections[i]
# 섹션이 너무 크면 문장 기반으로 재분할
section_tokens = len(self.tokenizer.encode(section_text))
if section_tokens > max_tokens:
sub_chunks = self.sentence_based_chunking(section_text, max_tokens)
chunks.extend(sub_chunks)
else:
chunks.append(section_text)
return chunks
# 사용 예시
chunker = DocumentChunker()
document = """
# RAG 시스템
RAG는 검색 증강 생성의 약자입니다. 이 시스템은 두 가지 주요 컴포넌트로 구성됩니다.
## 검색 컴포넌트
검색 컴포넌트는 벡터 데이터베이스를 사용합니다. 사용자 질문과 유사한 문서를 찾습니다.
## 생성 컴포넌트
생성 컴포넌트는 LLM을 사용하여 답변을 만듭니다. 검색된 컨텍스트를 바탕으로 작동합니다.
"""
# 세 가지 전략 비교
print("고정 크기:", len(chunker.fixed_size_chunking(document, chunk_size=100)))
print("문장 기반:", len(chunker.sentence_based_chunking(document, max_tokens=100)))
print("마크다운 인식:", len(chunker.markdown_aware_chunking(document, max_tokens=150)))
설명
이것이 하는 일: 위 코드는 세 가지 주요 청킹 전략을 구현하여 각각의 특징과 사용 사례를 보여줍니다. 같은 문서라도 어떻게 나누느냐에 따라 검색 품질이 크게 달라집니다.
첫 번째로, fixed_size_chunking은 가장 기본적인 방법입니다. 토큰 수를 기준으로 기계적으로 분할하며, 구현이 간단하고 예측 가능합니다.
chunk_size=500은 각 청크가 약 375 단어(토큰당 0.75 단어)가 되도록 하고, overlap=50은 인접 청크가 50 토큰씩 겹치게 합니다. 이 방법의 장점은 속도가 빠르고 토큰 제한을 정확히 지킬 수 있다는 것이지만, 문장 중간에서 잘릴 수 있다는 단점이 있습니다.
그 다음으로, sentence_based_chunking은 문장 경계를 존중합니다. 정규식으로 문장을 분리한 후, 토큰 제한 내에서 최대한 많은 문장을 하나의 청크로 묶습니다.
예를 들어, 현재 청크가 450 토큰이고 다음 문장이 100 토큰이면 제한(500토큰)을 초과하므로 현재 청크를 마무리하고 새 청크를 시작합니다. 이 방법은 문맥이 자연스럽게 보존되어 LLM이 이해하기 쉽지만, 청크 크기가 불균일할 수 있습니다.
세 번째로, markdown_aware_chunking은 문서 구조를 인식합니다. 마크다운 헤더(#, ##, ###)를 기준으로 섹션을 나누어, 각 주제별로 청크가 만들어집니다.
"# RAG 시스템"이라는 헤더 아래의 모든 내용이 하나의 논리적 단위가 됩니다. 섹션이 너무 크면 자동으로 문장 기반 청킹을 적용하여 재분할합니다.
이 방법은 기술 문서, API 문서, 튜토리얼처럼 명확한 구조가 있는 문서에 최적입니다. 마지막 예시에서는 같은 문서를 세 가지 방법으로 청킹한 결과를 비교합니다.
고정 크기는 기계적으로 분할하여 청크 수가 많을 수 있고, 문장 기반은 자연스럽게 나뉘며, 마크다운 인식은 구조를 유지하면서 가장 의미 있는 청크를 만듭니다. 여러분이 이 전략들을 사용하면 문서 타입에 맞는 최적의 청킹을 선택할 수 있습니다.
뉴스 기사는 문장 기반, 코드 문서는 구조 인식, 소설은 고정 크기가 적합할 수 있습니다. 실험을 통해 도메인에 맞는 전략을 찾는 것이 중요합니다.
실전 팁
💡 청크 크기는 5001000 토큰이 일반적으로 적합합니다. GPT-3.5의 컨텍스트 윈도우(4k)를 고려하면 35개 청크를 넣을 수 있습니다.
💡 오버랩을 너무 크게 하면 중복 비용이 발생합니다. 청크 크기의 10~15%가 적정선입니다.
💡 코드 블록(```)은 절대 분할하지 마세요. 정규식으로 코드 블록을 감지하고 전체를 하나의 단위로 취급하세요.
💡 PDF에서 추출한 텍스트는 줄바꿈이 이상할 수 있습니다. 전처리 단계에서 불필요한 줄바꿈을 제거하고 정규화하세요.
💡 A/B 테스트로 청킹 전략을 평가하세요. 같은 질문 세트로 여러 전략을 테스트하여 답변 품질을 비교하면 최적 전략을 찾을 수 있습니다.
7. 하이브리드 검색 - 키워드와 의미 검색의 결합
시작하며
여러분이 "iPhone 15 Pro의 가격은 얼마인가요?"라고 질문했는데, 시스템이 "아이폰의 역사"나 "스마트폰 가격 추세" 같은 문서를 가져온다면 어떨까요? 의미는 유사하지만 정확히 원하는 정보가 아닙니다.
순수 벡터 검색의 문제는 특정 키워드(제품명, 버전 번호, 날짜 등)를 정확히 매칭하지 못할 수 있다는 것입니다. 반대로 전통적인 키워드 검색은 정확한 매칭은 잘하지만 의미적 유사성을 이해하지 못합니다.
"구매하다"와 "사다"가 같은 의미라는 것을 모릅니다. 바로 이럴 때 필요한 것이 하이브리드 검색(Hybrid Search)입니다.
벡터 검색(의미론적 유사도)과 키워드 검색(정확한 매칭)을 결합하여 두 가지 장점을 모두 활용합니다. 특히 제품 검색, 법률 문서, 기술 문서처럼 정확한 용어가 중요한 도메인에서 필수적입니다.
개요
간단히 말해서, 하이브리드 검색은 벡터 기반 의미 검색과 전통적인 키워드 검색을 동시에 수행하고 결과를 결합하는 기술입니다. 하이브리드 검색이 필요한 이유는 각 방법의 약점을 보완하기 때문입니다.
벡터 검색은 의미는 잡지만 정확한 용어를 놓칠 수 있고, 키워드 검색은 정확한 매칭은 하지만 동의어나 패러프레이즈를 못 잡습니다. 둘을 결합하면 정확성과 포괄성을 동시에 확보할 수 있습니다.
예를 들어, "Python 3.11의 새로운 기능"이라는 질문에서 "Python 3.11"은 키워드로, "새로운 기능"은 의미론적으로 검색하면 최상의 결과를 얻습니다. 기존에는 둘 중 하나만 선택해야 했습니다.
이제는 Weaviate, Qdrant 같은 벡터 DB들이 하이브리드 검색을 네이티브로 지원하여 쉽게 구현할 수 있습니다. 하이브리드 검색의 핵심 요소는 벡터 검색(의미 유사도), 키워드 검색(BM25 알고리즘), 그리고 점수 결합(Reciprocal Rank Fusion 등)입니다.
각 검색 방법의 가중치를 조절하여 도메인에 맞게 최적화할 수 있습니다.
코드 예제
# Qdrant를 사용한 하이브리드 검색 구현
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from qdrant_client.models import Filter, FieldCondition, MatchValue
from openai import OpenAI
from typing import List
class HybridSearchRAG:
def __init__(self, openai_key):
self.openai = OpenAI(api_key=openai_key)
# Qdrant 로컬 모드 (프로토타입용)
self.qdrant = QdrantClient(":memory:")
self.collection_name = "hybrid_docs"
# 컬렉션 생성
self.qdrant.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(size=1536, distance=Distance.COSINE)
)
def add_document(self, doc_id: str, text: str, metadata: dict):
"""문서를 벡터 + 키워드 인덱싱"""
# 벡터 임베딩 생성
embedding = self.openai.embeddings.create(
input=text,
model="text-embedding-3-small"
).data[0].embedding
# Qdrant에 저장 (벡터 + 텍스트)
point = PointStruct(
id=doc_id,
vector=embedding,
payload={"text": text, **metadata}
)
self.qdrant.upsert(collection_name=self.collection_name, points=[point])
def hybrid_search(self, query: str, alpha: float = 0.5, top_k: int = 5):
"""
하이브리드 검색: 벡터 + 키워드
alpha: 0.0 = 순수 키워드, 1.0 = 순수 벡터, 0.5 = 균형
"""
# 1. 벡터 검색
query_embedding = self.openai.embeddings.create(
input=query,
model="text-embedding-3-small"
).data[0].embedding
vector_results = self.qdrant.search(
collection_name=self.collection_name,
query_vector=query_embedding,
limit=top_k * 2 # 더 많이 가져와서 재순위화
)
# 2. 키워드 검색 시뮬레이션 (Qdrant 전문 검색 기능 사용)
# 실제 프로덕션에서는 Qdrant의 payload 인덱스 활용
keyword_results = self.qdrant.scroll(
collection_name=self.collection_name,
scroll_filter=Filter(
must=[
# 간단한 키워드 매칭 (실제로는 BM25 사용)
FieldCondition(
key="text",
match=MatchValue(value=query.split()[0]) # 첫 단어로 필터
)
]
),
limit=top_k
)[0]
# 3. Reciprocal Rank Fusion (RRF)로 결과 결합
scores = {}
k = 60 # RRF 파라미터
# 벡터 검색 점수
for rank, result in enumerate(vector_results):
doc_id = result.id
scores[doc_id] = scores.get(doc_id, 0) + alpha * (1 / (k + rank + 1))
# 키워드 검색 점수
for rank, result in enumerate(keyword_results):
doc_id = result.id
scores[doc_id] = scores.get(doc_id, 0) + (1 - alpha) * (1 / (k + rank + 1))
# 4. 점수 순으로 정렬
ranked_ids = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
# 5. 최종 결과 반환
results = []
for doc_id, score in ranked_ids:
doc = self.qdrant.retrieve(collection_name=self.collection_name, ids=[doc_id])[0]
results.append({"id": doc_id, "score": score, "text": doc.payload["text"]})
return results
# 사용 예시
rag = HybridSearchRAG(openai_key="your-key")
# 문서 추가
rag.add_document(1, "iPhone 15 Pro의 가격은 1,250,000원입니다.", {"category": "product"})
rag.add_document(2, "스마트폰 가격은 최근 상승 추세입니다.", {"category": "trend"})
rag.add_document(3, "아이폰은 애플의 대표 제품입니다.", {"category": "general"})
# 하이브리드 검색
results = rag.hybrid_search("iPhone 15 Pro 가격", alpha=0.7) # 벡터 검색에 더 가중치
for r in results:
print(f"Score: {r['score']:.3f}, Text: {r['text']}")
설명
이것이 하는 일: 위 코드는 Qdrant 벡터 데이터베이스를 사용하여 벡터 검색과 키워드 검색을 결합하는 하이브리드 RAG 시스템을 구현합니다. alpha 파라미터로 두 검색 방법의 균형을 조절할 수 있습니다.
첫 번째로, HybridSearchRAG 클래스를 초기화할 때 Qdrant를 인메모리 모드로 실행합니다. 이는 프로토타입이나 테스트에 적합하며, 프로덕션에서는 Qdrant 서버를 별도로 띄워야 합니다.
컬렉션을 생성할 때 벡터 크기(1536, OpenAI 임베딩)와 거리 측정 방법(코사인)을 지정합니다. 그 다음으로, add_document 메서드는 문서를 벡터와 텍스트 모두로 저장합니다.
OpenAI API로 임베딩을 생성하고, Qdrant의 payload 필드에 원본 텍스트와 메타데이터를 함께 저장합니다. 이렇게 하면 나중에 벡터 검색과 키워드 검색 모두에서 활용할 수 있습니다.
세 번째로, hybrid_search 메서드의 핵심은 두 가지 검색을 동시에 수행하는 것입니다. 먼저 벡터 검색으로 의미적으로 유사한 문서를 찾고, 키워드 검색으로 정확히 매칭되는 문서를 찾습니다.
여기서는 간단한 예시로 첫 단어만 매칭했지만, 실제로는 BM25 알고리즘이나 전문 검색(full-text search) 기능을 사용합니다. 네 번째로, Reciprocal Rank Fusion(RRF)으로 두 검색 결과를 통합합니다.
각 검색 방법에서 나온 순위를 점수로 변환합니다. 1등은 1/(60+1), 2등은 1/(60+2) 점수를 받습니다.
alpha 파라미터로 가중치를 조절하는데, alpha=0.7이면 벡터 검색에 70%, 키워드 검색에 30% 가중치를 줍니다. 예를 들어, "iPhone 15 Pro"라는 정확한 용어가 중요하면 alpha를 낮추고(키워드 강화), 의미 이해가 더 중요하면 alpha를 높입니다(벡터 강화).
마지막으로, 통합된 점수로 문서를 재정렬하여 상위 top_k개를 반환합니다. 예시에서 "iPhone 15 Pro 가격" 쿼리는 doc1을 가장 높게 랭크합니다.
"iPhone 15 Pro"라는 정확한 용어 매칭(키워드 검색)과 "가격"이라는 의미 유사성(벡터 검색) 모두에서 높은 점수를 받기 때문입니다. 여러분이 이 시스템을 사용하면 훨씬 더 강력한 검색 품질을 얻을 수 있습니다.
제품명, 버전, 날짜 같은 정확한 용어는 키워드로 잡고, 전반적인 의미는 벡터로 잡아 최상의 결과를 제공합니다. alpha 값을 조정하여 도메인 특성에 맞게 최적화할 수 있습니다.
실전 팁
💡 alpha 값은 도메인에 따라 조정하세요. 법률/의료 문서는 낮은 alpha(0.30.5, 키워드 강조), 일반 대화는 높은 alpha(0.70.9, 의미 강조)가 적합합니다.
💡 키워드 검색에는 BM25 알고리즘을 사용하세요. Elasticsearch나 Qdrant의 전문 검색 기능이 이를 지원합니다.
💡 메타데이터 필터와 결합하면 더 강력합니다. "2024년 문서 중에서 하이브리드 검색"처럼 조건을 추가할 수 있습니다.
💡 A/B 테스트로 최적 alpha를 찾으세요. 실제 사용자 쿼리 로그로 여러 alpha 값을 테스트하여 정확도가 가장 높은 값을 선택하세요.
💡 쿼리 확장(query expansion)을 고려하세요. 사용자 쿼리에 동의어를 자동으로 추가하면 키워드 검색 재현율이 향상됩니다.
8. 프롬프트 엔지니어링 - RAG를 위한 효과적인 지시
시작하며
여러분이 완벽하게 관련된 문서를 검색했는데도 LLM이 엉뚱한 답변을 하거나, 컨텍스트를 무시하고 자기 지식으로 답한다면 어떻게 하시겠습니까? 이는 프롬프트 설계가 잘못되었기 때문일 가능성이 큽니다.
RAG 시스템에서 프롬프트는 검색만큼이나 중요합니다. 아무리 정확한 문서를 찾아도, LLM에게 어떻게 사용하라고 지시하느냐에 따라 결과가 천지 차이입니다.
"이 정보를 바탕으로 답하세요"와 "반드시 제공된 컨텍스트만 사용하고 없는 정보는 모른다고 하세요"는 완전히 다른 결과를 낳습니다. 바로 이럴 때 필요한 것이 RAG에 특화된 프롬프트 엔지니어링입니다.
컨텍스트 우선순위 지정, 환각 방지 지시, 출처 인용 요구 등을 명확히 프롬프트에 담아야 합니다. 이것이 RAG 시스템의 최종 품질을 결정짓는 마지막 퍼즐 조각입니다.
개요
간단히 말해서, RAG를 위한 프롬프트 엔지니어링은 LLM이 검색된 컨텍스트를 올바르게 활용하도록 정교하게 지시하는 기술입니다. 프롬프트가 중요한 이유는 LLM의 행동을 제어하는 유일한 수단이기 때문입니다.
좋은 프롬프트는 환각을 방지하고, 컨텍스트를 우선시하고, 답변에 출처를 포함시킵니다. 나쁜 프롬프트는 LLM이 내부 지식에 의존하게 만들어 RAG의 의미를 퇴색시킵니다.
예를 들어, 법률 자문 시스템에서는 "제공된 법률 조항에만 기반하여 답변하고, 개인적인 해석을 추가하지 마세요"라는 명확한 지시가 필수적입니다. 기존에는 단순히 "질문에 답하세요" 수준의 프롬프트를 사용했습니다.
이제는 역할 지정(role), 제약 조건(constraints), 출력 형식(format) 등을 상세히 명시하는 구조화된 프롬프트를 사용합니다. 효과적인 RAG 프롬프트의 핵심 요소는 컨텍스트 명시("다음 정보를 바탕으로"), 제약 조건("컨텍스트에 없으면 모른다고 답"), 그리고 출력 형식("답변 후 출처 표시")입니다.
이 세 가지를 명확히 하면 일관되고 신뢰할 수 있는 답변을 얻을 수 있습니다.
코드 예제
# RAG에 최적화된 다양한 프롬프트 템플릿
from openai import OpenAI
class RAGPromptTemplates:
def __init__(self, openai_key):
self.client = OpenAI(api_key=openai_key)
# 템플릿 1: 기본 RAG 프롬프트
def basic_rag_prompt(self, context: str, question: str):
"""가장 기본적인 RAG 프롬프트"""
prompt = f"""다음 컨텍스트를 바탕으로 질문에 답하세요.
컨텍스트:
{context}
질문: {question}
답변:"""
return self._generate(prompt)
# 템플릿 2: 환각 방지 강화 프롬프트
def hallucination_proof_prompt(self, context: str, question: str):
"""환각을 최소화하는 엄격한 프롬프트"""
system_prompt = """당신은 정확성을 최우선으로 하는 AI 어시스턴트입니다.
반드시 제공된 컨텍스트만을 사용하여 답변하세요.
컨텍스트에 답이 없거나 불확실하면 "제공된 정보로는 답변할 수 없습니다"라고 명확히 말하세요.
절대 추측하거나 컨텍스트 외부의 지식을 사용하지 마세요."""
user_prompt = f"""컨텍스트:
{context}
질문: {question}
위 컨텍스트만을 사용하여 답변하세요:"""
return self._generate_with_system(system_prompt, user_prompt)
# 템플릿 3: 출처 인용 포함 프롬프트
def citation_prompt(self, contexts: list, question: str):
"""출처를 명시하는 프롬프트 (contexts는 [{"text": "...", "source": "..."}] 형식)"""
context_text = "\n\n".join([
f"[출처 {i+1}: {ctx['source']}]\n{ctx['text']}"
for i, ctx in enumerate(contexts)
])
prompt = f"""다음 컨텍스트를 바탕으로 질문에 답하고, 답변에 사용한 출처를 명시하세요.
{context_text}
질문: {question}
답변 형식:
[답변 내용]
출처: [출처 1], [출처 2] 등
답변:"""
return self._generate(prompt)
# 템플릿 4: 단계별 추론 프롬프트 (복잡한 질문용)
def chain_of_thought_prompt(self, context: str, question: str):
"""복잡한 질문에 대해 단계별 추론을 유도"""
prompt = f"""다음 컨텍스트를 바탕으로 질문에 답하세요.
복잡한 질문이므로 단계별로 추론 과정을 보여주세요.
컨텍스트:
{context}
질문: {question}
단계별 답변:
3. 최종 답변:"""
설명
이것이 하는 일: 위 코드는 RAG 시스템에서 사용할 수 있는 네 가지 프롬프트 템플릿을 제공합니다. 각 템플릿은 서로 다른 사용 사례와 요구사항에 최적화되어 있습니다.
첫 번째로, basic_rag_prompt는 가장 간단한 형태입니다. 컨텍스트와 질문을 명확히 구분하여 제시하고 답변을 요청합니다.
구조가 단순하여 대부분의 경우에 작동하지만, LLM이 컨텍스트를 무시하고 자신의 지식으로 답변할 가능성이 있습니다. 프로토타입이나 간단한 질의응답에 적합합니다.
그 다음으로, hallucination_proof_prompt는 환각을 최소화하는 데 초점을 맞춥니다. system 메시지에서 "반드시 제공된 컨텍스트만 사용"하고 "불확실하면 모른다고 답"하라는 명확한 지시를 줍니다.
이는 법률, 의료, 금융 같은 고신뢰 도메인에서 필수적입니다. temperature=0.1로 낮게 설정하여 답변의 일관성과 결정론적 특성을 높입니다.
세 번째로, citation_prompt는 답변에 출처를 포함시킵니다. 각 컨텍스트에 출처 번호를 붙이고, 답변 형식에 "출처: [출처 1], [출처 2]"를 명시하도록 요구합니다.
이렇게 하면 사용자가 답변의 근거를 확인할 수 있어 신뢰도가 크게 향상됩니다. 연구, 저널리즘, 교육 분야에서 특히 유용합니다.
네 번째로, chain_of_thought_prompt는 복잡한 질문에 대해 단계별 추론을 유도합니다. "1.
먼저 컨텍스트에서 관련 정보를 찾습니다", "2. 그 다음 이 정보를 분석합니다"처럼 사고 과정을 구조화합니다.
이는 여러 정보를 종합해야 하는 복잡한 질문이나 수학 문제, 논리 추론에 효과적입니다. Chain-of-Thought(CoT) 기법이 LLM의 추론 능력을 크게 향상시킨다는 연구 결과가 있습니다.
마지막으로, 모든 템플릿은 temperature=0.1을 사용합니다. 이는 창의성보다 일관성과 정확성을 우선시하는 것입니다.
RAG 시스템에서는 같은 질문에 항상 비슷한 답변을 하는 것이 중요하므로 낮은 temperature가 적합합니다. 여러분이 이 템플릿들을 사용하면 상황에 맞는 최적의 프롬프트를 선택할 수 있습니다.
간단한 FAQ는 기본 템플릿, 법률 자문은 환각 방지, 학술 연구는 출처 인용, 복잡한 분석은 단계별 추론 템플릿을 사용하세요.
실전 팁
💡 시스템 메시지를 적극 활용하세요. 역할 정의와 제약 조건은 system에, 구체적인 작업은 user 메시지에 넣으면 더 효과적입니다.
💡 few-shot 예시를 추가하면 답변 품질이 향상됩니다. "예시: 질문: ... 답변: ..." 형식으로 2~3개 예시를 보여주세요.
💡 출력 형식을 명확히 지정하세요. JSON, 마크다운, 목록 등 원하는 형식을 프롬프트에 명시하면 파싱이 쉬워집니다.
💡 "반드시", "절대", "오직" 같은 강조 단어를 사용하여 중요한 지시를 강조하세요. LLM이 더 잘 따릅니다.
💡 A/B 테스트로 프롬프트를 최적화하세요. 같은 질문 세트로 여러 프롬프트 버전을 테스트하여 가장 좋은 것을 선택하세요.