S5 · 첫 Agentic RAG — 검색할지 직답할지
Agentic RAG 실전 (14시간) · Day 1
S3의 검색기를 S4의 그래프에 도구로 올려, LLM이 검색 여부를 스스로 결정하는 첫 에이전트를 만든다. S0 프로젝트의 .venv/.env 로 실행한다(LLM·Pinecone 호출).
목표
- 검색기를 LLM이 쓰는 도구로 만든다.
- 도구 호출 여부를 LLM이 결정하는 그래프를 조립한다.
- 불필요한 검색을 건너뛰는 동작을 확인한다.
선행: S2/S3에서 agentic-rag-lab 인덱스에 색인이 되어 있어야 한다.
1. 도구 호출(tool calling)
S4의 분기는 사람이 짠 규칙이 정했다. Agentic RAG에서는 그 자리를 LLM의 판단이 대신한다.
- LLM에 도구 목록을 알린다(bind_tools).
- 질문이 오면 LLM은 '그냥 답' 또는 '도구 호출 요청(tool call)'을 출력한다.
- 도구 호출이 나오면 실제 도구(검색)를 실행해 결과를 LLM에 돌려준다.
- LLM이 그 근거로 최종 답을 만든다.
그래프로는 agent ↔ tools 루프이며, "결과가 더 필요하면 또 검색" 도 이 루프로 표현된다.
2. 실행 준비
S0 프로젝트의 .venv 커널로 실행한다. .env 의 키를 로드한다.
import os
from getpass import getpass
# S0 프로젝트 루트의 .env 에서 키 로드 (없으면 입력으로 폴백)
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
for name in ["OPENAI_API_KEY", "PINECONE_API_KEY"]:
if not os.environ.get(name):
os.environ[name] = getpass(f"{name}: ")
print("키:", {k: bool(os.environ.get(k)) for k in ["OPENAI_API_KEY", "PINECONE_API_KEY"]})
키: {'OPENAI_API_KEY': True, 'PINECONE_API_KEY': True}
3. 검색기를 도구로
agentic-rag-lab 에 연결하고 검색을 @tool 로 감싼다. 독스트링은 LLM이 '언제 이 도구를 쓸지' 판단하는 근거이므로 명확히 쓴다.
import os
from pinecone import Pinecone # Pinecone 클라이언트
from langchain_pinecone import PineconeVectorStore, PineconeEmbeddings
from langchain_core.tools import tool # 함수를 '도구'로 만드는 데코레이터
embeddings = PineconeEmbeddings(model="multilingual-e5-large") # S3과 동일한 한국어 임베딩(1024차원)
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) # 키로 클라이언트 생성
index = pc.Index("agentic-rag-lab") # S2/S3에서 만든 인덱스에 연결
# ★ S5~S11이 사용할 '메인 코퍼스' 네임스페이스.
# S2는 demo, S3은 chunk_200/500/1000/1500 네임스페이스에만 색인했고,
# 네임스페이스를 지정하지 않으면 비어 있는 기본 네임스페이스를 조회하게 된다.
# 여기서는 S3에서 색인한 chunk_500 을 메인 코퍼스로 재사용한다. (S3 색인 셀을 먼저 실행할 것)
NAMESPACE = "chunk_500"
vector_store = PineconeVectorStore(index=index, embedding=embeddings, namespace=NAMESPACE)
# 인덱스 진단: 어떤 네임스페이스에 벡터가 몇 개 있는지 확인 (데이터 유무 점검)
try:
stats = index.describe_index_stats()
ns_map = getattr(stats, "namespaces", None) or (stats.get("namespaces") if isinstance(stats, dict) else {}) or {}
ns_counts = {}
for k, v in ns_map.items():
ns_counts[k] = getattr(v, "vector_count", v.get("vector_count") if isinstance(v, dict) else None)
print("네임스페이스별 벡터 수:", ns_counts)
if not ns_counts.get(NAMESPACE):
print(f"⚠️ '{NAMESPACE}' 네임스페이스에 데이터가 없습니다. S3의 색인 셀을 먼저 실행하세요.")
except Exception as e:
print("인덱스 통계 확인 생략:", e)
@tool
def search_docs(query: str) -> str:
"""사내 정책/기술/FAQ 문서에서 질문과 관련된 내용을 검색한다. 사내 규정·절차를 물을 때 사용."""
hits = vector_store.similarity_search(query, k=3) # 의미 검색으로 상위 3개 청크
if not hits: # 검색 결과가 없으면
return "관련 문서를 찾지 못했습니다."
# 각 청크를 '[제목] 본문' 형태로 합쳐 LLM이 읽기 좋게 반환
return "\n\n".join(f"[{h.metadata['title']}] {h.page_content}" for h in hits)
# 도구가 잘 동작하는지 단독 테스트 (LLM 없이 직접 호출)
print(search_docs.invoke("재택근무는 주 며칠까지 가능한가요?")[:160], "...")
네임스페이스별 벡터 수: {'demo': 3, 'chunk_500': 34, 'chunk_1000': 18, 'chunk_200': 89, 'chunk_1500': 12}
[재택근무 및 유연근무 제도] 재택근무는 주 최대 3일까지 가능하며, 매주 금요일은 전사 권장 재택일로 운영된다. 재택근무를 하려면 전주 목요일까지 다음 주 근무 형태를 그룹웨어에 등록하고 팀장 승인을 받아야 한다. 신규 입사자는 온보딩 기간(첫 4주) 동안에는 주 1회로 제한되며, 수 ...
4. 에이전트 노드
LLM에 도구를 바인딩(bind_tools)하고, 시스템 프롬프트로 "언제 검색하고 언제 직답할지" 정책을 준다. temperature=0 으로 일관성을 높인다.
from langchain_openai import ChatOpenAI # OpenAI LLM 래퍼
from langchain_core.messages import SystemMessage
llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0) # 기본 LLM (교체 가능)
llm_with_tools = llm.bind_tools([search_docs]) # ★ LLM에게 도구 목록을 쥐여 준다
SYSTEM = (
"당신은 '누구나테크'의 사내 문서 도우미입니다. "
"사내 정책·기술·FAQ가 필요한 질문이면 search_docs 도구로 근거를 찾아 답하고, "
"인사말이나 일반 상식은 도구 없이 바로 답하세요. "
"답변 끝에는 참고한 문서 제목을 괄호로 표기하세요."
)
# 에이전트 노드: 시스템 지침 + 지금까지의 대화를 LLM에 넣어, '직답 또는 도구호출'을 받는다.
def agent(state):
messages = [SystemMessage(content=SYSTEM)] + list(state["messages"]) # 지침을 맨 앞에 붙임
return {"messages": [llm_with_tools.invoke(messages)]} # LLM의 응답(또는 도구호출)을 누적
5. 그래프 조립 — agent ↔ tools 루프
- ToolNode: 도구 호출 요청을 받아 실제 도구를 실행하는 노드(여전히 langgraph.prebuilt).
- tools_condition: 마지막 메시지에 도구 호출이 있으면 "tools", 없으면 END 로 보내는 분기 함수.
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition # 도구 실행 노드 + 분기 함수
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(MessagesState) # 메시지 상태로 그래프 시작
builder.add_node("agent", agent) # ① LLM 판단 노드
builder.add_node("tools", ToolNode([search_docs])) # ② 도구 실행 노드
builder.add_edge(START, "agent") # 시작 → agent
# ③ agent의 결과를 보고 분기: 도구호출 있으면 tools, 없으면 END
builder.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: END})
builder.add_edge("tools", "agent") # ④ 검색 결과를 들고 다시 agent로 (루프)
graph = builder.compile(checkpointer=InMemorySaver()) # 체크포인터로 멀티턴 기억까지
print("그래프 컴파일 완료")
그래프 컴파일 완료
from IPython.display import Image, display
try:
display(Image(graph.get_graph().draw_mermaid_png())) # 그래프 구조 그림으로 확인
except Exception:
print(graph.get_graph().draw_mermaid())

