📚 Agentic AI - 기업용 자율 에이전트 개발 6장 · 에이전트 ④ RAG 문서검색 Agentic AI란

따라하기 — 출처와 함께 답하는 검색 에이전트

한 줄 요약

answer() 함수를 실행합니다. 1장에서 "3개월"이라고 지어냈던 질문에 "30일"이라고 정확히 답하고, 어느 문서 몇 쪽인지까지 밝힙니다.


1. 실행

터미널에서 실행 — 프로젝트 폴더 agentic_ai 에서, (agentic) 표시를 확인한 뒤.

python code/ch06_rag_agent.py
==============================================================
[2] 사내 문서를 근거로 답하기

  Q: 불량 상품 반품은 며칠 이내에 신청해야 하나요?
  A: 상품 불량·하자로 인한 반품은 상품 수령일로부터 30일 이내에 신청하셔야 합니다.
  근거: 환불교환정책.pdf 1쪽, 환불교환정책.pdf 2쪽

  Q: 스마트워치 Fit 5의 배터리는 얼마나 가나요?
  A: 승승 스마트워치 Fit 5의 배터리는 일반 사용 시 약 7일, GPS 사용 시 약 18시간 지속됩니다.
  근거: 제품매뉴얼_스마트워치.pdf 1쪽, 멤버십정책.pdf 1쪽, 제품매뉴얼_로봇청소기.pdf 1쪽

  Q: VIP 등급이 되려면 얼마를 구매해야 하나요?
  A: VIP 등급이 되려면 6개월 누적 구매 금액이 150만원 이상이어야 합니다.
  근거: 멤버십정책.pdf 1쪽, 직원핸드북.pdf 2쪽, 환불교환정책.pdf 2쪽

==============================================================
[3] 문서에 없는 것을 물으면
  A: 사내 문서에서 해외 배송에 대한 정보나 소요 기간을 찾을 수 없습니다.

2. 1장과 비교

[1장 — 도구 없음]
  Q: 승승장구몰에서 불량 상품 반품은 며칠 이내에 신청해야 하나요?
  A: "승승장구몰"의 정확한 반품 규정은 해당 쇼핑몰의 웹사이트에 명시되어 있습니다.
     하지만 일반적으로 전자상거래법에 따르면 ...
     * 상품을 받은 날로부터 3개월 이내
     * 또는 그 사실을 알았거나 알 수 있었던 날부터 30일 이내

[6장 — RAG]
  A: 상품 불량·하자로 인한 반품은 상품 수령일로부터 30일 이내에 신청하셔야 합니다.
  근거: 환불교환정책.pdf 1쪽, 환불교환정책.pdf 2쪽

같은 모델, 같은 질문, 다른 답. 차이를 만든 것은 근거를 넣어 준 것입니다.

그리고 근거가 어디서 왔는지 보여 줍니다. 의심스러우면 그 PDF의 그 쪽을 열어 확인하면 됩니다.


3. answer() 뜯어보기

아래는 code/ch06_rag_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

def answer(question: str) -> dict:
    """질문에 답하고, 어느 문서 몇 쪽을 근거로 삼았는지 함께 돌려준다."""
    docs = retriever.invoke(question)                  # ① 관련 조각 검색
    context = "\n\n".join(d.page_content for d in docs)  # ② 하나의 글로 합침
    body = get_chat(temperature=0).invoke(              # ③ 근거와 함께 질문
        PROMPT.format(context=context, question=question)).text.strip()

    sources, seen = [], set()                              # ④ 출처 정리
    for d in docs:
        key = (d.metadata.get("source", "?"), d.metadata.get("page", 0))
        if key not in seen:
            seen.add(key)
            sources.append(f"{key[0]} {key[1] + 1}쪽")
    return {"answer": body, "sources": sources}

핵심은 검색된 Document 4개가 두 갈래로 갈라진다는 것입니다.

질문retriever (k=4)Document ×4page_content (본문)metadata (출처)"\n\n".join으로 합침source · page를 꺼내 중복 제거PROMPT의 {context}LLM 호출"환불교환정책.pdf 1쪽"{"answer": 본문, "sources": 출처}출처는 LLM이 만든 것이 아닙니다 — 검색된 Document의 metadata를 우리가 꺼내 붙인 것입니다. 그래서 지어낼 수 없습니다.
본문과 출처가 갈라졌다가 마지막에 하나로 합쳐진다 — 출처는 LLM 이 만든 것이 아니다

