AI 블로그 완성기(1) - 시작!!
AI 요약
본 블로그 포스트에서는 AI 블로그를 통해 사용자의 질문에 대한 답변을 제공하는 RAG 챗봇 개발 과정을 소개하고 있습니다. Ollama로 직접 로컬 LLM을 돌리는 방식과 그 스택 구성 및 구현 방법을 설명하며, 특히 스트리밍 응답과 세션 관리 등의 기술적 요소들을 상세하게 다루고 있습니다.
AI 블로그 완성기(1) - 시작!!
"방문자가 이 블로그를 통해 나에 대해 빠르게 파악할 수 있을까?"
글을 하나하나 읽어보는 건 시간이 걸립니다. 채용 담당자나 다른 사람들이 방문했을 때, "이 사람은 어떤 프로젝트를 했지?", "어떤 기술 스택을 쓰지?" 같은 질문에 바로 답해줄 수 있는 인터페이스가 있으면 좋겠다고 생각했습니다.
그래서 블로그 글을 학습한 RAG 챗봇을 직접 만들어 넣기로 했습니다. 클라우드 API를 쓰면 간단하겠지만, AI 엔지니어를 목표로 하는 사람의 포트폴리오라면 직접 로컬에서 돌리는 게 더 의미 있다고 판단했습니다.
기술 스택 선택
가장 먼저 결정해야 했던 건 LLM을 어떻게 서빙할 것인가였습니다.
| 선택지 | 장점 | 단점 |
|---|---|---|
| 클라우드 API | 높은 품질, 간편한 연동 | 비용 발생, 외부 의존성 |
| Ollama (로컬) | 무료, 완전한 통제, 프라이버시 | 성능 한계, 리소스 소모 |
저는 Ollama를 사용한 로컬 LLM을 돌리는걸 선택했습니다. 비용 문제도 있었지만, "내 서버에서 LLM을 직접 돌린다"는 것 자체가 포트폴리오의 기술적 깊이를 보여줄 수 있다고 생각했습니다.
최종 스택은 이렇게 구성했습니다.
-
LLM: Ollama + qwen3:8b
-
임베딩 모델: nomic-embed-text (768차원, 한국어/영어 지원)
-
벡터 저장소: SQLite + JSON 배열 (별도 벡터 DB 없이)
-
프레임워크: Next.js API Routes + Prisma
-
배포: Docker Compose (blog + ollama 개별 컨테이너)
RAG 파이프라인 설계
RAG(Retrieval-Augmented Generation)의 핵심은 "질문과 관련된 문서를 찾아서 LLM에게 컨텍스트로 넘겨주는 것"입니다. 전체 흐름을 정리하면 다음과 같습니다.
1단계: 인덱싱 (데이터 준비)
블로그 글 → 마크다운 파싱 → 200~500자 청크로 분할 → 임베딩 생성 → SQLite 저장
각 블로그 포스트를 200~500자 단위의 청크로 나누고, Ollama의 nomic-embed-text 모델로 768차원 벡터를 생성합니다. 이 벡터는 SQLite의 PostChunk 테이블에 JSON 배열 형태로 저장됩니다.
포트폴리오 규모(수십~수백 개 청크)에서는 전용 벡터 DB 없이도 충분히 동작합니다.
2단계: 검색 (Retrieval)
사용자 질문 → 임베딩 생성 → 코사인 유사도 계산 → Top-5 청크 선택
사용자가 질문을 입력하면 같은 임베딩 모델로 질문 벡터를 만들고, 저장된 모든 청크와 코사인 유사도를 계산합니다. 가장 유사한 5개 청크를 선택하되, 같은 포스트에서 최대 2개까지만 가져오는 중복 제거 로직도 넣었습니다.
3단계: 생성 (Generation)
시스템 프롬프트 + 검색된 청크 + 대화 히스토리 → Ollama → 스트리밍 응답
검색된 청크를 시스템 프롬프트에 주입하고, Ollama에게 응답 생성을 요청합니다. 스트리밍으로 토큰 단위 출력을 받아 실시간으로 화면에 표시합니다.
구현 하이라이트
스트리밍 응답
Ollama의 스트리밍 API를 ReadableStream으로 감싸서 토큰이 생성될 때마다 프론트엔드로 전달합니다. 프론트엔드에서는 20ms 간격의 타이핑 애니메이션으로 자연스러운 UX를 만들었습니다.
// 스트리밍 응답을 ReadableStream으로 감싸는 핵심 로직
const stream = new ReadableStream({
async start(controller) {
const response = await fetch(ollamaUrl, {
method: 'POST',
body: JSON.stringify({ model, messages, stream: true })
});
const reader = response.body.getReader();
// 토큰 단위로 청크를 읽어서 전달
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
}
});
세션 관리
클라이언트에서 UUID를 생성하고 sessionStorage에 저장합니다. 페이지를 새로고침해도 대화가 유지되고, DB에도 세션별로 메시지가 기록되어 관리자 패널에서 분석할 수 있습니다.
Rate Limiting
IP당 분당 5회 요청 제한을 걸어 남용을 방지했습니다. 로컬 LLM은 동시 요청 처리가 어렵기 때문에 이 제한이 특히 중요합니다.
댓글 (0)
아직 댓글이 없습니다.