본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 10장: 실전 프로젝트 — 온디바이스 AI 시스템 구축
2026년 3월 15일·AI / ML·

10장: 실전 프로젝트 — 온디바이스 AI 시스템 구축

시리즈 전체의 기법을 종합하여 프라이버시 보존 문서 분석 시스템을 구축합니다. 로컬 LLM, 로컬 임베딩, 로컬 벡터 DB로 완전한 오프라인 AI를 실현합니다.

15분879자6개 섹션
llmperformancemlops
공유
on-device-ai10 / 10
12345678910
이전9장: 성능 벤치마킹과 최적화

들어가며

이전 9장에서는 온디바이스 AI 시스템의 성능을 측정하고 최적화하는 방법을 다루었습니다. 이번 마지막 장에서는 시리즈 전체에서 다룬 기법들을 하나의 실전 프로젝트로 통합합니다.

구축할 시스템은 프라이버시 보존 문서 Q&A 시스템입니다. 사용자가 로컬에 저장된 PDF 문서를 업로드하면, 문서 내용을 기반으로 질문에 답변하는 시스템입니다. 모든 처리가 디바이스 안에서 이루어지며, 어떤 데이터도 외부로 전송되지 않습니다.

시스템 아키텍처

전체 아키텍처는 네 가지 핵심 컴포넌트로 구성됩니다.

  1. 문서 수집기: PDF를 텍스트로 변환하고 청크로 분할합니다
  2. 로컬 임베딩 엔진: 텍스트 청크를 벡터로 변환합니다
  3. 로컬 벡터 DB: 벡터를 저장하고 유사도 검색을 수행합니다
  4. 로컬 LLM: 검색된 컨텍스트를 기반으로 답변을 생성합니다
PDF 문서 --> [문서 수집기] --> [임베딩 엔진] --> [벡터 DB]
                                                     |
사용자 질문 --> [임베딩 엔진] --> [벡터 검색] ----------+
                                     |
                              [컨텍스트 조합] --> [로컬 LLM] --> 답변
Info

이 아키텍처는 8장에서 다룬 온디바이스 RAG 패턴과 프라이버시 보존 패턴을 결합한 것입니다. 네트워크 연결이 전혀 없는 환경에서도 완전히 동작합니다.

환경 구성

의존성 설치

setup.sh
bash
# 프로젝트 디렉터리 생성
mkdir -p local-doc-qa && cd local-doc-qa
python -m venv .venv
source .venv/bin/activate
 
# 핵심 의존성 설치
pip install \
  chromadb==0.5.23 \
  sentence-transformers==3.3.1 \
  pymupdf==1.25.1 \
  requests==2.32.3 \
  rich==13.9.4
 
# llama.cpp 서버 빌드 (Metal 지원)
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
cmake -B build -DLLAMA_METAL=ON
cmake --build build --config Release -j
cd ..

모델 준비

download-models.sh
bash
# LLM 모델 다운로드 (Llama 3.2 7B Q4_K_M, 약 4.1GB)
huggingface-cli download \
  bartowski/Llama-3.2-7B-GGUF \
  Llama-3.2-7B-Q4_K_M.gguf \
  --local-dir models/
 
# 임베딩 모델은 sentence-transformers가 자동 다운로드
# nomic-embed-text-v1.5 (약 280MB)

구현

문서 수집기

PDF에서 텍스트를 추출하고 의미 단위로 청크를 분할합니다. 청크 크기와 오버랩은 검색 품질에 직접적인 영향을 미치므로 신중하게 설정해야 합니다.

document_ingester.py
python
import fitz  # PyMuPDF
from dataclasses import dataclass
 
 
@dataclass
class TextChunk:
    text: str
    source: str
    page_number: int
    chunk_index: int
 
 
def extract_text_from_pdf(pdf_path: str) -> list[tuple[int, str]]:
    """PDF에서 페이지별 텍스트를 추출합니다."""
    doc = fitz.open(pdf_path)
    pages = []
    for page_num, page in enumerate(doc):
        text = page.get_text("text")
        if text.strip():
            pages.append((page_num + 1, text))
    doc.close()
    return pages
 
 
