RAG는 무엇을 해결하는가
LLM은 회사 규정 PDF를 자동으로 알고 있지 않다. 그래서 질문을 받으면 먼저 관련 문서를 찾고, 그 내용을 근거로 답하게 만든다.
AI 코딩을 익힌 방식처럼, 작은 코드와 실행 결과로 RAG의 기본 흐름부터 실무 입문 개념까지 이해하는 초급 학습서.
LLM은 회사 규정 PDF를 자동으로 알고 있지 않다. 그래서 질문을 받으면 먼저 관련 문서를 찾고, 그 내용을 근거로 답하게 만든다.
PDF 전체를 매번 LLM에 넣지 않는다. 검색하기 좋은 크기의 텍스트 조각, 즉 청크로 나눈다.
| 개념 | 뜻 | 왜 필요한가 |
|---|---|---|
| 청크 | 긴 문서에서 분리한 작은 텍스트 조각 | 관련 있는 부분만 검색하고 LLM에 보낸다. |
| 오버랩 | 이전 청크의 마지막 문장을 다음 청크에도 넣는 것 | 청크 경계에서 문맥이 끊기는 일을 줄인다. |
if current_lines and next_length > chunk_size:
chunks.append(" ".join(current_lines))
# 마지막 문장 하나를 다음 청크에도 남긴다.
current_lines = current_lines[-overlap_lines:]임베딩 모델은 문장을 숫자 벡터로 바꾼다. 우리는 숫자의 개별 뜻을 해석하지 않고, 질문과 청크가 얼마나 가까운지만 비교한다.
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 API 또는 로컬 LLM으로 전달된다.
context = "\n\n".join(retrieved_chunks)
prompt = f"""
너는 사내 규정 질의응답 도우미다.
아래 [참고 문서]에 있는 내용만 근거로 답변하라.
[참고 문서]
{context}
[질문]
{question}
""".strip()정답: 임베딩 숫자가 아니라, 검색된 관련 청크의 원문 + 사용자 질문이다.
rag_openrouter.py는 임베딩 검색부터 OpenRouter 답변 생성까지 실제로 연결한다. 다음 2장은 하드코딩한 문자열 대신 TXT·PDF 문서를 입력으로 받는 단계다.
이제 규정을 Python 리스트에 직접 적지 않는다. data/company_rules.txt를 읽고, 그 파일에서 청크를 만들어 기존 RAG 흐름에 연결한다.
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 요청 제한이 생길 수 있으며, 이때 안내된 시간 뒤 다시 실행한다.
PDF에는 두 종류가 있다. 글자가 실제 텍스트로 저장된 PDF는 바로 추출할 수 있지만, 스캔한 문서처럼 페이지가 이미지인 PDF는 OCR로 글자를 읽어야 한다.
| PDF 종류 | 추출 방법 | 현재 파일 |
|---|---|---|
| 텍스트 PDF | pypdf로 글자 추출 | 해당 시 바로 청킹 가능 |
| 스캔형 PDF | 페이지 이미지 → OCR → 텍스트 | 소화기 사용법.pdf · OCR 필요 |
from pypdf import PdfReader
reader = PdfReader(pdf_file)
for page in reader.pages:
text = page.extract_text() or ""
print(text)소화기 사용법.pdf는 1페이지지만 텍스트가 비어 OCR이 필요했다. 반면 관리자매뉴얼.pdf는 185페이지·약 8만 자가 추출되어 OCR 없이 RAG에 연결할 수 있었다.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에서 204개 청크를 만들었다. “지원되는 브라우저는 무엇인가요?”라는 질문에 IE 9 이상 및 기타 브라우저라는 답과 [3] 출처를 반환했다.
이전 PDF RAG는 실행할 때마다 185페이지를 읽고 204개 청크를 다시 임베딩했다. 이제 Chroma 벡터 DB에 벡터와 원문·페이지 번호를 저장해 둔다.
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}],
)question_embedding = model.encode(
f"query: {question}",
normalize_embeddings=True,
).tolist()
results = collection.query(
query_embeddings=[question_embedding],
n_results=3,
)n_results=3은 점수(가까움) 순서대로 청크를 3개 가져오라는 뜻이다. 즉 top-3다. 초반 예제의 results[:3]과 같은 역할이다.관리자매뉴얼.pdf의 204개 청크를 chroma_db에 저장했다. “지원되는 브라우저는 무엇인가요?”라는 질문은 3페이지의 브라우저 호환성 문서를 가장 가깝게 찾았다.문서가 바뀌었을 때만 build_pdf_index.py를 실행한다. 평소 질문은 query_pdf_index.py만 실행한다.
chat_pdf_rag.py는 프로그램을 시작할 때 벡터 DB와 E5 모델을 한 번 열고, 이후에는 질문을 반복해서 받는다. 매 질문마다 PDF 전체를 다시 읽지 않는다.
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)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
답변이 이상하다고 해서 바로 LLM을 의심하지 않는다. LLM에 보내기 전에 출력한 top-3 후보에 답이 있는지부터 확인한다.
print("\n[벡터 DB 검색 후보]")
for rank, item in enumerate(retrieved_documents, start=1):
print(f"{rank}등 · 거리 {item['distance']:.3f}")
# 이 후보들을 LLM에 보내기 전에 사람이 먼저 확인할 수 있다.top-3 후보 중 2등 청크에 정답 문장이 분명히 있는데, LLM이 “문서에서 확인할 수 없습니다”라고 답했다. 이때 먼저 손봐야 할 곳은 검색일까, LLM 프롬프트/모델일까?
검색 후보에 답이 들어 있어도 LLM이 놓칠 수 있다. 그래서 프롬프트에는 “문서에 직접 답이 있으면 반드시 그 내용으로 답하라”는 규칙을 넣는다.
"반드시 제공된 참고 문서만 근거로 한국어로 답변하라. "
"참고 문서에 질문의 직접적인 답이 있으면, "
"그 내용을 쉬운 말로 요약해 답변하라. "
"참고 문서가 여러 개면 답을 포함한 문서를 우선해서 사용하라. "| 상태 | 먼저 볼 곳 |
|---|---|
| 후보에 답이 없음 | 검색: 청킹·임베딩·top-k |
| 후보에 답이 있는데 답변이 틀림 | LLM: 프롬프트·모델 |
이번에는 임베딩을 사용하지 않는다. 질문의 단어가 PDF 청크 안에 포함될 때마다 1점을 주고, 점수가 높은 순서로 top-3을 보여 준다.
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)sum(keyword in document for keyword in keywords)는 “각 단어가 있으면 1점, 없으면 0점”을 모두 더한다. Java라면 for문 안에서 조건이 참일 때 score++ 하는 것과 같다. 결과에는 점수를 만든 키워드 주변 문장도 출력한다..\.venv\Scripts\python.exe .\keyword_search_pdf.py를 실행하고 브라우저라고 입력해 보자. 이 검색은 의미가 아니라 글자 포함 여부만 본다.
compare_search_pdf.py는 같은 질문으로 키워드 검색과 E5 의미 검색을 각각 실행한다. 아직 두 결과를 합치지 않는다. 먼저 어떤 청크를 고르는지 비교만 한다.
# 키워드 검색
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를 실행하고 브라우저, 그다음 지원되는 브라우저는 무엇인가요?를 각각 시험해 보자.
처음에는 점수를 억지로 하나로 합치지 않는다. 의미 검색 top-3과 키워드 검색 top-3을 모은 뒤, 같은 청크는 하나로 합친다. 이것이 가장 단순한 하이브리드 검색이다.
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": ["키워드"]}.\.venv\Scripts\python.exe .\hybrid_search_pdf.py를 실행하고 브라우저를 입력해 보자. 후보마다 어떤 검색이 찾아냈는지 표시된다.
하이브리드 검색이 만든 5개 후보는 후보 풀이다. 아직 LLM에 보낼 최종 답안지가 아니다. 여기서 다시 가장 믿을 만한 몇 개를 고른다.
A는 키워드·의미 검색이 모두 찾았다. B는 의미 검색만, C는 키워드 검색만 찾았다. 첫 번째로 우선 확인할 청크는 무엇일까?