이미지 로딩 중...

Python으로 AI 에이전트 만들기 6편 - RAG 시스템 구축하기 - 슬라이드 1/11
A

AI Generated

2025. 11. 12. · 3 Views

Python으로 AI 에이전트 만들기 6편 - RAG 시스템 구축하기

대규모 언어 모델의 한계를 극복하는 RAG(Retrieval-Augmented Generation) 시스템을 구축하는 방법을 배워봅니다. 벡터 데이터베이스부터 문서 임베딩, 시맨틱 검색까지 실전에서 바로 활용할 수 있는 완전한 RAG 파이프라인을 단계별로 구현합니다.


목차

  1. RAG 시스템 개요 - AI의 지식 한계를 극복하는 방법
  2. 문서 로딩과 청킹 - 데이터를 RAG 시스템에 맞게 준비하기
  3. 벡터 임베딩과 저장 - 텍스트를 숫자로 변환하여 검색 가능하게 만들기
  4. 시맨틱 검색 구현 - 의미 기반으로 관련 문서 찾기
  5. 프롬프트 엔지니어링 - 검색된 컨텍스트로 정확한 답변 생성하기
  6. 대화형 RAG - 이전 대화를 기억하는 AI 만들기
  7. 하이브리드 검색 - 키워드와 의미 검색의 장점 결합하기
  8. RAG 평가와 최적화 - 시스템 성능 측정 및 개선하기
  9. 메타데이터 필터링과 하이브리드 쿼리 - 정교한 검색 제어하기
  10. 프로덕션 배포 고려사항 - 안정적이고 확장 가능한 RAG 시스템 만들기

1. RAG 시스템 개요 - AI의 지식 한계를 극복하는 방법

시작하며

여러분이 회사 내부 문서를 기반으로 질문에 답하는 AI 챗봇을 만들려고 할 때 이런 문제를 겪어본 적 있나요? GPT-4 같은 강력한 모델도 회사의 최신 정책이나 특정 프로젝트 문서에 대해서는 전혀 모른다는 사실을 깨닫게 됩니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. LLM(대규모 언어 모델)은 학습 시점까지의 데이터만 알고 있고, 특정 도메인이나 최신 정보에 대해서는 답변할 수 없습니다.

더 큰 문제는 모델을 재학습시키는 것은 비용과 시간이 엄청나게 든다는 점입니다. 바로 이럴 때 필요한 것이 RAG(Retrieval-Augmented Generation) 시스템입니다.

RAG는 외부 지식 베이스에서 관련 정보를 검색한 후, 그 정보를 컨텍스트로 제공하여 LLM이 정확한 답변을 생성하도록 돕습니다. 마치 오픈북 시험처럼, AI가 필요한 자료를 참고하면서 답변할 수 있게 만드는 것이죠.

개요

간단히 말해서, RAG는 검색(Retrieval)과 생성(Generation)을 결합한 AI 시스템입니다. 사용자의 질문을 받으면, 먼저 관련 문서를 검색하고, 그 문서를 바탕으로 답변을 생성합니다.

RAG가 필요한 이유는 명확합니다. 실시간으로 변하는 정보, 회사 내부 문서, 특정 도메인 지식 등 LLM이 학습하지 못한 정보를 활용해야 할 때가 많기 때문입니다.

예를 들어, 법률 문서 분석, 의료 기록 조회, 기술 문서 검색 같은 경우에 매우 유용합니다. 기존에는 모델을 파인튜닝하거나 프롬프트에 모든 정보를 넣어야 했다면, 이제는 필요한 정보만 동적으로 검색하여 제공할 수 있습니다.

이는 비용 절감과 응답 품질 향상이라는 두 마리 토끼를 잡을 수 있게 해줍니다. RAG의 핵심 특징은 세 가지입니다.

첫째, 문서를 벡터로 변환하여 의미 기반 검색이 가능합니다. 둘째, 검색된 문서를 컨텍스트로 제공하여 환각(hallucination)을 줄입니다.

셋째, 지식 베이스를 업데이트하면 즉시 반영되어 항상 최신 정보를 활용할 수 있습니다. 이러한 특징들이 RAG를 실무에서 가장 많이 사용되는 AI 패턴으로 만들었습니다.

코드 예제

# RAG 시스템의 기본 구조
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA

# 1. 벡터 스토어 초기화 - 문서를 벡터로 저장
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings
)

# 2. LLM과 검색기 설정
llm = ChatOpenAI(model="gpt-4", temperature=0)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

# 3. RAG 체인 생성 - 검색 + 생성 결합
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",  # 검색된 문서를 어떻게 조합할지
    retriever=retriever,
    return_source_documents=True  # 참고한 문서도 반환
)

# 4. 질문하기
response = qa_chain({"query": "RAG 시스템의 장점은?"})
print(response['result'])

설명

이것이 하는 일: 이 코드는 기본적인 RAG 시스템의 전체 파이프라인을 보여줍니다. 문서 저장소를 초기화하고, 검색 메커니즘을 설정하며, LLM과 연결하여 질의응답 시스템을 만듭니다.

첫 번째 단계에서는 벡터 스토어를 설정합니다. Chroma는 벡터 데이터베이스로, 문서를 숫자 벡터로 변환하여 저장합니다.

OpenAIEmbeddings는 텍스트를 1536차원의 벡터로 변환하는 역할을 하며, 이를 통해 의미적으로 유사한 문서를 찾을 수 있습니다. persist_directory를 설정하면 데이터가 디스크에 저장되어 재시작해도 유지됩니다.

두 번째 단계에서는 LLM과 검색기를 구성합니다. retriever는 사용자의 질문을 받아 가장 관련성 높은 상위 k개(여기서는 3개)의 문서를 찾아냅니다.

temperature=0으로 설정하면 LLM이 더 일관되고 결정론적인 답변을 생성하게 됩니다. 이는 사실 기반 질의응답에서 중요합니다.

세 번째 단계에서 RetrievalQA 체인을 생성합니다. chain_type="stuff"는 검색된 모든 문서를 하나의 프롬프트에 넣는 방식을 의미합니다.

다른 옵션으로는 map_reduce(각 문서를 개별 처리 후 병합)나 refine(순차적으로 답변을 개선)이 있습니다. return_source_documents=True를 설정하면 답변의 출처를 확인할 수 있어 신뢰성을 높일 수 있습니다.

마지막으로 질문을 던지면, 시스템은 자동으로 관련 문서를 검색하고, 그 문서를 컨텍스트로 제공하여 LLM이 답변을 생성합니다. 여러분이 이 코드를 사용하면 수천 개의 문서에서 필요한 정보만 정확하게 찾아 답변할 수 있습니다.

특히 회사 내부 위키, 제품 문서, 법률 자료 등을 검색하는 챗봇을 만들 때 매우 효과적입니다. 실무에서 이 시스템을 사용하면 세 가지 주요 이점을 얻습니다.

첫째, 모델 재학습 없이 새로운 정보를 즉시 활용할 수 있습니다. 둘째, 답변의 근거를 제시할 수 있어 신뢰성이 높습니다.

셋째, 비용 효율적입니다 - 전체 문서를 프롬프트에 넣지 않고 필요한 부분만 사용하기 때문입니다.

실전 팁

💡 벡터 데이터베이스는 용도에 맞게 선택하세요. Chroma는 개발용으로 좋고, 프로덕션에서는 Pinecone이나 Weaviate 같은 관리형 서비스가 더 안정적입니다. 특히 수백만 개 이상의 문서를 다룬다면 성능과 확장성이 중요합니다.

💡 search_kwargsk 값은 신중히 설정하세요. 너무 적으면 필요한 정보를 놓칠 수 있고, 너무 많으면 토큰 한계에 도달하거나 노이즈가 많아집니다. 보통 3-5개가 적절하며, 문서 크기에 따라 조정이 필요합니다.

💡 return_source_documents=True는 반드시 활성화하세요. 사용자에게 답변의 출처를 보여주면 신뢰도가 크게 향상되며, 디버깅할 때도 어떤 문서가 검색되었는지 확인할 수 있어 유용합니다.

💡 프로덕션 환경에서는 에러 핸들링을 추가하세요. 검색 결과가 없거나, API 호출이 실패하거나, 토큰 제한을 초과하는 경우에 대비한 처리가 필요합니다.


2. 문서 로딩과 청킹 - 데이터를 RAG 시스템에 맞게 준비하기

시작하며

여러분이 100페이지짜리 PDF 문서를 RAG 시스템에 넣으려고 할 때, 그냥 통째로 넣으면 될까요? 안타깝게도 그렇지 않습니다.

문서를 적절한 크기로 나누지 않으면 검색 품질이 떨어지고, 심지어 토큰 제한으로 에러가 발생할 수도 있습니다. 이런 문제는 RAG 시스템 구축의 첫 번째 관문입니다.

문서를 어떻게 나누느냐에 따라 검색 정확도가 크게 달라집니다. 너무 크게 나누면 불필요한 정보가 많이 포함되고, 너무 작게 나누면 문맥이 끊겨버립니다.

바로 이럴 때 필요한 것이 문서 청킹(chunking) 전략입니다. 적절한 크기로 문서를 나누면서도 의미적 연결성을 유지하는 것이 핵심입니다.

오버랩(overlap)을 사용하면 청크 간의 문맥 손실도 최소화할 수 있습니다.

개요

간단히 말해서, 문서 청킹은 큰 문서를 작고 관리 가능한 조각으로 나누는 과정입니다. 각 청크는 독립적으로 검색될 수 있으면서도 충분한 문맥을 담고 있어야 합니다.

청킹이 필요한 이유는 기술적 제약과 검색 품질 때문입니다. LLM에는 컨텍스트 윈도우 제한이 있고, 벡터 검색은 작은 단위에서 더 정확합니다.

