RAG2026-5일11차시 · Retriever 기초 실습14 / 18← 이전목차다음 →MetaContents

11차시. Retriever 기초 실습

실습 1 — Retriever 기초 실습 실습 따라 하기

관련 수업 자료: 11차시 「4. 실습 ① — Retriever 만들고 비교하기」(9~10쪽), 11차시 「8. 오늘의 완성 코드」(17쪽) · 실습 코드 11/retriever_test.py, 11/retriever.py, 11/retriever_fast.py, 11/retriever_test_fast.py

이 실습은 교안 해설(11_Retriever기초실습.md)의 실습 순서를 옮긴 것입니다. OpenAI 임베딩이나 채팅 모델을 부르는 파일은 .env 에 API 키가 있어야 돌아갑니다. 키가 없으면 실행 결과 샘플로 출력 모양을 확인합니다.

수업 자료와 이어지는 곳

실습 코드원본 PDF이어지는 내용
11/retriever_test.py9쪽11차시 「4. 실습 ① — Retriever 만들고 비교하기」 아래에 실었습니다
11/retriever.py17쪽11차시 「8. 오늘의 완성 코드」 아래에 실었습니다
11/retriever_fast.py-원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다
11/retriever_test_fast.py9쪽11차시 「4. 실습 ① — Retriever 만들고 비교하기」 아래에 실었습니다

실행 흐름

출력
질문 → search type
search type → 가까운 k개  (similarity)
search type → 가깝고 덜 겹치는 k개  (mmr)
가까운 k개 → MIN SCORE
가깝고 덜 겹치는 k개 → MIN SCORE
MIN SCORE → 번호와 출처를 붙인 문맥

실습 순서

retriever_test.py로 k를 바꾸고, retriever.py에서 similarity와 MMR 결과를 비교합니다. retriever_fast.py와 retriever_test_fast.py는 같은 검색을 더 짧은 경로로 부릅니다.

스스로 확인

  • Retriever를 표준 함수로 설명할 수 있는가?
  • k를 늘리면 노이즈가 늘어나는 이유를 아는가?
  • 문맥에 번호를 붙이는 이유를 인용과 연결할 수 있는가?

이어지는 실습 코드

실습 코드 · 11/retriever_fast.py

실습 코드: 실습/src/11/retriever_fast.py · 원본 PDF 에 같은 이름의 쪽이 없어 여기에 실었습니다.

Python
'''
번외편
이 파일은 AI 적용 사례를 위한 RAG(Retrieval-Augmented Generation) 프로젝트의 src/11/retriever_fast.py입니다.
속도를 높이기 위해 FAISS 인덱스를 로드하고, 질문에 대한 유사 문서를 검색하는 기능을 제공합니다.

'''
# ============================================================
# 11차시 | PPT: 11차시_Retriever기초실습.pptx
# 역할: 만들어 둔 FAISS 인덱스를 찾아 열고, 질문과 가까운 조각 3개를 출력
# 입력: src/11/faiss_index 또는 src/10/faiss_index
# 출력: 로드 시간, 검색 시간, 질문, 결과 개수, 순위·쪽·미리보기
# 흐름:
#   index.faiss가 있는 폴더 -> FAISS.load_local
#     -> similarity_search -> print
# ============================================================
# FAISS: 벡터(숫자 목록) 인덱스. embedding은 질문 문장을 그 숫자로 바꿉니다.
# vector: embedding 결과인 숫자 목록
# metadata: page_no, page, source 같은 부가 정보

# Path는 경로를 / 로 이어 붙일 수 있는 경로 객체입니다.
from pathlib import Path
from time import perf_counter

# .env를 읽는 도구, FAISS 저장소, OpenAI 임베딩을 가져옵니다.
from dotenv import load_dotenv
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# resolve()로 절대 경로를 만든 뒤 parent로 이 파일이 있는 폴더(src/11)를 얻습니다.
CURRENT_DIR = Path(__file__).resolve().parent
# 두 단계 위가 프로젝트 폴더입니다. .env 파일이 여기 있습니다.
PROJECT_DIR = CURRENT_DIR.parent.parent
# 11번 폴더를 먼저 보고, 없으면 10번 폴더의 인덱스를 씁니다.
INDEX_CANDIDATES = (
    CURRENT_DIR / "faiss_index",
    CURRENT_DIR.parent / "10" / "faiss_index",
)
# 후보를 차례로 보며 index.faiss가 있는 첫 폴더를 고릅니다. 하나도 없으면 None입니다.
INDEX_DIR = next(
    (path for path in INDEX_CANDIDATES if (path / "index.faiss").is_file()),
    None,
)
# 인덱스를 만들 때 쓴 임베딩 모델과 같아야 검색 결과가 맞습니다.
EMBED_MODEL = "text-embedding-3-small"
QUESTION = "환불과 교환은 어떻게 다른가요?"
RESULT_COUNT = 3

