LabHub

블로그

RAG: Retrieval-Augmented Generation 논문 분석과 실전 아키텍처

한국어English日本語中文

1. LLM의 Hallucination 문제와 RAG의 등장 배경

Large Language Model(LLM)은 방대한 텍스트 데이터로 사전 학습되어 자연어 이해와 생성에서 놀라운 성능을 보여준다. 그러나 LLM에는 근본적인 한계가 존재한다. 바로 Hallucination(환각) 문제다.

Hallucination이란 모델이 사실이 아닌 정보를 마치 사실인 것처럼 자신 있게 생성하는 현상을 말한다. 이 문제가 발생하는 근본 원인은 다음과 같다.

이러한 한계를 극복하기 위해 등장한 접근법이 Retrieval-Augmented Generation(RAG)이다. RAG의 핵심 아이디어는 단순하면서도 강력하다. LLM이 답변을 생성하기 전에, 외부 지식 저장소에서 관련 문서를 검색(Retrieve)하여 그 정보를 기반으로 답변을 생성(Generate)하는 것이다.

이를 통해 다음과 같은 이점을 얻을 수 있다.

  1. 사실 기반 응답: 검색된 실제 문서를 근거로 답변을 생성하므로 Hallucination이 줄어든다.
  2. 지식 업데이트 용이: 외부 데이터베이스만 업데이트하면 최신 정보를 반영할 수 있다. 모델 재학습이 필요 없다.
  3. 출처 제공 가능: 답변의 근거가 된 문서를 함께 제시하여 투명성과 신뢰성을 높일 수 있다.
  4. 도메인 특화 용이: 특정 도메인의 문서만 인덱싱하면 해당 분야에 특화된 시스템을 빠르게 구축할 수 있다.

2. 원본 RAG 논문 구조: Retriever + Generator

2020년 Facebook AI Research(현 Meta AI)의 Patrick Lewis 등이 발표한 논문 "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks"(NeurIPS 2020)는 RAG라는 개념을 최초로 공식화한 핵심 연구다.

2.1 논문의 핵심 제안

Lewis et al.은 사전 학습된 언어 모델의 Parametric Memory(파라미터 내 암묵적 지식)와 Non-Parametric Memory(외부 문서 인덱스의 명시적 지식)를 결합하는 일반적인(fine-tuning 가능한) 방법론을 제안했다. 구체적으로, Parametric Memory는 사전 학습된 seq2seq 모델(BART)이고, Non-Parametric Memory는 Wikipedia 전체를 Dense Vector Index로 구축한 것이다.

2.2 아키텍처 구성

RAG 모델의 아키텍처는 크게 두 가지 컴포넌트로 구성된다.

Retriever (검색기) - p_eta(z|x)

입력 쿼리 x가 주어지면, 관련 문서(passage) z를 검색하는 컴포넌트다. 논문에서는 Dense Passage Retrieval(DPR)을 사용하여 쿼리와 문서를 각각 Dense Vector로 인코딩한 후, Maximum Inner Product Search(MIPS)를 통해 top-k 관련 문서를 검색한다.

Generator (생성기) - p_theta(y_i|x, z, y_{1:i-1})

검색된 문서 z와 원래 입력 x를 함께 컨텍스트로 받아, 최종 출력 y를 생성하는 컴포넌트다. 논문에서는 BART-large를 Generator로 사용했다.

2.3 두 가지 RAG 변형

논문에서는 검색된 문서를 활용하는 방식에 따라 두 가지 변형을 제안한다.

RAG-Sequence

전체 출력 시퀀스를 생성할 때 하나의 검색된 문서를 일관되게 사용한다. 각 검색된 문서 z에 대해 전체 시퀀스를 생성한 후, 각 문서에 대한 확률을 marginalize한다.

p_RAG-Sequence(y|x) ≈ Σ_z p_eta(z|x) Π_i p_theta(y_i|x, z, y_{1:i-1})

