RAG CLASS · 01

RAG
초급 과정

AI 코딩을 익힌 방식처럼, 작은 코드와 실행 결과로 RAG의 기본 흐름부터 실무 입문 개념까지 이해하는 초급 학습서.

DongHakLee · RAG 입문 학습서
BEGINNER CURRICULUM

RAG 초급 과정 목차

CHAPTER 01

RAG는 무엇을 해결하는가

LLM은 회사 규정 PDF를 자동으로 알고 있지 않다. 그래서 질문을 받으면 먼저 관련 문서를 찾고, 그 내용을 근거로 답하게 만든다.

RAG는 질문과 관련된 문서를 먼저 검색하고, 그 문서를 근거로 LLM이 답을 생성하도록 하는 방식이다.
01 · 문서회사 규정 PDF원본 지식
02 · 검색관련 문서 조각질문의 근거
03 · 생성근거 기반 답변LLM의 최종 답
중요. 검색 결과는 답변이 아니다. LLM이 답할 때 참고할 근거 문서다.
CHAPTER 02

문서를 나누는 법: 청킹

PDF 전체를 매번 LLM에 넣지 않는다. 검색하기 좋은 크기의 텍스트 조각, 즉 청크로 나눈다.

개념왜 필요한가
청크긴 문서에서 분리한 작은 텍스트 조각관련 있는 부분만 검색하고 LLM에 보낸다.
오버랩이전 청크의 마지막 문장을 다음 청크에도 넣는 것청크 경계에서 문맥이 끊기는 일을 줄인다.
chunking.py · 핵심
if current_lines and next_length > chunk_size:
    chunks.append(" ".join(current_lines))

    # 마지막 문장 하나를 다음 청크에도 남긴다.
    current_lines = current_lines[-overlap_lines:]
기억할 것. 청킹은 답을 만드는 과정이 아니라, 문서를 검색 가능한 단위로 준비하는 과정이다.
CHAPTER 03

의미로 찾는 법: 임베딩 검색

임베딩 모델은 문장을 숫자 벡터로 바꾼다. 우리는 숫자의 개별 뜻을 해석하지 않고, 질문과 청크가 얼마나 가까운지만 비교한다.

질문연차는 언제 발생?텍스트
임베딩 모델multilingual-e5-small텍스트 → 벡터
검색 결과연차 발생 규정유사도 상위 청크
semantic_search.py · 핵심
model = SentenceTransformer("intfloat/multilingual-e5-small")

question_embedding = model.encode(f"query: {question}")
chunk_embeddings = model.encode(
    [f"passage: {chunk}" for chunk in chunks]
)

scores = util.cos_sim(question_embedding, chunk_embeddings)[0]
임베딩 숫자 = 검색용. 이 숫자를 LLM에 보내지 않는다. 검색으로 골라낸 청크 원문을 LLM에 보낸다.
CHAPTER 04

근거로 답하게 하기: 프롬프트

검색으로 찾은 상위 청크와 사용자 질문을 하나의 프롬프트로 조립한다. 이 전체 텍스트가 나중에 LLM API 또는 로컬 LLM으로 전달된다.

rag_prompt.py · 핵심
context = "\n\n".join(retrieved_chunks)

prompt = f"""
너는 사내 규정 질의응답 도우미다.
아래 [참고 문서]에 있는 내용만 근거로 답변하라.

[참고 문서]
{context}

[질문]
{question}
""".strip()

API에 보내는 것은 무엇인가?

정답: 임베딩 숫자가 아니라, 검색된 관련 청크의 원문 + 사용자 질문이다.

CHAPTER 05

외우지 말고, 이 흐름만 기억하기