예를 들어, 기술 매뉴얼에서 특정 기능을 설명하는 단락만 검색하고 싶을 때, 전체 문서를 하나의 벡터로 만들면 정확한 검색이 불가능합니다. 기존에는 고정된 크기로 자르거나 문단 단위로 나눴다면, 이제는 문서 구조를 이해하고 의미 단위로 나눌 수 있습니다.

제목, 문단, 리스트 등의 구조를 보존하면서 청킹하는 것이 가능합니다. 청킹의 핵심 특징은 세 가지입니다.

첫째, 청크 크기는 일반적으로 500-1000 토큰이 적절합니다. 둘째, 오버랩을 두어 문맥 손실을 방지합니다(보통 10-20%).

셋째, 문서 타입에 따라 다른 전략을 사용합니다. 이러한 특징들을 이해하면 검색 품질을 크게 향상시킬 수 있습니다.

코드 예제

from langchain.document_loaders import PyPDFLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 1. 문서 로더 선택 - PDF, 텍스트, 마크다운 등
pdf_loader = PyPDFLoader("company_policy.pdf")
documents = pdf_loader.load()  # 각 페이지가 하나의 Document 객체

# 2. 텍스트 스플리터 설정
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,  # 각 청크의 최대 문자 수
    chunk_overlap=200,  # 청크 간 겹치는 문자 수
    separators=["\n\n", "\n", " ", ""],  # 우선순위대로 분할 시도
    length_function=len  # 길이 측정 함수
)

# 3. 문서를 청크로 분할
chunks = text_splitter.split_documents(documents)

# 4. 메타데이터 확인
for i, chunk in enumerate(chunks[:3]):
    print(f"청크 {i+1}:")
    print(f"내용 길이: {len(chunk.page_content)}")
    print(f"메타데이터: {chunk.metadata}")
    print(f"내용 미리보기: {chunk.page_content[:100]}...")
    print("-" * 50)

설명

이것이 하는 일: 이 코드는 PDF 문서를 로드하고, 검색에 최적화된 크기의 청크로 분할합니다. 각 청크는 독립적으로 벡터화되어 검색될 수 있으며, 오버랩을 통해 문맥의 연속성을 유지합니다.

첫 번째 단계에서는 문서를 로드합니다. PyPDFLoader는 PDF의 각 페이지를 별도의 Document 객체로 만들며, 페이지 번호 같은 메타데이터도 자동으로 추출합니다.

LangChain은 다양한 로더를 제공하는데, TextLoader는 일반 텍스트, UnstructuredMarkdownLoader는 마크다운, Docx2txtLoader는 워드 문서를 처리합니다. 여러분의 데이터 형식에 맞는 로더를 선택하는 것이 첫 단계입니다.

두 번째 단계에서 텍스트 스플리터를 설정합니다. RecursiveCharacterTextSplitter는 가장 지능적인 스플리터로, separators 리스트의 순서대로 분할을 시도합니다.

먼저 빈 줄(\n\n)로 나누려고 하고, 안 되면 한 줄(\n), 그 다음 공백, 마지막으로 문자 단위로 자릅니다. 이 방식은 자연스러운 문맥 경계를 유지하면서 청크를 만듭니다.

chunk_size=1000은 각 청크가 최대 1000자까지 포함할 수 있다는 의미입니다. 영어는 보통 1 토큰 = 4자이므로, 약 250 토큰 정도입니다.

chunk_overlap=200은 인접한 청크가 200자를 공유한다는 뜻으로, 이는 문장이나 단락이 청크 경계에서 잘리더라도 다음 청크에서 문맥을 이해할 수 있게 합니다. 세 번째 단계에서 실제 분할이 일어납니다.

split_documents()는 각 Document를 청크로 나누면서 원본의 메타데이터(페이지 번호, 파일 이름 등)를 각 청크에 복사합니다. 이 메타데이터는 나중에 답변의 출처를 추적할 때 매우 유용합니다.

여러분이 이 코드를 사용하면 어떤 크기의 문서든 효과적으로 처리할 수 있습니다. 실무에서는 청크 크기를 조정해야 할 때가 많습니다.

기술 문서처럼 정보 밀도가 높으면 작게(500자), 소설이나 에세이처럼 서술이 길면 크게(1500자) 설정하는 것이 좋습니다. 검색 품질을 평가하면서 점진적으로 최적화하세요.

실전 팁

💡 청크 크기는 실험을 통해 최적값을 찾으세요. 일반적으로 500-1500 문자가 적절하지만, 문서 특성에 따라 다릅니다. 기술 문서는 작게, 서사가 있는 텍스트는 크게 설정하는 것이 좋습니다.

💡 오버랩은 청크 크기의 10-20%가 적절합니다. 너무 크면 중복이 많아 저장 공간이 낭비되고, 너무 작으면 문맥이 끊깁니다. 200자 오버랩은 보통 1-2 문장 정도에 해당합니다.

💡 메타데이터를 적극 활용하세요. 페이지 번호, 섹션 제목, 작성 날짜 등을 메타데이터에 추가하면 검색 필터링과 결과 정렬에 사용할 수 있습니다. chunk.metadata['source']로 원본 파일을 추적할 수 있습니다.

💡 토큰 기반 분할도 고려하세요. CharacterTextSplitter 대신 TokenTextSplitter를 사용하면 정확한 토큰 수로 제어할 수 있습니다. OpenAI 모델은 토큰 제한이 있으므로, 프로덕션에서는 토큰 기반이 더 안전합니다.

💡 문서 구조를 보존하는 스플리터도 있습니다. 마크다운이라면 MarkdownHeaderTextSplitter를 사용하면 헤더 구조를 유지하면서 청킹할 수 있어, 검색 품질이 더욱 향상됩니다.


3. 벡터 임베딩과 저장 - 텍스트를 숫자로 변환하여 검색 가능하게 만들기

시작하며

여러분이 "머신러닝의 장점"과 "딥러닝의 이점"이라는 두 문구가 얼마나 비슷한지 컴퓨터에게 어떻게 알려줄 수 있을까요? 단순 키워드 매칭으로는 두 문장에 공통 단어가 없으니 전혀 관련 없다고 판단할 것입니다.

이런 문제는 전통적인 검색 시스템의 한계입니다. 키워드 기반 검색은 동의어, 유사 표현, 문맥을 이해하지 못합니다.

"자동차"를 검색했을 때 "차량"이나 "승용차"가 포함된 문서를 찾지 못하는 것이 대표적인 예입니다. 바로 이럴 때 필요한 것이 벡터 임베딩입니다.

텍스트를 고차원 공간의 점(벡터)으로 변환하면, 의미가 비슷한 텍스트는 공간상에서 가까이 위치하게 됩니다. 이를 통해 시맨틱(의미 기반) 검색이 가능해집니다.

개요

간단히 말해서, 임베딩은 텍스트를 숫자 벡터로 변환하는 과정입니다. 예를 들어, "강아지"라는 단어는 [0.2, 0.8, -0.5, ...] 같은 수백~수천 개의 숫자 리스트로 표현됩니다.

임베딩이 필요한 이유는 의미 검색을 가능하게 하기 때문입니다. 벡터 공간에서 "강아지"와 "개"는 매우 가까운 위치에 있고, "고양이"는 비교적 가까우며, "자동차"는 멀리 떨어져 있습니다.

예를 들어, 법률 문서에서 "계약 해지"를 검색할 때, "계약 취소", "계약 파기" 같은 유사 표현도 함께 찾아줍니다. 기존에는 TF-IDF나 BM25 같은 통계 기반 방법을 사용했다면, 이제는 트랜스포머 기반의 임베딩 모델이 훨씬 강력한 의미 이해를 제공합니다.

OpenAI의 text-embedding-ada-002는 1536차원 벡터를 생성하며, 다국어도 잘 처리합니다. 임베딩의 핵심 특징은 세 가지입니다.

첫째, 벡터 간 거리(코사인 유사도)로 의미적 유사성을 측정할 수 있습니다. 둘째, 수백만 개의 벡터에서도 밀리초 단위로 검색 가능합니다(ANN 알고리즘 덕분).

셋째, 한 번 생성된 벡터는 재사용 가능하여 효율적입니다. 이러한 특징들이 RAG의 검색 능력을 강력하게 만듭니다.

코드 예제

from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
import os

# 1. 임베딩 모델 초기화
embeddings = OpenAIEmbeddings(
    model="text-embedding-ada-002",
    openai_api_key=os.getenv("OPENAI_API_KEY")
)

# 2. 텍스트를 벡터로 변환 (테스트)
sample_text = "RAG 시스템은 검색과 생성을 결합합니다"
vector = embeddings.embed_query(sample_text)
print(f"벡터 차원: {len(vector)}")  # 1536
print(f"벡터 샘플: {vector[:5]}")  # 첫 5개 요소

# 3. 청크들을 벡터 스토어에 저장
vectorstore = Chroma.from_documents(
    documents=chunks,  # 이전에 만든 청크들
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="company_docs"
)

# 4. 저장 완료 후 디스크에 영구 저장
vectorstore.persist()
print(f"총 {vectorstore._collection.count()} 개의 벡터가 저장되었습니다")

설명

이것이 하는 일: 이 코드는 텍스트 청크들을 벡터로 변환하고, 벡터 데이터베이스에 저장합니다. 각 청크는 1536차원의 숫자 리스트로 표현되며, 나중에 질문이 들어오면 유사한 벡터를 빠르게 찾을 수 있습니다.

첫 번째 단계에서 임베딩 모델을 초기화합니다. OpenAIEmbeddings는 OpenAI의 임베딩 API를 사용하는 래퍼입니다.

text-embedding-ada-002 모델은 비용 대비 성능이 우수하며, 1000 토큰당 $0.0001로 매우 저렴합니다. 이 모델은 영어뿐 아니라 한국어도 잘 처리하며, 비슷한 의미의 텍스트를 일관되게 가까운 벡터로 변환합니다.