def split_into_chunks(
    pages: list[tuple[int, str]],
    source: str,
    chunk_size: int = 512,
    overlap: int = 64,
) -> list[TextChunk]:
    """페이지 텍스트를 고정 크기 청크로 분할합니다."""
    chunks: list[TextChunk] = []
    chunk_index = 0
 
    for page_num, text in pages:
        words = text.split()
        start = 0
 
        while start < len(words):
            end = start + chunk_size
            chunk_text = " ".join(words[start:end])
 
            if len(chunk_text.strip()) > 50:
                chunks.append(TextChunk(
                    text=chunk_text,
                    source=source,
                    page_number=page_num,
                    chunk_index=chunk_index,
                ))
                chunk_index += 1
 
            start += chunk_size - overlap
 
    return chunks

로컬 임베딩과 벡터 저장

sentence-transformers를 사용하여 텍스트를 벡터로 변환하고, ChromaDB에 저장합니다. 모든 연산은 로컬 CPU 또는 GPU에서 수행됩니다.

vector_store.py
python
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
from document_ingester import TextChunk
 
 
class LocalVectorStore:
    def __init__(
        self,
        persist_dir: str = "./vector_db",
        embedding_model: str = "nomic-ai/nomic-embed-text-v1.5",
    ):
        self.embedder = SentenceTransformer(
            embedding_model,
            trust_remote_code=True,
        )
        self.client = chromadb.PersistentClient(
            path=persist_dir,
            settings=Settings(anonymized_telemetry=False),
        )
        self.collection = self.client.get_or_create_collection(
            name="documents",
            metadata={"hnsw:space": "cosine"},
        )
 
    def add_chunks(self, chunks: list[TextChunk]) -> int:
        """텍스트 청크를 벡터화하여 저장합니다."""
        texts = [chunk.text for chunk in chunks]
        embeddings = self.embedder.encode(
            texts,
            show_progress_bar=True,
            normalize_embeddings=True,
        ).tolist()
 
        ids = [f"{chunk.source}_{chunk.chunk_index}" for chunk in chunks]
        metadatas = [
            {
                "source": chunk.source,
                "page": chunk.page_number,
                "chunk_index": chunk.chunk_index,
            }
            for chunk in chunks
        ]
 
        self.collection.add(
            ids=ids,
            embeddings=embeddings,
            documents=texts,
            metadatas=metadatas,
        )
        return len(chunks)
 
    def search(
        self,
        query: str,
        n_results: int = 5,
    ) -> list[dict]:
        """쿼리와 유사한 청크를 검색합니다."""
        query_embedding = self.embedder.encode(
            [query],
            normalize_embeddings=True,
        ).tolist()
 
        results = self.collection.query(
            query_embeddings=query_embedding,
            n_results=n_results,
        )
 
        return [
            {
                "text": doc,
                "source": meta["source"],
                "page": meta["page"],
                "distance": dist,
            }
            for doc, meta, dist in zip(
                results["documents"][0],
                results["metadatas"][0],
                results["distances"][0],
            )
        ]

로컬 LLM 질의 엔진

검색된 컨텍스트를 프롬프트에 포함하여 로컬 LLM에 질의합니다. llama.cpp 서버의 HTTP API를 사용합니다.

qa_engine.py
python
import requests
from vector_store import LocalVectorStore
 
 
SYSTEM_PROMPT = """당신은 문서 분석 전문가입니다. 
주어진 컨텍스트만을 기반으로 질문에 답변하세요.
컨텍스트에 없는 내용은 "해당 정보를 문서에서 찾을 수 없습니다"라고 답변하세요.
답변은 한국어로 작성하세요."""
 
 
class LocalQAEngine:
    def __init__(
        self,
        vector_store: LocalVectorStore,
        llm_url: str = "http://localhost:8080",
    ):
        self.vector_store = vector_store
        self.llm_url = llm_url
 
    def build_prompt(
        self,
        question: str,
        contexts: list[dict],
    ) -> str:
        """검색 결과를 포함한 프롬프트를 구성합니다."""
        context_text = "\n\n---\n\n".join(
            f"[출처: {ctx['source']}, {ctx['page']}페이지]\n{ctx['text']}"
            for ctx in contexts
        )
 
        return (
            f"<|system|>\n{SYSTEM_PROMPT}\n"
            f"<|user|>\n"
            f"다음 문서 내용을 참고하여 질문에 답변하세요.\n\n"
            f"## 문서 내용\n{context_text}\n\n"
            f"## 질문\n{question}\n"
            f"<|assistant|>\n"
        )
 
    def ask(
        self,
        question: str,
        n_contexts: int = 5,
    ) -> dict:
        """질문에 대한 답변을 생성합니다."""
        contexts = self.vector_store.search(question, n_results=n_contexts)
 
        prompt = self.build_prompt(question, contexts)
 
        response = requests.post(
            f"{self.llm_url}/completion",
            json={
                "prompt": prompt,
                "n_predict": 1024,
                "temperature": 0.3,
                "top_p": 0.9,
                "stop": ["<|user|>", "<|system|>"],
            },
        )
 
        result = response.json()
 
        return {
            "answer": result["content"].strip(),
            "sources": [
                {"source": ctx["source"], "page": ctx["page"]}
                for ctx in contexts
            ],
            "tokens_generated": result.get("tokens_predicted", 0),
            "generation_time_ms": result.get("timings", {}).get(
                "predicted_ms", 0
            ),
        }

