PDF 챗봇을 직접 만들어 보았습니다 — 실제로 효과가 있었던 방법들
요약
PDF 문서를 기반으로 질문에 답변하는 챗봇을 구현하며 겪은 시행착오와 성공적인 RAG 파이프라인 구축 과정을 다룹니다. 단순 키워드 검색의 한계를 극복하기 위해 청킹, 임베딩, 벡터 DB, LLM을 결합한 표준적인 방식을 제안합니다.
핵심 포인트
- 단순 키워드 매칭과 TF-IDF 방식은 문맥 파악에 한계가 있음
- RAG의 핵심은 적절한 청크 분할, 임베딩, 검색, 생성의 단계적 결합
- OpenAI 임베딩 모델과 벡터 데이터베이스를 활용한 효율적 구현 가능
- 프롬프트에 검색된 문맥(context)을 포함하는 것이 답변 정확도에 결정적
지난달, 저는 사용자들이 PDF를 업로드하고 그에 대해 질문할 수 있는 기능을 구현해야 했습니다. 간단해 보이죠? 정규 표현식 (regex)을 좀 사용하거나 키워드 검색 (keyword search) 정도면 될 거라고 생각했습니다. 하지만 이틀 뒤, 저는 제가 만든 테스트 케이스와 정확히 일치하지 않는 질문에는 모두 실패하는 스파게티 코드의 벽을 마주하고 있었습니다.
저는 백엔드 개발자이지, 자연어 처리 (NLP) 연구원이 아닙니다. 하지만 저에게는 신뢰할 수 있고, 확장 가능하며, 며칠 내로 출시할 수 있는 솔루션이 필요했습니다. 제가 무엇을 시도했고, 무엇이 실패했으며, 마침내 어떤 방식이 제대로 작동했는지에 대한 이야기를 들려드리겠습니다.
문제 (The Problem)
한 클라이언트가 지식 베이스 (knowledge base) 기능을 원했습니다. PDF(매뉴얼, 보고서 등)를 업로드한 다음, 자연어 질문을 던지면 해당 PDF에서 답변을 추출해내는 기능입니다. 문서들은 비정형 (unstructured) 데이터였고, 때로는 수백 페이지에 달했습니다. 저는 이를 기존 웹 앱에 통합해야 했습니다.
처음 시도했던 것 (그리고 왜 힘들었는가)
저는 아주 단순한 방식으로 시작했습니다. PyPDF2로 텍스트를 추출하고, 단락별로 나눈 뒤, 간단한 TF-IDF 인덱스를 구축하여 가장 관련 있는 단락을 반환하는 방식이었습니다. 그런 다음 그 단락을 휴리스틱 (heuristic) 답변 추출 방식(예: 쿼리 단어가 포함된 문장 찾기)에 입력했습니다.
결과는 처참한 실패였습니다.
- 사용자는 "최대 온도는 얼마인가요?"라고 물었지만, PDF에는 "작동 온도: 150°C"라고 적혀 있었습니다. 제 키워드 매칭 (keyword matching)은 "최대 (maximum)"와 "작동 (operating)"이 다르기 때문에 이를 놓쳤습니다.
- 저는 단 하나의 단락만 반환했기 때문에 여러 문장으로 구성된 답변은 불가능했습니다.
- 모호함이 도처에 깔려 있었습니다. 예를 들어 "밸브 (the valve)"라는 단어는 50페이지 전에 언급되었을 수도 있습니다.
질의응답 (QA) 쌍을 사용하여 작은 BERT 모델을 미세 조정 (fine-tuning)하는 것도 시도해 보았습니다. 하지만 이는 클라이언트가 보유하지 않은 엄청난 양의 라벨링된 데이터 (labeled data)를 필요로 했습니다. 막다른 길이었습니다.
결국 성공한 방법: 청크 + 임베딩 + 검색 + 생성 (Chunk + Embed + Retrieve + Generate)
일주일간의 좌절 끝에, 저는 현재 AI 분야에서 거의 표준처럼 쓰이며 매우 효과적인 파이프라인으로 전환했습니다:
- PDF를 청크 (Chunk) 단위로 분할: 약 500 토큰(tokens) 정도의 겹치는(overlapping) 세그먼트로 분할합니다.
- 각 청크를 임베딩 (Embed): 임베딩 모델 (저는 OpenAI의
text-embedding-ada-002를 사용했습니다)을 사용하여 각 청크를 벡터(vector)로 변환합니다. - 벡터를 벡터 데이터베이스 (Vector Database)에 저장: Pinecone을 사용했지만, 어떤 것이든 상관없습니다. 프로토타이핑 단계라면 로컬 FAISS 인덱스도 작동합니다.
- 사용자가 질문을 던지면 → 질문을 임베딩합니다 → 코사인 유사도 (cosine similarity)를 기준으로 상위 K개의 청크를 검색 (retrieve) 합니다.
- 해당 청크들과 질문을 LLM (GPT-3.5-turbo)에 전달: "아래의 문맥(context)만을 사용하여 질문에 답하세요."라는 프롬프트와 함께 전달합니다.
제가 최종적으로 사용한 핵심 Python 코드는 다음과 같습니다 (단순화됨):
import openai
from PyPDF2 import PdfReader
import tiktoken
...
배운 점 (어렵게 얻은 교훈)
청크 크기 (Chunk size)가 중요합니다. 너무 작으면 (예: 200 토큰) → 답변이 파편화됩니다. 너무 크면 (예: 2000 토큰) → LLM의 컨텍스트 윈도우 (context window)가 관련 없는 정보로 가득 차게 되어 검색 정확도가 떨어집니다. 제 문서의 경우 50 토큰의 중첩(overlap)을 둔 500 토큰 크기가 잘 작동했습니다.
임베딩 모델 (Embedding model) 선택. 저렴하고 성능이 좋기 때문에 text-embedding-ada-002로 시작했습니다. 하지만 전문 분야 (법률, 의료 등)의 경우 미세 조정 (fine-tuned)된 모델이 필요할 수 있습니다. 저의 일반적인 매뉴얼에는 충분했습니다.
LLM은 항상 정직하지는 않습니다. "문맥만을 사용하라"는 프롬프트를 사용하더라도, GPT는 가끔 환각 (hallucination) 현상을 보였습니다. 그래서 후처리 (post-processing) 단계를 추가했습니다. 답변에 "문맥에 기반하여"와 같은 문구가 포함되어 있으면 괜찮지만, 청크에서 찾을 수 없는 내용이 포함되어 있다면 버립니다. 또한, 이처럼 좁은 범위의 작업에서는 GPT-4보다 저렴하면서도 환각이 적은 GPT-3.5-turbo와 같은 더 작은 모델을 사용할 수도 있습니다.
비용. 100페이지 분량의 PDF (약 1000개의 청크라고 가정)를 임베딩하는 데 약 $0.02가 듭니다. 각 쿼리(query)를 임베딩하는 비용은 무시할 수 있는 수준입니다. LLM 호출 비용은 쿼리당 약 $0.001입니다. 트래픽이 적은 앱이라면 이 정도면 괜찮습니다. 트래픽이 많다면 빈번한 답변을 캐싱 (caching)하거나 Llama 3와 같은 로컬 LLM 사용을 고려하십시오.
고려했던 대안들:
- LangChain을 사용했다면 상용구 코드 (boilerplate)를 줄일 수 있었겠지만, 저는 모든 단계를 이해하고 싶었습니다. 나중에 프로덕션 환경을 위해 LangChain으로 마이그레이션했으며, 매우 견고합니다.
- **전체 텍스트 검색 (Full-text search, Elasticsearch)**을 LLM과 결합하는 방식도 작동할 수 있지만, 의미론적 이해 (semantic understanding)를 놓치게 됩니다.
- ai.interwestinfo.com과 같은 **상용 서비스 (Commercial services)**는 턴키 (turnkey) 솔루션을 제공합니다. 인프라를 직접 구축하고 싶지 않다면 이는 타당한 선택입니다. 하지만 제가 설명한 방식은 개방적이고 커스터마이징이 가능합니다.
이 방식이 작동하지 않는 경우
- PDF에 복잡한 표, 다이어그램 또는 필기체가 포함되어 있다면 텍스트 추출만으로는 실패합니다. OCR 또는 멀티모달 (multimodal) 모델이 필요할 것입니다.
- 문서가 매우 크다면 (수천 페이지), 더 정교한 청킹 (chunking) 전략 (예: 섹션별 분할)과 더 나은 벡터 DB (vector DB)가 필요합니다.
- 지연 시간 (latency)이 매우 중요하다면 (<1초), LLM 호출이 병목 현상 (bottleneck)이 됩니다. 캐싱을 사용하거나 더 작은 모델을 사용할 수 있습니다.
지금 다시 한다면 다르게 할 점
저는 첫날부터 LangChain을 사용하여 그들의 RecursiveCharacterTextSplitter와 OpenAI 및 Pinecone과의 내장 통합 기능을 활용했을 것입니다. 하지만 처음부터 로우 코드 (raw code)를 작성한 것은 다행이라고 생각합니다. 덕분에 문제가 발생했을 때 파이프라인 (pipeline)을 디버깅하는 데 도움이 되었습니다.
또한, 피드백 루프 (feedback loop)를 추가하겠습니다. 사용자가 답변을 평가하게 하고, 그 평가를 사용하여 시간이 지남에 따라 검색 (retrieval) 또는 프롬프트 (prompt)를 미세 조정 (fine-tune)하는 방식입니다.
여러분의 차례입니다
이 스택 (chunk → embed → retrieve → generate)은 놀라울 정도로 견고합니다. 문서 Q&A 시스템을 구축하고 있다면, 여러분에게 어떤 방식이 효과적이었는지 듣고 싶습니다. 다른 검색 (retrieval) 방법을 사용하셨나요? 희소 검색 (Sparse) 대 밀집 검색 (dense)? 어떤 청킹 (chunking) 전략이 가장 좋은 결과를 주었나요? 여러분의 경험을 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기