두 번째 단계는 임베딩이 어떻게 작동하는지 보여주는 테스트입니다. embed_query()는 하나의 텍스트를 벡터로 변환합니다.

생성된 벡터는 1536개의 실수로 구성되며, 각 값은 보통 -1에서 1 사이입니다. 이 벡터는 고차원 공간에서 텍스트의 "위치"를 나타냅니다.

"RAG"와 "검색 증강 생성"은 다른 단어지만, 벡터 공간에서 매우 가까운 위치에 놓입니다. 세 번째 단계에서 실제 벡터 스토어를 생성합니다.

Chroma.from_documents()는 여러 작업을 한 번에 수행합니다: 각 청크를 벡터로 변환하고, 벡터와 원본 텍스트를 함께 저장하며, 빠른 검색을 위한 인덱스를 구축합니다. collection_name으로 여러 문서 세트를 분리 관리할 수 있습니다.

예를 들어, "hr_docs", "tech_docs", "legal_docs"처럼 부서별로 구분할 수 있습니다. persist_directory를 설정하면 벡터가 디스크에 저장됩니다.

이는 매우 중요한데, 그렇지 않으면 프로그램을 재시작할 때마다 모든 문서를 다시 벡터화해야 합니다. 수천 개의 문서는 벡터화하는 데 수 분이 걸리고 비용도 발생하므로, 반드시 영구 저장을 활성화하세요.

여러분이 이 코드를 사용하면 대규모 문서 컬렉션을 효율적으로 검색할 수 있습니다. 실무에서는 배치 처리를 고려하세요.

한 번에 수천 개의 청크를 벡터화하면 API 호출이 많아져 시간이 오래 걸립니다. LangChain은 자동으로 배치 처리를 하지만, chunk_size를 조정하거나 캐싱을 사용하면 더 최적화할 수 있습니다.

또한 문서가 업데이트되면 해당 벡터만 갱신하는 증분 업데이트 전략도 중요합니다.

실전 팁

💡 임베딩 비용을 줄이려면 캐싱을 활용하세요. LangChain의 CacheBackedEmbeddings를 사용하면 같은 텍스트는 다시 임베딩하지 않고 캐시에서 가져옵니다. 개발 중에는 Redis나 로컬 파일 캐시가 유용합니다.

💡 벡터 데이터베이스 선택은 규모에 따라 다릅니다. Chroma는 개발과 중소 규모에 적합하고, Pinecone이나 Weaviate는 수백만 벡터를 다루는 프로덕션용입니다. Pinecone은 관리가 쉽고, Weaviate는 자체 호스팅이 가능합니다.

💡 벡터 차원 수를 줄이고 싶다면 PCA나 다른 차원 축소 기법을 고려하세요. 하지만 보통은 그대로 사용하는 것이 정확도가 높습니다. 저장 공간이 부족한 경우에만 고려하세요.

💡 멀티모달 임베딩도 가능합니다. OpenAI의 CLIP 모델을 사용하면 이미지와 텍스트를 같은 벡터 공간에 넣을 수 있어, 이미지 검색이나 이미지-텍스트 매칭이 가능합니다.

💡 임베딩 품질을 평가하려면 유사도 매트릭스를 확인하세요. 비슷한 의미의 텍스트들이 높은 코사인 유사도(0.8 이상)를 보이는지 확인하면, 임베딩 모델이 제대로 작동하는지 검증할 수 있습니다.


4. 시맨틱 검색 구현 - 의미 기반으로 관련 문서 찾기

시작하며

여러분이 "프로젝트 마감일을 연장하는 방법"을 검색한다고 해봅시다. 키워드 검색으로는 정확히 "마감일", "연장"이라는 단어가 있는 문서만 찾을 것입니다.

하지만 "일정 조정 절차"나 "데드라인 변경 프로세스"라는 제목의 문서가 실제로는 더 유용할 수 있습니다. 이런 문제는 실제 업무에서 매우 흔합니다.

사용자가 사용하는 표현과 문서에 사용된 표현이 다를 때, 전통적인 검색은 관련 정보를 놓치게 됩니다. 특히 전문 용어가 많은 도메인에서 이 문제는 더욱 심각합니다.

바로 이럴 때 필요한 것이 시맨틱 검색입니다. 질문의 의미를 벡터로 변환하고, 벡터 공간에서 가장 가까운 문서들을 찾아냅니다.

단어가 정확히 일치하지 않아도 의미가 비슷하면 찾아낼 수 있습니다.

개요

간단히 말해서, 시맨틱 검색은 키워드가 아닌 의미를 기반으로 문서를 찾는 방법입니다. 사용자의 질문을 벡터로 변환한 후, 저장된 문서 벡터들과 유사도를 계산하여 가장 관련성 높은 것을 반환합니다.

시맨틱 검색이 필요한 이유는 자연어의 다양성 때문입니다. 같은 의미를 표현하는 방법은 무수히 많고, 사람들은 같은 개념을 다르게 표현합니다.

예를 들어, "휴가 신청 방법"을 검색할 때, "연차 사용 절차", "유급 휴일 요청 프로세스" 같은 문서도 찾아야 진정한 검색입니다. 기존에는 동의어 사전을 수동으로 관리하거나 쿼리 확장 기법을 사용했다면, 이제는 임베딩 모델이 자동으로 의미적 유사성을 학습합니다.

수백만 개의 텍스트로 학습된 모델은 언어의 미묘한 뉘앙스까지 이해합니다. 시맨틱 검색의 핵심 특징은 세 가지입니다.

첫째, 코사인 유사도를 사용해 벡터 간 거리를 측정합니다(값이 1에 가까울수록 유사). 둘째, MMR(Maximum Marginal Relevance) 같은 알고리즘으로 다양성과 관련성의 균형을 맞출 수 있습니다.

셋째, 메타데이터 필터링과 결합하여 더 정교한 검색이 가능합니다. 이러한 특징들이 RAG의 품질을 결정짓습니다.

코드 예제

from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

# 1. 기존 벡터 스토어 로드
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings,
    collection_name="company_docs"
)

# 2. 기본 유사도 검색 - 가장 유사한 k개 반환
query = "재택근무 정책은 어떻게 되나요?"
results = vectorstore.similarity_search(query, k=3)

for i, doc in enumerate(results):
    print(f"\n[검색 결과 {i+1}]")
    print(f"점수: {doc.metadata.get('score', 'N/A')}")
    print(f"출처: {doc.metadata.get('source', 'N/A')}")
    print(f"내용: {doc.page_content[:200]}...")

# 3. 점수와 함께 검색 - 유사도 점수 확인
results_with_scores = vectorstore.similarity_search_with_score(query, k=3)

for doc, score in results_with_scores:
    print(f"유사도: {score:.4f} | 출처: {doc.metadata.get('source')}")

# 4. MMR 검색 - 다양성과 관련성의 균형
mmr_results = vectorstore.max_marginal_relevance_search(
    query,
    k=3,
    fetch_k=10,  # 먼저 10개를 찾은 후, 그 중 다양한 3개 선택
    lambda_mult=0.5  # 0: 최대 다양성, 1: 최대 관련성
)

설명

이것이 하는 일: 이 코드는 사용자의 질문을 벡터로 변환하고, 저장된 수천 개의 문서 벡터와 비교하여 가장 관련성 높은 문서를 찾아냅니다. 여러 검색 방법을 제공하여 상황에 맞게 선택할 수 있습니다.

첫 번째 단계에서는 이전에 저장한 벡터 스토어를 불러옵니다. persist_directory를 지정하면 디스크에서 기존 벡터들을 로드하므로, 다시 벡터화할 필요가 없습니다.

이는 매우 빠르며(보통 1초 이내), API 비용도 들지 않습니다. collection_name으로 여러 문서 세트를 분리 관리하면, 사용자가 검색 범위를 선택할 수 있게 할 수 있습니다.

두 번째 단계는 가장 기본적인 검색 방법입니다. similarity_search()는 질문을 임베딩하고, 모든 문서 벡터와 코사인 유사도를 계산한 후, 상위 k개를 반환합니다.

내부적으로는 ANN(Approximate Nearest Neighbors) 알고리즘을 사용하여 밀리초 단위로 검색을 완료합니다. k=3은 보통 적절한 기본값이지만, 문서 길이와 질문 복잡도에 따라 조정해야 합니다.

세 번째 단계에서는 유사도 점수도 함께 반환받습니다. similarity_search_with_score()는 각 문서가 얼마나 관련성이 있는지 수치로 보여줍니다.

Chroma는 L2 거리를 사용하므로 점수가 작을수록 유사합니다(0에 가까울수록 거의 동일). 이 점수를 임계값으로 사용하면, 관련성이 낮은 문서는 필터링할 수 있습니다.

예를 들어, 점수가 1.5 이상이면 "관련 정보를 찾을 수 없습니다"라고 답할 수 있습니다. 네 번째 단계의 MMR은 더 고급 기법입니다.

max_marginal_relevance_search()는 먼저 fetch_k개(여기서는 10개)의 후보를 찾은 후, 그 중에서 서로 다른 정보를 담은 k개(3개)를 선택합니다. lambda_mult=0.5는 관련성과 다양성의 균형을 50:50으로 맞춥니다.

이는 비슷한 문서가 여러 번 반환되는 것을 방지하여, 더 풍부한 컨텍스트를 LLM에 제공합니다. 여러분이 이 코드를 사용하면 복잡한 질문에도 관련 정보를 정확히 찾을 수 있습니다.

실무에서는 하이브리드 검색도 고려하세요. 시맨틱 검색과 키워드 검색(BM25)을 결합하면, 정확한 용어 매칭과 의미 이해의 장점을 모두 활용할 수 있습니다.