통합 실행

모든 컴포넌트를 통합하여 CLI 도구로 구성합니다.

main.py
python
import sys
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from document_ingester import extract_text_from_pdf, split_into_chunks
from vector_store import LocalVectorStore
from qa_engine import LocalQAEngine
 
console = Console()
 
 
def ingest_documents(pdf_dir: str, store: LocalVectorStore) -> int:
    """디렉터리 내 모든 PDF를 벡터 DB에 수집합니다."""
    total_chunks = 0
    pdf_files = list(Path(pdf_dir).glob("*.pdf"))
 
    for pdf_path in pdf_files:
        console.print(f"  처리 중: {pdf_path.name}")
        pages = extract_text_from_pdf(str(pdf_path))
        chunks = split_into_chunks(pages, source=pdf_path.name)
        count = store.add_chunks(chunks)
        total_chunks += count
        console.print(f"  -> {count}개 청크 저장 완료")
 
    return total_chunks
 
 
def main():
    store = LocalVectorStore(persist_dir="./local_vector_db")
    engine = LocalQAEngine(vector_store=store)
 
    if len(sys.argv) > 1 and sys.argv[1] == "ingest":
        pdf_dir = sys.argv[2] if len(sys.argv) > 2 else "./documents"
        console.print(Panel("문서 수집 시작", style="bold"))
        total = ingest_documents(pdf_dir, store)
        console.print(f"총 {total}개 청크 수집 완료")
        return
 
    console.print(Panel("문서 Q&A 시스템 (종료: quit)", style="bold"))
 
    while True:
        question = console.input("\n질문: ")
        if question.lower() in ("quit", "exit", "q"):
            break
 
        with console.status("답변 생성 중..."):
            result = engine.ask(question)
 
        console.print(f"\n{result['answer']}")
        console.print(
            f"\n참조: {', '.join(f\"{s['source']} {s['page']}p\" for s in result['sources'])}",
            style="dim",
        )
 
 
if __name__ == "__main__":
    main()
Warning

llama.cpp 서버가 먼저 실행 중이어야 합니다. 9장에서 다룬 KV 캐시 양자화와 프롬프트 캐싱 옵션을 함께 적용하면 반복 질문에 대한 응답 속도가 크게 향상됩니다.

배포 옵션

CLI 도구

위에서 구현한 main.py를 그대로 CLI 도구로 사용할 수 있습니다. pyinstaller로 단일 실행 파일로 패키징하면 Python 환경 없이도 배포할 수 있습니다.

package-cli.sh
bash
pip install pyinstaller
pyinstaller --onefile --name local-doc-qa main.py
# dist/local-doc-qa 실행 파일 생성

데스크톱 애플리케이션

더 나은 사용자 경험을 원한다면, Electron이나 Tauri와 같은 프레임워크로 데스크톱 GUI를 구성할 수 있습니다. 백엔드는 동일한 Python 코드를 FastAPI로 감싸고, 프론트엔드에서 HTTP 호출로 연동합니다. Tauri는 Rust 기반으로 Electron 대비 메모리 사용량이 훨씬 적어 온디바이스 환경에 적합합니다.

시리즈 총정리

이것으로 온디바이스 AI 시리즈의 열 번째이자 마지막 장을 마칩니다. 시리즈 전체를 되돌아보며 각 장에서 다룬 핵심 내용을 정리합니다.

