Ollama와 RAG를 활용한 PDF 기반 로컬 챗봇 LLM 만들기

2025. 3. 27. 00:05· AI 인공지능
목차
  1. Ollama와 RAG를 활용한 PDF 기반 챗봇 개발
  2. 1. 개요
  3. 2. 코드 구조와 흐름
  4. 3. 세부 구현
  5. 4. 실행
  6. 5. 테스트
  7. 5. 성능 최적화 팁
  8. 마무리

Ollama

Ollama와 RAG를 활용한 PDF 기반 챗봇 개발

이번 포스트에서는 Python으로 Ollama와 RAG(검색 증강 생성)를 결합하여 PDF 문서를 기반으로 한 챗봇을 만드는 방법을 소개하겠습니다.
전반적인 프로세스는 아래와 같습니다.

  • PDF 문서 로드
  • 텍스트를 청킹(chunking)
  • 벡터 데이터베이스에 저장
  • 사용자의 질문에 따라 관련 내용을 검색 후 답변

진행하기 앞서 Ollama 의 설치가 필요하므로 설치가 되어있지 않은 분들은 아래 게시글을 참고해 주세요.
https://god-logger.tistory.com/204

 

[로컬 LLM 설치 가이드] 내 PC에 나만의 LLM AI 설치하기

본 포스팅은 개발 지식이 없으신 분들을 타겟하여 작성하였습니다. 참고 바랍니다.코딩 몰라도 괜찮아요!개발 지식이 없어도, 단계별로 따라 하기만 하면 로컬 PC에서 나만의 인공지능 모델을

god-logger.tistory.com

 

1. 개요

PDF 문서를 기반으로 질문에 답변하는 챗봇을 만드는 데 사용하는 기술 및 도구는 아래와 같습니다.  

  • Ollama : 로컬에서 실행 가능한 LLM(Large Language Model) 프레임워크
  • RAG : 검색 기반 생성 모델로, 외부 데이터를 활용해 답변의 정확성을 높임
  • SentenceTransformer : 텍스트를 임베딩으로 변환
  • ChromaDB : 벡터 데이터베이스로 검색 속도 향상
  • pymupdf4llm : PDF를 Markdown 형태로 변환
  • (langchain.text_splitter) RecursiveCharacterTextSplitter : 텍스트 청킹용 스플리터
  • Exaone3.5 : 대한민국 최고의 기업 LG에서 만든 인공지능 모델

목표는 사용자가 PDF 내용을 기반으로 자연스럽고 정확한 답변을 받을 수 있도록 하는 것이죠. 
PDF 문서 내용을 추출할 때 일반적인 텍스트로 뽑고 그걸 자른 뒤 테스트를 해보니 결과가 좋지 못했습니다.
좋은 임베딩 모델에 따라 차이를 보이긴 했지만 한계가 있었습니다.
이에 인공지능 모델이 좀 더 잘 이해할 수 있도록 마크다운 형태로 변환하여 진행하였고 그 결과가 좋아 마크다운 형태로 추출하는 예제로 진행하였습니다.

제공되는 예시코드는 하나의 파일에 차례대로 복사하여 붙여 넣으시면 됩니다.

2. 코드 구조와 흐름

코드는 크게 네 가지 단계로 나뉩니다.

  1. PDF 로드 및 텍스트 청킹
  2. 임베딩 생성 및 벡터 DB 저장
  3. 질문 처리 및 검색
  4. 대화형 챗봇 루프
텍스트 청킹 : 자연어 처리(NLP)에서 문장을 일정한 의미 단위(청크, chunk)로 나누는 과정
임베딩  : 텍스트, 이미지, 오디오 같은 데이터를 고정된 크기의 벡터 표현으로 변환 (텍스트 → 숫자)

 
이제 각 단계를 자세히 살펴보겠습니다.

3. 세부 구현

3.1 필요 라이브러리 설치

pip install pymupdf4llm sentence-transformers chromadb ollama langchain

 

3.2 PDF 로드 및 텍스트 청킹

PDF 파일을 읽고 텍스트를 적절한 크기로 나누는 작업입니다.