특히 제품 코드나 법률 조항 번호처럼 정확한 매칭이 중요한 경우에는 하이브리드 접근이 우수합니다.

실전 팁

💡 유사도 임계값을 설정하여 품질을 관리하세요. 점수가 너무 낮은(거리가 먼) 결과는 사용자에게 보여주지 않는 것이 좋습니다. "관련 정보를 찾을 수 없습니다"라고 정직하게 답하는 것이 잘못된 정보를 제공하는 것보다 낫습니다.

💡 MMR의 lambda_mult 값을 조정하여 사용 사례에 맞추세요. FAQ 같은 경우는 1.0(최대 관련성)으로 정확한 매칭을 우선하고, 리서치나 탐색적 검색은 0.3-0.5로 다양한 관점을 제공하세요.

💡 메타데이터 필터링을 활용하여 검색 범위를 제한하세요. vectorstore.similarity_search(query, filter={"department": "HR"})처럼 특정 부서나 날짜 범위로 필터링하면 정확도가 높아집니다.

💡 쿼리 확장 기법을 사용하면 검색 품질이 향상됩니다. 짧은 질문을 LLM으로 먼저 확장한 후 검색하거나, 여러 표현으로 바꾸어 검색한 결과를 합치는 방법이 효과적입니다.

💡 검색 결과를 로깅하여 지속적으로 개선하세요. 어떤 질문에 어떤 문서가 검색되었는지 기록하면, 검색 품질을 평가하고 청킹 전략이나 임베딩 모델을 최적화할 수 있습니다.


5. 프롬프트 엔지니어링 - 검색된 컨텍스트로 정확한 답변 생성하기

시작하며

여러분이 완벽하게 관련 문서를 검색했는데도 LLM이 엉뚱한 답변을 하거나, 문서에 없는 내용을 지어내는(환각) 경험을 해본 적 있나요? 검색한 정보를 어떻게 LLM에게 전달하느냐가 답변 품질을 좌우합니다.

이런 문제는 RAG 시스템의 가장 흔한 실패 원인입니다. 좋은 문서를 찾아도, 프롬프트가 명확하지 않으면 LLM이 제공된 정보를 무시하고 자신의 학습 데이터에서 답변을 만들어버립니다.

특히 제공된 문서가 불완전하거나 모순되는 경우 더 심합니다. 바로 이럴 때 필요한 것이 체계적인 프롬프트 엔지니어링입니다.

명확한 지시, 역할 설정, 제약 조건을 통해 LLM이 검색된 문서만 사용하여 답변하도록 유도할 수 있습니다. "문서에 없으면 모른다고 답하라"는 간단한 지시만으로도 환각이 크게 줄어듭니다.

개요

간단히 말해서, RAG 프롬프트는 검색된 문서와 사용자 질문을 LLM에게 전달하는 방법입니다. 어떻게 지시하느냐에 따라 같은 문서로도 전혀 다른 품질의 답변을 얻을 수 있습니다.

프롬프트 엔지니어링이 필요한 이유는 LLM이 기본적으로 창의적이고 추론을 좋아하기 때문입니다. RAG에서는 오히려 보수적으로, 주어진 정보만 사용하는 것이 중요합니다.

예를 들어, 법률 조언이나 의료 정보처럼 정확성이 중요한 도메인에서는 환각이 치명적일 수 있습니다. 기존에는 단순히 "이 문서를 읽고 답해"라고 했다면, 이제는 역할(전문가), 제약(문서만 사용), 형식(출처 포함) 등을 명확히 지정합니다.

이는 답변의 일관성과 신뢰성을 크게 향상시킵니다. 프롬프트 설계의 핵심 특징은 세 가지입니다.

첫째, 명확한 역할과 목표를 설정합니다(예: "당신은 회사 정책 전문가입니다"). 둘째, 제약 조건을 명시합니다(예: "제공된 문서에 없으면 모른다고 답하세요").

셋째, 출력 형식을 구조화합니다(예: "답변 후 출처를 명시하세요"). 이러한 특징들이 RAG를 프로덕션급으로 만들어줍니다.

코드 예제

from langchain.prompts import PromptTemplate
from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI

# 1. 커스텀 프롬프트 템플릿 정의
prompt_template = """당신은 회사 문서 전문가입니다. 아래 제공된 컨텍스트를 기반으로만 질문에 답변하세요.

중요한 규칙:
- 제공된 컨텍스트에 정보가 없으면 "제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 답하세요.
- 추측하거나 외부 지식을 사용하지 마세요.
- 답변 후 어떤 문서를 참고했는지 출처를 명시하세요.

컨텍스트:
{context}

질문: {question}

답변:"""

PROMPT = PromptTemplate(
    template=prompt_template,
    input_variables=["context", "question"]
)

# 2. LLM 설정
llm = ChatOpenAI(
    model="gpt-4",
    temperature=0,  # 결정론적 답변을 위해 0 사용
    max_tokens=500  # 답변 길이 제한
)

# 3. RAG 체인 생성
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    chain_type_kwargs={"prompt": PROMPT},
    return_source_documents=True
)

# 4. 실행 및 출처 표시
query = "재택근무 시 근무 시간은 어떻게 되나요?"
result = qa_chain({"query": query})

print(f"답변: {result['result']}\n")
print("참고 문서:")
for i, doc in enumerate(result['source_documents']):
    print(f"  {i+1}. {doc.metadata.get('source')} (페이지 {doc.metadata.get('page', 'N/A')})")

설명

이것이 하는 일: 이 코드는 검색된 문서를 체계적으로 LLM에 전달하여 정확하고 신뢰할 수 있는 답변을 생성합니다. 커스텀 프롬프트 템플릿을 통해 LLM의 행동을 제어하고, 출처 추적으로 답변의 근거를 명확히 합니다.

첫 번째 단계에서 프롬프트 템플릿을 정의합니다. 이 템플릿의 구조가 매우 중요합니다.

먼저 역할을 설정("회사 문서 전문가")하여 LLM이 어떤 관점에서 답변해야 하는지 명확히 합니다. 그 다음 명확한 규칙을 나열합니다.

"제공된 컨텍스트에 정보가 없으면"이라는 조건은 환각을 방지하는 핵심입니다. {context}{question}은 런타임에 자동으로 채워지는 변수입니다.

프롬프트 작성 시 주의할 점이 있습니다. "추측하지 마세요"는 LLM이 불확실할 때 조심스럽게 행동하도록 유도합니다.

"출처를 명시하세요"는 답변의 투명성을 높입니다. 이런 명시적 지시가 없으면 LLM은 자신 있게 틀린 정보를 제공할 수 있습니다.

두 번째 단계에서 LLM을 설정합니다. temperature=0은 매우 중요합니다.

이는 LLM이 가장 확률 높은 답변만 선택하게 하여, 창의성을 억제하고 일관성을 높입니다. 같은 질문에 항상 같은 답변을 받고 싶다면 0이 적합합니다.

max_tokens=500은 비용 제어와 답변 간결성을 위한 것인데, 너무 짧으면 답변이 잘릴 수 있으니 도메인에 맞게 조정하세요. 세 번째 단계에서 모든 것을 결합합니다.

chain_type_kwargs로 커스텀 프롬프트를 전달하면, 기본 프롬프트 대신 우리가 만든 것이 사용됩니다. return_source_documents=True는 필수입니다.

이를 통해 답변이 어느 문서에서 왔는지 추적할 수 있고, 사용자에게 출처를 보여줄 수 있습니다. 네 번째 단계에서 실제로 실행하고 결과를 처리합니다.

result['result']는 LLM이 생성한 답변이고, result['source_documents']는 검색된 문서들입니다. 이 문서들의 메타데이터(파일명, 페이지 번호 등)를 출처로 표시하면, 사용자가 원본을 직접 확인할 수 있어 신뢰도가 높아집니다.

여러분이 이 코드를 사용하면 생산적인 수준의 RAG 시스템을 만들 수 있습니다. 실무에서는 프롬프트를 계속 개선하세요.

사용자 피드백을 받으면서 어떤 경우에 잘못된 답변이 나오는지 분석하고, 프롬프트에 예시를 추가하거나 규칙을 구체화합니다. Few-shot 예시(좋은 답변의 예)를 프롬프트에 포함하면 품질이 크게 향상됩니다.

실전 팁

💡 Few-shot 예시를 프롬프트에 추가하면 답변 품질이 극적으로 향상됩니다. "예시: Q: ... A: ..." 형식으로 2-3개의 좋은 답변 예시를 보여주면, LLM이 원하는 형식과 톤을 더 잘 이해합니다.

💡 도메인별로 다른 temperature 값을 사용하세요. 사실 기반 QA는 0, 창의적 글쓰기는 0.7-0.9가 적합합니다. 법률이나 의료 같은 고위험 도메인은 반드시 0으로 설정하세요.

💡 시스템 메시지와 사용자 메시지를 구분하여 사용하면 더 효과적입니다. ChatGPT API의 system 역할로 지시를 주고, user 역할로 질문을 주면 LLM이 역할을 더 잘 이해합니다.

💡 답변 검증 단계를 추가하세요. LLM이 생성한 답변을 다시 LLM에게 보내서 "이 답변이 제공된 컨텍스트와 일치하는가?" 확인하면, 환각을 한 번 더 필터링할 수 있습니다.

💡 프롬프트 버전 관리를 하세요. 프롬프트는 코드만큼 중요하므로, Git에 저장하고 변경 사항을 추적하세요. A/B 테스트로 어떤 프롬프트가 더 나은지 데이터 기반으로 결정하세요.


6. 대화형 RAG - 이전 대화를 기억하는 AI 만들기

시작하며

