본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 22. 21:49

.NET 8, Ollama, React를 사용하여 30분 만에 로컬 RAG 챗봇 구축하기

요약

.NET 8, Ollama, React를 활용하여 데이터 유출 걱정 없이 로컬에서 실행되는 RAG 챗봇 구축 방법을 소개합니다. 비용 절감과 개인정보 보호를 위해 클라우드 API 대신 로컬 LLM과 임베딩 모델을 사용하는 아키텍처를 제안합니다.

핵심 포인트

  • Ollama를 활용한 완전한 로컬 LLM 환경 구축
  • RAG 기술을 통한 컨텍스트 윈도우 한계 및 비용 문제 해결
  • .NET 8과 React를 결합한 풀스택 아키텍처 구성
  • 데이터 보안을 위해 외부 클라우드 전송 없는 로컬 인제스션 구현

저는 40페이지 분량의 내부 API 명세서 PDF를 업로드하고 "검색 엔드포인트의 속도 제한(rate limit)은 무엇인가요?"라고 물었습니다. 그리고 다음과 같은 답변을 받았습니다: "API 키당 분당 100회 요청이며, 최대 200회까지 버스트(bursts)가 가능합니다. 문서의 4.2절을 참조하세요." 인용구와 함께 말이죠. 단 3초 만에 말입니다. 전체 스택은 제 노트북에서 실행됩니다. Ollama는 무료이며 로컬에서 실행되고, 제가 사용한 임베더(embedder) 또한 무료이며 로컬이기 때문에 개발 과정에서 LLM 크레딧 비용이 0달러 들었습니다. 저장소(repo)는 여기에 있습니다. 이슈(issues)와 PR(Pull Requests)은 언제나 환영합니다.

이것은 빌드 로그(build log)입니다. 모든 단계가 한 번에 성공하는 튜토리얼이 아니라, 어떤 결정이 유효했고 어떤 결정을 다시 했는지 알려드리는 빌드 로그입니다.

대부분의 "PDF와 채팅하기" 데모가 가진 문제점

2025년 초에 읽은 모든 "PDF와 채팅하기" 튜토리얼은 동일한 형태를 띠고 있었습니다: OpenAI를 열고, API 키를 붙여넣고, 50페이지 분량의 PDF를 컨텍스트 윈도우(context window)에 밀어 넣은 채 gpt-4를 호출하고, 답변을 얻고, 질문당 0.03달러를 지불하고, 이를 반복하는 방식입니다. 데모용으로는 작동할지 모릅니다. 하지만 실제로 업무에서 사용할 도구로서는 적합하지 않습니다. 그 이유는 다음과 같습니다:

  1. PDF에 고객 데이터, 내부 가격 정보 또는 미발표 기능이 포함되어 있을 수 있습니다. 이러한 데이터가 OpenAI의 학습 파이프라인이나 타인의 로그로 전송되는 것을 원치 않을 것입니다.
  2. 비용이 누적됩니다. 팀에서 하루에 50번 사용한다면, 사용자당 월 45달러가 소요됩니다.
  3. 긴 PDF에 대해 모델은 어차피 환각(hallucination) 현상을 일으킵니다. 100페이지 분량을 128k 컨텍스트 윈도우에 집어넣으면 모델은 중간 내용을 잊기 시작합니다.

해결책은 **RAG (Retrieval-Augmented Generation, 검색 증강 생성)**입니다. PDF 전체를 보내지 말고, 질문과 실제로 관련이 있는 3~5개의 청크(chunks)만 보내는 것입니다. 나머지 작업은 동일합니다: 청크를 임베딩(embed)하고, 질문을 임베딩하고, 가장 유사한 매칭 항목을 찾아 질문과 함께 LLM에 보냅니다. 하지만 비용과 개인정보 보호 측면 모두 100배 개선됩니다.

실제 요구 사항은 다음과 같습니다:

PDF를 업로드한다. 질문을 한다. 데이터가 내 노트북을 떠나지 않고 월간 청구서도 발생하지 않으며, 5초 이내에 인용구가 포함된 문서 답변을 얻는다.

