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

따라하기 — 정책 PDF를 잘라 벡터로 만들기

한 줄 요약

code/ch06_rag_agent.py 를 만들고, PDF 5종 → 8쪽 → 15조각 → 벡터 인덱스까지 만듭니다. 처음 한 번만 시간이 걸립니다.


1. 파일 만들기

code 폴더에 ch06_rag_agent.py 를 만들고 아래를 전부 붙여 넣습니다.

새 파일을 만드세요agentic_ai/code/ch06_rag_agent.py
VS Code 왼쪽 탐색기에서 code 폴더를 오른쪽 클릭 → New File → 파일명 ch06_rag_agent.py 입력 → 아래를 전부 복사해 붙여 넣고 저장(Ctrl+S / Cmd+S).

# -*- coding: utf-8 -*-
"""6장 — 에이전트 ④: RAG 문서검색 (임베딩 + 벡터검색)

실행:  python code/ch06_rag_agent.py

1장의 세 번째 질문을 해결합니다.
  "승승장구몰에서 불량 상품 반품은 며칠 이내에 신청해야 하나요?"
LLM 은 일반적인 전자상거래법을 우리 회사 규정인 양 답했습니다. 이제 사내 문서를 근거로 답합니다.

RAG 5단계
  ① 로드   PDF 를 읽어 들인다
  ② 청킹   긴 글을 검색하기 좋은 조각으로 자른다
  ③ 임베딩 조각을 '의미를 담은 숫자'로 바꾼다
  ④ 저장   벡터를 인덱스에 담아 파일로 보관한다   ← 한 번만 하면 된다
  ⑤ 검색   질문과 가까운 조각을 찾아 근거로 쓴다
"""
import sys
import pathlib

sys.path.append(str(pathlib.Path(__file__).resolve().parent))

from common import get_chat, get_embeddings, DOCS, DATA   # 공통 설정을 먼저 읽는다
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.tools.retriever import create_retriever_tool

INDEX_DIR = DATA / "policy_index"       # 만들어 둔 인덱스를 보관할 폴더
PDF_FILES = ["환불교환정책.pdf", "멤버십정책.pdf", "직원핸드북.pdf",
             "제품매뉴얼_스마트워치.pdf", "제품매뉴얼_로봇청소기.pdf"]


def build_index():
    """PDF 를 읽어 벡터 인덱스를 만든다. 이미 만들어 뒀으면 불러오기만 한다.

    [중요] 임베딩은 시간과 비용이 드는 작업이다.
    한 번 만들어 파일로 저장해 두면, 다음부터는 불러오기만 하면 된다.
    """
    emb = get_embeddings()

    if INDEX_DIR.exists():
        print(f"  기존 인덱스를 불러옵니다 ({INDEX_DIR.name}) — 임베딩을 다시 하지 않습니다")
        # 내가 직접 만든 인덱스만 불러와야 한다(남이 준 파일은 위험할 수 있음)
        return FAISS.load_local(str(INDEX_DIR), emb, allow_dangerous_deserialization=True)

    print("  인덱스를 새로 만듭니다 — 처음 한 번만 시간이 걸립니다")
    docs = []
    for name in PDF_FILES:                                   # ① 로드
        path = DOCS / name
        if not path.exists():
            print(f"    [건너뜀] {name} 없음")
            continue
        pages = PyPDFLoader(str(path)).load()
        for d in pages:
            d.metadata["source"] = name                      # 출처를 파일명으로 정리
        docs += pages
    print(f"    문서 {len(docs)}쪽 로드")

    # ② 청킹 — 500자씩 자르되 50자를 겹치게 해서 문장이 잘려도 뜻이 남게 한다
    chunks = RecursiveCharacterTextSplitter(
        chunk_size=500, chunk_overlap=50).split_documents(docs)
    print(f"    조각 {len(chunks)}개로 분할")

    vs = FAISS.from_documents(chunks, emb)                   # ③④ 임베딩 + 인덱스
    vs.save_local(str(INDEX_DIR))
    print(f"    인덱스 저장 완료 → {INDEX_DIR}")
    return vs


vectorstore = build_index()
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})   # ⑤ 가까운 조각 4개

PROMPT = (
    "너는 승승장구몰의 규정 안내 담당자다.\n"
    "아래 [문서]에 적힌 내용만 근거로 답한다.\n"
    "문서에 없는 내용은 절대 지어내지 말고 '사내 문서에서 찾을 수 없습니다'라고 답한다.\n\n"
    "[문서]\n{context}\n\n[질문] {question}\n\n[답변]"
)


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}


