우와한 개발자

[LangChain] LangChain으로 RAG 구현하기 — PyPDFLoader, Chroma, Retriever

by 우와한개발자

1. RAG(Retrieval Augmented Generation) ─ 검색 기반 생성

1) 개념

(1) RAG의 정의

  • RAG는 Retrieval Augmented Generation의 약자이다.
  • 검색 기반 생성이라고도 하며, LLM이 외부 지식을 실시간으로 참조하여 답변을 생성하는 아키텍처 패턴이다.
  • LLM이 자체적으로 알고 있는 지식에만 의존하지 않고, 외부 문서나 데이터베이스에서 관련 정보를 검색한 뒤 이를 context로 활용한다.

(2) RAG가 필요한 이유

  • LLM의 Knowledge cutoff 문제를 보완할 수 있다.
  • 기업 내부 문서나 최신 자료를 답변에 반영할 수 있다.
  • 검색된 문서를 근거로 답변하기 때문에 환각을 줄일 수 있다.
  • 답변의 근거가 되는 문서를 함께 제공할 수 있어 신뢰도를 높일 수 있다.

 

2) 전체 흐름

(1) 사전 준비 단계

  • 문서를 수집한다.
  • PDF, TXT, 웹 문서 등에서 텍스트를 추출한다.
  • 긴 문서를 chunk 단위로 분할한다.
  • 각 chunk를 임베딩 모델을 통해 벡터로 변환한다.
  • 임베딩 결과를 Vector DB에 저장한다.

(2) 사용자 질문 처리 단계

  • 사용자 질문을 입력받는다.
  • 사용자 질문을 임베딩 모델을 통해 벡터로 변환한다.
  • Vector DB에서 질문 벡터와 유사한 문서를 검색한다.
  • 검색된 문서를 프롬프트의 context로 넣는다.
  • LLM이 context를 참고하여 최종 답변을 생성한다.

(3) 흐름 요약

문서 수집 → 텍스트 추출 → Chunk 분할 → 임베딩 → Vector DB 저장

사용자 질문 → 질문 임베딩 → Retriever 검색 → Context 구성 → LLM 답변 생성

 

2. LangChain에서의 RAG 구성요소

(1) Document Loader

  • PDF, TXT, 웹 문서 등 외부 문서를 불러오는 컴포넌트이다.
  • 문서의 원본 형식에 따라 적절한 Loader를 사용한다.
  • 예를 들어 PDF 문서는 PyPDFLoader를 사용할 수 있다.

(2) Text Splitter

  • 긴 문서를 chunk 단위로 나누는 컴포넌트이다.
  • 문서가 너무 길면 LLM context에 한 번에 넣기 어렵기 때문에 적절한 크기로 분할한다.
  • chunk 크기와 overlap 설정에 따라 검색 품질이 달라질 수 있다.

(3) Embedding Model

  • 텍스트를 의미 기반의 수치 벡터로 변환하는 모델이다.
  • 문서 chunk와 사용자 질문을 같은 임베딩 모델로 벡터화해야 유사도 검색이 가능하다.
  • 질문 벡터와 문서 벡터 간의 유사도를 계산하여 관련 문서를 찾는다.

(4) Vector DB

  • 임베딩된 문서 벡터를 저장하는 데이터베이스이다.
  • Chroma, FAISS, Pinecone, pgvector 등을 사용할 수 있다.
  • 대량의 벡터 데이터를 저장하고 유사도 기반 검색을 수행한다.

(5) Retriever

  • 사용자 질문과 관련성이 높은 문서를 Vector DB에서 검색하는 컴포넌트이다.
  • 검색된 문서는 LLM 프롬프트의 context로 사용된다.
  • top-k, score threshold, metadata filter 등을 설정할 수 있다.

(6) LLM

  • 검색된 문서를 참고하여 최종 답변을 생성한다.
  • RAG 구조에서는 LLM이 단독으로 답변하는 것이 아니라, 검색된 context를 기반으로 답변한다.

 

3. Embedding ─ 벡터로 변환

(1) 개념

  • Embedding은 텍스트를 의미 기반의 수치 벡터로 변환하는 과정이다.
  • 비슷한 의미를 가진 문장은 벡터 공간에서도 가까운 위치에 배치된다.
  • RAG에서는 문서와 질문을 모두 벡터로 변환한 뒤 유사도를 계산한다.

(2) 유사도 계산 방식

  • Cosine Similarity: 두 벡터의 방향이 얼마나 비슷한지 계산한다.
  • Euclidean Distance: 두 벡터 사이의 거리를 계산한다.
  • Dot Product: 두 벡터의 내적을 계산하여 유사도를 측정한다.