여러분이 "그 정책의 예외 사항은 뭐야?"라고 물었을 때, AI가 "어떤 정책을 말씀하시는 건가요?"라고 반문한다면 어떨까요? 방금 전에 휴가 정책에 대해 이야기했는데, AI는 이미 잊어버린 것입니다.

이런 문제는 기본 RAG 시스템의 한계입니다. 각 질문을 독립적으로 처리하기 때문에, "그것", "또", "이전에 말한" 같은 대명사나 문맥 의존적 표현을 이해하지 못합니다.

실제 대화에서는 이런 표현이 매우 자연스럽고 흔하기 때문에 사용자 경험이 크게 떨어집니다. 바로 이럴 때 필요한 것이 대화 메모리(Conversational Memory)입니다.

이전 질문과 답변을 기억하여, 현재 질문의 문맥을 이해하고 자연스러운 대화를 이어갈 수 있습니다. 더 나아가, 이전 답변을 바탕으로 더 깊이 있는 질문에 답할 수 있습니다.

개요

간단히 말해서, 대화형 RAG는 이전 대화 내역을 유지하고 활용하는 시스템입니다. 새로운 질문이 들어오면, 대화 히스토리를 참고하여 문맥을 파악한 후, 관련 문서를 검색하고 답변을 생성합니다.

대화 메모리가 필요한 이유는 자연스러운 대화를 위해서입니다. 사람들은 매번 완전한 문장으로 질문하지 않습니다.

"그거", "또", "그럼" 같은 표현을 사용하며, 이전 답변을 바탕으로 추가 질문을 합니다. 예를 들어, "재택근무 정책은?" → "신청 방법은?" → "승인 기간은?" 같은 연속 질문에서 각각이 재택근무와 관련됨을 이해해야 합니다.

기존에는 사용자가 매번 완전한 문장으로 질문을 다시 작성해야 했다면, 이제는 자연스러운 대화 흐름으로 정보를 얻을 수 있습니다. 이는 사용자 만족도와 효율성을 크게 높입니다.

대화형 RAG의 핵심 특징은 세 가지입니다. 첫째, 대화 히스토리를 저장하고 관리합니다(보통 최근 N개만 유지).

둘째, 현재 질문을 이전 문맥을 고려하여 재구성합니다(query rewriting). 셋째, 답변 생성 시 이전 대화를 참고하여 일관성을 유지합니다.

이러한 특징들이 진정한 대화형 AI를 만듭니다.

코드 예제

from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.chat_models import ChatOpenAI

# 1. 대화 메모리 초기화
memory = ConversationBufferMemory(
    memory_key="chat_history",  # 히스토리를 저장할 키 이름
    return_messages=True,  # 메시지 객체로 반환
    output_key="answer"  # 답변을 저장할 키
)

# 2. LLM 설정
llm = ChatOpenAI(model="gpt-4", temperature=0)

# 3. 대화형 RAG 체인 생성
conversational_chain = ConversationalRetrievalChain.from_llm(
    llm=llm,
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    memory=memory,
    return_source_documents=True,
    verbose=True  # 디버깅용: 내부 과정 출력
)

# 4. 연속 대화 시뮬레이션
queries = [
    "재택근무 정책에 대해 알려주세요",
    "신청 방법은 어떻게 되나요?",  # "재택근무"가 생략됨
    "승인까지 얼마나 걸리나요?"  # "재택근무 신청"이 생략됨
]

for query in queries:
    print(f"\n질문: {query}")
    result = conversational_chain({"question": query})
    print(f"답변: {result['answer']}")
    print(f"사용된 문서 수: {len(result['source_documents'])}")

설명

이것이 하는 일: 이 코드는 대화 내역을 유지하면서 연속적인 질문에 답변하는 시스템을 구축합니다. 불완전한 질문도 이전 문맥을 고려하여 이해하고, 일관된 답변을 제공합니다.

첫 번째 단계에서 메모리를 초기화합니다. ConversationBufferMemory는 가장 단순한 메모리 타입으로, 모든 대화를 버퍼에 저장합니다.

memory_key는 프롬프트에서 대화 히스토리를 참조할 때 사용하는 변수명입니다. return_messages=True는 히스토리를 문자열이 아닌 메시지 객체 리스트로 저장하여, 누가 말했는지(사용자/AI) 구분할 수 있게 합니다.

output_key="answer"는 AI의 답변을 어느 키에서 가져올지 지정합니다. 메모리 타입은 상황에 맞게 선택할 수 있습니다.

ConversationBufferMemory는 모든 것을 기억하지만, 대화가 길어지면 토큰이 많이 소모됩니다. ConversationBufferWindowMemory는 최근 N개의 교환만 기억하여 토큰을 절약합니다.

ConversationSummaryMemory는 오래된 대화를 요약하여 저장하므로, 긴 대화에서도 토큰을 효율적으로 사용할 수 있습니다. 두 번째와 세 번째 단계에서 대화형 체인을 생성합니다.

ConversationalRetrievalChain은 일반 RetrievalQA와 달리, 질문을 받으면 먼저 대화 히스토리를 참고하여 질문을 재구성합니다. 예를 들어, "신청 방법은?"이라는 질문이 들어오면, 이전에 "재택근무"에 대해 물었다는 것을 파악하고, 내부적으로 "재택근무 신청 방법은?"으로 재구성한 후 검색합니다.

이 과정을 "query rewriting"이라고 합니다. verbose=True를 설정하면 내부 과정이 출력되어, 어떻게 질문이 재구성되고, 어떤 문서가 검색되는지 볼 수 있습니다.

개발 중에는 매우 유용하지만, 프로덕션에서는 비활성화하세요. 네 번째 단계에서 실제 대화를 시뮬레이션합니다.

첫 질문은 완전한 문장이지만, 두 번째와 세 번째는 주어가 생략되어 있습니다. 시스템은 자동으로 이전 문맥을 참고하여 이해합니다.

메모리는 각 교환 후 자동으로 업데이트되므로, 별도로 저장할 필요가 없습니다. 여러분이 이 코드를 사용하면 챗봇이 훨씬 더 자연스럽게 느껴집니다.

실무에서는 세션 관리가 중요합니다. 웹 애플리케이션이라면 각 사용자의 메모리를 세션 ID로 구분하여 저장해야 합니다.

Redis 같은 캐시를 사용하면, 여러 서버에서도 같은 대화를 이어갈 수 있습니다. 또한 대화가 너무 길어지면 자동으로 요약하거나 초기화하는 로직도 필요합니다.

실전 팁

💡 프로덕션에서는 ConversationBufferWindowMemory를 사용하여 최근 5-10개 교환만 유지하세요. 전체 히스토리를 유지하면 토큰이 폭발적으로 증가하고, 오래된 문맥이 현재 질문을 방해할 수 있습니다.

💡 세션 관리를 구현하세요. 각 사용자의 메모리를 user_id나 session_id로 구분하여 저장하고, 일정 시간 비활성화되면 자동으로 삭제하여 메모리를 절약하세요.

💡 대화 요약 기능을 활용하세요. ConversationSummaryMemory는 긴 대화를 자동으로 요약하여, 중요한 문맥은 유지하면서 토큰을 절약합니다. 특히 긴 고객 상담 시나리오에 유용합니다.

💡 민감한 정보는 메모리에서 자동 제거하세요. 신용카드 번호, 비밀번호 같은 정보가 대화에 나오면, 정규식으로 감지하여 메모리에 저장하기 전에 마스킹하거나 삭제하세요.

💡 대화 히스토리를 분석하여 사용자 경험을 개선하세요. 어떤 질문 패턴이 많은지, 어디서 사용자가 혼란을 겪는지 분석하면 프롬프트와 문서 구조를 최적화할 수 있습니다.


7. 하이브리드 검색 - 키워드와 의미 검색의 장점 결합하기

시작하며

여러분이 "프로젝트 X-2024의 예산 현황"을 검색한다고 해봅시다. "X-2024"는 정확히 매칭되어야 하는 프로젝트 코드인데, 시맨틱 검색은 "Y-2024"나 "X-2023"도 비슷하다고 판단할 수 있습니다.

반면 키워드 검색만 쓰면 "예산 현황"이 "재무 상태"로 표현된 문서는 놓칩니다. 이런 문제는 단일 검색 방법의 한계입니다.

시맨틱 검색은 의미는 잘 이해하지만 정확한 용어 매칭에 약하고, 키워드 검색은 정확한 매칭은 잘하지만 동의어나 다른 표현을 놓칩니다. 특히 제품 코드, 법률 조항 번호, 사람 이름 같은 경우 정확한 매칭이 필수입니다.

바로 이럴 때 필요한 것이 하이브리드 검색입니다. BM25 같은 전통적인 키워드 검색과 벡터 기반 시맨틱 검색을 결합하여, 두 방법의 장점을 모두 활용합니다.

각각의 결과를 적절히 조합하면 훨씬 정확한 검색이 가능합니다.

개요

간단히 말해서, 하이브리드 검색은 키워드 검색과 시맨틱 검색을 동시에 수행하고, 두 결과를 융합하는 방법입니다. 각 방법의 점수를 가중 평균하여 최종 순위를 결정합니다.

하이브리드 검색이 필요한 이유는 현실 세계의 검색 니즈가 복합적이기 때문입니다. 어떤 쿼리는 정확한 매칭이 중요하고(제품 번호), 어떤 쿼리는 의미 이해가 중요합니다(개념 설명).

예를 들어, "iPhone 15 Pro 사용법"에서 "iPhone 15 Pro"는 정확히 매칭되어야 하지만, "사용법"은 "가이드", "매뉴얼", "튜토리얼"도 찾아야 합니다. 기존에는 하나의 검색 방법을 선택해야 했다면, 이제는 두 가지를 모두 활용할 수 있습니다.

알파(alpha) 파라미터로 키워드와 시맨틱의 가중치를 조절하여, 도메인 특성에 맞게 최적화할 수 있습니다. 하이브리드 검색의 핵심 특징은 세 가지입니다.