RAG-Token

각 출력 토큰을 생성할 때마다 다른 검색된 문서를 참조할 수 있다. 토큰 레벨에서 문서별 확률을 marginalize한다.

p_RAG-Token(y|x) ≈ Π_i Σ_z p_eta(z|x) p_theta(y_i|x, z, y_{1:i-1})

2.4 주요 실험 결과

RAG 모델은 세 가지 Open-Domain QA 벤치마크(Natural Questions, TriviaQA, WebQuestions)에서 기존의 Parametric seq2seq 모델과 task-specific retrieve-and-extract 아키텍처를 모두 능가하는 state-of-the-art 성능을 달성했다. 특히 주목할 점은, RAG 모델이 기존 parametric-only 모델에 비해 더 specific하고, diverse하며, factual한 텍스트를 생성한다는 것이다.


3. Dense Passage Retrieval (DPR) 원리

RAG의 Retriever 컴포넌트에서 핵심적인 역할을 하는 것이 Dense Passage Retrieval(DPR)이다. Karpukhin et al.이 2020년 EMNLP에서 발표한 논문 "Dense Passage Retrieval for Open-Domain Question Answering"에서 제안된 방법이다.

3.1 기존 Sparse Retrieval의 한계

전통적인 정보 검색에서는 BM25 같은 Sparse Retrieval 방법이 주로 사용되었다. BM25는 TF-IDF 기반으로 키워드 매칭을 수행하는데, 다음과 같은 한계가 있다.

3.2 DPR의 Bi-Encoder 아키텍처

DPR은 Bi-Encoder(이중 인코더) 아키텍처를 사용한다. 두 개의 독립적인 BERT-base 인코더를 사용하여 쿼리와 문서를 각각 Dense Vector로 변환한다.

- Query Encoder: E_Q(q) → d차원 벡터
- Passage Encoder: E_P(p) → d차원 벡터

유사도는 두 벡터의 Inner Product(내적)으로 계산한다.

sim(q, p) = E_Q(q)^T · E_P(p)

이 아키텍처의 핵심 장점은 쿼리와 문서의 인코딩이 독립적이라는 것이다. 문서 인코딩은 오프라인에서 사전에 수행하고 FAISS 같은 ANN(Approximate Nearest Neighbor) 라이브러리에 인덱싱해 둘 수 있다. 검색 시에는 쿼리만 인코딩하면 되므로, 수백만 개의 문서에서도 밀리초 단위의 검색이 가능하다.

3.3 학습 방법

DPR은 In-Batch Negatives 전략으로 학습된다. 배치 내의 다른 질문에 대한 정답 passage를 Negative Sample로 활용하는 방법이다. 추가적으로 BM25로 검색했지만 정답이 아닌 passage를 Hard Negative로 사용하여 학습 효과를 높인다.

3.4 성능

DPR은 BM25 대비 Top-20 Passage Retrieval Accuracy에서 9%~19%의 절대적 성능 향상을 달성했다. 이는 제한된 양의 query-passage 쌍만으로도 고품질의 Dense Retriever를 학습할 수 있음을 보여준다.


4. Chunking 전략

RAG 시스템에서 문서를 적절한 크기의 Chunk로 분할하는 것은 검색 품질에 직접적인 영향을 미치는 핵심 단계다. LangChain은 다양한 Text Splitter를 제공하며, 주요 Chunking 전략은 다음과 같다.

4.1 Fixed-Size Chunking (고정 크기 분할)

가장 단순한 방법으로, 지정된 문자 수(Character) 또는 토큰 수(Token)를 기준으로 텍스트를 분할한다.

from langchain_text_splitters import CharacterTextSplitter

text_splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=1000,
    chunk_overlap=200,
    length_function=len,
)
docs = text_splitter.split_documents(documents)

chunk_overlap 파라미터는 인접한 Chunk 사이에 겹치는 부분을 두어 문맥 유실을 완화한다.

