11회차에서 RAG·임베딩·벡터DB를 '개념'으로 봤죠('오픈북 시험' 비유). 이번엔 그걸 코드로 직접 만듭니다. 만들 것: 내 문서(메모·PDF에서 뽑은 텍스트 등)를 임베딩해 '비슷한 부분 찾기' 검색기 — 이 회차 후반부 챗봇의 '검색' 절반입니다. 자료는 레포가 아니라 '여러분 자신의 문서'를 씁니다.
❓ 그냥 GPT에 자료 던지고 물으면 안돼?
아주 자연스러운 질문이고 — 자료가 적으면 그게 정답입니다. 문서 몇 개면 그냥 ChatGPT 파일 업로드(1회차)·Claude Projects·NotebookLM(2회차)에 넣고 물으면 끝이에요. RAG가 항상 필요한 건 아닙니다.
그런데 '그냥 던지기'는 규모가 커지면 네 군데서 깨집니다 — 그때 RAG(임베딩·벡터검색)가 필요해져요.
- 💰 비용 — RAG도 API를 씁니다(공짜 아님). 다만 임베딩은 문서당 1번·싸고, 비싼 건 생성 호출 — 매 질문에 문서 전체 대신 관련 조각만 보내, 비싼 생성 호출의 토큰이 수백 배 적다(문서 많고 질문 반복일수록 격차↑).
- 📏 용량 — 문서 수만 개·수천 페이지는 한 번에 안 들어감. 컨텍스트 윈도우가 커도 무한은 아님.
- 🎯 정확도 — 너무 많이 넣으면 모델이 중간 내용을 놓침('lost in the middle'). 관련 부분만 주면 더 정확.
- 👥 서비스화 — 1,000명이 쓰는 챗봇은 각자 파일 업로드를 못 시킴. 앱이 자동으로 찾아줘야 함 = RAG.
⚙️ 먼저 환경 한 번에 — 설치 & API 키
아래 코드들이 쓰는 라이브러리와 키를 여기 한곳에 모았습니다. 한 번 해두면 이 회차 코드가 다 돕니다(겁먹지 말고 복붙). 임베딩은 OpenAI API를 쓰니 키가 필요해요.
# 1) 라이브러리 설치 (터미널에서 한 번)
pip install openai chromadb pypdf python-dotenv rank-bm25 sentence-transformers ragas
# 2) API 키 — 프로젝트 폴더에 .env 파일을 만들고 아래 한 줄
# (코드에서 python-dotenv가 자동으로 읽어 OpenAI가 인식)
OPENAI_API_KEY=sk-여기에-본인-키
🧮 임베딩 — 의미를 숫자로 (복습 + 코드)
11회차 요약: 뜻이 비슷하면 좌표도 가깝다('강아지'와 '개'는 근처). 코드에선 임베딩 API/모델로 텍스트를 숫자 벡터로 바꿉니다. 질문도 같은 방식으로 벡터화해, 가까운 청크를 찾으면 그게 '의미로 검색(시맨틱 검색)'이에요.
# embed.py — 텍스트를 벡터로 (예: OpenAI 임베딩)
from openai import OpenAI
client = OpenAI()
def embed(text: str) -> list[float]:
r = client.embeddings.create(model='text-embedding-3-small', input=text)
return r.data[0].embedding # 숫자 벡터(예: 1536차원)
📄 문서 로딩 — PDF·txt를 텍스트로 (실무 1단계)
임베딩하려면 먼저 '글자'가 있어야 합니다. PDF·워드는 그냥 열면 글자가 안 나오니, 텍스트만 뽑아내는 단계가 필요해요 — 실무에서 손이 제일 많이 가는 곳입니다. txt는 그냥 읽고, PDF는 pypdf로 페이지마다 글자를 긁어옵니다.
# loader.py — 파일에서 텍스트만 뽑기
from pathlib import Path
from pypdf import PdfReader # pip install pypdf
def load_text(path: str) -> str:
p = Path(path)
if p.suffix.lower() == '.pdf':
reader = PdfReader(path)
return '\n'.join((pg.extract_text() or '') for pg in reader.pages)
return p.read_text(encoding='utf-8') # .txt / .md 등
✂️ 청킹 — 자르는 방식이 품질을 좌우
- 왜 자르나 — 문서 전체를 한 번에 넣을 수 없고, 질문과 관련된 '조각'만 찾아야 정확.
- 너무 크면 부정확(잡음 섞임), 너무 작으면 맥락 손실 — 문단·헤딩 단위가 무난.
- 오버랩 — 조각 경계에서 맥락이 끊기지 않게 약간 겹쳐 자르기.
# chunk.py — 문단 단위로 자르되, 단어 몇 개씩 겹치기(오버랩)
def chunk(text: str, size=500, overlap=80) -> list[str]:
words = text.split()
out, i = [], 0
while i < len(words):
out.append(' '.join(words[i:i + size])) # size개 단어 한 조각
i += size - overlap # overlap만큼 되돌려 겹치기
return out
# size·overlap 숫자를 바꿔보며 검색 품질을 비교하는 게 이 회차 과제
🗂️ 벡터 검색 — 가까운 것 빨리 찾기
질문 벡터와 각 청크 벡터의 '코사인 유사도'를 재서 가장 가까운 top-k를 고릅니다. 양이 적으면 numpy로 직접, 많아지면 로컬 벡터DB(Chroma·pgvector — 11회차에 나온 이름들)로 넘어갑니다.
# search.py — 가장 비슷한 top-k 청크 찾기 (간단 버전)
import numpy as np
def cosine(a, b):
a, b = np.array(a), np.array(b)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def top_k(query_vec, chunks, k=3):
scored = [(c['text'], cosine(query_vec, c['vec'])) for c in chunks]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:k] # (청크, 점수) 상위 k개
🗄️ 벡터DB 셋업 — Chroma에 저장하고 다시 안 만들기
numpy 버전은 매번 켤 때마다 전부 다시 임베딩합니다(느리고 돈 듦). Chroma의 PersistentClient는 결과를 디스크에 저장해, 두 번째 실행부터는 재임베딩 없이 바로 검색해요. 출처(source)도 같이 넣어 두면 답에 근거를 표시할 수 있습니다.
# store.py — Chroma에 저장(디스크 persist) & 검색
import chromadb
from embed import embed
client = chromadb.PersistentClient(path='./chroma_db') # 이 폴더에 저장
col = client.get_or_create_collection('docs') # 있으면 재사용
def index(chunks, source): # chunks: 문자열 리스트, source: 문서명(출처)
col.add(
ids=[f"{source}-{i}" for i in range(len(chunks))],
embeddings=[embed(c) for c in chunks], # 미리 만든 벡터를 그대로
documents=chunks,
metadatas=[{'source': source} for _ in chunks],
)
def search(question, k=3):
r = col.query(query_embeddings=[embed(question)], n_results=k)
docs, metas = r['documents'][0], r['metadatas'][0]
return [{'text': t, 'source': m['source']} for t, m in zip(docs, metas)]
🔧 만들 것 — '내 자료 검색기'
- 내 문서를 모아 텍스트로 → 청킹(문단 단위 + 약간 오버랩)
- 각 청크를 embed()로 벡터화해 저장(파일 또는 Chroma)
- 질문을 받아 벡터화 → top_k로 관련 청크를 점수·출처와 함께 반환
✅ 완료 기준
- 자연어 질문(예: '환불 규정')에 관련 청크 top-k를 점수와 함께 반환한다.
- 청킹 방식을 2가지로 바꿔, 검색 결과가 어떻게 달라지는지 눈으로 확인했다.
📌 1부 정리 — 임베딩·벡터검색
- 임베딩 = 의미를 숫자 좌표로. 가까운 좌표 = 비슷한 뜻.
- 청킹 전략이 RAG 품질의 절반 — 문단 단위 + 오버랩부터.
- 검색은 코사인 유사도 top-k. 작게는 numpy, 크면 Chroma 같은 벡터DB.
② 챗봇 & 첫 평가 — 검색기에 두뇌를 붙이기
여기까지가 '검색' 토대였습니다. 이제 이 검색기에 19회차 LLM 호출을 합쳐 '내 자료로 답하는 챗봇'을 완성하고, 처음으로 품질을 '숫자로' 재봅니다 — 뒤에 나올 평가 심화의 맛보기예요. '답이 좋아 보인다'는 함정에서 벗어나는 첫걸음.
🔗 RAG 파이프라인 — 세 단계
- retrieve — 질문으로 관련 청크 top-k 검색(이 회차 전반부 검색 결과).
- augment — 찾은 청크를 프롬프트에 끼워넣기('아래 근거만 보고 답하라').
- generate — LLM이 그 근거로 답 + 출처(문서·청크) 표시.
# rag.py — 검색결과를 근거로 답하기 (augment 프롬프트)
PROMPT = '''너는 사내 문서 비서다. 아래 [근거]만 사용해 답하라.
근거에 없으면 '자료에 없음'이라고 답하라. 답 끝에 사용한 출처를 표기하라.
[근거]
{context}
[질문]
{question}'''
context = '\n\n'.join(f"({c['source']}) {c['text']}" for c in hits)
answer = ask(PROMPT.format(context=context, question=q)) # 19회차 ask() 재사용
🏅 검색 품질 올리기 — 리랭킹·하이브리드
- 리랭킹 — top-k를 다시 정렬해 '진짜 관련 있는 것'을 위로(전용 reranker 모델 또는 LLM).
- 하이브리드 검색 — 키워드 검색 + 벡터 검색을 합쳐, 고유명사·숫자도 놓치지 않게.
- 둘 다 11회차 '시맨틱 검색' 개념의 실전 확장 — 답 품질이 눈에 띄게 오릅니다.
말로만 보면 막연하니, '이렇게 켠다' 실전 포인터를 하나씩 — 둘 다 검색을 살짝 손보는 작은 추가입니다(전체를 다시 안 짜도 됨):
- 리랭킹 켜기 — 벡터 검색으로 top-20처럼 넉넉히 뽑은 뒤, 크로스인코더 reranker로 다시 점수 매겨 상위 3개만 남기기. 'pip install sentence-transformers' → CrossEncoder('BAAI/bge-reranker-v2-m3')에 (질문, 청크) 쌍을 .predict()하면 관련도 점수가 나옵니다(로컬·무료, CPU도 OK).
- 하이브리드 켜기 — 'pip install rank-bm25'로 키워드 점수(BM25Okapi.get_scores)를 따로 구해, 벡터 점수와 합산(또는 순위 합치기)해 정렬. 고유명사·코드·숫자처럼 '단어 그대로 맞아야 하는' 질문에서 특히 효과.
- 순서 팁 — 보통 '하이브리드로 후보를 넓게 → 리랭킹으로 좁게'가 정석. 둘 다 켜기 전에, 앞 단계 Ragas 점수를 먼저 찍어두고 켠 뒤와 비교하세요(효과를 숫자로 확인).
🧪 첫 평가 — Ragas로 숫자 만들기
눈으로 '괜찮네' 하는 건 평가가 아닙니다. 질문+기대답 몇 개를 직접 만든 '골든 테스트셋'에 Ragas를 돌리면, 검색·답변 품질이 점수로 나옵니다.
- 골든셋 만들기 — 대표 질문 10개 + 기대 답/근거를 직접 작성
- pip install ragas → 파이프라인 답변을 골든셋과 비교 채점
- retrieval/answer 점수를 기록
📁 프로젝트 골격 — 파일을 어떻게 두고 돌리나
지금까지 만든 조각들(loader·chunk·embed·store·rag)을 한 폴더에 모으면 이런 모양입니다. ingest로 '문서를 한 번 넣어두고', chat으로 '계속 질문'하는 2단계 — 재실행 시 임베딩을 다시 안 하게 나눠둔 거예요.
ask-my-docs/
├─ docs/ # 내 PDF·txt 원본을 여기 넣기
├─ chroma_db/ # Chroma가 자동 생성(임베딩 저장소)
├─ loader.py # 파일 → 텍스트
├─ chunk.py # 텍스트 → 조각(오버랩)
├─ embed.py # 조각 → 벡터
├─ store.py # Chroma 저장/검색
├─ rag.py # 검색결과 + 19회차 ask()로 답하기
├─ ingest.py # docs/ 전부 읽어 Chroma에 적재(한 번)
├─ chat.py # 질문 받아 답+출처 출력(반복)
├─ .env # OPENAI_API_KEY (깃 제외)
└─ requirements.txt # openai chromadb pypdf python-dotenv
# rank-bm25 sentence-transformers ragas
- 준비 — pip install -r requirements.txt 후 .env에 키 넣고, docs/에 내 문서 넣기
- 적재 — python ingest.py (loader→chunk→embed→store 순으로 돌며 chroma_db/에 저장)
- 대화 — python chat.py (질문 입력 → 검색 → 근거로 답 + 출처 표시, 재임베딩 없음)
🔧 만들 것 — Ask-My-Docs 챗봇
- 이 회차 전반부 검색기 + augment 프롬프트 + 19회차 ask()를 연결
- 답변에 출처(문서명·청크)를 반드시 표시
- 골든셋으로 Ragas 점수를 뽑고, 청킹/top-k를 바꿔가며 재측정
✅ 완료 기준
- 질문하면 내 자료를 근거로, 출처와 함께 답한다.
- Ragas 점수가 숫자로 나온다.
- 청킹/top-k를 바꿨을 때 점수 변화를 before/after 표로 제시했다(← 평가 역량의 강한 증거).
📌 20회차 핵심
- RAG = retrieve → augment → generate. 답엔 항상 출처.
- 리랭킹·하이브리드로 검색 품질을 끌어올린다.
- '좋아 보인다' 대신 Ragas 점수로 — 바꿨을 때의 변화를 숫자로 본다.