아키텍처 (The architecture)

하나의 .NET 8 솔루션, 하나의 React 앱, 하나의 Ollama 프로세스, 그리고 클라우드 의존성 제로.

[ PDF 업로드 ]
      |
      v
...

핵심적인 세부 사항은 모든 것이 localhost에서 실행된다는 점입니다. Ollama는 http://localhost:11434에서 대기합니다. .NET API는 http://localhost:5000에서 대기합니다. React 개발 서버는 http://localhost:5173에서 대기합니다. 데이터는 기기를 떠나지 않습니다. 유일한 외부 네트워크 호출은 React 의존성을 설치하기 위한 npm 호출뿐이며, 이마저도 의존성을 캐싱해 두었다면 오프라인으로 수행할 수 있습니다.

파트 1 — PDF 인제스션 (the PDF ingestion)

전체 인제스션 (ingestion) 파이프라인은 두 개의 서비스로 구성됩니다: 텍스트 추출 및 청킹 (chunking)을 위한 PdfService, 그리고 각 청크를 벡터화 (vectorize)하기 위한 EmbeddingService입니다. 그 후 청크들은 VectorStore로 들어갑니다.

PdfServicePdfPig를 사용합니다. 이는 네이티브 의존성이 없는 순수 C# PDF 라이브러리입니다. 텍스트 추출은 쉬운 부분입니다. 흥미로운 부분은 청킹 (chunking)입니다.

