8차시. 첫 번째 RAG 실습
7. 실습 ③ — 답변 생성 붙이기
출처: 원본 PDF 08차시_첫번째RAG실습.pdf 13~14쪽 · 쪽 그림 13쪽 14쪽
검색이 잘 되는 걸 확인했으면, 이제 LLM을 붙입니다.
📝 src/first_rag.py — 완성본
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from prepare import prepare_chunks
load_dotenv()
# ===== 인덱싱 (딱 한 번) =====
chunks = prepare_chunks("data/manual.pdf")
emb = OpenAIEmbeddings(model="text-embedding-3-small")
store = FAISS.from_documents(chunks, emb)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
print("✓ 준비 완료\n")
# ===== 질의 (질문할 때마다) =====
def build_context(docs):
# 검색 결과를 번호 붙여 하나의 문자열로
parts = []
for i, d in enumerate(docs, 1):
parts.append(f"[{i}] ({d.metadata['filename']} "
f"p.{d.metadata['page_no']})\n{d.page_content}")
return "\n\n".join(parts)
def ask(question, k=3):
# ④ 검색
found = store.similarity_search(question, k=k)
if not found:
print("관련 자료를 찾지 못했습니다.")
return
# ⑤ 근거 기반 프롬프트로 답변 생성
prompt = (
"아래 자료만 근거로 답하세요.\n"
"자료에 없는 내용은 '자료에서 확인할 수 없습니다'라고 답하세요.\n"
"추측하지 마세요.\n\n"
f"[자료]\n{build_context(found)}\n\n"
f"[질문] {question}"
)
answer = llm.invoke(prompt).content
print("Q:", question)
print("A:", answer)
print("\n📎 참고한 자료:")
for d in found:
print(f" · {d.metadata['filename']} "
f"{d.metadata['page_no']}페이지")
print("-" * 55)
if __name__ == "__main__":
ask("환불은 며칠 이내에 신청해야 하나요?")실습 코드 · 08/first_rag.py
실습 코드: 실습/src/08/first_rag.py · 위의 원본 PDF 8차시 13~14쪽 「7️⃣ 실습 ③ — 답변 생성 붙이기」과 이어집니다.
# ============================================================
# 8차시 | PPT: 08차시_첫번째RAG실습.pptx
# 역할: 설명서 조각을 벡터로 저장한 뒤, 질문으로 근거를 찾아 답을 생성
# 입력: ../../data/manual.pdf, 질문 "환불은 며칠 이내에 신청해야 하나요?"
# 출력: 분할 개수, 인덱싱 진행 문장, 질문, 답변, 파일명과 쪽 번호
# 흐름:
# manual.pdf -> prepare_chuncks (chunk)
# -> OpenAIEmbeddings (embedding, vector)
# -> FAISS 저장소
# -> similarity_search -> prompt -> ChatOpenAI -> print
# ============================================================
# chunk: 문서를 잘라 낸 조각 (Document)
# embedding: 문장을 숫자 목록으로 바꾼 값. 뜻이 비슷하면 숫자도 가깝습니다.
# vector: 그 숫자 목록. FAISS가 이 목록을 저장하고 비슷한 것을 찾습니다.
# FAISS: 벡터를 저장하고, 질문과 가까운 조각을 빠르게 찾는 라이브러리
# prompt: 모델에게 주는 지시문. 여기에는 찾은 자료와 질문이 들어갑니다.
# metadata: 파일명, 쪽 번호처럼 조각에 붙은 부가 정보
from dotenv import load_dotenv
# FAISS가 없다는 오류가 나면 pip install faiss-cpu 로 설치합니다.
from langchain_community.vectorstores import FAISS
import os
import sys
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
# 7차시 폴더를 import 경로에 넣어 prepare.py를 찾게 합니다.
CURRENT_DIR = os.path.dirname(__file__)
INGEST_DIR = os.path.join(CURRENT_DIR, "..", "07")
sys.path.insert(0, INGEST_DIR)
from prepare import prepare_chuncks
# 환경 변수에서 API 키를 읽습니다. 이 주석본을 만들 때는 호출하지 않았습니다.
load_dotenv()
# ===== 인덱싱 단계: 프로그램이 뜰 때 딱 한 번 =====
# 6차시 읽기 + 7차시 분할을 이 한 줄로 다시 씁니다.
chunks = prepare_chuncks("../../data/manual.pdf")
print(f"문서 분할 완료 : {len(chunks)}개")
# text-embedding-3-small 모델로 각 chunk를 벡터로 바꿉니다. 네트워크 호출입니다.
emb = OpenAIEmbeddings(model="text-embedding-3-small")
print("임베딩 중...(조금 걸립니다)")
# 조각마다 임베딩을 구해 FAISS 색인에 넣습니다. 비용이 드는 곳이 여기입니다.
store = FAISS.from_documents(chunks, emb)
print("인덱싱 완료")
# 답변을 생성할 LLM 준비
# temperature=0이면 같은 질문에 되도록 같은 답을 내고, 지어내는 일이 줄어듭니다.
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0
)
print("준비 완료\n")
# ===== 질의 단계: 질문할 때마다 아래 두 함수만 돕니다 =====
def build_context(docs):
# 찾은 조각마다 번호, 파일명, 쪽 번호, 본문을 이어 하나의 자료 문자열로 만듭니다.
parts = []
# enumerate(docs, 1)은 번호를 1부터 셉니다. 사람이 읽는 [1], [2], [3]이 됩니다.
for i, doc in enumerate(docs, 1):
text = (
f"[{i}] "
f"({doc.metadata['filename']} "
f"p.{doc.metadata['page_no']})\n"
f"{doc.page_content}"
)
parts.append(text)
# 조각 사이에 빈 줄을 하나 두어 모델이 조각의 경계를 알아보게 합니다.
context = "\n\n".join(parts)
return context
def ask(question, k=3):
# 질문과 가까운 조각 k개를 찾습니다. 질문도 embedding으로 바뀝니다.
found = store.similarity_search(question, k=k)
if not found:
print("관련 자료를 찾지 못했습니다.")
return
context = build_context(found)
# 자료 밖 내용은 추측하지 말라고 지시하는 prompt입니다.
# 질문은 맨 뒤에 둡니다. 모델은 뒤쪽 내용에 더 강하게 반응합니다.
prompt = (
"아래 자료만 근거로 답하세요.\n"
"자료에 없는 내용은 '자료에서 확인할 수 없습니다'라고 답하세요.\n"
"추측하지 마세요.\n\n"
f"[자료]\n{context}\n\n"
f"[질문] {question}"
)
# invoke는 모델 응답 객체를 돌려줍니다. 글자는 .content에 들어 있습니다.
response = llm.invoke(prompt)
answer = response.content
print("질문 : ", question)
print("답변 : ", answer)
# filename은 ingest에서 함수 객체로 들어 있으므로, 함수 표시와 쪽 번호가 붙습니다.
for doc in found:
print(
f"{doc.metadata['filename']}"
f"{doc.metadata['page_no']}"
)
if __name__ == "__main__":
ask("환불은 며칠 이내에 신청해야 하나요?")실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/08
python first_rag.py
예시 출력 (API 키 필요, 실행하지 않음)
3쪽 로딩 완료 (총 1529 자)
3쪽 로딩 완료 (총 1529 자)
11 개
문서 분할 완료 : 11개
임베딩 중...(조금 걸립니다)
인덱싱 완료
준비 완료
질문 : 환불은 며칠 이내에 신청해야 하나요?
답변 : 상품 수령 후 7일 이내에 환불을 신청하실 수 있습니다.
<function basename at 0x...>2
<function basename at 0x...>2
<function basename at 0x...>2
(청크 11개는 같은 prepare_chuncks를 API 없이 실행한 결과입니다.
답변 문장은 manual.pdf 2쪽 환불 규정입니다.
출처 줄은 filename이 함수 객체라 주소가 실행마다 달라지고, k=3이라 3줄입니다.
유사도 점수는 이 파일이 출력하지 않으며, 기록된 점수도 없습니다.)코드 해설
설명서 조각을 벡터로 저장한 뒤, 질문으로 근거를 찾아 답을 생성입니다. 입력은 ../../data/manual.pdf, 질문 "환불은 며칠 이내에 신청해야 하나요?"이고, 출력은 분할 개수, 인덱싱 진행 문장, 질문, 답변, 파일명과 쪽 번호입니다.
주석에 적힌 흐름입니다.
manual.pdf -> prepare_chuncks (chunk)
-> OpenAIEmbeddings (embedding, vector)
-> FAISS 저장소
-> similarity_search -> prompt -> ChatOpenAI -> print코드 앞에서 고정해 두는 말입니다.
- chunk: 문서를 잘라 낸 조각 (Document)
- embedding: 문장을 숫자 목록으로 바꾼 값. 뜻이 비슷하면 숫자도 가깝습니다.
- vector: 그 숫자 목록. FAISS가 이 목록을 저장하고 비슷한 것을 찾습니다.
- FAISS: 벡터를 저장하고, 질문과 가까운 조각을 빠르게 찾는 라이브러리
- prompt: 모델에게 주는 지시문. 여기에는 찾은 자료와 질문이 들어갑니다.
- metadata: 파일명, 쪽 번호처럼 조각에 붙은 부가 정보
함수와 클래스로 나누어 읽습니다.
build_context: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.ask: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.
실습 코드 · 08/test_rag.py
실습 코드: 실습/src/08/test_rag.py · 위의 원본 PDF 8차시 13~14쪽 「7️⃣ 실습 ③ — 답변 생성 붙이기」과 이어집니다.
# ============================================================
# 8차시 | PPT: 08차시_첫번째RAG실습.pptx
# 역할: 같은 RAG 절차로 질문 4개의 답만 모아 출력
# 입력: ../../data/manual.pdf, TESTS 질문 4개
# 출력: 분할 개수, 인덱싱 문장, 질문 번호와 답변
# 흐름:
# manual.pdf -> chunk -> embedding -> FAISS
# -> 질문마다 similarity_search -> prompt -> 답변 print
# ============================================================
# chunk: 잘라 낸 문서 조각. embedding: 문장을 숫자 목록(vector)으로 바꾼 값
# vector: embedding이 만든 숫자 목록. FAISS: 그 목록에서 비슷한 조각을 찾는 저장소
# prompt: 찾은 자료와 질문으로 만든 지시문
from dotenv import load_dotenv
from langchain_community.vectorstores import FAISS
import os
import sys
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
# first_rag.py와 같이 07 폴더의 prepare.py를 가져옵니다.
CURRENT_DIR = os.path.dirname(__file__)
INGEST_DIR = os.path.join(CURRENT_DIR, "..", "07")
sys.path.insert(0, INGEST_DIR)
from prepare import prepare_chuncks
# .env의 OPENAI_API_KEY를 읽습니다. 없으면 아래 임베딩에서 AuthenticationError가 납니다.
load_dotenv()
# ===== 인덱싱: 질문 4개를 묻더라도 여기는 한 번만 =====
chunks = prepare_chuncks("../../data/manual.pdf")
print(f"문서 분할 완료 : {len(chunks)}개")
emb = OpenAIEmbeddings(model="text-embedding-3-small")
print("임베딩 중...(조금 걸립니다)")
store = FAISS.from_documents(chunks, emb)
print("인덱싱 완료")
# 답변을 생성할 LLM 준비
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0
)
print("준비 완료\n")
# build_context와 ask는 first_rag.py와 같습니다. 다른 점은 ask가 출력 대신 값을 돌려준다는 것입니다.
def build_context(docs):
parts = []
for i, doc in enumerate(docs, 1):
text = (
f"[{i}] "
f"({doc.metadata['filename']} "
f"p.{doc.metadata['page_no']})\n"
f"{doc.page_content}"
)
parts.append(text)
context = "\n\n".join(parts)
return context
def ask(question, k=3):
# 질문과 가까운 조각 k개. 검색만 할 때는 LLM 비용이 들지 않습니다.
found = store.similarity_search(question, k=k)
# 이 경우 None을 돌려주므로, 아래 반복문의 두 값 받기에서 오류가 납니다.
if not found:
print("관련 자료를 찾지 못했습니다.")
return
context = build_context(found)
# 근거만 쓰라는 줄과 없으면 없다고 하라는 줄, 이 두 줄이 오늘의 핵심입니다.
prompt = (
"아래 자료만 근거로 답하세요.\n"
"자료에 없는 내용은 '자료에서 확인할 수 없습니다'라고 답하세요.\n"
"추측하지 마세요.\n\n"
f"[자료]\n{context}\n\n"
f"[질문] {question}"
)
response = llm.invoke(prompt)
answer = response.content
# print("질문 : ", question)
# print("답변 : ", answer)
# for doc in found:
# print(
# f"{doc.metadata['filename']}"
# f"{doc.metadata['page_no']}"
# )
# 이 파일은 답과 찾은 조각만 돌려주고, 출력은 아래 반복문이 합니다.
return answer, found
# 수업 자료 9절의 네 가지 테스트입니다: ① 정상 ② 없는 내용 ③ 표현 변경 ④ 복합 질문
TESTS = [
"환불은 며칠 이내에 신청해야 하나요?",
"우리 회사 대표이사 이름이 뭔가요?",
"반품 절차를 알려주세요",
"환불과 교환은 어떻게 다른가요?",
]
# ②번에 "자료에서 확인할 수 없습니다"가 나오는지 꼭 확인합니다.
for i, question in enumerate(TESTS, 1):
print(f"질문 {i}: {question}")
answer, found = ask(question)
print(f"답변 {i}: ", answer)
print("=" * 50)실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)
===== 실행 결과 샘플 =====
실행: cd 실습/src/08
python test_rag.py
예시 출력 (API 키 필요, 실행하지 않음)
3쪽 로딩 완료 (총 1529 자)
3쪽 로딩 완료 (총 1529 자)
11 개
문서 분할 완료 : 11개
임베딩 중...(조금 걸립니다)
인덱싱 완료
준비 완료
질문 1: 환불은 며칠 이내에 신청해야 하나요?
답변 1: 상품 수령 후 7일 이내에 환불을 신청하실 수 있습니다.
==================================================
질문 2: 우리 회사 대표이사 이름이 뭔가요?
답변 2: 자료에서 확인할 수 없습니다
==================================================
질문 3: 반품 절차를 알려주세요
답변 3: 자료에는 반품 절차라는 항목이 없고, 환불은 수령 후 7일, 교환은 불량 시 30일입니다.
==================================================
질문 4: 환불과 교환은 어떻게 다른가요?
답변 4: 환불은 상품 수령 후 7일 이내이며 단순 변심의 왕복 배송비는 고객 부담입니다. 교환은 상품 불량일 때 30일 이내이며 배송비는 당사가 부담합니다.
==================================================
(11개는 API 없이 확인한 prepare_chuncks 결과입니다.
답변은 manual.pdf에 있는 문장만 사용했습니다. 대표이사 이름은 문서에 없습니다.
유사도 점수는 이 파일이 출력하지 않습니다.)코드 해설
같은 RAG 절차로 질문 4개의 답만 모아 출력입니다. 입력은 ../../data/manual.pdf, TESTS 질문 4개이고, 출력은 분할 개수, 인덱싱 문장, 질문 번호와 답변입니다.
주석에 적힌 흐름입니다.
manual.pdf -> chunk -> embedding -> FAISS
-> 질문마다 similarity_search -> prompt -> 답변 print코드 앞에서 고정해 두는 말입니다.
- chunk: 잘라 낸 문서 조각. embedding: 문장을 숫자 목록(vector)으로 바꾼 값
- vector: embedding이 만든 숫자 목록. FAISS: 그 목록에서 비슷한 조각을 찾는 저장소
- prompt: 찾은 자료와 질문으로 만든 지시문
함수와 클래스로 나누어 읽습니다.
build_context: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.ask: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.