import pymupdf4llm
from langchain.text_splitter import RecursiveCharacterTextSplitter

# PDF 파일 경로
file_path = "{파일 경로}"
# 로컬 인공지능 언어 모델 
model = "exaone3.5:latest"

def load_pdf(file_path):
    pdf_data = pymupdf4llm.to_markdown(file_path)
    text = "".join(pdf_data)  # 페이지별 텍스트를 하나의 문자열로 결합
    print("PDF파일 로드..")
    return text

def split_text(text):
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=150)
    return text_splitter.split_text(text)
  • pymupdf4 llm: Markdown 변환을 지원해 더 깔끔한 텍스트 추출이 가능합니다.
  • RecursiveCharacterTextSplitter를 사용해 텍스트를 1500자 단위로 나누고, 150자 오버랩을 설정해 문맥 손실을 줄입니다.

PDF 파일 경로를 지정해 주고, 사용할 모델을 지정해 줍니다. 
exaone3.5:latest를 이용하기 위해서는 ollama에 이미 해당 모델이 다운로드되어 있어야 합니다. 
필요에 따라 해당 모델은 변경하셔도 좋지만 한국어 능력은 한국에서 생산한 엑사원이 아무래도 좋을 듯합니다.

3.3 임베딩 및 벡터 DB 저장

텍스트를 벡터로 변환하고 ChromaDB에 저장합니다.

ChromaDB는 오픈소스 벡터 데이터베이스로, 텍스트, 이미지, 음성 등의 임베딩 데이터를 효율적으로 저장하고 검색할 수 있습니다. 고속 유사도 검색과 간단한 API를 제공하여 AI 응용 프로그램, 추천 시스템, 검색 엔진 등에 활용됩니다. Python 기반으로 개발되었으며, LangChain과 같은 AI 프레임워크와 쉽게 연동할 수 있습니다. 또한, 데이터 지속성(Persistence) 기능을 제공하여 인덱싱 된 정보를 유지할 수 있습니다.
로컬 환경에서 빠르게 테스트하기 좋지만 확장성에 제한이 있어 주로 개인 프로젝트나 소규모 AI 애플리케이션에서 많이 사용됩니다. 
from sentence_transformers import SentenceTransformer
import chromadb

embedder = SentenceTransformer("intfloat/multilingual-e5-small")
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="rag_collection",  metadata={"hnsw:space": "cosine"} )

print("문서 로드 및 임베딩 시작...")

raw_text = load_pdf(file_path)
chunks = split_text(raw_text)
embeddings = embedder.encode(chunks, convert_to_tensor=False)

for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
    collection.add(
        ids=[f"chunk_{i + 1}"],
        embeddings=[embedding.tolist()],
        metadatas=[{"text": chunk}],
    )
  • 임베딩 모델: multilingual-e5-small은 다국어 지원이 뛰어나 한국어 PDF에도 적합합니다.
  • ChromaDB: 로컬에 벡터 데이터를 저장하며, get_or_create_collection으로 컬렉션을 관리합니다.
여러 임베딩 모델을 적용해 보았는데, multilingual-e5-small이 나쁘지 않아 결정했습니다.
정확하게 어느 부분에서 좋았다고 할 정도로 비교한 건 아니고 그냥 여러 개 돌려 본 느낌으로만 결정한 부분이니 다른 좋은 임베딩 모델이 있다면 추천 부탁드립니다. 

테스트한 임베딩 모델
   all-MiniLM-L6-v2
   paraphrase-multilingual-mpnet-base-v2
   jhgan/ko-sroberta-multitask
   intfloat/multilingual-e5-small

 

3.4 질답 관리 클래스 생성 및 시스템  메세지 작성 

대화 히스토리 관리를 위한 클래스를 작성해봅니다.

벡터디비를 통해 가져온 데이터를 이용하여 프롬프트를 만들어주는 기능, 대화 저장 등의 기능을 가진 클래스입니다. 

