Python & AI Tutorials Logo
LangChain & LangGraph

9. 첫 번째 RAG 시스템 구축하기

지금까지 만든 앱은 LLM이 사전 학습한 데이터만 사용했습니다. 그래서 회사 내부 문서, 제품 매뉴얼처럼 LLM이 학습하지 않은 정보에 대해서는 답변할 수 없었습니다.

RAG(Retrieval-Augmented Generation)는 이 문제를 해결합니다. 사용자 질문이 들어오면 관련 문서를 먼저 검색하고, 검색된 내용을 질문과 함께 LLM에게 전달하여 그 내용을 바탕으로 답변하도록 만듭니다. LLM의 추론 능력에 여러분의 문서 지식을 더하는 것입니다.

이 장에서는 문서 준비(로딩, 청킹, 임베딩)부터 검색 기반 답변 생성까지, 완전한 RAG 파이프라인을 구축합니다. 완성된 시스템은 질문이 들어오면 관련 문서를 검색한 뒤, 질문과 함께 LLM에게 전달하여 그 문서 내용을 바탕으로 답변하도록 만듭니다. 문서에 있는 내용은 정확히 답하고, 없는 내용은 "모른다"고 솔직히 답하게 만드는 것—이것이 신뢰할 수 있는 RAG의 핵심입니다.

9.1) RAG 이해하기

9.1.1) 문제: LLM은 여러분의 데이터를 모릅니다

LLM은 위키백과, 뉴스 기사, 공개 코드 등의 인터넷 데이터로 학습했습니다. 당신 회사의 내부 문서나 어제 받은 계약서 등의 내용은 알 수 없습니다. 그래서 다음과 같은 질문에는 답할 수 없습니다.

  • "우리 회사 휴가 정책은?"
  • "이번 분기 매출 보고서 요약해줘"
  • "방금 받은 계약서에서 환불 조건은?"
python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# LLM이 본 적 없는 비공개 문서에 대해 질문합니다
response = llm.invoke("Acme Corp의 환불 정책은 무엇인가요?")
print(response.content)

출력:

Acme Corp의 환불 정책에 대한 구체적인 정보가 없습니다.
가장 정확하고 최신 정보를 얻으려면
공식 웹사이트를 확인하거나 고객 지원팀에 직접 문의하시는 것을 권장합니다.

이 예시에서 LLM은 정직하게 모른다고 말하고 있습니다. (또는 마치 아는 것처럼 그럴듯한 답변을 지어낼 수도 있습니다.)

그런데 만일 질문과 함께 환불 정책 문서를 제공하면 어떻게 될까요? LLM은 제공된 내용을 바탕으로 정확한 답변을 할 것입니다. 이것이 바로 RAG의 핵심 아이디어입니다.

9.1.2) 문서는 어떻게 넣어야 할까요?

가장 간단한 방법은 전체 문서를 프롬프트에 복사해서 붙여넣는 것입니다. 짧은 문서에는 이 방식이 실제로 잘 작동합니다.

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# 실제로는 훨씬 긴 내용이겠지만, 여기서는 다음 내용이 전체 문서 내용이라고 가정합니다
document_text = """
환불 정책 (2026년 1월 시행):
- 구매 후 30일 이내 원본 영수증과 함께 전액 환불.
- 30일 이후에는 스토어 크레딧만 가능.
- 디지털 제품은 다운로드 후 환불 불가.
- 결함 있는 제품은 언제든지 전액 환불 가능.
"""
 
response = llm.invoke(
    f"""다음 문서를 참고해서 답변해 주세요:
{document_text}
 
질문: 디지털 제품의 환불 정책은 무엇인가요?"""
)
print(response.content)

출력:

문서에 따르면 디지털 제품은 다운로드 후에는 환불이 불가합니다.
...

짧은 문서에는 잘 작동합니다. 그러나 문서가 매우 크면 어떻게 될까요? 이 경우 다음과 같은 심각한 문제를 발생시킵니다.

1. 컨텍스트 윈도우 제한: LLM은 한 번에 처리 가능한 토큰 수가 제한됩니다. GPT-5-mini의 경우 400K 토큰입니다. 그러나 회사 전체 문서는 이를 쉽게 초과합니다. 설령 들어간다 해도 컨텍스트가 길수록 답변이 느려지고 부정확해집니다.

2. 비용: LLM API는 토큰당 비용을 청구합니다. 답변에 필요한 내용은 한두 단락뿐인데 전체 문서를 보내면 비용이 급증하게 됩니다.

3. 정확도 저하: 문서 전체를 넣으면 정작 필요한 정보가 불필요한 내용 속에 묻혀버리게 됩니다. LLM이 관련 없는 정보에 주의를 빼앗기면서 답변 품질이 떨어질 수 있습니다.

RAG는 문서에서 관련 부분만 검색해서 제공함으로써 이 세 가지 문제를 모두 해결합니다.

9.1.3) 핵심 아이디어: 관련된 부분만 검색해서 질문과 함께 제공하기

RAG의 핵심은 간단합니다. LLM에게 질문을 던지기 전에, 먼저 관련 문서를 찾아서 질문과 함께 제공하는 것입니다.

작동 방식은 다음과 같습니다:

  1. 사용자가 질문을 합니다.
  2. 시스템이 문서 저장소에서 질문과 관련된 내용을 검색(Retrieval)합니다.
  3. 검색된 내용을 질문과 함께 프롬프트에 추가(Augmentation)합니다.
  4. LLM이 검색된 내용을 바탕으로 답변을 생성(Generation)합니다.