4.2 Recursive Character Splitting (재귀적 분할)

LangChain이 가장 권장하는 범용 Text Splitter다. 여러 단계의 구분자를 재귀적으로 적용하여 의미 단위를 최대한 보존한다.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""],
    length_function=len,
)
docs = text_splitter.split_documents(documents)

동작 원리는 다음과 같다.

  1. 먼저 \n\n(단락 구분)으로 분할을 시도한다.
  2. Chunk가 여전히 chunk_size를 초과하면 \n(줄바꿈)으로 분할한다.
  3. 그래도 초과하면 . (문장 단위)으로 분할한다.
  4. 최후의 수단으로 공백이나 문자 단위로 분할한다.

이 방식의 핵심은 큰 의미 단위를 먼저 보존하려고 시도하고, 필요한 경우에만 더 작은 단위로 내려간다는 점이다.

4.3 Semantic Chunking (의미 기반 분할)

Embedding 유사도를 기반으로 의미가 변하는 지점을 감지하여 분할하는 가장 고급 전략이다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings

text_splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
)
docs = text_splitter.split_documents(documents)

인접한 문장 간의 Embedding Cosine Similarity를 계산하고, 유사도가 급격히 떨어지는 지점을 Chunk 경계로 설정한다. 의미적으로 일관된 Chunk를 생성할 수 있지만, Embedding 계산 비용이 추가로 발생한다.

4.4 Chunking 전략 선택 가이드

전략적합한 상황비용
Fixed-Size균일한 구조의 문서, 빠른 프로토타이핑낮음
Recursive대부분의 일반적인 사용 사례 (권장 기본값)낮음
Semantic의미 경계가 중요한 문서, 고품질 요구높음

실전에서는 chunk_size를 500~1500 사이에서, chunk_overlap을 chunk_size의 10~20% 정도로 설정하는 것이 일반적이다. 최적값은 데이터 특성과 사용 사례에 따라 실험적으로 결정해야 한다.


5. Embedding 모델 선택

Chunk를 Vector로 변환하는 Embedding 모델의 선택은 검색 품질을 좌우하는 중요한 결정이다. MTEB(Massive Text Embedding Benchmark) 리더보드를 참고하면 주요 모델들을 비교할 수 있다.

5.1 OpenAI text-embedding-3 시리즈

from langchain_openai import OpenAIEmbeddings

# 고성능 모델
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")  # 3072차원

# 비용 효율 모델
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")  # 1536차원

5.2 Sentence-Transformers

from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/all-MiniLM-L6-v2"
)

5.3 BGE (BAAI General Embedding) 시리즈

from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-m3",
    model_kwargs={"device": "cuda"},
    encode_kwargs={"normalize_embeddings": True},
)

5.4 선택 기준

기준추천 모델
빠른 프로토타이핑OpenAI text-embedding-3-small
프로덕션 (품질 우선)OpenAI text-embedding-3-large 또는 Cohere embed-v4
프로덕션 (비용 우선, 셀프 호스팅)BGE-M3
한국어/다국어BGE-M3
경량/엣지 환경all-MiniLM-L6-v2

핵심 원칙은 반드시 자신의 실제 데이터로 벤치마크하는 것이다. MTEB 점수는 범용 벤치마크이므로, 특정 도메인에서의 성능은 다를 수 있다.


6. Vector Database 비교

Embedding 벡터를 저장하고 유사도 검색을 수행하는 Vector Database는 RAG 시스템의 인프라 핵심이다. 주요 Vector Database를 비교한다.

6.1 Chroma

from langchain_chroma import Chroma

vectorstore = Chroma.from_documents(
    documents=docs,
    embedding=embeddings,
    persist_directory="./chroma_db",
)

6.2 Pinecone

6.3 Weaviate

6.4 pgvector

CREATE EXTENSION vector;

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    content TEXT,
    embedding vector(1536)
);