class Message_manager:
    def __init__(self):
        self._system_msg = {"role": "system", "content": ""}
        self.queue = deque(maxlen=10)  # 최대 10개 대화 저장

    def create_msg(self, role, content):
        return {"role": role, "content": content}

    def system_msg(self, content):
        self._system_msg = self.create_msg("system", content)

    def append_msg(self, content):
        msg = self.create_msg("user", content)
        self.queue.append(msg)

    def get_chat(self):
        return [self._system_msg] + list(self.queue)

    def set_retrived_docs(self, docs):
        self.retrieved_docs = docs

    def append_msg_by_assistant(self, content):
        msg = self.create_msg("assistant", content)
        self.queue.append(msg)

    def generate_prompt(self, retrieved_docs):

        docs = "\n".join(retrieved_docs)

        prompt = [msgManager._system_msg,{
            "role": "system",
            "content": f"문서 내용: {docs}\n질문에 대한 답변은 문서 내용을 기반으로 정확히 제공하시오.",
        }] + list(msgManager.queue)

        return prompt


msgManager = Message_manager() # 객체 생성

msgManager.system_msg(
    "가장 마지막 'user'의 'content'에 대해 답변한다."
    "질문에 답할 때는 'system' 메시지 중 '문서 내용'에 명시된 부분을 우선 참고하여 정확히 답한다."
    "개행은 문장이 끝날때와 서로 다른 주제나 항목을 구분할 때 사용하며, 불필요한 개행은 넣지 않는다."
)

3.5 질문 처리 함수

사용자의 질문을 받아 관련 문서를 검색하고 답변을 생성합니다.

def retrieve_docs(query, collection, embedder, top_k=2):
    query_embedding = embedder.encode(query, convert_to_tensor=False)
    results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
    
    if not results["metadatas"]:  # 검색 결과가 없는 경우
        return ["관련 문서를 찾을 수 없습니다."]
    
    docs = [doc["text"] for doc in results["metadatas"][0]]
    return docs

def generate_answer(query, retrieved_docs, conversation_history):
    # 이전 대화 기록 추가 (최대 10개)
    msgManager.append_msg(query)
    
    # 스트리밍으로 답변 생성
    print("답변: ", end="", flush=True)
    full_answer = ""
    
    msg = msgManager.generate_prompt(retrieved_docs)

    for response in ollama.chat(model=model, messages=msg, stream=True):
        chunk = response["message"]["content"]
        print(chunk, end="", flush=True)  # 한 글자씩 출력
        full_answer += chunk

    return full_answer
  • retrieve_docs(검색): 질문의 임베딩을 생성하고, ChromaDB에서 가장 유사한 문서(top_k=1 또는 2)를 가져옵니다.
  • generate_answer(답변 생성): Ollama의 exaone3.5:2.4b 모델을 사용하며, 스트리밍 방식으로 실시간 출력합니다.
  • 대화 기록: conversation_history를 통해 이전 대화를 반영합니다. 해당 큐는 5개까지만 저장할 예정입니다. 

 

3.5 대화형 루프

사용자와의 인터랙션을 처리합니다.
한 번만 보내고 프로그램이 꺼지는 걸 방지하기 위해 무한 반복 처리를 하였습니다. 

from collections import deque

def chat_loop():
	
    print("RAG 챗봇 시작! 질문 입력 (종료하려면 'exit' 입력):")
    
    while True:
        query = input("> ")
        if query.lower() == "exit":
        	print("챗봇 종료!")
            break

        retrieved_docs = retrieve_docs(query, collection, embedder, top_k=2)
        
        answer = generate_answer(query, retrieved_docs)
        msgManager.append_msg_by_assistant(answer)
        
# 실행
if __name__ == "__main__":
    chat_loop()
  • deque: 최대 5개의 대화만 저장해 메모리 효율성을 유지합니다.
  • 루프: 사용자가 "exit"를 입력할 때까지 질문과 답변을 반복합니다.

 

4. 실행

  1. PDF 파일 경로(file_path)와 모델명(model)을 설정.
  2. 코드 실행 

제 경우 파이참 (인텔리J 기반 파이썬 IDE) 커뮤니티 버전으로 실행했습니다.

 