(3) 주의점

  • 문서를 저장할 때 사용한 임베딩 모델과 질문을 검색할 때 사용하는 임베딩 모델은 동일하게 맞추는 것이 좋다.
  • 서로 다른 임베딩 모델을 사용하면 벡터 공간이 달라져 검색 품질이 떨어질 수 있다.

 

4. Chunking ─ 문서 분할

(1) 개념

  • Chunking은 긴 문서를 검색 가능한 작은 단위로 나누는 과정이다.
  • LLM context에 문서 전체를 넣기 어렵기 때문에 적절한 크기로 분할한다.
  • RAG 성능은 chunk 크기와 overlap 설정에 큰 영향을 받는다.

(2) Chunk 크기

  • chunk가 너무 크면 불필요한 정보가 많이 포함되어 노이즈가 증가한다.
  • chunk가 너무 작으면 문맥이 끊겨 의미를 제대로 파악하기 어렵다.
  • 일반적으로 200~500 토큰 범위가 균형 잡힌 성능을 보이는 경우가 많다.

(3) Chunk Overlap

  • overlap은 인접한 chunk 사이에 일부 내용을 중복해서 포함하는 방식이다.
  • 문장이나 문단이 chunk 경계에서 잘려도 앞뒤 맥락을 유지할 수 있다.
  • 일반적으로 chunk size의 10~20% 정도를 overlap으로 설정한다.
  • overlap이 너무 작으면 맥락이 단절되고, 너무 크면 중복 정보가 많아져 비효율적이다.

 

5. Text Splitter

(1) RecursiveCharacterTextSplitter

  • LangChain의 RecursiveCharacterTextSplitter는 여러 구분자를 계층적으로 시도하며 문서를 분할한다.
  • 의미 단위를 최대한 보존하면서 설정된 chunk size 이하가 되도록 재귀적으로 분할한다.

(2) 분할 방식

  • 구분자 우선순위를 기준으로 문서를 나눈다.
  • 일반적으로 ["\n\n", "\n", " ", ""] 순서로 분할을 시도한다.
  • 먼저 문단 단위로 나누고, 그래도 크면 줄 단위, 단어 단위, 문자 단위로 점점 작게 나눈다.

(3) 처리 흐름

구분자 우선순위 적용 → Chunk 크기 확인 → 필요 시 재귀 분할 → Overlap 추가

(4) 코드 예시

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=100
)

chunks = text_splitter.split_documents(documents)

 

6. PDF 로딩 ─ PyPDFLoader

(1) 개념

  • PyPDFLoader는 LangChain에서 PDF 문서를 불러올 때 사용하는 Document Loader이다.
  • PDF 파일의 텍스트를 추출하여 LangChain Document 객체로 변환한다.

(2) 특징

  • PDF를 페이지 단위로 로딩할 수 있다.
  • 각 페이지의 메타데이터를 함께 추출할 수 있다.
  • 추출된 문서는 Text Splitter를 통해 chunk로 분할할 수 있다.

(3) 코드 예시

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("sample.pdf")
documents = loader.load()

 

7. Vector DB와 검색 방식

(1) Vector DB의 역할

  • 임베딩된 문서 벡터를 저장한다.
  • 사용자 질문 벡터와 문서 벡터 간의 유사도를 계산하여 관련 문서를 검색한다.
  • 대규모 문서 검색에서는 검색 속도와 정확도를 함께 고려해야 한다.

(2) Exact Search

  • 모든 벡터와 정확한 유사도 계산을 수행하는 방식이다.
  • 정확도는 높지만 데이터가 많아질수록 검색 속도가 느려질 수 있다.
  • 소규모 데이터에 적합하다.

(3) ANN

  • ANN은 Approximate Nearest Neighbor의 약자이다.
  • 모든 벡터를 정확히 비교하지 않고, 근사적으로 가장 가까운 벡터를 빠르게 찾는 방식이다.
  • 대규모 데이터 검색에 적합하지만 정확도가 약간 떨어질 수 있다.

(4) Hybrid Search

  • 키워드 검색과 벡터 검색을 결합한 방식이다.
  • BM25 기반 키워드 검색과 Vector 기반 의미 검색을 함께 사용한다.
  • 전문 용어, 고유명사, 약어가 중요한 검색에서 유용하다.

 

8. Retriever ─ Top-k 검색

(1) 개념

  • Retriever는 사용자 질문과 가장 관련성이 높은 문서를 찾아오는 컴포넌트이다.
  • Vector DB에 저장된 문서 중 질문과 유사도가 높은 문서를 검색한다.
  • 검색된 문서는 LLM에게 전달되는 context가 된다.

