RAG2026-5일8차시 · 첫 번째 RAG 실습10 / 19← 이전목차다음 →MetaContents

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️⃣ 실습 ③ — 답변 생성 붙이기」과 이어집니다.

Python
# ============================================================
# 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️⃣ 실습 ③ — 답변 생성 붙이기」과 이어집니다.

Python
# ============================================================
# 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: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절8차시 · 6. 실습 ② — 검색만 먼저 확인하기다음 절 →8차시 · 8. 실행 결과 확인