1. 청킹긴 문서를 검색용 작은 텍스트 조각으로 나눈다.
2. 임베딩문장을 의미 비교용 숫자 벡터로 바꾼다.
3. 검색질문과 가까운 청크를 몇 개 고른다.
4. 프롬프트관련 청크 원문과 질문을 LLM에 전달한다.
문서 → 청킹 → 임베딩 → 관련 청크 검색 → 관련 청크 + 질문 → LLM 답변
지금은 몰라도 된다. 코사인 유사도 수식, 벡터의 각 숫자 의미, 리랭커, API 세부 문법. 막히면 “문서에서 찾고, 찾은 원문으로 답한다”로 돌아오면 된다.

1장 완료 · 미니 RAG 실행 성공

rag_openrouter.py는 임베딩 검색부터 OpenRouter 답변 생성까지 실제로 연결한다. 다음 2장은 하드코딩한 문자열 대신 TXT·PDF 문서를 입력으로 받는 단계다.

CHAPTER 06 · REAL DOCUMENT

실제 문서 연결하기

이제 규정을 Python 리스트에 직접 적지 않는다. data/company_rules.txt를 읽고, 그 파일에서 청크를 만들어 기존 RAG 흐름에 연결한다.

01 · 파일 읽기company_rules.txt원본 문서
02 · 청킹3개의 텍스트 조각문장 단위 오버랩
03 · RAG 답변파일 근거 답변출처도 표시
rag_from_file.py · 핵심
from pathlib import Path

source_file = Path("data/company_rules.txt")
document = source_file.read_text(encoding="utf-8")
chunks = split_sentences_with_overlap(document)

# 이후 흐름은 1장과 같다: 임베딩 → 검색 → LLM 답변
retrieved_chunks = [chunk for _, chunk in results[:3]]
이번 장의 핵심 변화. chunks = [...]처럼 코드에 문서를 적던 방식에서, 파일을 읽어 동적으로 청크를 만드는 방식으로 바뀌었다. LLM에는 문서에 없는 수량·조건·예외를 추가하지 말라고 지시한다. PDF도 같은 흐름이며, 먼저 PDF에서 텍스트를 추출하는 단계만 추가된다.

실행 확인

.\\.venv\\Scripts\\python.exe .\\rag_from_file.py를 실행하면 원본 파일명, 생성된 청크 수, 검색된 근거, LLM 답변이 순서대로 출력된다. 무료 OpenRouter 모델은 일시적인 429 요청 제한이 생길 수 있으며, 이때 안내된 시간 뒤 다시 실행한다.

CHAPTER 07 · PDF INPUT

PDF는 바로 읽히지 않을 수도 있다

PDF에는 두 종류가 있다. 글자가 실제 텍스트로 저장된 PDF는 바로 추출할 수 있지만, 스캔한 문서처럼 페이지가 이미지인 PDF는 OCR로 글자를 읽어야 한다.

PDF 종류추출 방법현재 파일
텍스트 PDFpypdf로 글자 추출해당 시 바로 청킹 가능
스캔형 PDF페이지 이미지 → OCR → 텍스트소화기 사용법.pdf · OCR 필요
pdf_extract.py · 핵심
from pypdf import PdfReader

reader = PdfReader(pdf_file)
for page in reader.pages:
    text = page.extract_text() or ""
    print(text)
두 PDF의 차이. 소화기 사용법.pdf는 1페이지지만 텍스트가 비어 OCR이 필요했다. 반면 관리자매뉴얼.pdf는 185페이지·약 8만 자가 추출되어 OCR 없이 RAG에 연결할 수 있었다.
rag_from_pdf.py · 페이지 출처를 보존하는 핵심
for page_number, page in enumerate(reader.pages, start=1):
    page_text = page.extract_text() or ""
    for chunk in split_lines_with_overlap(page_text):
        documents.append({"page": page_number, "content": chunk})

# 검색 결과에 페이지 번호를 붙여 LLM으로 전달한다.
context = "\\n\\n".join(
    f"[페이지 {document['page']}]\\n{document['content']}"
    for document in retrieved_documents
)

실행 성공 · 실제 PDF RAG