# 8장에서 다른 도구들과 나란히 쓰기 위해 '도구'로도 만들어 둔다.
# 도구로 쓸 때는 "사양과 환불 기간"처럼 두 가지를 한 번에 묻는 일이 생기므로,
# 조각을 조금 더 넉넉히(6개) 가져와야 둘 다 걸린다.
policy_tool = create_retriever_tool(
    vectorstore.as_retriever(search_kwargs={"k": 6}),
    "policy_search",
    "승승장구몰의 사내 규정과 제품 매뉴얼을 검색한다. "
    "환불·교환·반품 기간, 멤버십 등급과 적립률, 제품 사양 같은 질문에 사용한다.",
)


if __name__ == "__main__":
    print("=" * 62)
    print("[1] 인덱스 준비")

    print("\n" + "=" * 62)
    print("[2] 사내 문서를 근거로 답하기")
    questions = [
        "불량 상품 반품은 며칠 이내에 신청해야 하나요?",
        "스마트워치 Fit 5의 배터리는 얼마나 가나요?",
        "VIP 등급이 되려면 얼마를 구매해야 하나요?",
    ]
    for q in questions:
        r = answer(q)
        print(f"\n  Q: {q}")
        print(f"  A: {r['answer']}")
        print(f"  근거: {', '.join(r['sources'])}")

    print("\n" + "=" * 62)
    print("[3] 문서에 없는 것을 물으면")
    r = answer("승승장구몰 해외 배송은 며칠 걸리나요?")
    print(f"  A: {r['answer']}")

    print("\n" + "=" * 62)
    print("관찰: 답이 어느 문서 몇 쪽에서 나왔는지 함께 보여 줍니다.")
    print("      문서에 없으면 지어내지 않고 '찾을 수 없다'고 답합니다.")

2. 첫 실행 — 인덱스 만들기

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

python code/ch06_rag_agent.py

화면은 이렇게 시작합니다.

  인덱스를 새로 만듭니다 — 처음 한 번만 시간이 걸립니다
    문서 8쪽 로드
    조각 15개로 분할
    인덱스 저장 완료 → .../agentic_ai/data/policy_index
==============================================================
[1] 인덱스 준비

==============================================================
[2] 사내 문서를 근거로 답하기
  ...

맨 위 네 줄이 이 절의 결과물입니다. 수십 초 걸립니다. 조각 15개를 각각 임베딩 API로 보내야 하니까요.

[1] 인덱스 준비 아래가 비어 있는 게 정상입니다. 인덱스 만들기는 vectorstore = build_index() 라는 모듈 맨 위 줄에서 일어나는데, 그 줄은 if __name__ == "__main__": 블록 바깥에 있습니다. 파이썬은 파일을 위에서부터 읽으므로 [1] 헤더가 찍히기 전에 이미 끝나 있습니다. 3~5장의 [1] 블록과 달리 헤더 아래에 아무것도 안 나오는 이유가 이것입니다.

끝나면 폴더가 하나 생깁니다.

data/policy_index/index.faiss46 KB — 벡터 15개index.pkl16 KB — 원문 텍스트와 metadata둘 다 있어야 불러올 수 있습니다. 하나만 옮기면 열리지 않습니다.
저장된 인덱스는 파일 두 개다 — 둘 다 있어야 열린다

3. 두 번째 실행부터

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

python code/ch06_rag_agent.py
  기존 인덱스를 불러옵니다 (policy_index) — 임베딩을 다시 하지 않습니다

즉시 넘어갑니다. 이유는 다음 절에서 자세히 다룹니다.


4. build_index() 뜯어보기

파일이 없어도 죽지 않는다

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

path = DOCS / name
if not path.exists():
    print(f"    [건너뜀] {name} 없음")
    continue

PDF 하나가 없어도 나머지로 진행합니다. 3장부터 반복된 원칙입니다 — 부분 실패가 전체를 무너뜨리지 않게.

출처 정리

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

for d in pages:
    d.metadata["source"] = name

앞 절에서 본 그대로입니다. 전체 경로 대신 파일명만 남깁니다.

임베딩과 인덱스가 한 줄

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

vs = FAISS.from_documents(chunks, emb)                   # ③④ 임베딩 + 인덱스

이 한 줄 안에서 두 가지가 일어납니다.

① 조각 15개를 각각 임베딩 모델에 보내 벡터로 바꾼다   ← 시간이 걸리는 부분
② 그 벡터들을 검색 가능한 인덱스로 조립한다