이 세 단계가 RAG(Retrieval-Augmented Generation)라는 이름의 유래입니다.

9.1.4) 관련된 내용 검색은 어떻게 할까요? (키워드 매칭의 한계)

검색 단계는 RAG의 핵심입니다. 질문과 관련된 내용을 제공해야 제대로 된 답변이 나올 수 있기 때문입니다. 그렇다면 어떻게 질문과 관련된 내용을 검색할까요?

가장 간단한 방법은 키워드 매칭입니다. 질문에 있는 단어를 포함하는 문서를 찾는 것이죠. 예를 들어 "디지털 제품의 환불 정책"을 묻는다면, "환불", "디지털", "제품"이라는 단어를 포함하는 문서를 검색하는 것입니다.

하지만 키워드 매칭은 치명적인 약점이 있습니다. 정확히 같은 단어가 있어야만 찾을 수 있습니다.

예를 들어볼까요? 다음 내용이 있는 환불 정책 문서가 있다고 가정합니다:

"구매 후 30일 이내 전액 환불 가능합니다."

사용자가 "어떻게 돈을 돌려받나요?"라고 물으면 어떻게 될까요? 이 문서는 검색되지 않습니다. 문서에 "돈을 돌려받다"라는 표현이 없기 때문입니다. 사람은 "환불"과 "돈을 돌려받다"가 같은 의미라는 걸 알지만, 키워드 검색은 단어만 보기 때문에 찾지 못합니다.

키워드 검색은 단어만 매칭합니다. 의미가 같아도 단어가 다르면 찾지 못하는 것이죠.

해결책은 의미 기반 검색입니다. 그리고 그것을 가능하게 하는 것이 바로 임베딩입니다.

9.1.5) 임베딩: 텍스트를 수치 벡터로 변환하기

임베딩(embedding)은 텍스트의 의미를 숫자 목록(벡터)로 표현한 것입니다. 임베딩 모델에 텍스트를 입력하면 수백~수천 개의 숫자로 이루어진 벡터로 변환됩니다.

python
from langchain_openai import OpenAIEmbeddings
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 단일 문장을 임베딩합니다
vector = embeddings_model.embed_query("어떻게 돈을 돌려받나요?")
 
print(f"벡터 차원: {len(vector)}")
print(f"처음 5개 값: {vector[:5]}")

출력:

벡터 차원: 1536
처음 5개 값: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]

차원(dimension)은 벡터를 구성하는 숫자의 개수입니다. text-embedding-3-small 모델은 모든 텍스트를 1,536개의 숫자로 표현합니다.

왜 이렇게 많은 숫자가 필요할까요? 각 차원마다 다루는 의미의 측면이 다르기 때문입니다:

  • 어떤 차원은 "행동/상태"를 구분할 수 있습니다
  • 어떤 다른 차원은 "구체적/추상적"의 정도를 나타낼 수 있습니다
  • 또 다른 차원은 "긍정적/부정적" 감정을 나타낼 수 있습니다
  • ... (1,536가지 의미 특성. 실제로 각 차원이 어떤 의미 측면을 다루는지는 알 수 없음)

2D 좌표 (x, y)가 평면 위의 한 점을 나타내듯이, 1,536차원 벡터는 1,536차원의 "의미 공간"에서 한 점을 나타냅니다. 차원이 많을수록 의미를 더 세밀하게 구분할 수 있습니다.

의미가 비슷하면 의미 공간에서 가까이 위치합니다. "환불 방법"과 "돈 돌려받기"는 다른 단어로 이루어진 표현이지만, 비슷한 의미이기 때문에 의미 공간에서는 가까운 자리에 놓입니다.

9.1.6) 의미 검색: 유사한 의미, 더 가까운 거리

문서와 쿼리를 모두 벡터로 변환했다면, 이제 벡터 간 유사도를 측정하여 가장 관련성 높은 문서를 찾을 수 있습니다. 이를 의미 검색(semantic search)이라고 합니다 — 키워드 매칭이 아닌, 의미의 유사성으로 검색하는 방식입니다.

가장 일반적인 유사도 측정 방법은 코사인 유사도(cosine similarity)로, 두 벡터 사이의 각도를 측정합니다. 벡터가 같은 방향을 가리킬수록 유사도가 높습니다. 1.0에 가까울수록 의미가 유사하고, 0에 가까울수록 관련성이 낮습니다.

환불 가능 기간은 어떻게 되나요?

[0.12, -0.45, 0.78, ...]

구매일로부터 30일 이내 전액 환불이 가능합니다.

[0.14, -0.42, 0.80, ...]

저희 사무실은 시애틀에 있습니다

[-0.67, 0.33, -0.11, ...]

가까움!
(유사도 ≈ 0.6415)

멀리 떨어짐
(유사도 ≈ 0.1706)

실제로 계산해보겠습니다:

python
from langchain_openai import OpenAIEmbeddings
import numpy as np
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 쿼리와 두 개의 후보 문서를 임베딩합니다
query_vec = embeddings_model.embed_query("환불 가능 기간은 어떻게 되나요?")
doc1_vec = embeddings_model.embed_query("구매일로부터 30일 이내 전액 환불이 가능합니다.")  # 관련 문서
doc2_vec = embeddings_model.embed_query("저희 사무실은 시애틀 다운타운에 위치해 있습니다.")  # 관련 없는 문서
 