관리자매뉴얼.pdf에서 204개 청크를 만들었다. “지원되는 브라우저는 무엇인가요?”라는 질문에 IE 9 이상 및 기타 브라우저라는 답과 [3] 출처를 반환했다.

CHAPTER 08 · VECTOR DATABASE

한 번 임베딩하고, 여러 번 검색하기

이전 PDF RAG는 실행할 때마다 185페이지를 읽고 204개 청크를 다시 임베딩했다. 이제 Chroma 벡터 DB에 벡터와 원문·페이지 번호를 저장해 둔다.

한 번만 실행build_pdf_index.pyPDF → 204개 벡터 → chroma_db
질문할 때마다 실행query_pdf_index.py질문만 E5로 임베딩
검색 결과관련 청크 + 페이지PDF 전체 재임베딩 없음
build_pdf_index.py · 한 번만 저장
client = chromadb.PersistentClient(path="chroma_db")
# 학습용: 이전 인덱스를 지우고 새로 만듭니다.
client.delete_collection(name="manual_pdf")
collection = client.create_collection(name="manual_pdf")

collection.add(
    ids=[...],
    embeddings=embeddings,
    documents=[...],
    metadatas=[{"source": source, "page": page}],
)
query_pdf_index.py · 질문만 검색
question_embedding = model.encode(
    f"query: {question}",
    normalize_embeddings=True,
).tolist()

results = collection.query(
    query_embeddings=[question_embedding],
    n_results=3,
)
top-k란? n_results=3은 점수(가까움) 순서대로 청크를 3개 가져오라는 뜻이다. 즉 top-3다. 초반 예제의 results[:3]과 같은 역할이다.
실제 결과. 관리자매뉴얼.pdf의 204개 청크를 chroma_db에 저장했다. “지원되는 브라우저는 무엇인가요?”라는 질문은 3페이지의 브라우저 호환성 문서를 가장 가깝게 찾았다.

실행 순서

문서가 바뀌었을 때만 build_pdf_index.py를 실행한다. 평소 질문은 query_pdf_index.py만 실행한다.

CHAPTER 09 · COMPLETE RAG

검색과 답변을 한 번에: 대화형 PDF RAG

chat_pdf_rag.py는 프로그램을 시작할 때 벡터 DB와 E5 모델을 한 번 열고, 이후에는 질문을 반복해서 받는다. 매 질문마다 PDF 전체를 다시 읽지 않는다.

01 · 질문사용자 입력예: 지원 브라우저는?
02 · Chroma 검색가까운 PDF 청크원문 + 페이지 번호
03 · OpenRouter근거 기반 답변출처도 함께 표시
chat_pdf_rag.py · 질문 반복의 핵심
while True:
    question = input("\n질문: ").strip()  # exit / 종료를 입력하면 끝
    if question.lower() in {"exit", "quit", "종료"}:
        break

    results = collection.query(
        query_embeddings=[model.encode(f"query: {question}", normalize_embeddings=True).tolist()],
        n_results=3,  # top-3: 가장 가까운 청크 3개
    )
    # LLM 호출 전에 1등·2등·3등 후보를 화면에 출력한다.
    print(retrieved_documents)
    answer = ask_llm(question, retrieved_documents)
여기서 완성된 흐름. 먼저 벡터 DB가 1등·2등·3등 후보를 출력하고, 그 뒤 LLM에는 찾아낸 텍스트와 질문만 전달한다. n_results=3은 가까운 청크 3개를 가져오는 top-3이다. LLM은 세 청크와 질문을 함께 읽어 답에 쓸 근거를 고른다. exit 또는 종료를 입력하면 채팅을 끝낸다.

실행 명령

$env:OPENROUTER_API_KEY = [Environment]::GetEnvironmentVariable('OPENROUTER_API_KEY', 'User'); .\.venv\Scripts\python.exe .\chat_pdf_rag.py