API 호출 15번이 여기서 일어납니다.

파일로 저장

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

vs.save_local(str(INDEX_DIR))

메모리에 있는 인덱스를 디스크에 씁니다. 다음 절의 주제입니다.


5. retriever — 검색기

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

retriever = vectorstore.as_retriever(search_kwargs={"k": 4})   # ⑤ 가까운 조각 4개

검색기(retriever) 는 "질문을 주면 관련 조각을 돌려주는 것"입니다.

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

docs = retriever.invoke("불량 상품 반품은 며칠 이내인가요?")
print(len(docs))                    # 4
print(docs[0].page_content[:100])
print(docs[0].metadata)             # {'source': '환불교환정책.pdf', 'page': 0}

k — 몇 개를 가져올까

k=4 는 "가장 가까운 4개"라는 뜻입니다.

k 결과
작다 (1~2) 놓칠 수 있다. 답의 근거가 부족하다
적당하다 (3~5) 이 과정의 선택
크다 (10+) 관련 없는 조각이 섞여 답이 흐려진다. 토큰이 는다

우리 문서는 15조각뿐이라 k=4 면 전체의 약 4분의 1을 봅니다. 문서가 많아지면 더 올려야 할 수도 있습니다.

3절에서 정리한 관계를 다시 보세요 — 인덱스는 창고, 검색기는 창구입니다. as_retriever(...) 가 창고에 창구를 하나 붙이는 일입니다.

코드 아래쪽에 k=6 짜리가 하나 더 있는 것을 눈여겨보세요. 8장에서 도구로 쓸 때는 "사양과 환불 기간"처럼 두 주제를 한 번에 묻는 일이 생겨서, 조금 더 넉넉히 가져와야 둘 다 걸립니다. 같은 인덱스에서 검색기를 여러 개 만들 수 있습니다.


6. 지금까지 나온 숫자 세 개

이 장에서 사람이 정한 값이 셋입니다.

무엇 맞바꾸는 것
chunk_size=500 조각 크기 검색 정확도 ↔ 충분한 근거 확보
chunk_overlap=50 겹치는 양 안전망 ↔ 중복
k=4 가져올 조각 수 근거를 빠짐없이 찾기 ↔ 관련 없는 내용 줄이기

정답이 없습니다. 문서 종류와 질문 종류에 따라 달라집니다.

RAG의 품질은 대부분 이 세 숫자와 문서 정리 상태에서 결정됩니다. 모델을 더 좋은 것으로 바꾸는 것보다 이쪽을 손보는 게 효과가 큽니다. 그리고 이건 전부 사람의 일입니다.


스스로 해 보기

  1. 검색기만 따로 써 보세요. LLM 없이 검색만 확인하는 연습입니다.
    python for d in retriever.invoke("반품 기간"): print(f" [{d.metadata['source']} {d.metadata['page']+1}쪽] {d.page_content[:60]}...")
  2. k110 으로 바꿔 결과가 어떻게 달라지는지 보세요.
  3. chunk_size 를 바꿔 다시 만들어 보세요. data/policy_index 폴더를 먼저 지워야 새로 만들어집니다.
  4. 검색되는 조각의 page_content 를 실제로 읽어 보세요. PDF에서 뽑은 텍스트가 어떻게 생겼는지 보는 것이 목적입니다.

핵심 정리

  • PDF 5종 → 8쪽 → 15조각 → 벡터 인덱스. 첫 실행에만 수십 초 걸립니다.
  • FAISS.from_documents(chunks, emb) 한 줄에서 임베딩(API 호출 15번)과 인덱스 조립이 함께 일어납니다.
  • vs.save_local(...) 로 파일에 저장하면 index.faissindex.pkl 이 생깁니다.
  • retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) — 질문을 주면 가까운 조각 4개를 돌려줍니다.
  • 같은 인덱스에서 검색기를 여러 개 만들 수 있습니다. 8장용은 k=6 입니다.
  • 사람이 정한 값 셋 — chunk_size · chunk_overlap · k. 모두 장단점을 따져 정해야 하며 정답이 없습니다.
  • RAG 품질은 대부분 이 세 값과 문서 정리에서 결정됩니다. 모델을 바꾸는 것보다 효과가 큽니다.
← 이전 절03. 의미가 비슷하면 숫자도 비슷하다 임베딩다음 절 →05. 따라하기 인덱스를 저장하고 다시 불러오기