def cosine_similarity(a, b):
    """두 벡터 간의 코사인 유사도를 계산합니다."""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
 
print(f"쿼리 vs '환불' 문서:    {sim1:.4f}")
print(f"쿼리 vs '사무실' 문서:   {sim2:.4f}")

출력:

쿼리 vs '환불' 문서:    0.6415
쿼리 vs '사무실' 문서:   0.1706

(실제 값은 모델에 따라 다를 수 있습니다)

환불 문서가 훨씬 높은 점수를 받았습니다. 임베딩 모델은 "환불 가능 기간"과 "30일 이내 전액 환불"이 의미적으로 관련되어 있음을 이해합니다. 이것이 의미 검색이며, RAG의 핵심 메커니즘입니다.

9.1.7) RAG 파이프라인 개요

지금까지 배운 개념들을 합치면 다음과 같은 RAG 파이프라인이 만들어집니다:

2단계: 검색 및 답변 생성

1단계: 지식 주입

📄 전체 문서

✂️ 문서조각으로 분할

🔢 문서조각 임베딩

벡터 스토어

❓ 사용자 질문

🔢 질문 임베딩

🔍 유사도 검색

📋 관련 문서조각 추출

📝 프롬프트 증강
(질문 + 관련 문서조각)

🤖 LLM

관련 문서조각 기반 답변

파이프라인은 두 단계로 구성됩니다:

지식 주입(최초 한 번 수행, 또는 문서가 변경될 때 수행):

  1. 문서 로드(Loading): 다양한 소스(Markdown, PDF 등)에서 텍스트 데이터를 추출합니다.
  2. 텍스트 분할(Chunking): 검색 정밀도를 높이고 LLM의 입력 제한을 준수하기 위해 긴 문서를 작은 단위의 문서조각(Chunk)으로 나눕니다.
  3. 벡터 변환(Embedding): 임베딩 모델을 사용하여 문서조각을 의미 기반의 수치형 벡터(Vector)로 변환합니다.
  4. 벡터 저장(Storage): 변환된 벡터와 원본 텍스트를 벡터 데이터베이스에 저장(Indexing)합니다.

검색 및 답변 생성(사용자 질문마다 수행):

  1. 질문 임베딩: 사용자의 질문을 지식 주입시 사용했던 것과 동일한 모델을 통해 수치형 벡터로 변환합니다.
  2. 유사도 검색(Retrieval): 질문 벡터와 의미적으로 가장 가까운 상위 K개의 문서조각들을 벡터 데이터베이스에서 추출합니다.
  3. 프롬프트 증강(Augmentation): 원래의 질문에 검색된 문서조각들을 결합하여 프롬프트를 증강시킵니다.
  4. 답변 생성(Generation): LLM은 제공된 문서조각들을 참조(Reference)하여 근거 기반 답변을 생성합니다.

9.2) 문서 로딩 및 청킹

이번 섹션에서는 RAG 파이프라인의 지식 주입 과정 중 다음 두 단계를 다룹니다.

  1. 문서 로드: 파일에서 텍스트 데이터 읽어오기
  2. 텍스트 분할(청킹): 텍스트 데이터를 검색 가능한 작은 조각으로 나누기

다음 섹션(9.3)에서는 이 청크들을 벡터로 변환하고 저장하는 방법을 배웁니다.

9.2.1) 파일에서 문서 로딩하기

RAG 파이프라인의 첫 단계는 문서를 Python 객체로 읽어오는 것입니다. LangChain은 다양한 파일 형식을 지원하는 문서 로더(Document Loaders)를 제공합니다. 주요 로더는 다음과 같습니다:

  • TextLoader: 일반 텍스트(.txt) 및 마크다운(.md) 파일
  • PyPDFLoader: PDF(.pdf) 파일을 페이지 단위로 로드
  • CSVLoader: CSV(.csv) 파일의 각 행을 하나의 문서로 로드
  • UnstructuredMarkdownLoader: 마크다운(.md) 파일을 구조(헤더, 리스트 등)까지 파악하여 로드

어떤 로더를 사용하든 결과는 동일한 Document 객체 형태로 반환됩니다. 각 Document 객체는 두 가지 주요 속성을 가집니다:

  • page_content: 문서의 텍스트 내용
  • metadata: 파일 경로, 페이지 번호 등의 메타정보를 담은 딕셔너리

이 튜토리얼에서는 TextLoader를 사용하여 마크다운 파일을 읽어오겠습니다.

샘플 문서 준비

먼저, 작업할 샘플 문서를 만들어보겠습니다. 프로젝트에 data/docs/ 폴더를 만들고 다음 파일을 추가하세요:

bash
mkdir -p data/docs

data/docs/refund_policy.md 생성:

markdown
# 환불 정책
 
**시행일**: 2026년 1월 1일
 
## 표준 반품
 
모든 물리적 제품은 구매 후 30일 이내에 전액 환불을 위해 반품될 수 있습니다.
원본 영수증 또는 주문 확인 이메일이 필요합니다. 제품은 원래 포장 상태이고
사용되지 않은 상태여야 합니다.
 
30일 이후에는 스토어 크레딧으로만 반품이 가능합니다. 스토어 크레딧은 만료되지 않습니다.
 