(2) Top-k 검색

  • Top-k는 유사도 점수가 높은 상위 k개의 문서를 반환하는 방식이다.
  • 예를 들어 k=3이면 질문과 가장 유사한 문서 3개를 가져온다.

(3) k 값 조절

  • k 값이 너무 작으면 필요한 정보가 검색되지 않을 수 있다.
  • k 값이 너무 크면 불필요한 문서가 포함되어 노이즈가 증가할 수 있다.
  • 데이터와 질문 유형에 맞게 적절한 값을 조정해야 한다.

(4) 필터링 옵션

  • 메타데이터를 기준으로 검색 범위를 제한할 수 있다.
  • 예를 들어 특정 과목, 특정 사용자, 특정 문서 유형만 검색하도록 제한할 수 있다.

(5) 코드 예시

retriever = db.as_retriever(
    search_kwargs={"k": 3}
)

docs = retriever.invoke(question)

 

9.  Retriever — MMR 검색

(1) 개념

  • MMR은 Maximal Marginal Relevance의 약자이다
  • 유사도가 높으면서 서로 내용이 겹치지 않는 문서를 선택하는 검색 방식이다
  • Top-k는 유사도 순으로 문서를 가져오기 때문에 비슷한 내용의 문서가 중복으로 포함될 수 있다
  • MMR은 이미 선택된 문서와 내용이 겹치는 문서를 제외하면서 다양한 관점의 문서를 가져온다

(2) Top-k vs MMR

구분 Top-k MMR
선택 기준 유사도 높은 순 유사도 높고 중복 없는 순
문서 다양성 낮음 (비슷한 내용 중복 가능) 높음 (다양한 관점 포함)
적합한 경우 특정 정보를 정확히 찾을 때 다양한 근거가 필요할 때

(3) 왜 단순히 상위 n개만 뽑으면 안 되는가

  • 예를 들어 사용자가 "파이썬 리스트 사용법"을 질문했다고 가정한다
  • Similarity Search만 사용하면 append 설명, append 예시, append 주의사항처럼 비슷한 문서가 상위에 몰릴 수 있다
  • 이 경우 슬라이싱, 삭제, 정렬처럼 다른 유용한 내용은 검색 결과에서 제외될 수 있다
  • MMR은 이런 중복을 줄이고, 서로 다른 관점의 문서를 함께 가져오는 데 유리하다

(4) 예시

순위 문서 유사도
1 리스트 append 설명 0.95
2 리스트 append 예시 0.94
3 리스트 append 주의사항 0.93
4 리스트 슬라이싱 설명 0.80
  • Similarity Search는 상위 3개를 그대로 가져오기 때문에 append 관련 문서만 선택될 수 있다
  • MMR은 append 설명을 선택한 뒤, 다음 문서를 고를 때 이미 선택된 문서와 너무 비슷한 문서의 점수를 낮춘다
  • 따라서 append 예시 대신 슬라이싱 설명처럼 다른 정보를 가진 문서가 선택될 수 있다

(5) MMR 처리 흐름

  • 먼저 질문과 유사한 후보 문서를 넉넉하게 가져온다 (fetch_k)
  • 예를 들어 fetch_k=20이면 질문과 유사한 후보 문서 20개를 먼저 가져온다
  • 이후 관련성과 다양성을 함께 고려하여 최종 k개의 문서를 선택한다
MMR 점수 = 질문과의 유사도 - λ × 이미 선택된 문서들과의 최대 유사도
  • λ가 클수록 다양성을 더 중시하고, λ가 작을수록 유사도를 더 중시한다

(6) 코드 예시

retriever = db.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 3,        # 최종 반환할 문서 수
        "fetch_k": 20  # 후보로 가져올 문서 수 (이 중에서 MMR로 선별)
    }
)

docs = retriever.invoke(question)

 

10. LangChain RAG 구현 예시

  • RAG 구현은 크게 문서 저장 단계와 질문 답변 단계로 나눌 수 있다.
  • 문서 저장 단계에서는 PDF 문서를 로딩하고, chunk로 분할한 뒤, 임베딩하여 Vector DB에 저장한다.
  • 질문 답변 단계에서는 사용자의 질문과 유사한 문서를 Vector DB에서 검색하고, 검색 결과를 context로 구성하여 LLM에게 전달한다.
  • 두 단계는 하나의 RAG 흐름으로 연결된다.
1단계: 문서 저장
PDF → Text 추출 → Chunk 분할 → Embedding → Chroma DB 저장

