ν°λ―Έλ κ°μ±μ λ΄μ Full-Stack RAG (Retrieval-Augmented Generation) μΉ μ ν리μΌμ΄μ μ λλ€. Python(FastAPI) λ°±μλμ Next.js νλ‘ νΈμλλ‘ κ΅¬μ±λμ΄ μμΌλ©°, λ‘컬 νκ²½μμ μμ νκ³ κ°λ ₯ν λ¬Έμ κ²μ λ° μ§λ¬Έ λ΅λ³ κΈ°λ₯μ μ 곡ν©λλ€.
μ¬μ©μ μΈμ¦, μ¬μ©μλ³ λ¬Έμ 격리, νμ΄λΈλ¦¬λ κ²μ + Cross-Encoder μ¬μ λ ¬, λ¬Έμ μμ½ λͺ¨λ, μ€νΈλ¦¬λ° λ΅λ³, κ·Έλ¦¬κ³ κ²μ/μμ± νμ§μ μ λμ μΌλ‘ μΈ‘μ νλ νκ° νμ΄νλΌμΈκΉμ§ κ°μΆ νμ€ν μμ€ν μ λλ€.
- μΉ κΈ°λ° μΈν°νμ΄μ€: ν°λ―Έλ μ€νμΌμ λͺ¨λνκ³ μ§κ΄μ μΈ Web UI
- μ¬μ©μ μΈμ¦ & 격리:
- μ΄λ©μΌ/λΉλ°λ²νΈ κΈ°λ° νμκ°μ Β·λ‘κ·ΈμΈ (NextAuth v5 + JWT)
- μ¬μ©μλ³(
user_id) λ¬ΈμΒ·μΈλ±μ€ κ²©λ¦¬λ‘ λ©ν° ν λνΈ μ§μ
- λ¬Έμ κ΄λ¦¬:
- Drag & Drop νμΌ μ
λ‘λ (
.txt,.md,.pdf,.docx,.xlsx,.pptx,.csv,.hwpxμ§μ)- μ§μ νμ₯μλ νμ λ μ§μ€νΈλ¦¬(
src/rag/ingestion/parsers)λ₯Ό λ¨μΌ μμ€λ‘ κ΄λ¦¬νλ©°,GET /config/supported-extensionsλ‘ μ‘°νλ©λλ€. μ νμ μΆκ° μ UI νμ© νμλ μλ λ°μ.
- μ§μ νμ₯μλ νμ λ μ§μ€νΈλ¦¬(
- μλ νμ± β μ²νΉ β λ²‘ν° μΈλ±μ±
- λ¬Έμλ³ λͺ©λ‘ κ΄λ¦¬, κ²μ, κ°λ³/μΌκ΄ μμ (μΈλ±μ€ λκΈ° μ 리)
- μ¦λΆ μΈλ±μ±: νμΌ ν΄μ μΆμ μΌλ‘ λ³κ²½λ νμΌλ§ μ¬μΈλ±μ± (CLI)
- Drag & Drop νμΌ μ
λ‘λ (
- κ³ κΈ κ²μ & μ±ν
:
- μ 체 λ¬Έμ μ±ν : μ λ‘λλ λͺ¨λ λ¬Έμλ₯Ό λμμΌλ‘ μ§λ¬Έ
- λ¬Έμ λ΄ μ±ν (Document Scope): νΉμ λ¬Έμ μμμλ§ μ§λ¬Έ-λ΅λ³
- λ¬Έμ μμ½ λͺ¨λ (
doc_mode): "μμ½ν΄μ€"μ κ°μ μμ½ μλλ₯Ό κ°μ§νλ©΄ top-k κ²μ λμ ν΄λΉ λ¬Έμμ μ²ν¬λ₯Ό μμλλ‘(μνsummary_max_chunks, κΈ°λ³Έ 50) LLMμ ν¬μ - μ€νΈλ¦¬λ° λ΅λ³: SSE(Server-Sent Events) κΈ°λ° ν ν° λ¨μ μ€μκ° μΆλ ₯
- νμ€ν 리 μ μ₯: λν λ΄μ© μλ μ μ₯ λ° μ‘°ν
- RAG μμ§:
- Hybrid Search: BM25(ν€μλ) + Vector(μλ―Έ) κ²μμ RRF λλ κ°μ€ν©μΌλ‘ κ²°ν©
- Reranking: Cross-Encoder(
bge-reranker-v2-m3) κΈ°λ° μ λ° μ¬μ λ ¬ (κΈ°λ³Έ ON) - Query Expansion: LLMμΌλ‘ μ§λ¬Έμ μ¬λ¬ κ²μ λ³νμΌλ‘ μ¬μμ± (κ΅μ°¨ μΈμ΄ νβμ ν¬ν¨)
- νκ΅μ΄ ννμ λΆμ: Kiwi κΈ°λ° ν ν¬λμ΄μ§μΌλ‘ νκ΅μ΄ ν€μλ κ²μ νμ§ ν₯μ
- λ€μν μ²νΉ μ λ΅: μΌλ° ν μ€νΈ / Markdown ꡬ쑰 보쑴 / Semantic(λ¬Έμ₯ μλ² λ© μ μ¬λ)
- μ μ°ν λͺ¨λΈ μ§μ:
- Cloud: Google Gemini (κΈ°λ³Έ
gemini-2.5-flash) - Local: Ollama (Llama 3, Mistral λ±) μ°λ κ°λ₯
- Embedding: Google Gemini Embedding 2 (
gemini-embedding-2, κΈ°λ³Έ) / λ‘컬 SentenceTransformers μ ν κ°λ₯ (embedding.provider) - Vector Store: Qdrant(κΈ°λ³Έ) / FAISS μ ν
- Cloud: Google Gemini (κΈ°λ³Έ
- CLI λꡬ: μΈλ±μ±Β·κ²μΒ·μ§μλ₯Ό ν°λ―Έλμμ μ§μ μν (
python -m cli.main) - νκ° νμ΄νλΌμΈ: κ²μ λ©νΈλ¦(Recall@k, MRR λ±) + RAGAS(LLM-as-judge) μ λ νκ° λ° λ¦¬ν¬νΈ μμ±
graph TD
Client["Browser (Next.js)"] <-->|REST / SSE| API["API Server (FastAPI)"]
Client <-->|Auth / Session| Auth["NextAuth v5 + Prisma"]
Auth --> DB[("SQLite (User / Document)")]
subgraph Backend
API --> Engine["RAG Engine"]
Engine -->|Hybrid Search| Retriever["BM25 + Vector + RRF"]
Retriever --> Reranker["Cross-Encoder Reranker"]
Engine -->|Store/Search| VectorDB[("Qdrant / FAISS")]
Engine -->|Generate| LLM["LLM (Gemini / Ollama)"]
end
subgraph Ingestion
Upload["File Upload"] --> Parser["Document Parser (txt/md/pdf/docx/xlsx/pptx/csv/hwpx)"]
Parser --> Chunker["Chunker (text / markdown / semantic)"]
Chunker --> Embedder["Embedding (Gemini / Local)"]
Embedder --> VectorDB
end
subgraph Tooling
CLI["CLI (Typer)"] --> Engine
Evals["Evaluation (Metrics + RAGAS)"] --> Engine
end
- Framework: Next.js 16 (App Router)
- Language: TypeScript 5.x / React 19
- Styling: TailwindCSS 4
- State Management: Zustand (Global Store)
- Auth: NextAuth v5 (Credentials Provider) +
@auth/prisma-adapter - ORM / DB: Prisma 5 + SQLite
- UI Components: Lucide React, react-markdown, next-themes
- Framework: FastAPI (Python 3.12+)
- ASGI Server: Uvicorn
- Package Manager:
uv(Rust based) - CLI: Typer + Rich
- Logging: structlog (JSON ꡬ쑰ν λ‘κΉ )
- Vector DB: Qdrant (Docker, κΈ°λ³Έ) / FAISS (μ΅μ )
- Embedding: Google Gemini Embedding 2 (
gemini-embedding-2, 3072-dim, κΈ°λ³Έ) / λ‘컬 SentenceTransformers μ ν κ°λ₯ (embedding.providerμ€μ μΌλ‘ μ ν) - LLM: Google Gemini API / Ollama
- Reranker:
BAAI/bge-reranker-v2-m3(Cross-Encoder) - Keyword Search: rank-bm25
- Morphology: KiwiPiePy (νκ΅μ΄ ννμ λΆμ)
- Evaluation: RAGAS (faithfulness / answer_relevancy / context_precision / context_recall)
μ νν ν¨ν€μ§/λ²μ μ pyproject.toml, ui/package.jsonμ νμ μ°μ μ°Έμ‘°νμΈμ.
λͺ¨λ κ΅¬μ± μμ(Qdrant, API, UI)λ₯Ό Docker Composeλ‘ ν λ²μ μ€νν μ μμ΅λλ€. compose.yamlμ λ€μ 3κ° μλΉμ€λ₯Ό λμλλ€.
| μλΉμ€ | μ€λͺ | ν¬νΈ |
|---|---|---|
qdrant |
λ²‘ν° λ°μ΄ν°λ² μ΄μ€ | 6333 (REST), 6334 (gRPC) |
api |
FastAPI λ°±μλ (RAG μμ§) | 8000 |
ui |
Next.js νλ‘ νΈμλ | 3000 |
# 1. λ°±μλ νκ²½ λ³μ μ€μ (Google Gemini API Key λ±)
cp .env.example .env
vi .env
# GOOGLE_API_KEY=... # Gemini μ¬μ© μ
# API_KEY=... # API β UI κ° μΈμ¦ ν€ (production νμ)
# OLLAMA_BASE_URL=... # Ollama μ¬μ© μ (μ΅μ
)
# 2. νλ‘ νΈμλ νκ²½ λ³μ μ€μ (NextAuth)
cp ui/.env.example ui/.env # λ컀μ©
# AUTH_SECRET=$(openssl rand -base64 32) λ‘ μ±μ°κ³
# API_KEY λ μ .env μ κ°κ³Ό λμΌνκ² λ§μΆλ€.
# 3. μλΉμ€ μ€ν
docker compose up -d --buildμ€ν ν λΈλΌμ°μ μμ http://localhost:3000μΌλ‘ μ μνμΈμ.
μ΅μ΄ μ μ μ νμκ°μ
(/register) β λ‘κ·ΈμΈ(/login) ν μ¬μ©ν©λλ€.
βΉοΈ μ΄μ(production) λ°°ν¬ μ
compose.yamlμAPP_ENV=productionμ£Όμμ ν΄μ νμΈμ.API_KEYκ° λ―Έμ€μ μνλ‘ κΈ°λλλ©΄ API λ―Έλ€μ¨μ΄κ°RuntimeErrorλ‘ fail-fast ν©λλ€.
- Docker: Qdrant μ€νμ μν΄ νμ
- Node.js: 18.0.0 μ΄μ
- Python: 3.12 μ΄μ
- uv: Python ν¨ν€μ§ λ§€λμ (
pip install uv)
git clone https://github.com/okdoittttt/rag.git
cd rag
# λ°±μλ νκ²½λ³μ μ€μ
echo "GOOGLE_API_KEY=your_gemini_api_key" > .env
# Qdrant μ€μ (λ‘컬 μ€ν)
echo "QDRANT_HOST=localhost" >> .env
echo "QDRANT_PORT=6333" >> .envDockerλ₯Ό μ¬μ©νμ¬ Qdrant λ²‘ν° λ°μ΄ν°λ² μ΄μ€λ₯Ό μ€νν©λλ€.
# Docker 컨ν
μ΄λ μ€ν
docker run -d --name qdrant \
-p 6333:6333 \
-v $(pwd)/data/qdrant_storage:/qdrant/storage \
qdrant/qdrantFastAPI μλ²λ₯Ό μ€νν©λλ€.
# μμ‘΄μ± μ€μΉ λ° κ°μνκ²½ λκΈ°ν
uv sync
# μλ² μ€ν (ν¬νΈ 8000)
PYTHONPATH=src uv run uvicorn api.main:app --host 127.0.0.1 --port 8000 --reloadμ ν°λ―Έλμ μ΄κ³ Next.js ν΄λΌμ΄μΈνΈλ₯Ό μ€νν©λλ€.
cd ui
# νκ²½ λ³μ μ€μ (.env.local μ .env λ³΄λ€ μ°μ λ‘λλ¨)
cp .env.example .env.local
# AUTH_SECRET : openssl rand -base64 32 λ‘ μμ±
# API_KEY : λ£¨νΈ .env μ κ°κ³Ό λμΌνκ²
# API_URL : λ‘컬 μ§μ μ€ν μ http://127.0.0.1:8000
# UPLOAD_DIR : λ°±μλ νμ© κ²½λ‘μ μΌμΉ (μ λκ²½λ‘ κΆμ₯, μ: <repo>/data/uploads)
# μμ‘΄μ± μ€μΉ
npm install
# DB(SQLite) μ€ν€λ§ μ μ© (μ΅μ΄ 1ν)
npx prisma migrate deploy # λλ κ°λ° νκ²½: npx prisma migrate dev
# κ°λ° μλ² μ€ν (ν¬νΈ 3000)
npm run devλΈλΌμ°μ μμ http://localhost:3000 μΌλ‘ μ μνμ¬ νμκ°μ
ν μ¬μ©ν©λλ€.
μΉ UI μμ΄ ν°λ―Έλμμ μ§μ μΈλ±μ±Β·κ²μΒ·μ§μλ₯Ό μνν μ μμ΅λλ€.
Typer κΈ°λ° CLI(cli/main.py)λ cli.main λͺ¨λλ‘ μ€νν©λλ€.
# λ¬Έμ μΈλ±μ± (λλ ν°λ¦¬/νμΌ κ²½λ‘)
PYTHONPATH=src uv run python -m cli.main index ./docs
PYTHONPATH=src uv run python -m cli.main index ./docs --reset # κΈ°μ‘΄ μΈλ±μ€ μ΄κΈ°ν ν μ¬μΈλ±μ±
# κ²μ (LLM νΈμΆ μμ΄ κ΄λ ¨ μ²ν¬λ§ νμΈ β λλ²κΉ
μ©)
PYTHONPATH=src uv run python -m cli.main search "RAG μ²νΉ μ λ΅" --top-k 5 --alpha 0.5
# μ§μ-μλ΅ (κ²μ + LLM μμ±)
PYTHONPATH=src uv run python -m cli.main ask "μ΄ λ¬Έμμ ν΅μ¬ λ΄μ©μ μλ €μ€" \
--rerank --expand --show-context --provider geminiask μ£Όμ μ΅μ
: --top-k, --rerank(μ¬μ λ ¬), --expand(쿼리 νμ₯),
--show-context(μ°Έμ‘° μ²ν¬ νμ), --provider(gemini/ollama), --verbose.
searchλ --alpha(0.0=BM25 ~ 1.0=Vector, κΈ°λ³Έ 0.5)λ‘ νμ΄λΈλ¦¬λ κ°μ€μΉλ₯Ό μ‘°μ νκ³ ,
indexλ --resetμΌλ‘ κΈ°μ‘΄ μΈλ±μ€λ₯Ό μ΄κΈ°νν©λλ€.
| Method | Path | μ€λͺ |
|---|---|---|
POST |
/ask |
κ²μ + LLM λ΅λ³ μμ± (μ°Έμ‘° μ²ν¬ ν¬ν¨) |
POST |
/ask/stream |
SSE μ€νΈλ¦¬λ° λ΅λ³ (μ°Έμ‘° μ μ μ‘ ν ν ν° μ€νΈλ¦¬λ°) |
POST |
/search |
LLM μμ΄ νμ΄λΈλ¦¬λ κ²μ κ²°κ³Όλ§ λ°ν |
POST |
/index |
ν μ€νΈ/νμΌ μ²νΉ λ° μΈλ±μ± (λμΌ source μ¬μΈλ±μ± μ κΈ°μ‘΄ μ²ν¬ μ 리) |
POST |
/index/stream |
SSEλ‘ μΈλ±μ± μ§ν μν©(νμ±βμ²νΉβμλ² λ© μ§νλ₯ βμλ£) μ€νΈλ¦¬λ° |
DELETE |
/index/by-source |
filename + user_id λ¨μ μΈλ±μ€ μμ |
GET |
/config/supported-extensions |
μ λ‘λ νμ© νμ₯μ λͺ©λ‘ μ‘°ν (νμ λ μ§μ€νΈλ¦¬ κΈ°λ°) |
GET/POST |
/config/system-prompt |
μμ€ν ν둬ννΈ μ‘°ν/κ°±μ |
/ask μμ²μ doc_mode, source_filter, summarize_override, expand, rerank,
provider, user_id λ±μΌλ‘ λμμ μΈλ°νκ² μ μ΄ν μ μμ΅λλ€.
(μ€ν€λ§: src/api/schemas.py)
μΈμ¦: APIλ
X-API-Keyν€λ κΈ°λ° μΈμ¦μ μ¬μ©ν©λλ€.productionνκ²½μμλAPI_KEYκ° νμμ λλ€.
# λ¨μ/ν΅ν© ν
μ€νΈ μ€ν
uv run pytest골λ μ (JSONL)μ κΈ°λ°μΌλ‘ κ²μΒ·μμ± νμ§μ μ λ μΈ‘μ νκ³ Markdown 리ν¬νΈλ₯Ό μμ±ν©λλ€. μμΈν λ΄μ©μ docs/evaluation.mdλ₯Ό μ°Έκ³ νμΈμ.
# λΉ λ₯Έ νκ· (κ²μ λ©νΈλ¦λ§: Recall@k, Precision@k, MRR, source_recall)
python -m evals.ragas_runner --golden evals/golden_set.jsonl --out evals/history/quick.csv
# Reranker A/B λΉκ΅
python -m evals.ragas_runner --out evals/history/baseline.csv
python -m evals.ragas_runner --rerank --out evals/history/with_rerank.csv
python -m evals.report --current evals/history/with_rerank.csv --baseline evals/history/baseline.csv
# λ΅λ³ μμ± + RAGAS(LLM-as-judge) μ 체 νκ°
python -m evals.ragas_runner --with-answer --with-ragas --out evals/history/full.csvμΈ‘μ μ§ν: recall@{1,3,5,10}, precision@5, mrr, source_recall,
RAGASμ faithfulness / answer_relevancy / context_precision / context_recall,
κ·Έλ¦¬κ³ κ²μ/μμ± latency.
μλ²κ° μ€ν μ€μΌ λ http://localhost:8000/docs μμ Swagger UIλ₯Ό ν΅ν΄ APIλ₯Ό μ§μ ν
μ€νΈν μ μμ΅λλ€.