## 디지털 제품
 
디지털 제품(소프트웨어 라이선스, 전자책, 온라인 강좌)은 다운로드 또는 액세스 링크가
활성화되면 환불이 불가능합니다. 액세스를 방해하는 기술적 문제가 발생하면
7일 이내에 지원팀에 연락하여 교체 또는 환불을 받으세요.
 
## 결함 있는 제품
 
결함 있는 제품은 언제든지 전액 환불 또는 교체를 위해 반품될 수 있습니다.
결함에 대한 설명을 포함해주세요. 결함 있는 반품의 배송비는
회사에서 부담합니다.
 
## 구독 서비스
 
월간 구독은 언제든지 취소할 수 있습니다. 환불은 청구 주기의 남은 일수를 기준으로
비례 배분됩니다. 연간 구독은 처음 14일 이내에 전액 환불될 수 있습니다.
14일 이후에는 환불이 불가능하지만 청구 기간이 끝날 때까지 액세스가 계속됩니다.

data/docs/shipping_info.md 생성:

markdown
# 배송 정책
 
## 국내 배송
 
표준 배송 (5-7 영업일): $50 이상 주문 시 무료, 그 외 $5.99.
특급 배송 (2-3 영업일): $12.99.
익일 배송 (다음 영업일): $24.99.
 
## 국제 배송
 
국제 주문은 추적 가능한 항공 우편으로 배송됩니다. 배송 시간은
목적지에 따라 다르며, 일반적으로 10-21 영업일입니다. 국제 배송비는
무게와 목적지를 기준으로 결제 시 계산됩니다.
 
관세 및 수입세는 구매자의 책임이며 배송비에 포함되지 않습니다.
 
## 주문 추적
 
모든 주문에는 배송 후 24시간 이내에 이메일로 전송되는 추적 번호가 포함됩니다.
이메일로 전송된 추적 링크 또는 배송업체 웹사이트에서 주문을 추적하세요.
 
## 분실 또는 손상된 패키지
 
패키지가 분실되거나 손상된 상태로 도착하면 48시간 이내에 지원팀에 연락하세요.
추가 비용 없이 교체품을 배송해드립니다. 손상된 제품의 경우
손상 및 포장 사진을 제공해주세요.

이제 TextLoader를 사용하여 이 파일들을 로드하겠습니다:

python
from pathlib import Path
from langchain_community.document_loaders import TextLoader
 
# data/docs 디렉토리의 모든 .md 파일 로드
docs_dir = Path("data/docs")
 
for md_file in docs_dir.glob("*.md"):
    loader = TextLoader(str(md_file), encoding="utf-8")
    docs = loader.load()
    
    if docs:  # 비어있지 않은지 확인
        doc = docs[0]  # 단일 파일이므로 첫 번째 Document
        print(f"파일: {doc.metadata['source']}")
        print(f"길이: {len(doc.page_content)}자")
        print(f"미리보기: {doc.page_content[:80]}...")
        print()

출력:

파일: data/docs/refund_policy.md
길이: 892자
미리보기: # 환불 정책
...
 
파일: data/docs/shipping_info.md
길이: 654자
미리보기: # 배송 정책
...

참고: TextLoader는 단일 파일 경로를 입력으로 받지만, 다른 문서 로더와의 일관된 인터페이스를 위해 List[Document]를 반환합니다. (예: PDFLoader는 페이지별로 여러 Document를 반환)

9.2.2) 문서를 작은 조각으로 나누기: 청킹(Chunking)

위의 두 문서는 쉽게 설명하기 위해 매우 짧은 예시를 사용했습니다. 그러나 실제 애플리케이션에서는 수백~수천 페이지 문서를 다루는 경우가 많습니다. 이런 문서 전체를 하나의 벡터로 임베딩하면 어떻게 될까요? 수천 개의 개념이 하나의 벡터에 압축되어, 정작 필요한 내용을 정확히 찾을 수 없게 됩니다.

청킹(chunking)은 문서를 작고 의미 있는 조각으로 나누는 것입니다. 목표는 단순합니다. 사용자가 질문하면 문서 전체가 아니라 답변과 직접 관련된 특정 단락만 검색되게 하기 위함입니다.

청크의 크기는 검색과 답변 품질에 직접적인 영향을 미칩니다:

  • 너무 크면: 여러 주제가 하나의 청크에 섞여 임베딩이 부정확해지기 때문에 관련 내용을 찾기 어려워집니다. 설령 찾더라도 불필요한 내용이 함께 LLM에 전달되어 답변 품질이 떨어질 수 있습니다.
  • 너무 작으면: LLM이 제대로 답변하는 데 필요한 정보가 충분히 전달되지 않을 수 있습니다. 예를 들어 "배송비는 $5.99입니다"라는 문장만 검색되면, 이것이 $50 미만 주문에만 해당된다는 것을 LLM이 알 수 없어 부정확한 답변을 할 수 있습니다.
  • 적절하면: 하나의 주제가 충분한 문맥과 함께 담겨, 정확한 검색과 답변이 가능합니다.

9.2.3) 청크 크기와 오버랩 조절하기