1장에서는 온디바이스 AI의 개념과 클라우드 AI 대비 장점을 살펴보며 시리즈의 기반을 다졌습니다. 지연 시간, 프라이버시, 오프라인 가용성이라는 세 가지 핵심 동기를 확인했습니다.

2장에서는 대규모 언어 모델의 구조와 트랜스포머 아키텍처를 이해했습니다. 어텐션 메커니즘과 토큰 생성 과정을 파악하는 것이 이후 최적화의 출발점이었습니다.

3장에서는 양자화 기법을 깊이 다루었습니다. FP16에서 Q4_K_M까지 다양한 양자화 수준의 트레이드오프를 분석하고, GGUF 포맷의 구조를 살펴보았습니다.

4장에서는 llama.cpp의 아키텍처와 동작 원리를 탐구했습니다. C/C++ 기반의 경량 추론 엔진이 어떻게 다양한 하드웨어에서 효율적으로 동작하는지 이해했습니다.

5장에서는 모델 서빙 인프라를 구축했습니다. llama.cpp 서버 설정, API 구성, 동시 요청 처리 등 프로덕션 환경에 필요한 실무 지식을 다루었습니다.

6장에서는 LoRA와 어댑터 기반 파인튜닝을 통해 범용 모델을 특정 도메인에 적응시키는 방법을 배웠습니다. 소량의 데이터로도 의미 있는 성능 향상을 달성할 수 있음을 확인했습니다.

7장에서는 Apple Silicon, NVIDIA GPU, 모바일 NPU 등 에지 하드웨어의 특성과 선택 기준을 비교했습니다.

8장에서는 하이브리드 추론, 오프라인 우선, 프라이버시 보존, 온디바이스 RAG, 투기적 디코딩 등 실전 설계 패턴을 학습했습니다.

9장에서는 TTFT, 토큰 처리량, 메모리 사용량 등 핵심 지표의 벤치마킹 방법론과 KV 캐시 양자화, 컨텍스트 프루닝 등 최적화 기법을 다루었습니다.

그리고 이번 10장에서는 이 모든 지식을 종합하여 프라이버시 보존 문서 Q&A 시스템을 처음부터 끝까지 구축했습니다.

Tip

온디바이스 AI는 빠르게 발전하고 있는 분야입니다. 새로운 양자화 기법, 더 효율적인 모델 아키텍처, 더 강력한 에지 하드웨어가 계속 등장하고 있습니다. 이 시리즈에서 다룬 기본 원리를 바탕으로 최신 동향을 꾸준히 따라가시기 바랍니다.

온디바이스 AI는 단순한 기술적 선택이 아니라, 사용자의 데이터 주권을 존중하고 네트워크 환경에 구애받지 않는 AI 서비스를 가능하게 하는 패러다임입니다. 이 시리즈가 여러분의 온디바이스 AI 여정에 실질적인 도움이 되었기를 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#llm#performance#mlops

관련 글

AI / ML

9장: 성능 벤치마킹과 최적화

온디바이스 AI 시스템의 성능 벤치마킹 방법론, 핵심 지표, 하드웨어별 성능 비교, 그리고 토큰 처리량과 메모리 사용을 최적화하는 기법을 다룹니다.

2026년 3월 13일·13분
AI / ML

8장: 온디바이스 AI 애플리케이션 패턴

온디바이스 AI를 활용한 실전 애플리케이션 설계 패턴 — 하이브리드 추론, 오프라인 우선, 프라이버시 보존, 개인화 학습, 그리고 에지-클라우드 협업을 다룹니다.

2026년 3월 11일·13분
AI / ML

7장: 엣지 하드웨어와 전용 가속기

온디바이스 AI를 위한 하드웨어 가속기 — Apple Neural Engine, Qualcomm NPU, NVIDIA Jetson, Intel NPU의 아키텍처와 성능 특성을 비교합니다.

2026년 3월 9일·15분
이전 글9장: 성능 벤치마킹과 최적화

댓글

목차

약 15분 남음
  • 들어가며
  • 시스템 아키텍처
  • 환경 구성
    • 의존성 설치
    • 모델 준비
  • 구현
    • 문서 수집기
    • 로컬 임베딩과 벡터 저장
    • 로컬 LLM 질의 엔진
    • 통합 실행
  • 배포 옵션
    • CLI 도구
    • 데스크톱 애플리케이션
  • 시리즈 총정리