따라하기 — 정책 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]블록과 달리 헤더 아래에 아무것도 안 나오는 이유가 이것입니다.
끝나면 폴더가 하나 생깁니다.
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의 품질은 대부분 이 세 숫자와 문서 정리 상태에서 결정됩니다. 모델을 더 좋은 것으로 바꾸는 것보다 이쪽을 손보는 게 효과가 큽니다. 그리고 이건 전부 사람의 일입니다.
스스로 해 보기
- 검색기만 따로 써 보세요. LLM 없이 검색만 확인하는 연습입니다.
python for d in retriever.invoke("반품 기간"): print(f" [{d.metadata['source']} {d.metadata['page']+1}쪽] {d.page_content[:60]}...") k를1과10으로 바꿔 결과가 어떻게 달라지는지 보세요.chunk_size를 바꿔 다시 만들어 보세요.data/policy_index폴더를 먼저 지워야 새로 만들어집니다.- 검색되는 조각의
page_content를 실제로 읽어 보세요. PDF에서 뽑은 텍스트가 어떻게 생겼는지 보는 것이 목적입니다.
핵심 정리
- PDF 5종 → 8쪽 → 15조각 → 벡터 인덱스. 첫 실행에만 수십 초 걸립니다.
FAISS.from_documents(chunks, emb)한 줄에서 임베딩(API 호출 15번)과 인덱스 조립이 함께 일어납니다.vs.save_local(...)로 파일에 저장하면index.faiss와index.pkl이 생깁니다.retriever = vectorstore.as_retriever(search_kwargs={"k": 4})— 질문을 주면 가까운 조각 4개를 돌려줍니다.- 같은 인덱스에서 검색기를 여러 개 만들 수 있습니다. 8장용은
k=6입니다. - 사람이 정한 값 셋 —
chunk_size·chunk_overlap·k. 모두 장단점을 따져 정해야 하며 정답이 없습니다. - RAG 품질은 대부분 이 세 값과 문서 정리에서 결정됩니다. 모델을 바꾸는 것보다 효과가 큽니다.