내 PDF 문서를 로컬 LLM에게 검색시키기 — RAG 실전 구축

내 PDF 문서를 로컬 LLM에게 검색시키는 RAG 구축 방법을 정리합니다. PDF 텍스트 추출, 청킹, 메타데이터, 임베딩, 벡터 유사도 검색과 로컬 API 연결까지 단계별로 설명합니다.

blue artificial intelligence chatgpt teal llm ai search open ai ai seo GENERIC 실물

Photo by Aerps.com on Unsplash

목차

준비 조건과 전체 구조

Python이 설치되어 있고 로컬 LLM 서버가 HTTP API를 제공한다고 가정한다. 예를 들어 Ollama나 llama.cpp server처럼 OpenAI 호환 API를 제공하는 서버를 사용할 수 있으며, 실제 서버에 따라 주소와 모델 지정 방식을 바꿔야 한다. PDF 파일은 docs 폴더에 준비하며, 스캔 이미지 PDF는 별도 OCR(이미지에서 문자를 읽는 기술)이 필요하고 OCR 결과도 원문과 대조해 품질을 확인해야 한다.

구조는 PDF 텍스트 추출 → 청킹 → 임베딩 생성 → 벡터 저장 → 유사도 검색 → 로컬 LLM 답변 생성 순서로 진행한다. 임베딩은 문장을 숫자 벡터로 바꾸는 과정이고, RAG는 검색 결과를 LLM의 질문 문맥에 넣어 답변하게 하는 방식이다.

PDF 추출부터 벡터 검색까지

먼저 PDF 페이지별 텍스트와 페이지 번호를 함께 저장한다. 페이지 정보가 있어야 답변이 어긋날 때 원문 위치를 다시 확인할 수 있다.

pip install pypdf sentence-transformers faiss-cpu requests numpy
from pathlib import Path
from pypdf import PdfReader
from sentence_transformers import SentenceTransformer
import faiss, json, numpy as np

chunks, meta = [], []
for pdf in Path("docs").glob("*.pdf"):
    for page_no, page in enumerate(PdfReader(str(pdf)).pages, 1):
        text = (page.extract_text() or "").strip()
        for start in range(0, len(text), 800):
            part = text[start:start + 800].strip()
            if part:
                chunks.append(part)
                meta.append({"file": pdf.name, "page": page_no})

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
vectors = model.encode(chunks, normalize_embeddings=True)
index = faiss.IndexFlatIP(vectors.shape[1])
index.add(np.asarray(vectors, dtype="float32"))
faiss.write_index(index, "documents.faiss")
Path("metadata.json").write_text(json.dumps({"chunks": chunks, "meta": meta}, ensure_ascii=False), encoding="utf-8")

임베딩과 원문 메타데이터는 함께 관리해야 한다. 이 예제는 단순화를 위해 텍스트를 800자 단위로 고정해 자르지만, 실제 문서에서는 문단·제목·문장 경계를 고려하고 필요하면 청크를 일정 부분 겹치게 구성하는 편이 검색 품질에 유리하다. 검색 시 질문도 같은 임베딩 모델로 변환한 뒤, 내적 또는 코사인 유사도(벡터 방향의 가까움)를 계산해 상위 결과를 가져온다. 예제에서는 동일한 임베딩 모델로 문서와 질문을 변환하며, 실제 서비스에서는 검색 목적에 맞는 모델을 선택하고 모델이 query/document 전용 인코딩을 지원한다면 해당 방식을 사용하는 것이 좋다. IndexFlatIP는 단순하고 이해하기 쉽지만 문서 수가 많아지면 별도 벡터 데이터베이스를 검토할 수 있다.

question = "환불 조건은 무엇인가요?"
q = model.encode([question], normalize_embeddings=True).astype("float32")
scores, ids = index.search(q, 4)
context = "\n\n".join(
    f"[{meta[i]['file']} p.{meta[i]['page']}]\n{chunks[i]}"
    for i in ids[0]
    if i >= 0
)

로컬 LLM 연결과 선택 기준

검색된 context와 질문을 프롬프트에 넣어 로컬 LLM API로 전송한다. 아래 예시의 주소는 OpenAI 호환 API를 제공하는 로컬 서버의 예시이며, 실제 사용하는 서버에 맞게 주소와 모델 지정 방식을 변경해야 한다. messages는 대화 메시지 목록, role은 메시지 발신 유형, content는 전달할 프롬프트를 뜻하고, model은 서버에 등록된 모델 이름이다. 로컬 LLM 자체는 모델 크기, 양자화 방식, 서버 종류를 실행 환경과 요구 사항에 맞춰 선택해야 한다. 프롬프트에는 “제공된 문서에 근거해 답하고, 근거가 없으면 모른다고 말하라”는 제한을 포함해야 한다.

import requests

prompt = f"문서 근거만 사용해 답하세요. 근거가 없으면 모른다고 하세요.\n문서:\n{context}\n질문: {question}"
response = requests.post(
    "http://127.0.0.1:8000/v1/chat/completions",
    json={"messages": [{"role": "user", "content": prompt}]},
    timeout=120
)
print(response.json())
비교 항목 단순 파일 검색 RAG 파이프라인
검색 방식 키워드 일치 의미 유사도
답변 생성 직접 읽기 로컬 LLM 연결
구축 난이도 낮음 중간
적합한 문서 짧고 명확한 문서 여러 PDF와 자연어 질문

정확한 단어를 찾아야 하면 키워드 검색이 유리하고, 표현이 달라도 의미를 찾아야 하면 벡터 검색이 적합하다. 문서 수가 적으면 파일 기반 저장으로 시작하고, 문서가 늘거나 필터링·권한 관리가 필요하면 벡터 데이터베이스를 선택하라.

자주 묻는 질문

스캔 PDF도 바로 검색할 수 있나요?

아닙니다. 텍스트 추출 결과가 비어 있으면 OCR로 페이지 이미지를 문자로 변환한 뒤 같은 청킹 과정을 적용해야 합니다. 표와 각주가 많은 문서는 추출 후 원문과 내용이 맞는지 일부 페이지를 확인하세요.

RAG를 쓰면 환각이 완전히 사라지나요?

아닙니다. 검색 결과가 틀리거나 관련 청크가 누락되면 로컬 LLM도 잘못 답할 수 있습니다. 답변에 페이지 메타데이터를 표시하고, 중요한 내용은 원문과 대조하는 절차를 두는 것이 좋습니다.

결론

PDF RAG는 추출 품질, 청크 설계, 임베딩 일관성, 검색 결과 검증이 핵심이다. 작은 문서로 파이프라인을 검증한 뒤 규모와 보안 요구에 맞춰 저장소를 확장하라.

관련 글