문서를 청크로 나누려면 텍스트 분할기가 필요합니다. 텍스트 분할기(Text Splitter)는 LangChain이 제공하는 도구로, 긴 문서를 작은 조각으로 나누는 역할을 합니다. 상황에 따라 적절한 분할기를 선택하는 것은 매우 중요합니다.

  • RecursiveCharacterTextSplitter: 여러 구분자를 계층적으로 시도하여 문맥을 최대한 보존합니다. 범용적으로 가장 많이 쓰이는 분할기입니다.
  • CharacterTextSplitter: 지정한 단일 구분자(기본값: \n\n)만으로 분할합니다. 구조가 단순한 문서에 적합합니다.
  • MarkdownHeaderTextSplitter: 마크다운 헤더(#, ##)를 기준으로 분할합니다. 문서의 목차 구조를 그대로 살리고 싶을 때 효과적입니다.

RecursiveCharacterTextSplitter 방식이 효과적일까요?

이 분할기는 큰 단위에서 작은 단위로 구분자를 바꿔가며 자를 위치를 찾습니다. 기본 순서는 다음과 같습니다(separators 파라미터를 통해 변경할 수 있음):

단락(\n\n) → 줄바꿈(\n) → 단어( )

이 방식은 가능한 큰 의미 단위부터 분할하려고 시도합니다. 먼저 단락 경계에서 분할을 시도하고, 단락이 chunk_size보다 길면 줄바꿈, 단어 순으로 기준을 좁혀가며 자릅니다. 단어 중간을 임의로 끊는 대신 가장 자연스러운 구분점을 찾아내기 때문에, 분할된 청크들이 의미적으로 온전한 정보를 가지게 될 확률이 높아집니다.

주요 파라미터

  • chunk_size: 각 청크의 최대 문자 수입니다. 예를 들어 chunk_size=400이면 각 청크는 400자를 넘지 않습니다.
  • chunk_overlap: 인접한 청크 간에 겹치는 문자 수입니다. 예를 들어 chunk_overlap=80이면 앞 청크의 마지막 80자가 다음 청크의 시작 부분에 다시 포함됩니다.
  • separators: 텍스트를 자를 때 사용하는 구분자 우선순위 목록입니다. 앞에 있는 구분자 순으로 시도하며, 앞 구분자로 분할했을 때 chunk_size를 초과하게 되면 다음 구분자로 시도하여 chunk_size를 넘기지 않도록 합니다.

오버랩이란 무엇이고 왜 필요할까요?

오버랩은 분할된 청크들이 서로의 내용을 일부 공유하는 것을 말합니다. 앞선 청크의 마지막 일부분을 다음 청크에 포함하는 방식입니다.

이렇게 하는 이유는 분할된 청크를 AI가 더욱 쉽게 이해할 수 있도록 하기 위함입니다. 만일 어떤 문서 조각을 읽을 때 앞선 내용을 전혀 모르면 왜 이러한 내용이 언급되고 있는지 이해하기 어려울 수 있습니다. 오버랩은 앞 조각의 끝부분 내용을 뒤 조각이 조금 물고 있게 하여, 어느 조각을 읽더라도 내용이 자연스럽게 이어지도록 도와줍니다.

📄 원본 문서
단락1 | 단락2 | 단락3 | 단락4

✂️ 분할

📋 청크 1

단락1

📋 청크 2

단락1 끝부분
+ 단락2

📋 청크 3

단락2 끝부분
+ 단락3

📋 청크 4

단락3 끝부분
+ 단락4

이제 실제로 문서를 분할해보겠습니다:

python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# 문서를 로드합니다
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
 
# 분할기를 구성합니다
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " "],
)
 
chunks = text_splitter.split_documents(docs)
 
print(f"{len(chunks)}개의 청크로 분할했습니다\n")
for i, chunk in enumerate(chunks):
    print(f"--- 청크 {i} (소스: {chunk.metadata['source']}) ---")
    print(f"길이: {len(chunk.page_content)}자")
    print(chunk.page_content[:120])
    print()

출력:

4개의 청크로 분할했습니다
 
--- 청크 0 (source: data/docs/refund_policy.md) ---
Length: 374 characters
# 환불 정책
...
 
--- Chunk 1 (source: data/docs/refund_policy.md) ---
Length: 267 characters
## 디지털 제품
...
 
--- Chunk 2 (source: data/docs/refund_policy.md) ---
Length: 204 characters
## 결함 있는 제품
...
 
--- Chunk 3 (source: data/docs/refund_policy.md) ---
Length: 315 characters
## 구독 서비스
...

참고: 위의 예제에서는 오버랩이 발생하지 않았습니다. 그 이유는 각 문단이 첫 번째 구분자인 \n## 를 기준으로 chunk_size보다 작게 깔끔하게 잘렸기 때문입니다. 오버랩은 문서의 특정 문단이 chunk_size보다 길어서 해당 내용을 두 개 이상의 조각으로 나누어야 할 때 발생합니다.

9.3) ChromaDB를 사용한 벡터 저장 및 검색

9.3.1) 벡터 스토어란 무엇인가요?

벡터 스토어(또는 벡터 데이터베이스)는 임베딩 벡터를 기반으로 데이터를 저장하고 검색하는 데 최적화된 데이터베이스입니다. 전통적인 데이터베이스가 정확한 필드 값으로 쿼리하는 것과 달리(SELECT * FROM products WHERE category = 'electronics'), 벡터 스토어는 쿼리와 가장 유사한 의미를 가진 항목을 찾습니다.