위 갈래만 LLM에 들어갑니다. 아래 갈래(metadata)는 모델에게 가지 않고 옆으로 빠져 출처 목록이 됩니다. 8절에서 이 아래 갈래가 잘리면 출처가 사라지는 이유가 여기 있습니다.

① 검색

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

docs = retriever.invoke(question)

LLM이 아직 등장하지 않았습니다. 임베딩과 벡터 검색만으로 조각 4개를 찾습니다.

② 조각을 하나로 합치기

아래는 code/ch06_rag_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

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

조각 4개를 빈 줄 하나로 구분해 이어 붙입니다. 이게 프롬프트의 {context} 자리에 들어갑니다.

③ LLM 호출 — 이제 등장

아래는 code/ch06_rag_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

body = get_chat(temperature=0).invoke(
    PROMPT.format(context=context, question=question)).text.strip()

이 장에서 유일한 LLM 호출입니다. 검색은 LLM 없이 했고, 답을 문장으로 만드는 데만 씁니다.

RAG에서 LLM이 하는 일은 생각보다 적습니다. "찾은 내용을 사람이 읽을 문장으로 정리"하는 것뿐입니다. 정확도의 대부분은 검색 단계에서 결정됩니다. 그래서 4절의 세 숫자(chunk_size·overlap·k)가 중요한 것입니다.

④ 출처 정리 — 중복 제거

아래는 code/ch06_rag_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

sources, seen = [], set()
for d in docs:
    key = (d.metadata.get("source", "?"), d.metadata.get("page", 0))
    if key not in seen:
        seen.add(key)
        sources.append(f"{key[0]} {key[1] + 1}쪽")

왜 중복 제거가 필요한가 — 한 쪽에서 조각 여러 개가 검색될 수 있습니다. 그러면 이렇게 되죠.

[중복 제거 없음] 환불교환정책.pdf 1쪽, 환불교환정책.pdf 1쪽, 환불교환정책.pdf 2쪽, 멤버십정책.pdf 1쪽
[중복 제거 후]   환불교환정책.pdf 1쪽, 환불교환정책.pdf 2쪽, 멤버십정책.pdf 1쪽

set 으로 이미 본 것을 기억하면서, 순서는 list 로 유지합니다. 가까운 것부터 나오게요.

page + 1 인 이유

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

f"{key[0]} {key[1] + 1}쪽"

PyPDFLoaderpage0부터 시작합니다. 사람은 1쪽부터 세니까 1을 더합니다.

작지만 이런 게 사용자 경험입니다. "0쪽"이라고 표시하면 사람이 문서를 찾을 때 헷갈립니다.


4. 출처가 좀 이상한데요?

두 번째 질문의 출처를 다시 보세요.

  Q: 스마트워치 Fit 5의 배터리는 얼마나 가나요?
  근거: 제품매뉴얼_스마트워치.pdf 1쪽, 멤버십정책.pdf 1쪽, 제품매뉴얼_로봇청소기.pdf 1쪽

멤버십정책이 왜 나왔을까요? 배터리와 아무 상관 없는데요.

이유는 단순합니다. k=4 라서 무조건 4개를 가져옵니다. 관련 있는 게 1개뿐이어도 나머지 3개를 채웁니다. 3절의 유사도 표에서 본 그대로 — 관련 없는 것도 0.5쯤은 나옵니다.

질문: 스마트워치 Fit 5의 배터리는 얼마나 가나요?이 네 개가 출처로 표시된다제품매뉴얼(배터리)0.81제품매뉴얼(충전)0.62멤버십정책0.55관련이 낮지만 검색된 문서로봇청소기매뉴얼0.52관련이 낮지만 검색된 문서환불교환정책0.51직원핸드북0.50대안 — 유사도 하한 0.6에서 자른다k=4 — 여기서 자른다k는 '몇 개를 가져올지'만 정합니다. 관련성의 최솟값은 정하지 않으므로, 관련 없는 조각도 검색 결과와 출처에 포함될 수 있습니다.
k=4 는 관련 없는 조각도 네 개를 채워 출처에 섞는다

답 자체는 정확합니다. 모델이 관련 있는 조각만 골라 썼기 때문입니다. 그런데 출처 표시는 부정확합니다.

어떻게 개선하나

방법 어떻게
유사도 하한을 둔다 점수가 낮은 조각은 버린다
답에 실제로 쓰인 것만 표시 LLM에게 "몇 번 조각을 썼는지" 함께 답하게 한다
k 를 줄인다 놓칠 위험이 커진다