-- 유사도 검색
SELECT content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY distance
LIMIT 5;

6.5 Milvus

6.6 비교 요약

DB유형최대 규모Hybrid Search추천 시나리오
Chroma임베디드~100만제한적프로토타이핑, 개발
PineconeManaged SaaS수십억지원프로덕션, 관리 최소화
Weaviate오픈소스/클라우드수억강력 지원Hybrid Search 중심
pgvectorPostgreSQL 확장~1억SQL 조합PostgreSQL 기존 인프라
Milvus오픈소스 분산수십억지원대규모 시스템

7. Advanced RAG 패턴

기본 RAG(Naive RAG)는 단순한 "검색 후 생성" 파이프라인이다. 프로덕션 환경에서는 검색 품질과 생성 품질을 높이기 위한 다양한 Advanced RAG 패턴이 필요하다.

7.1 Re-ranking

기본 RAG에서 초기 검색(Retrieval)은 Bi-Encoder의 벡터 유사도를 사용하는데, 이는 속도는 빠르지만 정밀도가 부족할 수 있다. Re-ranking은 초기 검색 결과를 Cross-Encoder로 다시 평가하여 정밀도를 높이는 패턴이다.

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

# Cross-Encoder 모델 로드
model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
compressor = CrossEncoderReranker(model=model, top_n=3)

# Re-ranking Retriever 구성
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}),
)

동작 흐름은 다음과 같다.

  1. Bi-Encoder로 넓은 범위의 후보 문서(top-20)를 빠르게 검색한다.
  2. Cross-Encoder가 쿼리와 각 후보 문서를 쌍으로 입력받아 직접적인 관련성 점수를 매긴다.
  3. 재정렬된 상위 문서(top-3)만 Generator에 전달한다.

Cross-Encoder는 Bi-Encoder보다 정밀하지만, 모든 후보에 대해 개별 추론이 필요하므로 대규모 검색에는 적합하지 않다. 따라서 Bi-Encoder로 후보를 좁힌 후 Cross-Encoder로 재정렬하는 2단계 파이프라인이 표준 패턴이다.

7.2 HyDE (Hypothetical Document Embeddings)

Gao et al.(2022)이 제안한 HyDE는 쿼리와 문서 사이의 의미적 갭(Semantic Gap)을 해소하기 위한 패턴이다. 사용자의 질문은 짧고 추상적인 반면, 답변이 포함된 문서는 길고 구체적이다. 이 차이로 인해 직접적인 벡터 유사도 검색이 최적이 아닐 수 있다.

HyDE의 핵심 아이디어는 다음과 같다.

  1. 사용자 쿼리를 받으면, LLM에게 "이 질문에 답하는 가상의 문서를 작성해 달라"고 요청한다.
  2. 생성된 가상 문서(Hypothetical Document)를 Embedding한다. 이 가상 문서는 사실과 다를 수 있지만, 실제 관련 문서와 유사한 형식과 어휘를 가진다.
  3. 이 Embedding으로 실제 문서를 검색한다.
from langchain.chains import HypotheticalDocumentEmbedder
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

llm = ChatOpenAI(model="gpt-4o-mini")
base_embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

hyde_embeddings = HypotheticalDocumentEmbedder.from_llm(
    llm=llm,
    base_embeddings=base_embeddings,
    prompt_key="web_search",
)

# HyDE Embedding으로 검색
results = vectorstore.similarity_search_by_vector(
    hyde_embeddings.embed_query("RAG 시스템의 성능을 평가하는 방법은?")
)

Encoder의 Dense Bottleneck이 가상 문서의 Hallucination을 필터링하는 역할을 하므로, 가상 문서가 정확하지 않아도 관련 실제 문서를 효과적으로 검색할 수 있다.

7.3 Self-RAG

