에이전트가 검색 전략을 스스로 판단하고 실패를 자동 수정하는 Agentic RAG, CRAG, Self-RAG 등 고급 RAG 패턴을 심층 분석합니다.
지금까지 다룬 RAG 파이프라인은 사전에 정의된 순서대로 작동합니다. 질문이 들어오면 검색하고, 검색 결과를 LLM에 전달하여 답변을 생성합니다. 그러나 이 선형적(Linear) 파이프라인은 다음과 같은 상황에서 한계를 드러냅니다.
첫째, 검색 결과가 부적절한데 이를 감지하지 못하고 답변을 생성합니다. 둘째, 질문이 모호하거나 복잡하여 한 번의 검색으로는 충분한 정보를 얻지 못합니다. 셋째, 여러 문서를 교차 참조해야 하는 멀티홉(Multi-hop) 질문에 대응하지 못합니다.
고급 RAG 패턴은 이러한 한계를 LLM의 추론 능력을 활용하여 극복합니다.
Self-RAG(Self-Reflective RAG)는 2023년 발표된 연구에서 제안된 패턴으로, LLM이 검색의 필요성을 스스로 판단하고, 검색 결과의 관련성을 평가하며, 생성된 답변의 품질을 자체 검증하는 시스템입니다.
Self-RAG는 세 가지 특수 토큰을 통해 자기 성찰을 수행합니다.
1. Retrieve Token: 검색이 필요한가?
- [검색 필요] --> 검색 수행
- [검색 불필요] --> 직접 응답
2. Relevance Token: 검색된 문서가 관련 있는가?
- [관련 있음] --> 생성에 활용
- [관련 없음] --> 폐기 후 재검색
3. Critique Token: 생성된 답변이 충실한가?
- [충실함] --> 최종 응답으로 출력
- [충실하지 않음] --> 재생성from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Literal
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
class SelfRAGState(TypedDict):
question: str
retrieved_docs: List[str]
generation: str
relevance_check: str
faithfulness_check: str
retry_count: int
llm = ChatOpenAI(model="gpt-4o", temperature=0)
def decide_retrieval(state: SelfRAGState) -> SelfRAGState:
"""검색이 필요한지 판단"""
prompt = ChatPromptTemplate.from_messages([
("system", """질문에 답변하기 위해 외부 문서 검색이 필요한지 판단하세요.
일반 상식으로 답변 가능하면 "불필요", 특정 정보가 필요하면 "필요"로만 답하세요."""),
("human", "{question}")
])
result = (prompt | llm).invoke({"question": state["question"]})
state["relevance_check"] = result.content.strip()
return state
def retrieve_documents(state: SelfRAGState) -> SelfRAGState:
"""문서 검색 수행"""
# 실제 구현에서는 벡터 검색 + 하이브리드 검색 + 리랭킹
results = retriever.invoke(state["question"])
state["retrieved_docs"] = [doc.page_content for doc in results]
return state
def check_relevance(state: SelfRAGState) -> SelfRAGState:
"""검색 결과의 관련성 평가"""
prompt = ChatPromptTemplate.from_messages([
("system", """검색된 문서가 질문에 답하기에 충분한 정보를 포함하는지 평가하세요.
"관련" 또는 "비관련"으로만 답하세요."""),
("human", """질문: {question}
검색 결과:
{documents}""")
])
docs_text = "\n\n".join(state["retrieved_docs"])
result = (prompt | llm).invoke({
"question": state["question"],
"documents": docs_text
})
state["relevance_check"] = result.content.strip()
return state
def generate_answer(state: SelfRAGState) -> SelfRAGState:
"""답변 생성"""
prompt = ChatPromptTemplate.from_messages([
("system", "주어진 컨텍스트만을 사용하여 질문에 답변하세요."),
("human", """컨텍스트:
{context}
질문: {question}""")
])
context = "\n\n".join(state["retrieved_docs"])
result = (prompt | llm).invoke({
"question": state["question"],
"context": context
})
state["generation"] = result.content
return state
def check_faithfulness(state: SelfRAGState) -> SelfRAGState:
"""생성된 답변의 충실도 검증"""
prompt = ChatPromptTemplate.from_messages([
("system", """답변이 컨텍스트에 있는 정보만을 사용했는지 검증하세요.
환각이 없으면 "충실", 환각이 있으면 "불충실"로만 답하세요."""),
("human", """컨텍스트:
{context}
답변: {answer}""")
])
context = "\n\n".join(state["retrieved_docs"])
result = (prompt | llm).invoke({
"context": context,
"answer": state["generation"]
})
state["faithfulness_check"] = result.content.strip()
return state
# 라우팅 함수
def route_retrieval(state: SelfRAGState) -> Literal["retrieve", "generate_direct"]:
if "필요" in state.get("relevance_check", "필요"):
return "retrieve"
return "generate_direct"
def route_relevance(state: SelfRAGState) -> Literal["generate", "retry"]:
if "관련" in state.get("relevance_check", ""):
return "generate"
return "retry"
def route_faithfulness(state: SelfRAGState) -> Literal["output", "retry"]:
if "충실" in state.get("faithfulness_check", ""):
return "output"
if state.get("retry_count", 0) >= 2:
return "output" # 최대 재시도 후 현재 결과 반환
return "retry"
def increment_retry(state: SelfRAGState) -> SelfRAGState:
state["retry_count"] = state.get("retry_count", 0) + 1
return state
# 그래프 구성
workflow = StateGraph(SelfRAGState)
workflow.add_node("decide_retrieval", decide_retrieval)
workflow.add_node("retrieve", retrieve_documents)
workflow.add_node("check_relevance", check_relevance)
workflow.add_node("generate", generate_answer)
workflow.add_node("check_faithfulness", check_faithfulness)
workflow.add_node("increment_retry", increment_retry)
workflow.set_entry_point("decide_retrieval")
workflow.add_conditional_edges("decide_retrieval", route_retrieval)
workflow.add_edge("retrieve", "check_relevance")
workflow.add_conditional_edges("check_relevance", route_relevance)
workflow.add_edge("generate", "check_faithfulness")
workflow.add_conditional_edges("check_faithfulness", route_faithfulness)
workflow.add_edge("increment_retry", "retrieve")
workflow.add_edge("generate_direct", END)
graph = workflow.compile()CRAG(Corrective RAG)는 검색 결과의 품질을 평가하고, 품질에 따라 세 가지 경로로 분기하는 패턴입니다. 2024년 발표된 논문에서 제안되었으며, 검색 실패에 대한 체계적인 대응 전략을 제공합니다.
검색 결과 평가:
|
+--> [정확] --> 검색 결과를 그대로 사용하여 생성
|
+--> [부정확] --> 웹 검색 등 외부 소스에서 추가 검색
|
+--> [모호] --> 검색 결과 유지 + 외부 소스 보완 검색from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Literal
from langchain_openai import ChatOpenAI
from langchain_community.tools import TavilySearchResults
class CRAGState(TypedDict):
question: str
retrieved_docs: List[str]
web_results: List[str]
quality_assessment: str
final_context: List[str]
generation: str
llm = ChatOpenAI(model="gpt-4o", temperature=0)
web_search = TavilySearchResults(max_results=3)
def retrieve(state: CRAGState) -> CRAGState:
"""내부 지식 베이스에서 검색"""
results = retriever.invoke(state["question"])
state["retrieved_docs"] = [doc.page_content for doc in results]
return state
def assess_quality(state: CRAGState) -> CRAGState:
"""검색 결과 품질 평가"""
prompt = ChatPromptTemplate.from_messages([
("system", """검색된 문서가 질문에 정확히 답할 수 있는지 평가하세요.
- "정확": 질문에 대한 명확한 답이 문서에 있음
- "부정확": 질문과 관련 없는 문서가 대부분임
- "모호": 관련 정보가 일부 있으나 완전하지 않음
위 세 가지 중 하나로만 답하세요."""),
("human", """질문: {question}
검색 결과:
{documents}""")
])
docs_text = "\n\n".join(state["retrieved_docs"][:3])
result = (prompt | llm).invoke({
"question": state["question"],
"documents": docs_text
})
state["quality_assessment"] = result.content.strip()
return state
def route_by_quality(state: CRAGState) -> Literal["correct", "incorrect", "ambiguous"]:
assessment = state.get("quality_assessment", "")
if "정확" in assessment:
return "correct"
elif "부정확" in assessment:
return "incorrect"
return "ambiguous"
def use_retrieved(state: CRAGState) -> CRAGState:
"""검색 결과를 그대로 사용"""
state["final_context"] = state["retrieved_docs"]
return state
def search_web(state: CRAGState) -> CRAGState:
"""웹 검색으로 대체"""
results = web_search.invoke(state["question"])
state["web_results"] = [r["content"] for r in results]
state["final_context"] = state["web_results"]
return state
def combine_sources(state: CRAGState) -> CRAGState:
"""내부 검색 + 웹 검색 결합"""
results = web_search.invoke(state["question"])
state["web_results"] = [r["content"] for r in results]
state["final_context"] = state["retrieved_docs"] + state["web_results"]
return state
def generate(state: CRAGState) -> CRAGState:
"""최종 답변 생성"""
prompt = ChatPromptTemplate.from_messages([
("system", "주어진 컨텍스트를 기반으로 질문에 답변하세요."),
("human", """컨텍스트:
{context}
질문: {question}""")
])
context = "\n\n".join(state["final_context"])
result = (prompt | llm).invoke({
"question": state["question"],
"context": context
})
state["generation"] = result.content
return state
# CRAG 그래프 구성
crag = StateGraph(CRAGState)
crag.add_node("retrieve", retrieve)
crag.add_node("assess", assess_quality)
crag.add_node("use_retrieved", use_retrieved)
crag.add_node("search_web", search_web)
crag.add_node("combine", combine_sources)
crag.add_node("generate", generate)
crag.set_entry_point("retrieve")
crag.add_edge("retrieve", "assess")
crag.add_conditional_edges("assess", route_by_quality, {
"correct": "use_retrieved",
"incorrect": "search_web",
"ambiguous": "combine"
})
crag.add_edge("use_retrieved", "generate")
crag.add_edge("search_web", "generate")
crag.add_edge("combine", "generate")
crag.add_edge("generate", END)
crag_graph = crag.compile()CRAG의 핵심 가치는 검색 실패에 대한 명시적인 폴백(Fallback) 전략을 제공한다는 것입니다. 기본 RAG가 잘못된 컨텍스트로 환각을 생성하는 반면, CRAG는 검색 품질이 낮으면 다른 소스에서 보완 정보를 가져옵니다.
Adaptive RAG는 질문의 복잡도에 따라 검색 전략을 동적으로 선택하는 패턴입니다. 단순한 사실 질문은 한 번의 검색으로 처리하고, 복잡한 분석 질문은 멀티스텝 검색을 수행합니다.
from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Literal
class AdaptiveRAGState(TypedDict):
question: str
complexity: str
retrieved_docs: List[str]
sub_questions: List[str]
intermediate_answers: List[str]
final_answer: str
def classify_complexity(state: AdaptiveRAGState) -> AdaptiveRAGState:
"""질문 복잡도 분류"""
prompt = ChatPromptTemplate.from_messages([
("system", """질문의 복잡도를 분류하세요.
- "단순": 하나의 문서에서 답을 찾을 수 있는 사실 질문
- "비교": 여러 항목을 비교해야 하는 질문
- "분석": 여러 문서를 종합하여 추론이 필요한 질문
위 세 가지 중 하나로만 답하세요."""),
("human", "{question}")
])
result = (prompt | llm).invoke({"question": state["question"]})
state["complexity"] = result.content.strip()
return state
def route_by_complexity(state: AdaptiveRAGState) -> Literal["simple", "compare", "analyze"]:
complexity = state.get("complexity", "")
if "단순" in complexity:
return "simple"
elif "비교" in complexity:
return "compare"
return "analyze"
def simple_retrieval(state: AdaptiveRAGState) -> AdaptiveRAGState:
"""단순 검색 - 단일 쿼리"""
results = retriever.invoke(state["question"])
state["retrieved_docs"] = [doc.page_content for doc in results[:5]]
return state
def decompose_question(state: AdaptiveRAGState) -> AdaptiveRAGState:
"""복잡한 질문을 하위 질문으로 분해"""
prompt = ChatPromptTemplate.from_messages([
("system", """주어진 질문을 답변하기 위해 필요한 하위 질문들을 생성하세요.
각 줄에 하나의 하위 질문을 작성하세요. 최대 3개."""),
("human", "{question}")
])
result = (prompt | llm).invoke({"question": state["question"]})
state["sub_questions"] = [
q.strip() for q in result.content.strip().split("\n")
if q.strip()
]
return state
def multi_step_retrieval(state: AdaptiveRAGState) -> AdaptiveRAGState:
"""멀티스텝 검색 - 하위 질문별 검색 후 통합"""
all_docs = []
intermediate = []
for sub_q in state["sub_questions"]:
results = retriever.invoke(sub_q)
docs = [doc.page_content for doc in results[:3]]
all_docs.extend(docs)
# 하위 질문에 대한 중간 답변 생성
context = "\n\n".join(docs)
answer = (
ChatPromptTemplate.from_messages([
("human", "컨텍스트:\n{context}\n\n질문: {question}")
]) | llm
).invoke({"context": context, "question": sub_q})
intermediate.append(answer.content)
state["retrieved_docs"] = all_docs
state["intermediate_answers"] = intermediate
return stateGraph RAG는 문서에서 엔티티(Entity)와 관계(Relationship)를 추출하여 지식 그래프(Knowledge Graph)를 구성하고, 이를 검색에 활용하는 패턴입니다. 일반적인 벡터 검색이 개별 청크 수준의 지엽적 질문에 강하다면, Graph RAG는 "전체 문서에서 가장 중요한 주제는 무엇인가?"같은 전역적(Global) 질문에 강합니다.
일반 RAG:
"프로젝트 A의 일정은?" --> 해당 청크 검색 --> 답변
Graph RAG:
"프로젝트 간의 의존 관계는?" --> 지식 그래프 탐색 --> 관계 기반 답변from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
import json
import networkx as nx
def extract_entities_and_relations(text, llm):
"""텍스트에서 엔티티와 관계를 추출"""
prompt = ChatPromptTemplate.from_messages([
("system", """텍스트에서 주요 엔티티(사람, 조직, 개념, 기술 등)와
그들 사이의 관계를 추출하세요.
JSON 형식으로 응답하세요:
[
{"source": "엔티티A", "relation": "관계", "target": "엔티티B"}
]"""),
("human", "{text}")
])
result = (prompt | llm).invoke({"text": text})
try:
relations = json.loads(result.content)
return relations
except json.JSONDecodeError:
return []
def build_knowledge_graph(documents, llm):
"""문서들로부터 지식 그래프 구축"""
graph = nx.DiGraph()
for doc in documents:
relations = extract_entities_and_relations(doc.page_content, llm)
for rel in relations:
graph.add_edge(
rel["source"],
rel["target"],
relation=rel["relation"]
)
print(f"노드 수: {graph.number_of_nodes()}")
print(f"엣지 수: {graph.number_of_edges()}")
return graph
# 그래프 기반 검색
def graph_search(graph, query_entities, max_hops=2):
"""지식 그래프에서 관련 서브그래프 추출"""
relevant_nodes = set()
for entity in query_entities:
if entity in graph:
# BFS로 인접 노드 탐색
for node in nx.bfs_tree(graph, entity, depth_limit=max_hops):
relevant_nodes.add(node)
subgraph = graph.subgraph(relevant_nodes)
# 서브그래프의 관계를 텍스트로 변환
context_parts = []
for source, target, data in subgraph.edges(data=True):
context_parts.append(
f"{source} --[{data['relation']}]--> {target}"
)
return "\n".join(context_parts)Graph RAG는 지식 그래프 구축에 상당한 LLM 비용이 소요됩니다. 또한 그래프의 품질이 엔티티/관계 추출의 정확도에 크게 의존합니다. 명확한 구조화된 관계가 중요한 도메인(조직도, 제품 의존성, 규정 체계 등)에서 가장 큰 가치를 제공합니다.
| 패턴 | 적합한 상황 | 복잡도 | 비용 |
|---|---|---|---|
| Naive RAG | PoC, 단순 Q/A | 낮음 | 낮음 |
| Advanced RAG | 프로덕션 기본 | 중간 | 중간 |
| Self-RAG | 환각 최소화가 중요 | 높음 | 높음 |
| CRAG | 검색 실패 대비 필요 | 중간 | 중간 |
| Adaptive RAG | 질문 유형이 다양 | 높음 | 높음 |
| Graph RAG | 관계 기반 질문 | 매우 높음 | 매우 높음 |
모든 질문에 가장 복잡한 패턴을 적용할 필요는 없습니다. 2026년 기준 프로덕션에서 가장 효과적인 접근법은, Advanced RAG(하이브리드 검색 + 리랭킹)를 기본으로 하고, 필요에 따라 CRAG의 폴백 전략을 추가하는 것입니다. Agentic RAG는 복잡한 멀티홉 질문이 빈번한 도메인에서 도입을 검토하세요.
고급 RAG 패턴은 LLM의 추론 능력을 활용하여 기본 RAG의 한계를 극복합니다. Self-RAG는 검색 필요성 판단과 답변 검증을 자동화하고, CRAG는 검색 실패에 대한 체계적인 대응 전략을 제공하며, Adaptive RAG는 질문 복잡도에 따른 동적 전략 선택을 가능하게 합니다. Graph RAG는 엔티티 간 관계를 활용한 전역적 질문 응답에 강점을 보입니다. LangGraph는 이러한 복잡한 워크플로우를 상태 그래프로 명확하게 구현할 수 있는 프레임워크입니다.
다음 장에서는 이 시리즈의 마지막으로, 지금까지 다룬 모든 구성 요소를 조합하여 프로덕션 수준의 RAG 시스템을 구축하는 방법을 다룹니다. 모니터링, 캐싱, 보안, 배포 전략까지 실전에 필요한 내용을 종합적으로 살펴보겠습니다.
이 글이 도움이 되셨나요?
모니터링, 캐싱, 보안, 확장성, 배포 전략까지 프로덕션 수준의 RAG 시스템을 설계하고 운영하는 실전 가이드입니다.
RAGAS, 충실도, 컨텍스트 정밀도 등 RAG 시스템의 품질을 객관적으로 측정하는 평가 프레임워크와 핵심 메트릭을 다룹니다.
Cross-Encoder 리랭킹의 원리, Cohere Rerank API, 오픈소스 리랭커 비교, 그리고 프로덕션 환경에서의 효과적인 리랭킹 전략을 다룹니다.