시리즈 전체의 기법을 종합하여 프라이버시 보존 문서 분석 시스템을 구축합니다. 로컬 LLM, 로컬 임베딩, 로컬 벡터 DB로 완전한 오프라인 AI를 실현합니다.
이전 9장에서는 온디바이스 AI 시스템의 성능을 측정하고 최적화하는 방법을 다루었습니다. 이번 마지막 장에서는 시리즈 전체에서 다룬 기법들을 하나의 실전 프로젝트로 통합합니다.
구축할 시스템은 프라이버시 보존 문서 Q&A 시스템입니다. 사용자가 로컬에 저장된 PDF 문서를 업로드하면, 문서 내용을 기반으로 질문에 답변하는 시스템입니다. 모든 처리가 디바이스 안에서 이루어지며, 어떤 데이터도 외부로 전송되지 않습니다.
전체 아키텍처는 네 가지 핵심 컴포넌트로 구성됩니다.
PDF 문서 --> [문서 수집기] --> [임베딩 엔진] --> [벡터 DB]
|
사용자 질문 --> [임베딩 엔진] --> [벡터 검색] ----------+
|
[컨텍스트 조합] --> [로컬 LLM] --> 답변
이 아키텍처는 8장에서 다룬 온디바이스 RAG 패턴과 프라이버시 보존 패턴을 결합한 것입니다. 네트워크 연결이 전혀 없는 환경에서도 완전히 동작합니다.
# 프로젝트 디렉터리 생성
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 ..# 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에서 텍스트를 추출하고 의미 단위로 청크를 분할합니다. 청크 크기와 오버랩은 검색 품질에 직접적인 영향을 미치므로 신중하게 설정해야 합니다.
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 chunkssentence-transformers를 사용하여 텍스트를 벡터로 변환하고, ChromaDB에 저장합니다. 모든 연산은 로컬 CPU 또는 GPU에서 수행됩니다.
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에 질의합니다. llama.cpp 서버의 HTTP API를 사용합니다.
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 도구로 구성합니다.
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()llama.cpp 서버가 먼저 실행 중이어야 합니다. 9장에서 다룬 KV 캐시 양자화와 프롬프트 캐싱 옵션을 함께 적용하면 반복 질문에 대한 응답 속도가 크게 향상됩니다.
위에서 구현한 main.py를 그대로 CLI 도구로 사용할 수 있습니다. pyinstaller로 단일 실행 파일로 패키징하면 Python 환경 없이도 배포할 수 있습니다.
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 시스템을 처음부터 끝까지 구축했습니다.
온디바이스 AI는 빠르게 발전하고 있는 분야입니다. 새로운 양자화 기법, 더 효율적인 모델 아키텍처, 더 강력한 에지 하드웨어가 계속 등장하고 있습니다. 이 시리즈에서 다룬 기본 원리를 바탕으로 최신 동향을 꾸준히 따라가시기 바랍니다.
온디바이스 AI는 단순한 기술적 선택이 아니라, 사용자의 데이터 주권을 존중하고 네트워크 환경에 구애받지 않는 AI 서비스를 가능하게 하는 패러다임입니다. 이 시리즈가 여러분의 온디바이스 AI 여정에 실질적인 도움이 되었기를 바랍니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
온디바이스 AI 시스템의 성능 벤치마킹 방법론, 핵심 지표, 하드웨어별 성능 비교, 그리고 토큰 처리량과 메모리 사용을 최적화하는 기법을 다룹니다.
온디바이스 AI를 활용한 실전 애플리케이션 설계 패턴 — 하이브리드 추론, 오프라인 우선, 프라이버시 보존, 개인화 학습, 그리고 에지-클라우드 협업을 다룹니다.
온디바이스 AI를 위한 하드웨어 가속기 — Apple Neural Engine, Qualcomm NPU, NVIDIA Jetson, Intel NPU의 아키텍처와 성능 특성을 비교합니다.