public List<DocumentChunk> ExtractAndChunk(
    string documentId, string documentName, Stream pdfStream)
{
...

주의 깊게 봐야 할 두 가지 사항이 있습니다.

첫째, 저는 문자가나 토큰 (token)이 아닌 단어 (words) 단위로 청킹을 합니다. 단어 기반 청킹은 매우 단순하며 크기를 예측할 수 있습니다: 500단어 ≈ 650토큰으로, 임베더 (embedder)의 입력 제한 범위 내에 충분히 들어옵니다. 토큰 인지형 (Token-aware) 청킹이

nomic-embed-text는 137M 파라미터 규모의 임베딩 (embedding) 모델입니다. CPU에서 실행되며, 제 M1 맥에서 청크 (chunk)당 약 50ms가 소요되고 768차원 벡터를 생성합니다. 차원 수는 제 코드에서 중요하지 않습니다. VectorStore는 이를 float[]로 처리하기 때문입니다. 나중에 다른 임베더 (embedder)로 교체하고 싶을 때, 모델 이름 문자열 하나만 바꾸면 나머지는 그대로 작동합니다.

중요한 연결 설정은 Program.cs에 있습니다:

builder.Services.AddHttpClient<OllamaService>(client =>
{
    client.BaseAddress = new Uri(ollamaBaseUrl);
...

5분 타임아웃 (timeout) 설정은 과한 걱정이 아닙니다. Ollama에 처음 질문을 던질 때, 모델은 디스크에서 메모리로 로드되어야 합니다. llama3.2를 사용하는 콜드 스타트 (cold start) 시에는 815초가 소요됩니다. CPU 전용 머신에서는 긴 답변을 생성할 때 실제 생성 시간이 3060초까지 걸릴 수 있습니다. HttpClient의 기본 타임아웃은 100초입니다. 이는 문제가 될 수 있습니다.

파트 3 — 벡터 스토어 (vector store)

여기서 저는 실제 벡터 데이터베이스 (vector database)를 사용할 뻔했습니다. ChromaDB, Qdrant, pgvector 모두 좋은 옵션입니다. 하지만 저는 락 (lock)을 사용한 인메모리 (in-memory) 리스트를 구현하여 배포했습니다.

public class VectorStore
{
    private readonly List<DocumentChunk> _chunks = new();
...

코사인 유사도 (cosine similarity)는 표준 교과서 공식입니다. 별도의 트릭은 없습니다. 브루트 포스 (brute-force) 스캔의 시간 복잡도는 O(n * d)이며, 여기서 n은 청크의 개수이고 d는 임베딩 차원입니다. n=1000개 청크와 d=768일 때, 쿼리당 768k 번의 곱셈이 발생합니다. 최신 CPU에서는 약 5ms 내에 실행됩니다. 몇 개의 PDF가 업로드된 개인용 챗봇의 경우, 브루트 포스가 정답입니다.

언제 실제 벡터 데이터베이스로 전환해야 할까요? n이 약 50,000개 청크(대략 대형 PDF 200권 분량)를 초과하거나, 검색 지연 시간 (latency) 예산이 20ms 미만으로 떨어질 때입니다. 이 앱은 두 경우 모두 해당되지 않습니다.

lock을 넣은 이유는 React 프론트엔드가 여러 브라우저 탭에서 동시에 /api/chat을 호출할 수 있고, AddChunks가 업로드 엔드포인트에서 실행되기 때문입니다. List<T>에 대한 동시 읽기 및 쓰기는 예외를 발생시킵니다. 이 정도 규모에서는 5줄짜리 lock이 실제 데이터베이스를 사용하는 것보다 비용이 적게 듭니다.

파트 4 — RAG 파이프라인 (RAG pipeline)

ChatService.AnswerQuestionAsync가 전체 RAG 파이프라인 (RAG pipeline)입니다. 다섯 단계가 모두 하나의 메서드에 담겨 있으며, 30초 안에 모두 읽을 수 있습니다:

public async Task<ChatResponse> AnswerQuestionAsync(ChatRequest request)
{
    // 1. 무료 로컬 모델을 사용하여 사용자의 질문을 임베딩 (Embed) 합니다.
...

시스템 프롬프트 (System prompt)는 파일 전체에서 가장 중요한 줄입니다:

"제공된 컨텍스트 (Context)만을 사용하여 답변하세요. 만약 컨텍스트에 충분한 정보가 없다면, 그렇다고 말하세요."

이 단 한 문장이 환각 (Hallucination)을 80% 줄여줍니다. 이 문장이 없다면, llama3.2는 PDF에 다른 내용이 적혀 있더라도 "속도 제한은 분당 100회입니다"라고 즐겁게 답변할 것입니다. 왜냐하면 분당 100회는 모델이 학습을 통해 배운 일반적인 답변이기 때문입니다. 하지만 이 문장이 있으면, 모델은 제가 보낸 청크 (Chunks)에서 답을 찾거나, 답을 찾을 수 없음을 인정합니다.

topK: 5는 제가 방어해야 할 마법의 숫자입니다. 5개의 청크 × 500단어 = 2,500단어의 컨텍스트입니다. 이는 llama3.2 (8k 컨텍스트)에게 적절한 프롬프트 (Prompt) 크기이며, "검색 및 업로드 엔드포인트의 속도 제한을 비교하세요"와 같은 복합적인 질문에 실제로 답변할 수 있을 만큼 충분한 여유를 모델에게 제공합니다. 3개는 너무 적었습니다. 10개는 노이즈 (Noise)를 유발하기 시작했습니다.

파트 5 — 내가 실수한 것들

여러분이 이 부분을 보러 오셨을 겁니다. 저를 괴롭혔던 다섯 가지를 비용이 많이 든 순서대로 나열하겠습니다.

5.1 "인메모리 벡터 스토어 (in-memory vector store)"의 트레이드오프 (Trade-off)

작성하기 빨랐기 때문에 인메모리 List<DocumentChunk>를 구현하여 배포했습니다. 그 대가는 .NET API를 재시작할 때 업로드된 모든 문서가 사라진다는 것입니다. 사용자는 다시 업로드해야 합니다.

데모용으로는 괜찮습니다. 하지만 실제 도구로서는 괜찮지 않습니다. 해결 방법은 AddChunks 시점에 임베딩 (Embeddings)을 SQLite에 영구 저장하고, 시작 시 로드하는 것입니다. 코드 약 30줄 정도면 됩니다. 저는 계속 "다음 주말에 해야지"라고 스스로에게 말하며 아직 하지 않았습니다. 만약 이 코드를 포크 (Fork)하여 이 기능을 추가한다면, 저에게 PR (Pull Request)을 보내주세요.

5.2 PDF 텍스트 추출 순서

PdfPig는 PDF의 콘텐츠 스트림(content stream)에 나타나는 순서대로 텍스트를 추출합니다. 대부분의 PDF에서는 우리가 읽는 순서와 일치합니다. 하지만 일부 PDF(학술 논문, 다단 레이아웃, 스캔 후 OCR 처리된 문서)의 경우, 이 순서가 완전히 잘못될 수 있습니다. 한 페이지가 문단 구분 없이 "결론 섹션 1 서론 ... 토론"과 같은 식으로 반환될 수 있습니다.

해결 방법은 page.Text를 사용하되 PdfPig의 ReadingOrderDetector를 함께 사용하거나, 순서가 깨진 경우 OCR(Tesseract NuGet 래퍼를 통한 Tesseract)로 대체하는 것입니다. 저의 실제 사용 사례(내부 API 문서, 잘 정돈된 PDF)에서는 기본 설정이 잘 작동합니다. 하지만 스캔된 PDF에서는 그렇지 않습니다. 저는 README에 이 제한 사항을 문서화했으며, 사용자의 PDF가 제대로 작동하지 않을 때는 사용자에게 솔직하게 알립니다.

5.3 5분 HTTP 타임아웃이 첫 실전 세션을 거의 망칠 뻔했습니다

앞서 언급했듯이, HttpClient의 기본 타임아웃은 100초입니다. 제 컴퓨터에서 4개 문단 분량의 RAG 컨텍스트에 대한 llama3.2의 응답은 35~50초가 걸립니다. 더 느린 CPU에서는 90초가 걸릴 수도 있습니다. 제가 실행한 처음 세 번의 엔드 투 엔드 (End-to-End) 테스트는 100초에서 타임아웃이 발생했고, 저는 RAG 파이프라인이 고장 난 줄 알았습니다. 고장 난 것이 아니었습니다. 모델이 단지 느렸을 뿐입니다.

이제 저는 Ollama 클라이언트에 대해 client.Timeout = TimeSpan.FromMinutes(5)를 설정합니다. 이는 제가 경험한 최악의 상황보다 3배의 안전 마진을 제공합니다. 5분 타임아웃은 Ollama가 모델을 처음 다운로드할 때(첫 요청 시 pull 단계가 지연 실행됨) 모델 로드에 2~3분이 걸릴 수 있기 때문에 유용하기도 합니다.

5.4 채팅 답변과 문서 청크 사이의 상관관계 부재

모델이 "섹션 4.2를 참조하세요"라고 말할 때, 사용자는 PDF의 어느 문서 청크 (document chunk)가 섹션 4.2에 해당하는지 알고 싶어 합니다. 저는 chunkIndex, score, 그리고 200자 텍스트 발췌본이 포함된 Sources를 반환합니다. 하지만 React 프론트엔드는 답변만 보여줄 뿐, 출처를 인라인 (inline)으로 렌더링하지는 않습니다.

이것은 백엔드 버그가 아니라 UI 버그입니다. 데이터는 존재합니다. 단지 아직 출처 인용 (source-citation) UI를 구축하지 않았을 뿐입니다. UI를 구축하게 되면, 어시스턴트 메시지는 다음과 같이 보일 것입니다:

검색 엔드포인트(search endpoint)의 속도 제한(rate limit)은 API 키당 분당 100회 요청입니다. [Source: api-spec.pdf, chunk 23, score 0.89]

이러한 디테일이 바로 "데모용"과 "신뢰할 수 있는 도구"를 가르는 차이입니다. 제 할 일 목록(to-do list)에 추가해 두었습니다.

5.5 "무료 로컬 LLM"의 "무료"에는 숨겨진 비용이 있습니다

Ollama는 무료입니다. 모델도 무료입니다. 노트북에서 실행하는 것도 무료입니다. 무료가 아닌 것은 처음 설정할 때 들어가는 여러분의 시간입니다.

Windows에서 Ollama는 시스템 서비스로 설치됩니다. 처음 실행하는 ollama pull nomic-embed-text는 274MB를 다운로드합니다. 처음 실행하는 ollama pull llama3.2는 2.0GB를 다운로드합니다. 10Mbps 연결 환경에서는 30분이 걸립니다. 데이터 사용량이 제한된 연결(호텔 WiFi, 모바일 핫스팟)에서는 1시간이 걸릴 수도 있습니다. 엄격한 방화벽 뒤에 있는 기업용 노트북의 경우, Ollama는 HTTPS를 사용하지만 모델 블롭(model blobs)은 일부 기업용 프록시가 차단하는 CDN에서 가져오기 때문에 아예 작동하지 않을 수도 있습니다.

솔직한 마케팅 문구는 다음과 같아야 합니다: "런타임(runtime)에는 무료이지만, 처음에는 2GB 다운로드와 30분의 설정 시간이 필요합니다." 저는 이 정도의 트레이드오프(trade-off)는 괜찮다고 생각합니다. 하지만 기술적 지식이 없는 이해관계자에게 이 도구를 데모하기 전에는, 반드시 그들의 기기에서 ollama pull을 먼저 실행하고 모델이 로드될 때까지 기다려야 한다는 교훈을 얻었습니다. 5년 된 노트북에서의 콜드 스타트(cold-start) 시간은 첫 번째 질문에 대해 20초 이상 걸릴 수 있습니다.

리포지토리 및 실행 방법

전체 소스 코드는 github.com/ZalaAvinash/AI-Document-Chatbot-RAG-에서 확인할 수 있습니다. 로컬에서 실행하려면:

# 1. Ollama를 설치하고 두 개의 모델을 가져옵니다 (총 약 2.3 GB)
ollama pull nomic-embed-text
ollama pull llama3.2
...

또는 Docker를 사용할 수 있습니다 (Docker는 첫 번째 모델 다운로드를 포함하여 Ollama를 대신 처리해 줍니다):

docker-compose up --build
# 처음 실행 시 모델 다운로드를 위해 약 5분 정도 기다리세요
# http://localhost 접속

.NET을 사용하지 않는 팀원들에게는 Docker 방식을 추천합니다. 저는 이후 실행 시 속도가 더 빠른 네이티브(native) 방식을 일상적으로 사용합니다.

마치며

로컬 RAG (Retrieval-Augmented Generation) 챗봇은 2026년 현재, 0달러의 예산으로도 실제로 프로덕션 환경에서 사용할 준비가 된 몇 안 되는 AI 기능 중 하나입니다. 필요한 요소들은 모두 갖춰져 있습니다. 무료 로컬 LLM (Large Language Model) 실행기 (Ollama), 무료 로컬 임베더 (nomic-embed-text), 30줄의 C# 코드로 구현된 교과서적인 RAG 파이프라인, 그리고 ChatGPT를 사용해 본 사람이라면 누구나 조작법을 알고 있는 React 프론트엔드까지 말이죠.

저를 가장 놀라게 했던 점은 "정답은 PDF 안에 있는데, 사용자가 찾지 못할 뿐이다"라는 상황이 해결할 가치가 있는 실제 문제인 경우가 얼마나 빈번한가 하는 점이었습니다. 저는 지난 2주 동안 API 명세서, 공급업체 계약서, 200페이지 분량의 컴플라이언스(compliance) 문서, 그리고 연구 논문 등 네 가지의 서로 다른 실제 문서에 이를 사용해 보았습니다. 모든 경우에서 챗봇은 5초 이내에 답변을 제공했으며, 소스 청크 (source chunk)로 클릭하여 확인할 수 있는 인용 (citation)을 함께 제시했습니다. 모델이 반드시 인용하도록 강제되어 있기 때문에 환각 (hallucination) 현상은 드물며 발견하기도 쉽습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0