첫째, BM25로 키워드 매칭 점수를, 벡터 유사도로 의미 매칭 점수를 각각 계산합니다. 둘째, Reciprocal Rank Fusion(RRF) 같은 알고리즘으로 두 점수를 융합합니다.

셋째, 도메인에 따라 가중치를 조정할 수 있습니다(예: 기술 문서는 키워드 중심, 일반 문서는 시맨틱 중심). 이러한 특징들이 검색 정확도를 크게 향상시킵니다.

코드 예제

from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

# 1. 벡터 기반 검색기 (시맨틱)
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 2. 키워드 기반 검색기 (BM25)
# 모든 문서를 메모리에 로드 (작은 데이터셋용)
documents = vectorstore.get()['documents']  # 모든 문서 가져오기
bm25_retriever = BM25Retriever.from_texts(documents)
bm25_retriever.k = 5  # 상위 5개 반환

# 3. 하이브리드 검색기 생성
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5]  # 50:50 비율 (조정 가능)
)

# 4. 검색 실행
query = "프로젝트 X-2024의 예산 승인 절차"
results = ensemble_retriever.get_relevant_documents(query)

print(f"총 {len(results)}개의 결과:")
for i, doc in enumerate(results[:3]):
    print(f"\n[결과 {i+1}]")
    print(f"내용: {doc.page_content[:150]}...")
    print(f"출처: {doc.metadata.get('source', 'N/A')}")

설명

이것이 하는 일: 이 코드는 두 가지 검색 방법을 동시에 사용하여 더 정확한 검색 결과를 제공합니다. 키워드 검색으로 정확한 용어를 찾고, 시맨틱 검색으로 유사한 의미를 찾아 결과를 통합합니다.

첫 번째 단계에서 벡터 검색기를 설정합니다. 이는 이전에 배운 시맨틱 검색과 동일합니다.

search_kwargs={"k": 5}로 상위 5개를 가져오도록 설정합니다. 이 검색기는 질문의 의미를 이해하고, 다른 표현으로 쓰여진 관련 문서도 찾아냅니다.

두 번째 단계에서 BM25 검색기를 만듭니다. BM25는 TF-IDF의 발전된 버전으로, 단어 빈도와 문서 빈도를 고려한 통계 기반 랭킹 알고리즘입니다.

BM25Retriever.from_texts()는 모든 문서를 메모리에 로드하여 역색인(inverted index)을 구축합니다. 주의할 점은 이 방법은 소규모 데이터셋(수천~수만 문서)에 적합하며, 대규모라면 Elasticsearch 같은 전문 검색 엔진을 사용해야 합니다.

BM25는 "프로젝트 X-2024" 같은 정확한 문자열을 매우 잘 찾습니다. 단어가 정확히 일치하면 높은 점수를 주고, 희귀한 단어(예: "X-2024")가 나타나면 더 높은 가중치를 줍니다.

반면 "the", "is" 같은 흔한 단어는 무시합니다. 세 번째 단계에서 두 검색기를 결합합니다.

EnsembleRetriever는 여러 검색기의 결과를 융합하는 래퍼입니다. weights=[0.5, 0.5]는 두 방법을 동등하게 취급한다는 의미입니다.

만약 정확한 키워드 매칭이 더 중요하다면 [0.7, 0.3]으로, 의미 이해가 더 중요하다면 [0.3, 0.7]로 조정할 수 있습니다. 내부적으로 Reciprocal Rank Fusion(RRF) 알고리즘을 사용합니다.

RRF는 각 검색기에서 문서의 순위를 보고, 역수를 더하는 방식입니다. 예를 들어, 어떤 문서가 BM25에서 1위, 벡터 검색에서 3위라면, 점수는 1/1 + 1/3 = 1.33이 됩니다.

이렇게 계산된 점수로 최종 순위를 매깁니다. 네 번째 단계에서 실제로 검색을 수행합니다.

"프로젝트 X-2024"는 BM25가 정확히 찾고, "예산 승인 절차"는 시맨틱 검색이 "재무 승인 프로세스" 같은 유사 표현도 찾아냅니다. 최종 결과는 두 방법에서 모두 높은 점수를 받은 문서가 상위에 위치합니다.

여러분이 이 코드를 사용하면 검색 품질이 크게 향상됩니다. 실무에서는 도메인에 맞게 가중치를 조정하세요.

기술 문서나 제품 카탈로그는 키워드 비중을 높이고(0.7:0.3), 일반 지식이나 개념 설명은 시맨틱 비중을 높이세요(0.3:0.7). A/B 테스트로 최적 비율을 찾는 것이 좋습니다.

또한 사용자 피드백(검색 결과가 도움이 되었는지)을 수집하여 지속적으로 개선하세요.

실전 팁

💡 대규모 데이터셋에서는 Elasticsearch를 BM25 검색기로 사용하세요. Elasticsearch는 수억 개의 문서도 밀리초 단위로 검색할 수 있으며, 분산 처리와 샤딩을 지원합니다. LangChain의 ElasticsearchRetriever를 사용하면 쉽게 통합할 수 있습니다.

💡 가중치는 도메인과 쿼리 타입에 따라 동적으로 조정하세요. 숫자나 코드가 포함된 쿼리는 자동으로 키워드 비중을 높이고, "어떻게", "왜" 같은 질문은 시맨틱 비중을 높이는 휴리스틱을 사용할 수 있습니다.

💡 쿼리 확장 기법을 BM25와 함께 사용하세요. 동의어 사전을 활용하거나, LLM으로 쿼리를 여러 표현으로 변환한 후 각각 검색하면 재현율(recall)이 향상됩니다.

💡 Reranking 모델을 추가하면 품질이 한 단계 더 올라갑니다. 하이브리드 검색으로 후보 20개를 뽑은 후, Cross-encoder 같은 정교한 모델로 재정렬하면 최종 상위 결과의 정확도가 크게 향상됩니다.

💡 성능 모니터링을 설정하세요. 각 검색 방법의 지연 시간과 결과 품질을 추적하여, 병목이 있는지 확인하세요. BM25는 보통 매우 빠르지만, 벡터 검색은 데이터베이스 성능에 따라 달라집니다.


8. RAG 평가와 최적화 - 시스템 성능 측정 및 개선하기

시작하며

여러분이 RAG 시스템을 만들었는데, 사용자들이 "답변이 정확하지 않아요"라고 피드백을 줍니다. 어디가 문제일까요?

검색이 잘못되었을까요, 아니면 답변 생성이 잘못되었을까요? 객관적인 측정 없이는 알 수 없습니다.

이런 문제는 모든 AI 시스템이 겪는 과제입니다. 주관적으로 "괜찮다"고 느껴도, 실제로는 많은 경우 잘못된 답변을 하고 있을 수 있습니다.

더 큰 문제는 어떤 부분을 개선해야 할지 모른다는 것입니다. 청킹 크기를 바꿔야 할까요, 임베딩 모델을 바꿔야 할까요, 프롬프트를 개선해야 할까요?

바로 이럴 때 필요한 것이 체계적인 평가 프레임워크입니다. 검색 품질(Retrieval Quality)과 생성 품질(Generation Quality)을 각각 측정하고, 어느 단계에 문제가 있는지 파악해야 합니다.

측정할 수 있어야 개선할 수 있습니다.

개요

간단히 말해서, RAG 평가는 시스템의 성능을 정량적으로 측정하는 과정입니다. 검색이 올바른 문서를 찾았는지, 생성된 답변이 정확하고 관련성 있는지를 메트릭으로 평가합니다.

RAG 평가가 필요한 이유는 지속적인 개선을 위해서입니다. 비즈니스 요구사항이 바뀌고, 문서가 업데이트되며, 사용자 질문 패턴이 변합니다.

정기적으로 평가하지 않으면 시간이 지날수록 품질이 저하됩니다. 예를 들어, 새로운 제품 라인이 추가되었는데 기존 청킹 전략으로는 잘 검색되지 않을 수 있습니다.

기존에는 수동으로 몇 개 질문을 테스트하는 것이 전부였다면, 이제는 RAGAS 같은 프레임워크로 자동화된 평가가 가능합니다. Context Relevance, Answer Relevance, Faithfulness 같은 메트릭으로 다각도로 품질을 측정할 수 있습니다.

RAG 평가의 핵심 메트릭은 네 가지입니다. 첫째, Context Relevance는 검색된 문서가 질문과 관련성이 있는지 측정합니다.

둘째, Context Recall은 필요한 모든 정보를 검색했는지 측정합니다. 셋째, Faithfulness는 답변이 검색된 문서에 근거하는지(환각이 없는지) 측정합니다.

넷째, Answer Relevance는 답변이 질문에 직접적으로 대응하는지 측정합니다. 이러한 메트릭들을 이해하면 시스템의 약점을 정확히 파악할 수 있습니다.

코드 예제

from ragas import evaluate
from ragas.metrics import context_relevancy, answer_relevancy, faithfulness
from datasets import Dataset

# 1. 평가 데이터셋 준비
eval_data = {
    "question": [
        "재택근무 정책의 주요 내용은?",
        "연차 신청은 어떻게 하나요?",
        "회사 주차장 이용 규정은?"
    ],
    "answer": [
        # RAG 시스템이 생성한 답변들
        "주 2회 재택 가능하며, 사전 승인 필요",
        "인사 포털에서 신청 후 팀장 승인",
        "직원 주차는 선착순이며, 방문객은 1층 사용"
    ],
    "contexts": [
        # 각 질문에 사용된 검색 문서들 (리스트의 리스트)
        ["재택근무는 주 2회까지 허용되며, 최소 3일 전 신청해야 함"],
        ["연차는 인사 포털에서 신청하고, 직속 상사의 승인이 필요함"],
        ["직원 주차는 선착순 배정. 방문객은 1층 주차장 이용"]
    ],
    "ground_truth": [
        # 정답 (평가용 - 선택사항)
        "주 2회 재택 가능하며 3일 전 신청",
        "인사 포털에서 신청 후 상사 승인",
        "직원은 선착순, 방문객은 1층"
    ]
}