6. 실행 — 검색 vs 직답
thread_id 로 대화를 묶어 맥락을 유지한다.
from langchain_core.messages import HumanMessage
def ask(question, thread):
cfg = {"configurable": {"thread_id": thread}} # 대화 식별자
result = graph.invoke({"messages": [HumanMessage(content=question)]}, cfg)
msgs = result["messages"]
# 체크포인터로 히스토리가 누적되므로, '이번 턴'(마지막 사용자 메시지 이후)에
# 도구 호출이 있었는지만 확인해야 정확하다.
last_human = max(i for i, m in enumerate(msgs) if m.type == "human")
used_search = any(getattr(m, "tool_calls", None) for m in msgs[last_human:])
print("Q:", question)
print("A:", msgs[-1].content)
print("→ 검색 도구 사용:", used_search, "\n")
# 두 질문의 비교가 섞이지 않도록 질문마다 다른 thread 를 쓴다.
ask("재택근무는 주 며칠까지 가능한가요?", thread="q-search") # 사내 규정 → 검색 필요
ask("안녕하세요! 반가워요", thread="q-greeting") # 인사 → 검색 없이 직답
Q: 재택근무는 주 며칠까지 가능한가요?
A: 재택근무는 주 최대 3일까지 가능하며, 매주 금요일은 전사 권장 재택일로 운영됩니다. 재택근무를 하려면 전주 목요일까지 다음 주 근무 형태를 그룹웨어에 등록하고 팀장 승인을 받아야 합니다. (재택근무 및 유연근무 제도)
→ 검색 도구 사용: True
Q: 안녕하세요! 반가워요
A: 안녕하세요! 반갑습니다. 무엇을 도와드릴까요?
→ 검색 도구 사용: False
🔍 관찰 포인트
- "재택근무..." 는 검색 도구 사용: True, 답변에 참고 문서 제목이 표기된다.
- "안녕하세요" 는 False — 불필요한 검색을 건너뛴다(Naive RAG는 인사에도 검색).
- 내부적으로 agent(판단) → tools(검색) → agent(근거로 답) 루프가 돈다.
✏️ 미니 실습
- 같은 thread 로 "그럼 핵심 근무시간은 언제죠?" 를 이어 물어 멀티턴을 확인한다.
- 검색해도 답이 없는 질문(예: "주차장 정책")을 던져 반응을 본다 → S7·S8에서 개선.
- 시스템 프롬프트를 다듬어 근거 문서 제목이 항상 표기되게 한다.
# 미니 실습 1. 같은 thread 로 이어 물어 멀티턴 확인하기
# 같은 thread_id 를 쓰면 체크포인터가 이전 대화를 이어받습니다.
# 따라서 두 번째 질문은 첫 번째 질문의 맥락을 가진 상태에서 처리됩니다.
ask("재택근무는 주 며칠까지 가능한가요?", thread="multi")
ask("그럼 핵심 근무시간은 언제죠?", thread="multi") # 같은 thread → 앞 맥락 유지
Q: 재택근무는 주 며칠까지 가능한가요?
A: 재택근무는 주 최대 3일까지 가능하며, 매주 금요일은 전사 권장 재택일로 운영됩니다. 재택근무를 하려면 전주 목요일까지 다음 주 근무 형태를 그룹웨어에 등록하고 팀장 승인을 받아야 합니다. (재택근무 및 유연근무 제도)
→ 검색 도구 사용: True
Q: 그럼 핵심 근무시간은 언제죠?
A: 핵심 근무시간(코어타임)은 오전 10시부터 오후 4시까지입니다. 이 시간 동안에는 사내 메신저에서 즉시 응답이 가능해야 합니다. (재택근무 및 유연근무 제도)
→ 검색 도구 사용: False
실습 1 결과 해설
- 첫 질문인 재택근무는 주 며칠까지 가능한가요? 는 사내 규정을 묻는 질문이라 검색 도구 사용: True 가 나온다. 즉, 에이전트가 먼저 검색이 필요하다고 판단한 것이다.
- 이어서 같은 thread="multi" 로 그럼 핵심 근무시간은 언제죠? 를 보내면, 앞선 대화 맥락이 체크포인터에 남아 있으므로 두 질문이 같은 대화로 이어진다.
- 이때 두 번째 질문에서는 검색 도구 사용: False 가 나올 수 있다. 이미 앞 대화 맥락과 모델의 일반화 능력만으로 답할 수 있다고 판단했기 때문이다.
- 즉, 멀티턴의 핵심은 "같은 thread 안에서 상태가 이어진다"는 점이고, Agentic RAG의 핵심은 "매 턴마다 꼭 검색하지 않고 필요할 때만 검색한다"는 점이다.
# 미니 실습 2. 검색해도 답이 없는 질문을 던져 반응 확인하기
# 코퍼스에 없는 질문을 넣으면, 현재 에이전트가 어떻게 응답하는지 관찰할 수 있습니다.
# S7, S8 에서는 이런 경우를 더 잘 다루도록 라우팅/자기교정 전략을 추가합니다.
ask("주차장 정책은 어떻게 되나요?", thread="no-answer")
Q: 주차장 정책은 어떻게 되나요?
A: 현재 사내 문서에서 주차장 정책에 대한 구체적인 내용은 확인되지 않습니다. 추가로 필요한 정보가 있으면 알려주세요.
→ 검색 도구 사용: True
실습 2 결과 해설
- 주차장 정책은 어떻게 되나요? 질문에서는 검색 도구 사용: True 가 나왔으므로, 에이전트는 이 질문이 사내 문서 검색이 필요한 유형이라고 판단했다.
- 하지만 최종 답변은 "현재 사내 문서에서 주차장 정책에 대한 구체적인 내용은 확인되지 않습니다"처럼 나왔다. 즉, 검색은 했지만 코퍼스 안에서 충분한 근거를 찾지 못한 것이다.
- 이 결과는 현재 S5 단계의 한계도 보여 준다. "검색을 했지만 답이 없다"는 상황을 더 정교하게 다루려면, 이후 S7의 라우팅 개선이나 S8의 자기교정(Self-RAG) 같은 전략이 필요하다.
- 따라서 이 실습은 실패가 아니라, Agentic RAG가 실제 서비스에서 왜 후속 개선 단계를 필요로 하는지 보여 주는 예시로 읽으면 된다.
# 미니 실습 3. 시스템 프롬프트를 다듬어 근거 문서 제목이 항상 표기되게 하기
# 위에서 만든 search_docs 도구는 그대로 재사용하고,
# 시스템 프롬프트만 더 강하게 바꾼 새 그래프를 별도로 만들어 비교합니다.
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
IMPROVED_SYSTEM = (
"당신은 '누구나테크'의 사내 문서 도우미입니다. "
"사내 정책·기술·FAQ가 필요한 질문이면 search_docs 도구로 근거를 찾아 답하고, "
"인사말이나 일반 상식은 도구 없이 바로 답하세요. "
"검색 결과를 참고해 답했다면, 답변 마지막 줄에 반드시 '근거 문서: <문서 제목>' 형식으로 문서 제목을 적으세요. "
"검색 결과에 근거가 없으면 모른다고 말하고 추측하지 마세요."
)
# 시스템 프롬프트만 달라진 새 agent 노드입니다.
def agent_with_titles(state):
messages = [SystemMessage(content=IMPROVED_SYSTEM)] + list(state["messages"])
return {"messages": [llm_with_tools.invoke(messages)]}
# 원본 graph 와 비교할 수 있도록 별도 그래프를 만듭니다.
builder_titles = StateGraph(MessagesState)
builder_titles.add_node("agent", agent_with_titles)
builder_titles.add_node("tools", ToolNode([search_docs]))
builder_titles.add_edge(START, "agent")
builder_titles.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: END})
builder_titles.add_edge("tools", "agent")
graph_titles = builder_titles.compile(checkpointer=InMemorySaver())
# 새 그래프를 호출하는 작은 헬퍼 함수입니다.
def ask_with_titles(question, thread):
cfg = {"configurable": {"thread_id": thread}}
result = graph_titles.invoke({"messages": [HumanMessage(content=question)]}, cfg)
msgs = result["messages"]
print("Q:", question)
print("A:", msgs[-1].content)
print()
ask_with_titles("재택근무는 주 며칠까지 가능한가요?", thread="title-check")
Q: 재택근무는 주 며칠까지 가능한가요?
A: 재택근무는 주 최대 3일까지 가능합니다. 매주 금요일은 전사 권장 재택일로 운영되며, 재택근무를 하려면 전주 목요일까지 다음 주 근무 형태를 그룹웨어에 등록하고 팀장 승인을 받아야 합니다. 신규 입사자는 온보딩 기간(첫 4주) 동안에는 주 1회로 제한되며, 수습 종료 후 일반 기준이 적용됩니다. 직무 특성상 상시 대면이 필요한 일부 직군은 팀 단위로 별도 기준을 둘 수 있습니다.
근거 문서: 재택근무 및 유연근무 제도
실습 3 결과 해설
- 강화된 시스템 프롬프트를 쓴 새 그래프에서는 답변 마지막에 근거 문서: 재택근무 및 유연근무 제도가 실제로 출력되었다.
- 즉, 프롬프트를 조금 더 구체적으로 쓰는 것만으로도, 같은 도구와 같은 검색기를 써도 출력 형식의 일관성을 높일 수 있음을 보여 준다.
- 이 실습의 핵심은 "모델이 더 똑똑해졌다"가 아니라, 에이전트의 응답 정책을 프롬프트로 명시하면 원하는 형식을 더 안정적으로 유도할 수 있다는 점이다.
- 실제 서비스에서는 이런 방식으로 출처 표기, 확신 수준, 근거 부족 시 응답 방식 등을 프롬프트에서 더 엄격하게 통제한다.
정리 — Day 1 마무리
- 검색기를 도구로 만들고(@tool, bind_tools), LLM이 검색 여부를 판단하는 첫 Agentic RAG를 완성했다.
- ToolNode + tools_condition 로 agent ↔ tools 루프를 구성하고 체크포인터로 멀티턴을 얹었다.
- Day 1: 환경(S0) → 개념(S1) → 벡터검색(S2) → RAG 파이프라인(S3) → LangGraph(S4) → 첫 Agentic RAG(S5).
- Day 2 예고: create_agent(S6), 라우팅·재작성(S7), 자기교정(S8), 재순위(S9), 프로덕션·평가(S10), 캡스톤(S11).
'AI System > Agentic RAG: Agent 시스템을 위한 Vector RAG 설계' 카테고리의 다른 글
| d02 - 8. Adaptive RAG (0) | 2026.06.16 |
|---|---|
| d02 - 7. create_agent (0) | 2026.06.16 |
| d01 - 5. LangGraph (0) | 2026.06.15 |
| d01 - 4. RAG 파이프라인 (0) | 2026.06.15 |
| d01 - 3. Pinecone (0) | 2026.06.15 |