다음에 다룰 실무 포인트. 검색 결과가 질문의 실제 메뉴 용어와 어긋날 수 있다. 이때는 청킹 규칙, 검색 개수(top-k), 키워드 검색 결합, 리랭커를 조절해 검색 품질을 개선한다.
CHAPTER 10 · DEBUGGING

RAG가 틀리면, 먼저 검색 결과를 본다

답변이 이상하다고 해서 바로 LLM을 의심하지 않는다. LLM에 보내기 전에 출력한 top-3 후보에 답이 있는지부터 확인한다.

01 · 후보에 답 없음검색 문제청킹·임베딩·top-k를 점검
02 · 후보에 답 있음LLM 문제프롬프트·모델을 점검
03 · 후보가 섞임검색 품질 문제top-k나 검색 방식을 조절
후보에 정답이 없으면 검색 문제, 후보에 정답이 있는데 답이 틀리면 LLM 문제다.
chat_pdf_rag.py · 이미 추가한 진단 출력
print("\n[벡터 DB 검색 후보]")
for rank, item in enumerate(retrieved_documents, start=1):
    print(f"{rank}등 · 거리 {item['distance']:.3f}")

# 이 후보들을 LLM에 보내기 전에 사람이 먼저 확인할 수 있다.

미니 퀴즈

top-3 후보 중 2등 청크에 정답 문장이 분명히 있는데, LLM이 “문서에서 확인할 수 없습니다”라고 답했다. 이때 먼저 손봐야 할 곳은 검색일까, LLM 프롬프트/모델일까?

CHAPTER 11 · PROMPTING

근거를 찾았으면, 답으로 쓰게 한다

검색 후보에 답이 들어 있어도 LLM이 놓칠 수 있다. 그래서 프롬프트에는 “문서에 직접 답이 있으면 반드시 그 내용으로 답하라”는 규칙을 넣는다.

chat_pdf_rag.py · system prompt 핵심
"반드시 제공된 참고 문서만 근거로 한국어로 답변하라. "
"참고 문서에 질문의 직접적인 답이 있으면, "
"그 내용을 쉬운 말로 요약해 답변하라. "
"참고 문서가 여러 개면 답을 포함한 문서를 우선해서 사용하라. "
프롬프트가 하는 일. 새 지식을 추가하는 것이 아니다. 이미 검색된 후보 중에서 무엇을 우선해 답해야 하는지 LLM에게 분명히 알려 주는 것이다.
상태먼저 볼 곳
후보에 답이 없음검색: 청킹·임베딩·top-k
후보에 답이 있는데 답변이 틀림LLM: 프롬프트·모델
CHAPTER 12 · KEYWORD SEARCH

PDF 청크에서 단어를 세어 찾기

이번에는 임베딩을 사용하지 않는다. 질문의 단어가 PDF 청크 안에 포함될 때마다 1점을 주고, 점수가 높은 순서로 top-3을 보여 준다.

질문브라우저는 무엇인가요?단어: 브라우저는, 무엇인가요
점수단어 포함 개수포함하면 1점
결과점수 높은 청크top-3 출력
keyword_search_pdf.py · 핵심
keywords = re.findall(r"[가-힣A-Za-z0-9]+", question)

def keyword_score(document):
    # True는 1, False는 0처럼 더해진다.
    return sum(keyword in document for keyword in keywords)

results.sort(key=lambda item: item["score"], reverse=True)
초반 search.py와 같은 원리. sum(keyword in document for keyword in keywords)는 “각 단어가 있으면 1점, 없으면 0점”을 모두 더한다. Java라면 for문 안에서 조건이 참일 때 score++ 하는 것과 같다. 결과에는 점수를 만든 키워드 주변 문장도 출력한다.

실행 명령

.\.venv\Scripts\python.exe .\keyword_search_pdf.py를 실행하고 브라우저라고 입력해 보자. 이 검색은 의미가 아니라 글자 포함 여부만 본다.

CHAPTER 13 · COMPARISON