2단계: 질문 답변
Question → Retriever 검색 → Context 구성 → Prompt → LLM → Answer

 

1) PDF 문서 저장

  • 이 단계는 RAG의 사전 준비 단계이다.
  • PDF 문서를 읽고, 문서를 chunk 단위로 나눈 뒤, 각 chunk를 임베딩하여 Chroma Vector DB에 저장한다.
  • 보통 문서를 처음 등록하거나 문서 내용이 변경되었을 때 실행한다.
  • 같은 문서를 반복 저장하면 Vector DB에 중복 데이터가 쌓일 수 있으므로 주의해야 한다.
from dotenv import load_dotenv
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

load_dotenv()

loader = PyPDFLoader("data/ai_policy.pdf")

docs = loader.load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200
)

splits = splitter.split_documents(docs)

vectorstore = Chroma.from_documents(
    documents=splits,
    embedding=OpenAIEmbeddings(),
    persist_directory="./chroma_db"
)

print("완료")

 

  • PyPDFLoader는 PDF 파일을 읽어서 LangChain Document 객체로 변환한다.
  • loader.load()는 PDF 내용을 페이지 단위로 불러온다.
  • RecursiveCharacterTextSplitter는 긴 문서를 chunk 단위로 분할한다.
  • chunk_size=1000은 하나의 chunk 크기를 의미한다.
  • RecursiveCharacterTextSplitter의 기본 chunk_size는 토큰 기준이 아니라 문자 길이 기준으로 동작한다.
  • chunk_overlap=200은 chunk 사이에 일부 내용을 중복으로 포함하여 문맥이 끊기지 않도록 한다.
  • OpenAIEmbeddings는 텍스트를 벡터로 변환한다.
  • Chroma.from_documents는 분할된 문서를 임베딩한 뒤 Chroma Vector DB에 저장한다.
  • persist_directory="./chroma_db"는 벡터 DB를 로컬 폴더에 저장한다는 의미이다.

 

2) 질문 검색 및 답변 생성

  • 이 단계는 사용자의 질문을 처리하는 단계이다.
  • 이미 저장된 Chroma Vector DB를 불러온다.
  • 사용자의 질문과 유사한 문서를 검색한다.
  • 검색된 문서를 context로 구성한 뒤 LLM에게 전달한다.
  • LLM은 검색된 문서를 참고하여 최종 답변을 생성한다.
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate

load_dotenv()

llm = ChatOpenAI(model="gpt-4o-mini")

db = Chroma(
    persist_directory="./chroma_db",
    embedding_function=OpenAIEmbeddings()
)

retriever = db.as_retriever(search_kwargs={"k": 3})

prompt = ChatPromptTemplate.from_template(
    """
다음 문서를 참고하여 답변하시오.

문서:
{context}

질문:
{question}
"""
)

chain = prompt | llm

while True:
    question = input("질문 : ")

    docs = retriever.invoke(question)

    context = "\n\n".join(doc.page_content for doc in docs)

    result = chain.invoke(
        {
            "context": context,
            "question": question
        }
    )

    print(result.content)

 

  • Chroma는 앞에서 저장한 Vector DB를 다시 불러온다.
  • persist_directory="./chroma_db"는 저장된 Chroma DB 위치를 의미한다.
  • embedding_function=OpenAIEmbeddings()는 사용자 질문을 벡터로 변환할 때 사용한다.
  • 문서 저장 단계에서 사용한 임베딩 모델과 질문 검색 단계에서 사용하는 임베딩 모델은 동일하게 맞추는 것이 좋다.
  • db.as_retriever(search_kwargs={"k": 3})는 질문과 유사한 상위 3개의 문서를 검색하도록 설정한다.
  • retriever.invoke(question)은 사용자 질문과 관련된 문서를 검색한다.
  • context는 검색된 문서들의 page_content를 하나의 문자열로 합친 값이다.
  • prompt는 검색된 문서와 사용자 질문을 LLM에게 전달하기 위한 템플릿이다.
  • chain = prompt | llm은 Prompt와 LLM을 연결한 LangChain 파이프라인이다.
  • result.content는 LLM이 생성한 최종 답변이다.

 

3) 두 단계를 분리하는 이유

  • 문서 저장 단계는 매번 실행할 필요가 없다.
  • 문서를 저장할 때마다 임베딩 비용이 발생한다.
  • 같은 문서를 반복 저장하면 Vector DB에 중복 데이터가 쌓일 수 있다.
  • 따라서 문서 저장 코드는 문서를 처음 등록하거나 수정할 때 실행하고, 질문 답변 코드는 사용자가 질문할 때마다 실행하는 것이 좋다.

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기