본문 바로가기
AI System/Agentic RAG: Agent 시스템을 위한 Vector RAG 설계

d01 - 6. AgenticRAG 실습

by Toddler_AD 2026. 6. 15.

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의 판단이 대신한다.

  1. LLM에 도구 목록을 알린다(bind_tools).
  2. 질문이 오면 LLM은 '그냥 답' 또는 '도구 호출 요청(tool call)'을 출력한다.
  3. 도구 호출이 나오면 실제 도구(검색)를 실행해 결과를 LLM에 돌려준다.
  4. 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(근거로 답) 루프가 돈다.
 

✏️ 미니 실습

  1. 같은 thread 로 "그럼 핵심 근무시간은 언제죠?" 를 이어 물어 멀티턴을 확인한다.
  2. 검색해도 답이 없는 질문(예: "주차장 정책")을 던져 반응을 본다 → S7·S8에서 개선.
  3. 시스템 프롬프트를 다듬어 근거 문서 제목이 항상 표기되게 한다.
# 미니 실습 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