Asai et al.(2023)이 제안한 Self-RAG는 LLM이 스스로 검색의 필요성을 판단하고, 생성 결과를 자기 비판적으로 평가하는 패턴이다.

Self-RAG의 핵심 메커니즘은 Reflection Token(반성 토큰)이다.

이 Reflection Token들은 모델의 어휘에 추가되어 일반 토큰처럼 학습되며, 추론 시 모델이 자율적으로 생성한다. Self-RAG는 7B, 13B 파라미터 규모에서 ChatGPT와 retrieval-augmented Llama2-chat을 Open-Domain QA, Reasoning, Fact Verification 등에서 능가하는 성능을 보였다.

7.4 Corrective RAG (CRAG)

Yan et al.(2024)이 제안한 CRAG는 검색된 문서의 품질을 능동적으로 평가하고 보정하는 패턴이다.

CRAG의 핵심 컴포넌트는 다음과 같다.

  1. Retrieval Evaluator: 경량 모델이 검색된 문서의 관련성을 Correct, Incorrect, Ambiguous 세 가지로 판단한다.
  2. Knowledge Refinement: 검색된 문서에서 핵심 정보만 추출하고 불필요한 부분을 제거한다. Decompose-then-Recompose 알고리즘을 사용한다.
  3. Web Search Fallback: Retrieval Evaluator가 Incorrect로 판단하면, 정적 코퍼스 대신 웹 검색으로 대체하여 더 나은 정보를 찾는다.
[Query][Retriever][Retrieval Evaluator]
              CorrectKnowledge RefinementGenerator
              IncorrectWeb SearchKnowledge RefinementGenerator
              Ambiguous → 두 경로 결합 → Generator

이 패턴의 강점은 검색 품질이 낮은 상황에서도 자동으로 대체 경로를 활성화하여 강건한 응답을 생성한다는 점이다.


8. Evaluation 메트릭: RAGAS 프레임워크

RAG 시스템의 성능을 체계적으로 평가하기 위해 RAGAS(Retrieval Augmented Generation Assessment) 프레임워크가 널리 사용된다. Es et al.(2023)이 제안한 RAGAS는 Ground Truth 없이도 RAG 파이프라인을 평가할 수 있는 자동화된 메트릭을 제공한다.

8.1 Faithfulness (충실도)

생성된 답변이 검색된 Context에 얼마나 충실한지 측정한다. Hallucination 감지의 핵심 메트릭이다.

Faithfulness = (Context에 의해 뒷받침되는 Claim) / (전체 Claim)

동작 방식은 다음과 같다.

  1. LLM이 생성된 답변에서 개별 Claim(주장)을 추출한다.
  2. 각 Claim이 제공된 Context에 의해 뒷받침(Support)되는지 판단한다.
  3. 뒷받침되는 Claim의 비율을 계산한다.

값은 0~1 사이이며, 1에 가까울수록 답변이 Context에 충실하다는 의미이다.

8.2 Answer Relevance (답변 관련성)

생성된 답변이 원래 질문에 얼마나 관련 있는지 측정한다.

동작 방식은 다음과 같다.

  1. 생성된 답변으로부터 역으로 질문을 생성(Reverse Engineering)한다.
  2. 생성된 질문들과 원래 질문의 Embedding 유사도를 계산한다.
  3. 평균 유사도가 Answer Relevance 점수가 된다.

이 방식은 답변이 질문의 핵심을 다루고 있는지, 불필요한 정보를 포함하고 있지는 않은지를 간접적으로 측정한다.

8.3 Context Recall (맥락 재현율)

검색된 Context가 Ground Truth 답변을 생성하는 데 필요한 정보를 얼마나 포함하고 있는지 측정한다.

Context Recall = (Context에서 지지되는 GT 문장 수) / (전체 GT 문장 수)

이 메트릭은 Ground Truth가 필요한 유일한 메트릭이다. Retriever의 성능을 직접적으로 평가한다.