def load_store():
    # 인덱스가 없으면 무엇을 확인할지 알려 주는 오류를 냅니다.
    if INDEX_DIR is None:
        raise FileNotFoundError(
            "FAISS 인덱스를 찾을 수 없습니다. src/10 또는 src/11의 "
            "faiss_index 폴더를 확인하세요."
        )

    # .env와 인덱스 경로를 현재 작업 폴더가 아닌 프로젝트 파일 위치 기준으로 찾습니다.
    load_dotenv(PROJECT_DIR / ".env")
    # 질문을 벡터로 바꿀 임베딩 객체입니다. 이때부터 API 키가 필요합니다.
    embeddings = OpenAIEmbeddings(model=EMBED_MODEL)

    # 기존 인덱스만 로드해 매번 PDF 분할과 임베딩을 다시 하지 않습니다.
    # allow_dangerous_deserialization=True는 내가 만든 인덱스 파일이라 믿고 연다는 표시입니다.
    return FAISS.load_local(
        str(INDEX_DIR),
        embeddings,
        allow_dangerous_deserialization=True,
    )

def main():
    # perf_counter()는 초 단위 시계입니다. 시작 시각을 적어 두었다가 끝난 시각에서 뺍니다.
    load_started = perf_counter()
    store = load_store()
    # :.2f 는 소수점 둘째 자리까지 보여 줍니다.
    print(f"인덱스 로드: {perf_counter() - load_started:.2f}초 ({INDEX_DIR})")

    search_started = perf_counter()
    # 검색을 한 번만 수행해 질문 임베딩 API 요청도 한 번으로 제한합니다.
    documents = store.similarity_search(QUESTION, k=RESULT_COUNT)
    print(f"검색 및 질문 임베딩: {perf_counter() - search_started:.2f}초")
    print(f"질문: {QUESTION}\n검색 결과: {len(documents)}개")

    # enumerate(..., start=1)로 순위를 1부터 붙입니다.
    for rank, document in enumerate(documents, start=1):
        # page_no가 없으면 page를, 그것도 없으면 '-'를 씁니다.
        page = document.metadata.get("page_no", document.metadata.get("page", "-"))
        # split()과 join으로 줄바꿈과 여러 칸 공백을 한 칸으로 줄인 뒤 앞 120자만 자릅니다.
        preview = " ".join(document.page_content.split())[:120]
        print(f"{rank}. 페이지 {page}: {preview}")

# 직접 실행할 때만 main()을 부릅니다. retriever_test_fast.py는 import해서 load_store만 씁니다.
if __name__ == "__main__":
    main()

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
실행: cd 실습/src/11
      python retriever_fast.py
예시 출력 (API 키 필요, 실행하지 않음)
인덱스 로드: (초 단위 시간 미기록) (인덱스 폴더 경로)
검색 및 질문 임베딩: (초 단위 시간 미기록)
질문: 환불과 교환은 어떻게 다른가요?
검색 결과: 3개
1. 페이지 2: 4. 환불 및 교환 규정 환불 상품 수령 후 7 일 이내에 환불을 신청하실 수 있습니다.
2. 페이지 2: 교환 상품 불량의 경우 30 일 이내에 교환이 가능합니다. 이 경우 배송비는 당사가 부담합니다.
3. 페이지 3: 7. 고객센터 안내 운영 시간은 평일 09:00 ~ 18:00 입니다.
(본문은 manual.pdf 문장을 형식에 맞춘 예시입니다. 경과 시간과 유사도 점수는 기록되지 않았습니다.)

코드 해설

11/retriever_fast.py를 위에서 아래로 읽으면 이 차시의 실습이 됩니다.

함수와 클래스로 나누어 읽습니다.

  • load_store: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
  • main: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

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

코드 설명

  • store.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 10}) 는 검색 옵션을 모두 search_kwargs 딕셔너리 안에 넣습니다. k를 바깥에 as_retriever(k=3) 처럼 쓰면 TypeError가 납니다. 여기서 많이들 헷갈리십니다.
  • retriever.invoke(QUESTION) 은 Document 리스트를 돌려줍니다. 본문은 d.page_content, 쪽 번호 같은 부가 정보는 d.metadata 딕셔너리에 들어 있습니다.
  • retriever_test.py 의 preview.replace("\n", " ") 는 결과를 다시 담지 않아서 줄바꿈이 그대로 출력됩니다. 문자열은 바뀌지 않는 값이라 preview = preview.replace(...) 처럼 받아야 합니다. 실행 결과 샘플에서 본문이 여러 줄로 찍힌 까닭이 이것입니다.
  • "filter": {"source": "../../data/manual.pdf"} 는 metadata의 source 값과 글자 하나까지 같아야 걸립니다. 경로를 인덱스를 만들 때와 다르게 적으면 결과가 0개가 됩니다.
  • retriever.py 의 search() 는 retriever를 쓰지 않고 similarity_search_with_relevance_scores 를 직접 부릅니다. 점수를 보고 MIN_SCORE 아래를 버려야 하기 때문입니다. 이 파일의 기준값은 0.1로, 수업 자료의 0.47과 다릅니다. 기준값은 내 인덱스의 점수 분포를 먼저 보고 정합니다.
  • retriever.py 실행 결과 샘플의 [1] <function basename at 0x...> 는 filename 자리에 문자열 대신 함수 자체가 들어 있을 때 찍히는 모양입니다. metadata.get('filename','?') 는 키가 없을 때만 '?'를 쓰므로, 값이 엉뚱하면 그대로 보입니다.
← 이전 절11차시 · 체크리스트와 과제다음 절 →11차시 · 퀴즈 1 — 조각에 [1]부터 번호 붙여 문맥 만들기