5. 테스트

테스트를 위해 청약홈에 아무 공고문을 다운로드하여 진행해 보았습니다. 

파주 운정신도시 아파트군요!

해당 문서를 보며 관련 질문들을 작성하고 이에 대한 답변을 확인하는 테스트를 해본 결과는 아래와 같습니다. 

> 주의해야할 사항들도 알려줘.
임베딩 생성: 0.07초
벡터 검색:0. 00초

답변: ### 주의해야 할 사항들

1. **대출 지원 미지원**:
  - 사업주체는 분양대금 납부와 관련된 대출 금융기관 알선을 제공하지 않습니다. 따라서 청약 및 계약 전에 개인적으로 대출 계획을 세우고 금융기관과 상담하는 것이 중요합니다.

2. **공급 금액 상세**:
  - 공급 금액은 인지대, 소유권 이전 등기비용, 취득세 등 제세공과금을 포함하지 않습니다. 잔금 납부 시에는 이러한 비용이 추가로 발생할 수 있으므로 이에 대한 예산을 별도로 확보해야 합니다.

3. **납부 기한 및 연체료**:
  - 잔금 납부 기한을 지키지 않으면 연체료가 부과될 수 있습니다. 납부 일정을 엄격히 준수해야 합니다.

4. **공급 조건의 변동 가능성**:
  - 초기 공고 이후 공급 시점에 따라 분양 조건이 변경될 수 있습니다. 따라서 최종 계약 전에 조건 변동 사항을 반드시 확인해야 합니다.

5. **구체적인 타입별 정보**:
  - 제공된 표에서 볼 수 있듯이, 각 주택 유형 (예: 59A, 59B, 84A 타입)에 따라 공급 금액과 면적이 다릅니다. 계약 전에 원하는 주택형을 정확히 확인하고 선택해야 합니다.

이러한 사항들을 미리 인지하고 대비함으로써 원활한 계약과 입주를 진행할 수 있습니다. 추가적인 정보나 의문 사항이 있으면 공식 공고나 관련 법률 조항을 참조하거나 전문가와 상담하는 것을 권장드립니다.
LLM 추론: 14.47초 
[총 소요 시간: 14.54초]

> 59타입은 몇개정도 공고에 나왔어?
임베딩 생성: 0.07초 
벡터 검색: 0.00초

답변: 공고에 명시된 59타입 세대 수는 **3개**입니다.

* 59A 타입: 1세대 (902동 2001호)
* 59B 타입: 2세대 (901동 1804호, 907동 1204호)

LLM 추론: 6.31초 
[총 소요 시간: 6.39초]

글씨로 쓰니 안이뻐서 그냥 캡쳐된 사진도 올립니다.

꽤 정확하고 만족할만한 수준의 답변으로 확인하였습니다.
실제 테스트시에는 7B를 사용했는데, 2.4B도 나름 괜찮은 퀄리티로 답변하는 걸 확인할 수 있었습니다.

시스템 사양이 낮다면 2.4B로 진행하시는 걸 추천드립니다.

5. 성능 최적화 팁

  • 청크 크기 조정: chunk_size와 chunk_overlap을 조정해 검색 정확도와 속도를 최적화하세요.
  • 임베딩 모델 변경: 다른 한국어 특화 모델(jhgan/ko-sroberta-multitask 등)로 변경 가능.
  • Ollama 모델: 다른 강력한 모델로 변경하여 비교

 

마무리

Ollama와 RAG를 활용해 PDF 기반 챗봇을 만들어 보았습니다.
회사에서 PDF 파일을 읽어야 하는데, 외부로 유출되면 안 되는 PDF 파일이다 보니 아무래도 ChatGPT나 다른 LLM에 올려 물어보기엔 보안적인 문제가 있었습니다.
이왕 이렇게 된 거 퇴근 이후 간단하게 만들어 사용해 보았는데 속도나 퀄리티가 나름 만족스러워 이를 공유하고자 글을 작성하게 되었습니다.
개인 프로젝트나 학습 용도로 적합한 것 같습니다.
제게도 재밌는 공부가 된 것 같네요.