RAG에서 벡터 스토어는 문서 청크와 그 임베딩을 함께 저장합니다. 사용자가 질문하면 질문을 벡터로 변환한 후, 벡터 스토어에서 가장 유사한 벡터를 가진 청크를 검색합니다.

9.3.2) 벡터 스토어 선택과 ChromaDB 설정

대표적인 벡터 스토어로는 ChromaDB, Pinecone, Weaviate, pgvector(PostgreSQL 확장) 등이 있습니다. 이들은 호스팅 방식(로컬 vs. 클라우드), 처리 규모, 운영 복잡도 면에서 차이가 있습니다. 이 책에서는 ChromaDB를 사용합니다 — 오픈소스이고, 별도 서버 설정 없이 로컬 머신에서 바로 실행되며, 개발 단계뿐만 아니라 중소 규모 프로덕션까지 폭넓게 활용할 수 있습니다.

ChromaDB는 여러 가지 방식으로 사용할 수 있습니다:

  • 로컬 모드 (pip): Python 라이브러리 설치만으로 즉시 사용 가능합니다. 별도 서버 인프라 없이 로컬 디렉토리에 데이터를 저장하고 불러올 수 있습니다.
  • 독립 서버 (Docker): ChromaDB를 별도 서버 프로세스로 실행합니다. 여러 애플리케이션이 하나의 벡터 스토어를 공유해야 할 때 유용합니다.
  • 관리형 클라우드 서비스 (Chroma Cloud): ChromaDB 클라우드 서비스를 이용합니다. 호스팅, 스케일링, 유지보수를 Chroma Cloud가 대신 처리하기 때문에 인프라 운영 부담 없이 안정적으로 서비스를 제공할 수 있습니다.

ChromaDB를 pip 방식으로 설치해보겠습니다.

bash
pip install chromadb langchain-chroma

chromadb는 벡터 스토어 핵심 라이브러리이고, langchain-chroma는 LangChain 라이브러리에서 ChromaDB를 직접 사용할 수 있게 해주는 통합 패키지입니다.

9.3.3) 임베딩 모델 선택하기

맨 처음 정해야 할 것은 임베딩 모델을 선택하는 것입니다. 임베딩 벡터는 같은 모델을 사용했을 때만 서로 비교할 수 있습니다. 따라서 문서를 저장할 때와 쿼리할 때 반드시 동일한 임베딩 모델을 사용해야 합니다.

OpenAI는 다음과 같은 임베딩 모델을 제공합니다:

모델차원설명
text-embedding-3-small1536품질과 비용의 균형이 좋음
text-embedding-3-large3072더 높은 품질, 더 높은 비용

이 책에서는 OpenAI의 text-embedding-3-small 모델을 사용합니다. 높은 효율성과 저렴한 비용으로, 일반적인 검색, RAG, 가성비를 중시하는 프로젝트에 적합한 선택입니다.

python
from langchain_openai import OpenAIEmbeddings
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 작동하는지 확인합니다
test_vector = embedding_model.embed_query("test")
print(f"임베딩 차원: {len(test_vector)}")

출력:

임베딩 차원: 1536

비용 안내: 임베딩 API 호출은 LLM 호출보다 훨씬 저렴하지만, 엄연히 비용이 발생하는 작업입니다. 데이터베이스에 문서를 저장(색인)할 때 각 청크당 한 번, 그리고 사용자가 질문(검색)할 때 질문 내용에 대해 한 번씩 API 호출이 필요합니다. 현재 가격은 OpenAI 가격 페이지를 참고하세요.

9.3.4) ChromaDB에 청크 저장하기

이제 우리가 배워온 것들을 종합하여 청크를 벡터 스토어에 저장해봅시다. 문서를 로드하고, 청크로 분할하고, 청크를 임베딩한 후, 임베딩 벡터와 함께 ChromaDB에 저장합니다.

python
# ingest.py - 완전한 수집 파이프라인
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 1단계: 문서를 로드합니다
# DirectoryLoader: 디렉토리에서 패턴에 맞는 파일을 찾아 로드합니다.
# 실제 파일 로드는 loader_cls로 전달된 로더(여기서는 TextLoader)가 담당합니다.
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"{len(documents)}개의 문서를 로드했습니다")
 
# 2단계: 청크로 분할합니다
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"{len(chunks)}개의 청크를 생성했습니다")
 
# 3단계: 임베딩 모델을 생성합니다
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 4단계: 벡터 스토어를 생성하고 청크를 수집합니다
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
 
print(f"{len(chunks)}개의 청크를 data/chroma_db/의 ChromaDB에 저장했습니다")

출력:

2개의 문서를 로드했습니다
8개의 청크를 생성했습니다
8개의 청크를 data/chroma_db/의 ChromaDB에 저장했습니다

Chroma.from_documents() 메서드는 한 번의 호출로 두 가지 작업을 수행합니다:

  1. documents 파라미터로 입력받은 청크들을 임베딩 모델에 전달하여 임베딩 벡터를 구합니다.
  2. 각 청크를 임베딩 벡터와 함께 ChromaDB에 저장합니다.

9.3.5) 저장된 벡터 스토어 불러오기

이전 섹션에서는 문서를 벡터 스토어에 저장했습니다. 이 저장 작업은 최초 한 번만(또는 문서 변경 시) 수행하면 됩니다. 이후에는 저장된 벡터 스토어를 불러와서 바로 사용할 수 있습니다.

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 저장된 벡터 스토어 불러오기 — 재임베딩 불필요
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
print(f"벡터 스토어 로드: {len(vector_store.get()['ids'])}개의 청크")

