
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. 코드 구조와 흐름
코드는 크게 네 가지 단계로 나뉩니다.
- PDF 로드 및 텍스트 청킹
- 임베딩 생성 및 벡터 DB 저장
- 질문 처리 및 검색
- 대화형 챗봇 루프
텍스트 청킹 : 자연어 처리(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. 실행
- PDF 파일 경로(file_path)와 모델명(model)을 설정.
- 코드 실행

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 ) 를 실행해보자. (0) | 2025.04.05 |
---|---|
[로컬 LLM 설치 가이드] 내 PC에 나만의 LLM AI 설치하기 (0) | 2025.03.22 |
Chatbox vs Open-WebUI : 간편함과 UI 사이에서의 고민 (0) | 2025.03.11 |
OpenAI Whisper - STT 및 자동 자막 생성 (0) | 2025.02.10 |

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. 코드 구조와 흐름
코드는 크게 네 가지 단계로 나뉩니다.
- PDF 로드 및 텍스트 청킹
- 임베딩 생성 및 벡터 DB 저장
- 질문 처리 및 검색
- 대화형 챗봇 루프
텍스트 청킹 : 자연어 처리(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. 실행
- PDF 파일 경로(file_path)와 모델명(model)을 설정.
- 코드 실행

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 ) 를 실행해보자. (0) | 2025.04.05 |
---|---|
[로컬 LLM 설치 가이드] 내 PC에 나만의 LLM AI 설치하기 (0) | 2025.03.22 |
Chatbox vs Open-WebUI : 간편함과 UI 사이에서의 고민 (0) | 2025.03.11 |
OpenAI Whisper - STT 및 자동 자막 생성 (0) | 2025.02.10 |