8.4 Context Precision (맥락 정밀도)

검색된 Context 중에서 실제로 관련 있는 항목이 상위에 위치하는지 측정한다. 관련 문서가 검색 결과의 앞쪽에 올수록 점수가 높다.

8.5 RAGAS 사용 예시

from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_recall,
    context_precision,
)
from datasets import Dataset

# 평가 데이터셋 구성
eval_data = {
    "question": ["RAG란 무엇인가?"],
    "answer": ["RAG는 검색 증강 생성으로, LLM이 외부 문서를 검색하여 답변을 생성하는 방법이다."],
    "contexts": [["RAG(Retrieval-Augmented Generation)는 외부 지식을 검색하여 LLM의 생성에 활용하는 기법이다."]],
    "ground_truth": ["RAG는 Retrieval-Augmented Generation의 약자로, 외부 지식 소스에서 관련 정보를 검색하여 LLM의 응답 생성을 보강하는 방법론이다."],
}
dataset = Dataset.from_dict(eval_data)

# 평가 실행
result = evaluate(
    dataset=dataset,
    metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
)
print(result)

프로덕션 환경에서는 이러한 메트릭을 CI/CD 파이프라인에 통합하여 RAG 시스템의 변경(Chunking 전략 수정, 모델 교체 등)이 품질에 미치는 영향을 자동으로 모니터링하는 것이 권장된다.


9. LangChain + ChromaDB 실전 구현 예제

지금까지 다룬 개념을 종합하여, LangChain과 ChromaDB로 실전 RAG 파이프라인을 구현하는 전체 코드를 작성한다.

9.1 환경 설정 및 패키지 설치

pip install langchain langchain-openai langchain-chroma langchain-community
pip install chromadb pypdf sentence-transformers

9.2 Document Loading 및 Chunking

import os
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# PDF 문서 로딩
loader = DirectoryLoader(
    "./documents",
    glob="**/*.pdf",
    loader_cls=PyPDFLoader,
)
documents = loader.load()
print(f"로드된 문서 수: {len(documents)}")

# Recursive Character Splitting
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""],
    length_function=len,
)
splits = text_splitter.split_documents(documents)
print(f"생성된 Chunk 수: {len(splits)}")

9.3 Embedding 및 Vector Store 구축

from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

# Embedding 모델 설정
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
    openai_api_key=os.getenv("OPENAI_API_KEY"),
)

# ChromaDB Vector Store 구축 및 영구 저장
vectorstore = Chroma.from_documents(
    documents=splits,
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="rag_collection",
)
print(f"Vector Store에 저장된 문서 수: {vectorstore._collection.count()}")

9.4 Retriever 구성

# 기본 Retriever
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5},
)

# MMR(Maximal Marginal Relevance) Retriever - 다양성 확보
retriever_mmr = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 5,
        "fetch_k": 20,      # 초기 검색 후보 수
        "lambda_mult": 0.7,  # 관련성(1.0)과 다양성(0.0) 사이 균형
    },
)

9.5 RAG Chain 구성 및 실행

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# LLM 설정
llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0,
    openai_api_key=os.getenv("OPENAI_API_KEY"),
)

# Prompt Template
prompt = ChatPromptTemplate.from_template("""
다음 Context를 기반으로 질문에 답변하세요.
Context에 답변에 필요한 정보가 없으면 "제공된 문서에서 관련 정보를 찾을 수 없습니다."라고 답변하세요.

Context:
{context}

질문: {question}

답변:
""")

# Context 포맷팅 함수
def format_docs(docs):
    return "\n\n---\n\n".join(
        f"[출처: {doc.metadata.get('source', 'unknown')}, "
        f"페이지: {doc.metadata.get('page', 'N/A')}]\n{doc.page_content}"
        for doc in docs
    )

# LCEL(LangChain Expression Language)로 RAG Chain 구성
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# 실행
question = "RAG 시스템에서 Chunking 전략의 종류와 각각의 장단점은 무엇인가?"
answer = rag_chain.invoke(question)
print(answer)