dataset = Dataset.from_dict(eval_data)

# 2. 평가 실행
result = evaluate(
    dataset,
    metrics=[
        context_relevancy,  # 검색된 문서의 관련성
        answer_relevancy,   # 답변의 질문 부합도
        faithfulness        # 답변의 문서 충실도 (환각 방지)
    ]
)

# 3. 결과 분석
print("평가 결과:")
print(f"Context Relevancy: {result['context_relevancy']:.3f}")
print(f"Answer Relevancy: {result['answer_relevancy']:.3f}")
print(f"Faithfulness: {result['faithfulness']:.3f}")

# 4. 개별 질문 분석 (문제 있는 질문 찾기)
for idx, row in enumerate(result.to_pandas().itertuples()):
    if row.faithfulness < 0.7:  # 환각이 의심되는 경우
        print(f"\n[경고] 질문 {idx+1}에서 환각 가능성")
        print(f"질문: {eval_data['question'][idx]}")
        print(f"Faithfulness: {row.faithfulness:.3f}")

설명

이것이 하는 일: 이 코드는 RAG 시스템의 품질을 다각도로 평가하여 수치화합니다. 어느 부분이 약한지 파악하여, 청킹, 검색, 프롬프트 중 무엇을 개선할지 결정할 수 있게 합니다.

첫 번째 단계에서 평가 데이터셋을 준비합니다. 실제 프로덕션에서는 사용자가 자주 묻는 질문들을 샘플링하여 사용합니다.

question은 사용자의 질문, answer는 RAG가 생성한 답변, contexts는 검색된 문서들입니다. 중요한 점은 contexts가 리스트의 리스트라는 것입니다 - 각 질문마다 여러 개의 검색 문서가 있을 수 있습니다.

ground_truth는 선택사항으로, 정답을 아는 경우 제공하면 더 정확한 평가가 가능합니다. 평가 데이터셋은 대표성이 있어야 합니다.

다양한 질문 타입(사실 질문, 방법 질문, 비교 질문 등)과 난이도를 포함하세요. 보통 50-100개 질문이면 시스템의 전반적인 성능을 파악할 수 있습니다.

실제 사용자 로그에서 추출하면 가장 현실적인 평가가 됩니다. 두 번째 단계에서 실제 평가를 실행합니다.

RAGAS는 내부적으로 LLM을 사용하여 평가합니다. context_relevancy는 "검색된 문서가 질문과 관련 있는가?"를 LLM에게 판단하게 합니다.

faithfulness는 "답변의 각 주장이 문서에 있는가?"를 확인합니다. answer_relevancy는 "답변이 질문에 직접 대답하는가?"를 평가합니다.

각 메트릭은 0-1 사이 값으로, 1에 가까울수록 좋습니다. 세 번째 단계에서 전체 평균 점수를 확인합니다.

일반적인 기준은 다음과 같습니다: 0.8 이상이면 우수, 0.6-0.8이면 개선 필요, 0.6 미만이면 심각한 문제입니다. 어떤 메트릭이 낮은지에 따라 개선 방향이 달라집니다.

context_relevancy가 낮으면 검색 문제(청킹이나 임베딩 개선), faithfulness가 낮으면 프롬프트 문제(환각 방지 지시 강화), answer_relevancy가 낮으면 생성 문제(더 명확한 지시)입니다. 네 번째 단계에서 개별 질문을 분석합니다.

전체 평균은 괜찮아도 특정 질문에서 심각한 문제가 있을 수 있습니다. faithfulness < 0.7인 경우를 찾아내면, 어떤 타입의 질문에서 환각이 발생하는지 패턴을 파악할 수 있습니다.

예를 들어, 비교 질문이나 복잡한 다단계 추론에서 문제가 많다면, 그런 질문에 특화된 프롬프트를 만들 수 있습니다. 여러분이 이 코드를 사용하면 데이터 기반으로 시스템을 개선할 수 있습니다.

실무에서는 CI/CD 파이프라인에 평가를 통합하세요. 코드나 문서가 업데이트될 때마다 자동으로 평가를 실행하여, 성능 저하를 조기에 발견할 수 있습니다.

또한 프로덕션에서 실시간 모니터링도 중요합니다. 사용자의 만족도 피드백(thumbs up/down)을 수집하여 실제 사용 환경에서의 품질을 추적하세요.

실전 팁

💡 평가 데이터셋을 정기적으로 업데이트하세요. 새로운 제품, 정책, 사용자 질문 패턴을 반영하여 평가 세트를 진화시켜야 현실적인 성능을 측정할 수 있습니다.

💡 A/B 테스트로 변경 사항의 효과를 검증하세요. 청킹 크기를 변경하거나 새로운 프롬프트를 도입할 때, 기존 버전과 새 버전을 평가 세트로 비교하여 실제로 개선되었는지 확인하세요.

💡 비용을 고려하여 평가 빈도를 조절하세요. RAGAS는 LLM을 사용하므로 비용이 발생합니다. 개발 중에는 작은 샘플(10-20개)로 빠르게 반복하고, 배포 전에는 전체 세트로 최종 검증하세요.

💡 도메인 특화 메트릭을 추가하세요. 의료나 법률 도메인이라면 전문 용어의 정확도, 금융이라면 숫자의 정확도 등 도메인별 중요 사항을 별도로 측정하세요.

💡 Human-in-the-loop 평가를 병행하세요. 자동 평가는 효율적이지만, 정기적으로 사람이 직접 답변을 검토하여 자동 평가가 놓친 문제를 찾아내세요. 특히 뉘앙스가 중요한 경우 사람의 판단이 필수입니다.


9. 메타데이터 필터링과 하이브리드 쿼리 - 정교한 검색 제어하기

시작하며

여러분이 "2024년 1분기 영업 보고서"를 찾는다고 해봅시다. 시스템에는 수년간의 보고서가 있는데, 시맨틱 검색만으로는 2019년 보고서도 유사하다고 판단할 수 있습니다.

날짜와 부서 같은 구조화된 정보를 어떻게 활용할 수 있을까요? 이런 문제는 순수 벡터 검색의 한계입니다.

의미는 잘 이해하지만, "2024년", "1분기", "영업부" 같은 정확한 필터 조건을 강제하기 어렵습니다. 사용자는 특정 범위 내에서만 검색하고 싶을 때가 많은데, 벡터 유사도만으로는 이를 보장할 수 없습니다.

바로 이럴 때 필요한 것이 메타데이터 필터링입니다. 각 문서에 날짜, 부서, 문서 타입 같은 구조화된 정보를 메타데이터로 저장하고, 검색 시 이를 필터 조건으로 사용합니다.

벡터 유사도와 메타데이터 필터를 결합하면 매우 정교한 검색이 가능합니다.

개요

간단히 말해서, 메타데이터 필터링은 벡터 검색에 전통적인 데이터베이스 필터를 결합하는 방법입니다. "2024년이면서 영업부 문서 중에서 의미적으로 유사한 것"처럼 조건을 결합할 수 있습니다.

메타데이터 필터링이 필요한 이유는 실무 요구사항이 복잡하기 때문입니다. 사용자는 종종 "최근 6개월", "우리 팀", "승인된 문서만" 같은 제약을 가지고 검색합니다.

예를 들어, 법률 문서 검색에서 "2023년 이후 개정된 조항"만 찾거나, HR 시스템에서 "내 부서의 정책"만 검색하는 경우가 흔합니다. 기존에는 검색 후 필터링(post-filtering)을 했다면, 이제는 검색 전 필터링(pre-filtering)이 가능합니다.

수백만 문서 중 조건에 맞는 것만 검색 대상으로 하므로, 속도와 정확도가 모두 향상됩니다. 메타데이터 필터링의 핵심 특징은 세 가지입니다.

첫째, 구조화된 필터(날짜, 카테고리)와 비구조화된 검색(텍스트 유사도)을 결합합니다. 둘째, 필터는 정확한 매칭이므로 결과의 정밀도를 보장합니다.

셋째, 권한 관리에도 활용 가능합니다(사용자별로 접근 가능한 문서만 검색). 이러한 특징들이 엔터프라이즈급 RAG를 만듭니다.

코드 예제

from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from langchain.schema import Document
from datetime import datetime

# 1. 메타데이터가 풍부한 문서 생성
documents = [
    Document(
        page_content="2024년 1분기 영업 실적이 전년 대비 15% 증가",
        metadata={
            "department": "sales",
            "year": 2024,
            "quarter": 1,
            "doc_type": "report",
            "author": "김철수"
        }
    ),
    Document(
        page_content="2024년 2분기 마케팅 예산 계획안",
        metadata={
            "department": "marketing",
            "year": 2024,
            "quarter": 2,
            "doc_type": "plan",
            "author": "이영희"
        }
    ),
    # ... 더 많은 문서
]

# 2. 벡터 스토어에 저장
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(documents, embeddings)

# 3. 메타데이터 필터와 함께 검색
query = "영업 실적이 어떻게 되나요?"

# 예시 1: 특정 부서만
results = vectorstore.similarity_search(
    query,
    k=3,
    filter={"department": "sales"}
)

# 예시 2: 복합 조건 (2024년이면서 보고서 타입)
results = vectorstore.similarity_search(
    query,
    k=3,
    filter={"year": 2024, "doc_type": "report"}
)

# 예시 3: 범위 조건 (Chroma의 경우)
results = vectorstore.similarity_search(
    query,
    k=3,
    filter={"year": {"$gte": 2023, "$lte": 2024}}  # 2023-2024년
)