두 번째가 가장 정확하지만 프롬프트가 복잡해집니다. 이 과정에서는 검색된 것 전부를 정직하게 표시하는 쪽을 택했습니다.

"근거는 이 안에 있습니다" 정도로 이해하면 됩니다. 완벽한 출처 추적은 생각보다 까다로운 문제이고, RAG를 실제로 쓸 때 가장 많이 손보게 되는 부분입니다.


5. 검색만 따로 확인하기

LLM 없이 검색 단계만 보는 것이 디버깅의 출발점입니다.

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

for i, d in enumerate(retriever.invoke("불량 상품 반품은 며칠 이내에 신청해야 하나요?"), 1):
    print(f"  [{i}] {d.metadata['source']} {d.metadata['page']+1}쪽")
    print(f"      {d.page_content[:100].replace(chr(10), ' ')}...")

답이 이상할 때 여기부터 봅니다.

답이 이상하다검색된 조각에 답이 있나?없다검색 문제 — chunk_size · k · 문서 자체있다프롬프트 문제 — 근거 제한을 강화검색 문제인지 프롬프트 문제인지 구분하면 수정할 곳을 찾을 수 있습니다.
답이 이상할 때는 검색 문제인지 프롬프트 문제인지부터 가른다

5장에서 out["messages"] 를 보라고 했던 것과 같은 이야기입니다. 어느 단계에서 틀렸는지부터 가르는 것이 디버깅입니다.


6. 배터리 답을 기억해 두세요

  Q: 스마트워치 Fit 5의 배터리는 얼마나 가나요?
  A: 일반 사용 시 약 7일, GPS 사용 시 약 18시간 지속됩니다.

일반 7일 / GPS 18시간.

5장에서 본 리뷰를 다시 떠올려 보세요.

2점  GPS를 켜면 하루도 못 갑니다. 일주일 간다는 설명과 너무 달라요

두 사실이 나란히 놓이면 무엇이 보이나요?

  • 제품은 사양대로 동작하고 있습니다 (GPS 18시간 ≈ 하루 미만)
  • 고객은 "일주일"만 보고 샀습니다
  • 즉, 제품 결함이 아니라 표기 문제입니다

이 판단은 CSV만 봐서도, 문서만 봐서도 안 나옵니다. 8장에서 이 둘을 합칩니다.


스스로 해 보기

  1. 위의 검색 확인 코드를 실행해 어떤 조각이 검색되는지 보세요.
  2. 문서에 있는 다른 내용을 물어 보세요.
    python for q in ["교환 배송비는 누가 부담하나요?", "골드 등급의 적립률은 몇 퍼센트인가요?", "로봇청소기 필터는 언제 교체하나요?"]: r = answer(q) print(f"\n Q: {q}\n A: {r['answer']}\n 근거: {', '.join(r['sources'])}")
  3. data/docs/ 의 PDF를 직접 열어, 답이 정말 그렇게 적혀 있는지 대조해 보세요.
  4. 문서에 없는 질문 3개를 만들어 던지고, 전부 "찾을 수 없다"고 하는지 확인하세요.

3번이 중요합니다. 출처가 있다는 것은 확인할 수 있다는 뜻이고, 확인해 보는 것이 이 기능의 목적입니다.


핵심 정리

  • 1장의 "3개월"이 6장에서 "30일"이 됐습니다. 차이를 만든 것은 근거를 넣어 준 것입니다.
  • answer() 는 네 단계 — 검색 → 조각 합치기 → LLM 호출 → 출처 정리.
  • 검색에는 LLM이 안 쓰입니다. LLM은 "찾은 것을 문장으로 정리"하는 데만 씁니다.
  • 정확도의 대부분은 검색 단계에서 결정됩니다. chunk_size·overlap·k 가 중요한 이유입니다.
  • 출처는 set 으로 중복 제거하고, page + 1 로 사람이 세는 방식에 맞춥니다.
  • 출처 표시는 완벽하지 않습니다. k 개를 무조건 가져오므로 관련 없는 것도 섞입니다. 실무에서 가장 많이 손보는 부분입니다.
  • 디버깅은 검색 결과부터 봅니다. 조각에 답이 있는지 없는지로 원인이 갈립니다.
  • 배터리 일반 7일 / GPS 18시간 — 5장의 리뷰 불만과 나란히 놓으면 표기 문제라는 판단이 나옵니다. 8장에서 합칩니다.
← 이전 절06. 지어내지 않게 만드는 한 문장다음 절 →08. 검색을 도구로 만들면 다른 도구와 나란히 선다