9.6 출처 정보를 포함한 응답

from langchain_core.runnables import RunnableParallel

# 출처 정보와 답변을 함께 반환하는 Chain
rag_chain_with_sources = RunnableParallel(
    {"context": retriever, "question": RunnablePassthrough()}
).assign(
    answer=lambda x: (
        prompt.invoke(
            {"context": format_docs(x["context"]), "question": x["question"]}
        )
        | llm
        | StrOutputParser()
    ).invoke(x["question"])
)

# 더 간결한 방식
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

# Stuff Documents Chain (검색된 문서를 하나의 Context로 합침)
combine_docs_chain = create_stuff_documents_chain(llm, prompt)

# Retrieval Chain
retrieval_chain = create_retrieval_chain(retriever, combine_docs_chain)

# 실행 - context와 answer가 함께 반환됨
response = retrieval_chain.invoke({"input": question})
print("답변:", response["answer"])
print("\n참조 문서:")
for i, doc in enumerate(response["context"], 1):
    print(f"  [{i}] {doc.metadata.get('source', 'unknown')} "
          f"(p.{doc.metadata.get('page', 'N/A')})")

9.7 Conversational RAG (대화형 RAG)

from langchain_core.prompts import MessagesPlaceholder
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

# 대화 히스토리를 고려한 Prompt
contextualize_prompt = ChatPromptTemplate.from_messages([
    ("system", "이전 대화 맥락을 고려하여 사용자의 최신 질문을 독립적으로 이해할 수 있도록 재구성하세요."),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

# 세션별 히스토리 관리
store = {}

def get_session_history(session_id: str):
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]

# 대화형 RAG Chain 구성
conversational_rag = RunnableWithMessageHistory(
    retrieval_chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history",
    output_messages_key="answer",
)

# 대화 실행
config = {"configurable": {"session_id": "user_001"}}

response1 = conversational_rag.invoke(
    {"input": "RAG란 무엇인가?"},
    config=config,
)
print(response1["answer"])

response2 = conversational_rag.invoke(
    {"input": "그것의 주요 장점은?"},  # "그것" = 이전 대화의 RAG
    config=config,
)
print(response2["answer"])

이 구현은 기본적인 RAG 파이프라인이다. 프로덕션으로 가기 위해서는 앞서 다룬 Re-ranking, HyDE 등의 Advanced 패턴 적용, RAGAS를 통한 체계적 평가, 그리고 모니터링과 로깅 인프라 구축이 추가로 필요하다.


10. 정리

RAG는 LLM의 Hallucination 문제를 해결하기 위한 가장 실용적이고 효과적인 접근법이다. Lewis et al.(2020)의 원본 논문에서 제안된 Retriever + Generator 구조는 이후 다양한 Advanced 패턴으로 발전하며 프로덕션 수준의 AI 시스템 구축의 핵심 아키텍처로 자리잡았다.

효과적인 RAG 시스템을 구축하기 위한 핵심 결정 사항을 정리하면 다음과 같다.

  1. Chunking 전략: RecursiveCharacterTextSplitter를 기본으로 시작하되, 데이터 특성에 따라 Semantic Chunking을 고려한다.
  2. Embedding 모델: 다국어가 필요하면 BGE-M3, 영어 중심이면 OpenAI text-embedding-3 시리즈가 안정적이다.
  3. Vector Database: 프로토타이핑에는 Chroma, 프로덕션에는 워크로드에 맞는 DB를 선택한다.
  4. Advanced 패턴: Re-ranking은 거의 모든 경우에 적용할 가치가 있으며, HyDE와 CRAG는 검색 품질이 부족할 때 고려한다.
  5. 평가: RAGAS 메트릭을 CI/CD에 통합하여 지속적으로 품질을 모니터링한다.

References

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다