# 4. 결과 확인
for doc in results:
    print(f"부서: {doc.metadata['department']}, 연도: {doc.metadata['year']}")
    print(f"내용: {doc.page_content[:100]}...\n")

설명

이것이 하는 일: 이 코드는 각 문서에 메타데이터를 부여하고, 검색 시 이를 필터 조건으로 사용합니다. "의미적으로 유사하면서 동시에 특정 조건을 만족하는 문서"를 찾아내어, 검색 정밀도를 크게 높입니다.

첫 번째 단계에서 메타데이터가 풍부한 문서를 만듭니다. 각 Document 객체는 page_content(검색될 텍스트)와 metadata(구조화된 정보)를 모두 가집니다.

메타데이터는 파이썬 딕셔너리로, 어떤 정보든 넣을 수 있습니다. 중요한 것은 검색 시 자주 사용할 필드를 미리 정의하는 것입니다.

날짜(year, quarter), 조직(department, team), 분류(doc_type, category), 권한(access_level, owner) 등이 일반적입니다. 메타데이터 설계가 중요합니다.

너무 많으면 관리가 어렵고, 너무 적으면 검색 제어력이 떨어집니다. 일반적으로 5-10개 필드가 적절합니다.

날짜는 타임스탬프보다 year, month 같이 분리하는 것이 쿼리하기 쉽습니다. 카테고리는 계층 구조(category="sales/reports")보다 여러 필드(department="sales", doc_type="report")가 유연합니다.

두 번째 단계에서 문서를 벡터 스토어에 저장합니다. 메타데이터는 텍스트와 함께 저장되지만 벡터화되지 않습니다.

즉, 메타데이터는 검색 공간에 영향을 주지 않고, 순수하게 필터로만 사용됩니다. 이는 성능과 정확도 면에서 이상적입니다.

세 번째 단계에서 다양한 필터 패턴을 보여줍니다. 예시 1은 단순 등호 조건으로, "sales" 부서 문서만 검색합니다.

예시 2는 AND 조건으로, 2024년이면서 동시에 보고서인 문서만 대상으로 합니다. 예시 3은 범위 조건으로, $gte(greater than or equal)와 $lte(less than or equal)로 2023-2024년 사이를 지정합니다.

필터 문법은 벡터 데이터베이스마다 다릅니다. Chroma는 MongoDB 스타일($gte, $in 등), Pinecone은 자체 문법을 사용합니다.

LangChain이 일부 추상화를 제공하지만, 고급 쿼리는 데이터베이스별 문서를 참고해야 합니다. OR 조건은 $or 연산자로, IN 조건은 {"department": {"$in": ["sales", "marketing"]}}처럼 표현합니다.

네 번째 단계에서 결과를 확인합니다. 필터가 제대로 작동하는지 메타데이터를 출력하여 검증하세요.

개발 중에는 k를 크게 설정하여(예: 10) 경계 케이스를 확인하는 것이 좋습니다. 여러분이 이 코드를 사용하면 사용자에게 훨씬 정교한 검색 경험을 제공할 수 있습니다.

실무에서는 동적 필터를 구현하세요. 사용자가 UI에서 날짜 범위, 부서, 문서 타입을 선택하면, 이를 필터 딕셔너리로 변환하여 검색합니다.

또한 권한 관리에도 활용하세요. 현재 사용자의 권한을 메타데이터 필터로 적용하면, 접근할 수 없는 문서는 자동으로 제외됩니다.

실전 팁

💡 메타데이터에 인덱스를 생성하여 성능을 최적화하세요. 대부분의 벡터 데이터베이스는 자주 필터링되는 필드에 인덱스를 지원합니다. 날짜나 카테고리 같은 필드에 인덱스를 만들면 수백만 문서에서도 빠르게 필터링됩니다.

💡 계층적 필터를 구현하려면 여러 필드를 조합하세요. 예를 들어, department, team, project로 세분화하면, 사용자가 원하는 수준에서 검색할 수 있습니다. "영업부 전체" 또는 "영업부 A팀" 또는 "영업부 A팀 X프로젝트"처럼 선택할 수 있게 하세요.

💡 시간 기반 필터는 현재 시간을 기준으로 동적으로 계산하세요. "최근 6개월"을 하드코딩하지 말고, datetime.now()에서 계산하여 항상 최신 기준으로 필터링하세요.

💡 필터와 벡터 검색의 순서를 최적화하세요. 필터가 매우 선택적(결과가 적음)이면 먼저 필터링 후 벡터 검색이 빠르고, 필터가 광범위하면 벡터 검색 후 필터링이 나을 수 있습니다. 벡터 데이터베이스가 자동 최적화하지만, 이해하고 있으면 성능 문제 디버깅에 도움됩니다.

💡 메타데이터 품질을 모니터링하세요. 문서 업로드 시 메타데이터 검증(필수 필드, 데이터 타입, 값 범위)을 수행하여, 불완전하거나 잘못된 메타데이터로 인한 검색 문제를 예방하세요.


10. 프로덕션 배포 고려사항 - 안정적이고 확장 가능한 RAG 시스템 만들기

시작하며

여러분이 완벽하게 작동하는 RAG 시스템을 로컬에서 만들었습니다. 이제 수천 명의 사용자에게 서비스하려면 무엇을 준비해야 할까요?

로컬 개발과 프로덕션 환경은 완전히 다른 도전 과제를 가집니다. 이런 문제는 모든 AI 시스템이 직면하는 현실입니다.

동시에 100명이 질문하면 어떻게 될까요? API 비용이 폭발하면?

시스템이 다운되면? 보안 문제는?

프로덕션 환경에서는 신뢰성, 확장성, 비용, 보안 모두를 고려해야 합니다. 바로 이럴 때 필요한 것이 체계적인 프로덕션 아키텍처입니다.

캐싱으로 비용을 줄이고, 로드 밸런싱으로 확장성을 확보하며, 모니터링으로 문제를 조기에 발견하고, 보안 조치로 데이터를 보호해야 합니다. 개발에서 운영으로의 전환은 단순히 코드를 서버에 올리는 것 이상입니다.

개요

간단히 말해서, 프로덕션 배포는 RAG 시스템을 실제 사용자에게 안정적으로 서비스하기 위한 모든 준비 과정입니다. 성능, 비용, 보안, 모니터링 등 다양한 측면을 고려해야 합니다.

프로덕션 준비가 필요한 이유는 개발 환경과 운영 환경의 요구사항이 다르기 때문입니다. 개발에서는 빠른 실험이 중요하지만, 운영에서는 안정성이 최우선입니다.

예를 들어, 하루에 1만 개의 질문을 처리한다면, API 비용, 응답 속도, 에러 처리가 모두 중요해집니다. 기존에는 단순히 서버에 배포하는 것이 전부였다면, 이제는 마이크로서비스 아키텍처, 서버리스, 컨테이너화 등 다양한 옵션을 고려합니다.

각 컴포넌트(검색, 생성, 캐싱)를 독립적으로 확장할 수 있어야 합니다. 프로덕션 RAG의 핵심 고려사항은 다섯 가지입니다.

첫째, 캐싱으로 중복 요청을 줄여 비용과 지연시간을 절감합니다. 둘째, 비동기 처리로 동시성을 높입니다.

셋째, 에러 핸들링과 재시도 로직으로 안정성을 확보합니다. 넷째, 로깅과 모니터링으로 문제를 추적합니다.

다섯째, 보안 조치로 데이터와 API를 보호합니다. 이러한 사항들이 엔터프라이즈급 시스템을 만듭니다.

코드 예제

from langchain.cache import RedisCache
from langchain.callbacks import get_openai_callback
from redis import Redis
import logging
from functools import wraps
import time

# 1. 캐싱 설정 - 중복 질문 방지
redis_client = Redis(host='localhost', port=6379, db=0)
# langchain.llm_cache = RedisCache(redis_client)  # LLM 응답 캐싱

# 2. 비용 추적 데코레이터
def track_costs(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        with get_openai_callback() as cb:
            result = func(*args, **kwargs)
            logging.info(f"API 호출: {cb.total_tokens} 토큰, ${cb.total_cost:.4f}")
            return result
    return wrapper

# 3. 에러 핸들링과 재시도
def safe_rag_query(qa_chain, query, max_retries=3):
    """안전한 RAG 쿼리 실행"""
    for attempt in range(max_retries):
        try:
            # 타임아웃 설정
            result = qa_chain({"query": query}, timeout=30)

            # 결과 검증
            if not result.get('result'):
                raise ValueError("빈 응답")

            return result

        except Exception as e:
            logging.error(f"시도 {attempt + 1} 실패: {str(e)}")

            if attempt == max_retries - 1:
                # 최종 실패 시 대체 응답
                return {
                    "result": "죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
                    "source_documents": [],
                    "error": str(e)
                }

            time.sleep(2 ** attempt)  # 지수 백오프

# 4. 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('rag_system.log'),
        logging.StreamHandler()
    ]
)

# 5. 프로덕션용 RAG 함수
@track_costs
def production_rag_query(query: str, user_id: str = None):
    """프로덕션 환경용 RAG 쿼리"""

    # 사용자 정보 로깅
    logging.info(f"쿼리 시작 - User: {user_id}, Query: {query[:50]}...")

    # 입력 검증
    if not query or len(query) < 3:
        return {"result": "질문이 너무 짧습니다. 더 구체적으로 작성해주세요."}

    if len(query) > 500:
        return {"result": "질문이 너무 깁니다. 500자 이내로 작성해주세요."}

    # 안전한 실행
    result = safe_rag_query(qa_chain, query)

    # 결과 로깅
    logging.info(f"쿼리 완료 - 문서

#Python#RAG#VectorDB#LangChain#AI#python

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.