Output:

벡터 스토어 로드: 8개의 청크

이제 문서를 다시 임베딩할 필요 없이 저장된 벡터 스토어만 불러오면 바로 검색을 시작할 수 있습니다.

9.3.6) 유사도 검색하기

벡터 스토어를 불러왔으므로, 이제 쿼리와 의미적으로 유사한 청크를 검색할 수 있습니다. top-K 파라미터는 반환할 결과의 개수를 지정합니다:

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
# 질문과 관련된 청크를 검색합니다
query = "디지털 제품을 반품할 수 있나요?"
results = vector_store.similarity_search(query, k=2)
 
print(f"쿼리: {query}")
print(f"{len(results)}개의 결과를 찾았습니다\n")
 
for i, doc in enumerate(results):
    print(f"--- 결과 {i + 1} (소스: {doc.metadata['source']}) ---")
    print(doc.page_content[:200])
    print()

출력:

쿼리: 디지털 제품을 반품할 수 있나요?
2개의 결과를 찾았습니다
 
--- 결과 1 (소스: data/docs/refund_policy.md) ---
## 디지털 제품
 
...
 
--- 결과 2 (소스: data/docs/refund_policy.md) ---
# 환불 정책
 
**시행일**: 2026년 1월 1일
 
## 표준 반품
 
...

질문에 대해 디지털 제품 청크가 가장 높은 유사도로 검색되었고, 그 다음으로 환불 정책 청크가 검색되었습니다.

similarity_search_with_score를 사용하면 결과와 함께 유사도 점수도 알 수 있습니다:

python
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
 
for doc, score in results_with_scores:
    # ChromaDB는 거리를 반환합니다 (낮을수록 더 유사함)
    print(f"점수: {score:.4f} | 소스: {doc.metadata['source']}")
    print(f"  {doc.page_content[:200]}...")
    print()

출력:

점수: 0.5942 | 소스: data/docs/refund_policy.md
  ## 디지털 제품
 
...
 
점수: 0.9577 | 소스: data/docs/refund_policy.md
  # 환불 정책
 
...

ChromaDB는 유사도 점수(높을수록 유사)가 아닌 거리 점수(낮을수록 유사)를 사용합니다. 디지털 제품 청크의 거리는 0.5942로 가장 낮아, 가장 관련성이 높은 결과임을 나타냅니다.

9.4) 완전한 RAG 체인 구축하기

이제 완전한 RAG 시스템을 만들어보겠습니다: 관련 문서를 먼저 검색하고, 검색된 내용을 질문과 함께 LLM에게 전달하여 제공된 정보 기반으로 답변하게 만듭니다.

9.4.1) 프롬프트 템플릿 설계하기

프롬프트 템플릿에서 가장 중요한 것은 "제공된 문서(컨텍스트)만을 기반으로 답변하라"는 지시입니다. 이 지시가 없으면 LLM이 검색된 문서를 무시하고 사전 학습 데이터 기반으로 지어내어 답변할 수 있습니다.

python
from langchain_core.prompts import ChatPromptTemplate
 
rag_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "당신은 고객 서비스 담당 직원입니다. "
     "제공된 컨텍스트만 사용하여 사용자의 질문에 답변하세요. "
     "컨텍스트에 답변할 충분한 정보가 없으면 "
     "\"해당 질문에 답변할 충분한 정보가 없습니다.\"라고 말하세요.\n\n"
     "컨텍스트:\n{context}"),
    ("human", "{question}"),
])

시스템 메시지에서 제공된 컨텍스트만 사용해서 LLM이 답변하도록 강제하고 있습니다. 특히 "정보가 불충분하면 모른다고 말하라"는 지시가 핵심입니다. 이것이 없으면 LLM이 그럴듯하지만 근거 없는 답변을 만들어낼 수 있습니다.

9.4.2) RAG 체인 만들기

이제 RAG 체인을 구성할 준비가 끝났습니다. 리트리버, 프롬프트 템플릿, LLM을 연결하기만 하면 됩니다.

완성된 RAG 시스템은 다음과 같이 동작하게 됩니다:

  1. 사용자 질문을 받습니다
  2. 벡터 스토어에서 관련 청크를 검색합니다
  3. 검색된 청크와 질문을 프롬프트 템플릿에 넣어 프롬프트를 생성합니다
  4. LLM이 답변을 생성합니다

context

question

사용자 질문

벡터 스토어 검색

검색된 청크들
(하나의 문자열로 결합)

프롬프트 생성

LLM

답변

RAG 체인은 Chapter 6에서 배운 LCEL | 연산자로 연결해보겠습니다.

python
# rag_chain.py - 완전한 RAG 파이프라인
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
 
 
def format_docs(docs):
    """검색된 문서를 단일 컨텍스트 문자열로 결합합니다."""
    return "\n\n---\n\n".join(doc.page_content for doc in docs)
 
 