전체코드

더보기
import pymupdf4llm

from sentence_transformers import SentenceTransformer
from langchain.text_splitter import RecursiveCharacterTextSplitter
import chromadb
import ollama
import time
from collections import deque

file_path = "{파일경로+파일명}"  # 실제 PDF
model = "exaone3.5:latest"  # 사용할 Ollama 모델

# 1. PDF 로드 및 청킹
def load_pdf(file_path):

    pdf_data = pymupdf4llm.to_markdown(file_path)
    text = "".join(pdf_data)  # 페이지별 텍스트를 하나의 문자열로 결합
    print("PDF파일 로드..")
    return text


def split_text(text):
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
    return text_splitter.split_text(text)


# 2. 임베딩 및 벡터 DB 저장

print("환경 구성 중.")
embedder = SentenceTransformer("intfloat/multilingual-e5-small")
client = chromadb.PersistentClient(path="./chroma_db")
client.delete_collection("rag_collection")
collection = client.get_or_create_collection(name="rag_collection",  metadata={"hnsw:space": "cosine"} ) # 코사인 메트릭 설정)

print("문서 로드 및 임베딩 시작...")
start_time = time.time()
raw_text = load_pdf(file_path)
chunks = split_text(raw_text)
embeddings = embedder.encode(chunks, convert_to_tensor=False)

for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
    collection.add(
        ids=[f"chunk_{i + 1 }"],
        embeddings=[embedding.tolist()],
        metadatas=[{"text": chunk}],
    )
    
print(f"문서 준비 완료! 소요 시간: {time.time() - start_time:.2f}초")

# 3. 질문 처리 함수
def retrieve_docs(query, collection, embedder, top_k=2):
    start = time.time()
    query_embedding = embedder.encode(query, convert_to_tensor=False)

    print(f"임베딩 생성: {time.time() - start:.2f}초")

    start = time.time()
    results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
    print(f"벡터 검색: {time.time() - start:.2f}초")

    if not results["metadatas"]:  # 검색 결과가 없는 경우
        return ["관련 문서를 찾을 수 없습니다."]

    docs = [doc["text"] for doc in results["metadatas"][0]]

    return docs

# 4. 메세지 매니저 클래스 구현
class Message_manager:
    def __init__(self):
        self._system_msg = {"role": "system", "content": ""}
        self.queue = deque(maxlen=10)  # 최대 10개 대화 저장

    def create_msg(self, role, content):
        return {"role": role, "content": content}

    def system_msg(self, content):
        self._system_msg = self.create_msg("system", content)

    def append_msg(self, content):
        msg = self.create_msg("user", content)
        self.queue.append(msg)

    def get_chat(self):
        return [self._system_msg] + list(self.queue)

    def set_retrived_docs(self, docs):
        self.retrieved_docs = docs

    def append_msg_by_assistant(self, content):
        msg = self.create_msg("assistant", content)
        self.queue.append(msg)

    def generate_prompt(self, retrieved_docs):

        docs = "\n".join(retrieved_docs)

        prompt = [msgManager._system_msg,{
            "role": "system",
            "content": f"문서 내용: {docs}\n질문에 대한 답변은 문서 내용을 기반으로 정확히 제공하시오.",
        }] + list(msgManager.queue)

        return prompt


msgManager = Message_manager()


# 시스템 메세지 등록
msgManager.system_msg(
    "가장 마지막 'user'의 'content'에 대해 답변한다."
    "질문에 답할 때는 'system' 메시지 중 '문서 내용'에 명시된 부분을 우선 참고하여 정확히 답한다."
    "개행은 문장이 끝날때와 서로 다른 주제나 항목을 구분할 때 사용하며, 불필요한 개행은 넣지 않는다."
)