같은 질문, 두 검색 결과

compare_search_pdf.py는 같은 질문으로 키워드 검색과 E5 의미 검색을 각각 실행한다. 아직 두 결과를 합치지 않는다. 먼저 어떤 청크를 고르는지 비교만 한다.

질문브라우저같은 질문을 사용
키워드 검색포함 횟수 점수점수 높은 순
의미 검색벡터 거리거리 낮은 순
compare_search_pdf.py · 같은 질문을 두 번 사용
# 키워드 검색
keyword_results.sort(key=lambda item: item["score"], reverse=True)

# 의미 검색
question_embedding = model.encode(f"query: {question}", normalize_embeddings=True).tolist()
semantic_results = collection.query(
    query_embeddings=[question_embedding],
    n_results=TOP_K,
)
비교할 것. 키워드 검색은 “왜 이 청크를 골랐는지”가 단어와 점수로 보인다. 의미 검색은 표현이 달라도 찾을 수 있지만, 결과를 보고 맞는지 확인해야 한다.

실행 명령

.\.venv\Scripts\python.exe .\compare_search_pdf.py를 실행하고 브라우저, 그다음 지원되는 브라우저는 무엇인가요?를 각각 시험해 보자.

CHAPTER 14 · HYBRID SEARCH

두 검색의 후보 목록을 합친다

처음에는 점수를 억지로 하나로 합치지 않는다. 의미 검색 top-3과 키워드 검색 top-3을 모은 뒤, 같은 청크는 하나로 합친다. 이것이 가장 단순한 하이브리드 검색이다.

의미 top-3벡터로 찾은 후보자연스러운 질문에 강함
키워드 top-3단어로 찾은 후보정확한 명칭에 강함
후보 풀합치고 중복 제거LLM에 줄 근거 후보
hybrid_search_pdf.py · 합치는 핵심
hybrid_candidates = {}

for item in semantic_candidates:
    hybrid_candidates[item["id"]] = {**item, "found_by": ["의미"]}

for item in keyword_candidates:
    if item["id"] in hybrid_candidates:
        hybrid_candidates[item["id"]]["found_by"].append("키워드")
    else:
        hybrid_candidates[item["id"]] = {**item, "found_by": ["키워드"]}
중복 판단 기준은 청크 ID. 같은 페이지라도 청크가 다르면 ID가 다르다. 반대로 키워드와 의미 검색이 같은 청크를 찾으면 ID가 같으므로 한 번만 남기고 “의미, 키워드”라고 표시한다.

실행 명령

.\.venv\Scripts\python.exe .\hybrid_search_pdf.py를 실행하고 브라우저를 입력해 보자. 후보마다 어떤 검색이 찾아냈는지 표시된다.

CHAPTER 15 · FINAL TOP-K

후보를 모은 것과, 답에 쓸 근거는 다르다

하이브리드 검색이 만든 5개 후보는 후보 풀이다. 아직 LLM에 보낼 최종 답안지가 아니다. 여기서 다시 가장 믿을 만한 몇 개를 고른다.

후보 풀A, B, C, D, E키워드·의미 검색이 모은 결과
다시 정렬어떤 후보가 더 믿을 만한가?두 검색 모두 찾았는지도 봄
최종 top-kA, B, DLLM에 보낼 근거
처음 적용할 간단한 규칙. 두 검색이 모두 찾은 청크를 우선 후보로 본다. 그다음 의미 검색만 찾은 청크와 키워드 검색만 찾은 청크를 검토한다. 나중에는 이 순서를 자동으로 정하는 리랭커를 배운다.
후보 풀 = 넓게 모은 보기 목록. 최종 top-k = 그중 LLM에게 실제로 보여 줄 근거 목록.

미니 퀴즈

A는 키워드·의미 검색이 모두 찾았다. B는 의미 검색만, C는 키워드 검색만 찾았다. 첫 번째로 우선 확인할 청크는 무엇일까?