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.py | 9쪽 | 11차시 「4. 실습 ① — Retriever 만들고 비교하기」 아래에 실었습니다 |
11/retriever.py | 17쪽 | 11차시 「8. 오늘의 완성 코드」 아래에 실었습니다 |
11/retriever_fast.py | - | 원본 PDF 에 짝이 되는 쪽이 없어 이 페이지 아래에 실었습니다 |
11/retriever_test_fast.py | 9쪽 | 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 에 같은 이름의 쪽이 없어 여기에 실었습니다.
'''
번외편
이 파일은 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','?')는 키가 없을 때만 '?'를 쓰므로, 값이 엉뚱하면 그대로 보입니다.