# 5. 답변 생성 함수
def generate_answer(query, retrieved_docs):
    start = time.time()

    # 이전 대화 기록 추가 (최대 10개)
    msgManager.append_msg(query)

    # 스트리밍으로 답변 생성
    print("답변: ", end="", flush=True)
    full_answer = ""

    msg = msgManager.generate_prompt(retrieved_docs)

    for response in ollama.chat(model=model, messages=msg, stream=True):
        chunk = response["message"]["content"]
        print(chunk, end="", flush=True)  # 한 글자씩 출력
        full_answer += chunk
    print()  # 줄바꿈
    print(f"LLM 추론: {time.time() - start:.2f}초")
    return full_answer

# 6. 대화형 루프
def chat_loop():

    print("RAG 챗봇 시작! 질문 입력 (종료하려면 'exit' 입력):")

    while True:
        query = input("> ")
        if query.lower() == "exit":
            print("챗봇 종료!")
            break

        start_time = time.time()
        retrieved_docs = retrieve_docs(query, collection, embedder, top_k=2)

        answer = generate_answer(query, retrieved_docs)
        msgManager.append_msg_by_assistant(answer)

        print(f"[총 소요 시간: {time.time() - start_time:.2f}초]\n")


# 실행
if __name__ == "__main__":
    chat_loop()

 

반응형
저작자표시 비영리 변경금지 (새창열림)

'AI 인공지능' 카테고리의 다른 글

이미지 생성 유명 모델 Flux.1 dev ( 또는 schnell ) 를 실행해보자.  (1) 2025.04.05
ㅡ[로컬 LLM 설치 가이드] 내 PC에 나만의 LLM AI 설치하기  (2) 2025.03.22
Chatbox vs Open-WebUI : 간편함과 UI 사이에서의 고민  (0) 2025.03.11
OpenAI Whisper - STT 및 자동 자막 생성  (0) 2025.02.10
  1. Ollama와 RAG를 활용한 PDF 기반 챗봇 개발
  2. 1. 개요
  3. 2. 코드 구조와 흐름
  4. 3. 세부 구현
  5. 4. 실행
  6. 5. 테스트
  7. 5. 성능 최적화 팁
  8. 마무리
'AI 인공지능' 카테고리의 다른 글
  • 이미지 생성 유명 모델 Flux.1 dev ( 또는 schnell ) 를 실행해보자.
  • ㅡ[로컬 LLM 설치 가이드] 내 PC에 나만의 LLM AI 설치하기
  • Chatbox vs Open-WebUI : 간편함과 UI 사이에서의 고민
  • OpenAI Whisper - STT 및 자동 자막 생성
신·기록
신·기록
개발 관련 이슈 해결방안, 활용 방법, 제품 리뷰 등을 기록하는 개인 블로그
문제해결집개발 관련 이슈 해결방안, 활용 방법, 제품 리뷰 등을 기록하는 개인 블로그
신·기록
문제해결집
신·기록
전체
오늘
어제
  • 분류 전체보기 (183) N
    • 뉴스 (9)
    • 제품 리뷰 (19)
    • 운영체제 및 서버 (56) N
      • Linux (26)
      • Mac OS (8)
      • Windows (1) N
      • Proxmox (9) N
      • Nginx (2)
      • Raspberry Pi (3)
      • Odroid M2 (4)
    • AI 인공지능 (7)
    • 모바일 (4)
      • Flutter (1)
      • 안드로이드 (1)
    • 데이터베이스 (14) N
      • Mysql (10) N
    • 자바 (11)
      • Spring Framework (9)
    • 자바스크립트 (20)
      • Node JS (4)
      • Electron Framework (11)
      • React & Next.js (4)
    • 클라우드 플랫폼 (4)
    • 학습 (4)
    • 이슈 (22) N
    • 비디오게임 에뮬레이터 (2)
    • CPU 성능 순위 (1)
    • 그래픽카드 성능 순위 (1)
    • 기타 (7)

블로그 메뉴

  • 이용 원칙
  • 태그
  • 방명록

인기 글

최근 글

09-06 05:31
관련 문의는 방명록에 부탁드립니다.
본 블로그의 스킨 출처는 아래와 같습니다.
hELLO · Designed By 정상우.v4.2.2
신·기록
Ollama와 RAG를 활용한 PDF 기반 로컬 챗봇 LLM 만들기
상단으로

티스토리툴바

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.