def build_rag_chain():
    """완전한 RAG 체인을 구축하고 반환합니다."""
    # 벡터 스토어를 로드합니다
    embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
    vector_store = Chroma(
        persist_directory="data/chroma_db",
        collection_name="company_docs",
        embedding_function=embedding_model,
    )
 
    # 리트리버를 생성합니다 (k=3은 상위 3개 청크를 반환한다는 의미)
    retriever = vector_store.as_retriever(search_kwargs={"k": 3})
 
    # 프롬프트를 정의합니다
    rag_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "당신은 고객 서비스 담당 직원입니다. "
         "제공된 컨텍스트만 사용하여 사용자의 질문에 답변하세요. "
         "컨텍스트에 답변할 충분한 정보가 없으면 "
         "\"해당 질문에 답변할 충분한 정보가 없습니다.\"라고 말하세요.\n\n"
         "컨텍스트:\n{context}"),
        ("human", "{question}"),
    ])
 
    # LLM을 초기화합니다
    llm = ChatOpenAI(model="gpt-5-mini")
 
    # LCEL을 사용하여 체인을 구성합니다
    rag_chain = (
        {"context": retriever | format_docs, "question": lambda x: x}
        | rag_prompt
        | llm
        | StrOutputParser()
    )
 
    return rag_chain
 
 
if __name__ == "__main__":
    chain = build_rag_chain()
    answer = chain.invoke("디지털 제품을 반품할 수 있나요?")
    print(answer)

출력:

디지털 제품은 다운로드 또는 액세스 링크가 활성화되면 환불이 불가능합니다.
단, 기술적 문제로 액세스할 수 없는 경우 7일 이내에 지원팀에 연락하면 교체 또는 환불을 받을 수 있습니다.

체인 구성을 단계별로 분석해봅시다:

python
rag_chain = (
    {"context": retriever | format_docs, "question": lambda x: x}
    | rag_prompt
    | llm
    | StrOutputParser()
)

사용자가 chain.invoke("디지털 제품을 반품할 수 있나요?")를 호출하면 다음과 같이 동작합니다:

  1. 딕셔너리 단계:
    • retriever | format_docs: 질문으로 벡터 스토어를 검색하고, 찾은 청크들을 하나의 문자열로 결합합니다
    • lambda x: x: 질문을 그대로 전달합니다
    • 결과: {"context": "검색된 청크들 (단일 문자열로 결합된 형태)", "question": "디지털 제품을 반품할 수 있나요?"}
  2. rag_prompt: 딕셔너리의 값들로 프롬프트 템플릿의 {context}{question}을 채웁니다
  3. llm: 완성된 프롬프트를 LLM에 전달합니다
  4. StrOutputParser(): LLM 응답에서 텍스트만 추출합니다

LCEL의 자세한 동작 방식은 Chapter 6을 참조하세요.

9.4.3) 답변 가능한 질문과 불가능한 질문으로 테스트하기

RAG 시스템은 답할 수 있는 질문(문서에 정보가 존재함)과 답할 수 없는 질문(문서에 정보가 없음) 모두를 처리해야 합니다. 두 시나리오를 테스트해봅시다:

python
# test_rag.py - 다양한 질문으로 RAG 체인 테스트
from rag_chain import build_rag_chain
 
chain = build_rag_chain()
 
test_questions = [
    # 답변 가능 — 정보가 문서에 있음
    "물리적 제품의 환불 정책은 무엇인가요?",
    "특급 배송 비용은 얼마인가요?",
    "6개월 후에 결함 있는 제품을 반품할 수 있나요?",
    # 답변 불가능 — 정보가 문서에 없음
    "직원 휴가 정책은 무엇인가요?",
    "어떤 프로그래밍 언어를 사용하나요?",
]
 
for question in test_questions:
    print(f"질문: {question}")
    answer = chain.invoke(question)
    print(f"답변: {answer}\n")
    print("-" * 60)

출력:

질문: 물리적 제품의 환불 정책은 무엇인가요?
답변: 모든 물리적 제품은 구매 후 30일 이내에 전액 환불을 위해 반품될 수 있습니다. ...
 
------------------------------------------------------------
질문: 특급 배송 비용은 얼마인가요?
답변: 특급 배송 (2-3 영업일)은 $12.99입니다.
 
------------------------------------------------------------
질문: 6개월 후에 결함 있는 제품을 반품할 수 있나요?
답변: 네, 결함 있는 제품은 언제든지 전액 환불 또는 교체를 위해 반품될 수 있습니다. ...
 
------------------------------------------------------------
질문: 직원 휴가 정책은 무엇인가요?
답변: 해당 질문에 답변할 충분한 정보가 없습니다.
 
------------------------------------------------------------
질문: 어떤 프로그래밍 언어를 사용하나요?
답변: 해당 질문에 답변할 충분한 정보가 없습니다.
 
------------------------------------------------------------

결과는 우리가 원하는 동작을 정확히 보여줍니다:

  • 답변 가능한 질문: 검색된 문서 기반으로 정확한 답변을 제공합니다. LLM은 문서에 포함되지 않은 내용을 추가하지 않습니다.
  • 답변 불가능한 질문: "해당 질문에 답변할 충분한 정보가 없습니다"라고 응답합니다. LLM은 검색된 컨텍스트에 관련 정보가 없음을 정확히 파악하고 답변을 지어내지 않습니다.

이것이 RAG의 힘입니다. LLM이 여러분의 데이터로 답변하고, 모를 때는 솔직하게 인정합니다. 모든 답변이 문서로 뒷받침되어 일반 LLM보다 훨씬 신뢰할 수 있습니다.