A domain-restricted RAG chatbot for rapid crisis-survival training across 25 topics (first aid, natural disasters, human-caused crises, emergency kits, utility outages, …). Replies in the user's language (Persian or English) and supports multi-turn follow-ups.
Stack: an OpenAI-compatible LLM (local Qwen2.5-7B-Instruct via Ollama by default, provider-swappable to a hosted API) + RAG + Prompt Engineering, with multilingual embeddings, a reranker, Qdrant vector DB, and a FastAPI service.
app/static/index.html is a single-file emergency-response UI with adaptive light/dark themes, full RTL layout, bundled Vazirmatn Persian-digits fonts, and four tabs:
- گفتگو (chat) — RAG chat with multi-turn context (last ~3 turns, client-side) and a «گفتگوی جدید» reset button to start fresh. Replies in the user's language.
- حالت اضطرار (Panic Mode) — tap an emergency (earthquake, severe bleeding, fire, CPR, flood, shelter) → full-screen, one-step-at-a-time guidance with a fixed 115 call bar.
- جعبهابزار نجات (SOS) — alarm + whistle (WebAudio), a Morse-SOS screen strobe, and a GPS location beacon.
- کیت اضطراری (go-bag) — a downloadable SVG infographic + a checklist with a readiness score saved in
localStorage. - Installable PWA — same-origin web manifest and service worker, branded launcher/maskable icons, app shortcuts, standalone display, and an offline fallback page.
- RTL admin dashboard —
/admin(alias/panel) shows anonymous online, daily, page-view, chat, and PWA-install counts; optionalADMIN_TOKENprotects the aggregate JSON endpoint.
Panic Mode, SOS, and the kit are fully client-side and are cached by the PWA for offline use after the first visit (no backend/model). Only chat calls the API and therefore still needs a connection.
┌─────────────────────────────────────────────────────┐
User (fa) │ FastAPI (app/server.py) │
─────────────▶│ POST /chat {question} │
│ │ │
│ ▼ │
│ RAGPipeline (app/rag.py) │
│ 1. embed query ──▶ e5-base (query: prefix) │
│ 2. vector search ─▶ Qdrant (top_k=12) │
│ 3. rerank ─▶ bge-reranker-v2-m3 (top_n=4) │
│ 4. domain guard ─▶ refuse if low relevance │
│ 5. build context + system prompt │
│ 6. generate ─▶ Qwen2.5-7B via vLLM (OpenAI API)│
│ │ │
│ {answer, sources} ◀┘ │
└─────────────────────────────────────────────────────┘
Offline (one-time): data/raw/*.md ─▶ scripts/ingest.py ─▶ Qdrant
Components & responsibilities
- Knowledge base (
data/raw/*.md): curated Persian crisis content, the single source of truth. - Ingestion (
scripts/ingest.py): normalize Persian text, heading-aware chunking, embed, upsert to Qdrant. - Embedder (
multilingual-e5-base): turns Persian text into vectors; strong Persian, CPU-friendly. - Vector DB (Qdrant): cosine similarity search over chunks.
- Reranker (
bge-reranker-v2-m3): reorders candidates by true relevance, cuts noise before the LLM. - LLM (Qwen2.5-7B-Instruct via vLLM): generates grounded Persian answers.
- Domain guard + system prompt: language-matched (Persian/English), domain-only (reranker-score guard), source-grounded, safety-first. Refuses off-topic and asks a clarifying question when a scenario is ambiguous.
Data flow: query → embed → retrieve 12 → rerank to 4 → relevance check → grounded prompt → Qwen → answer + citations.
- RAG + prompt engineering over fine-tuning: small, factual, frequently-updated, safety-critical domain. RAG keeps knowledge in editable docs, gives citations, and avoids hallucination. No training data or GPU-hours needed. Fine-tuning (LoRA/QLoRA/full) would bake knowledge into weights — hard to update, risky for medical accuracy.
- Qwen2.5-7B-Instruct: strong Persian, Apache-2.0 (commercially safe), great instruction-following and RAG grounding, fits a single 24GB GPU at 4-bit/fp8. Alternatives rejected: Aya (CC-BY-NC, non-commercial), Gemma (restrictive license), Llama 3.1 (weaker Persian), Persian-only models (weaker instruction-following). Low on VRAM? Switch to Qwen2.5-3B-Instruct in
configs/config.yaml.
- Collect: official first-aid orgs (Red Crescent / Red Cross), disaster-management agencies, FEMA-equivalent guides — translated/adapted to Persian and verified.
- Format: Markdown (
.md) with#title and##sections. Simple, diff-able, and the chunker is heading-aware. - Chunking: heading-aware, ~500 chars with 80-char overlap. Persian is information-dense; this keeps each section coherent without splitting steps.
- Metadata: every chunk stores
title,section,category(one of the four domains), andsource. Used for citations and could enable category filtering.
| Minimum | Recommended | |
|---|---|---|
| GPU | 1× 12GB (e.g. RTX 3060) running Qwen-3B, or none (CPU/llama.cpp) | 1× 24GB (RTX 3090/4090) for Qwen-7B fp8 |
| CPU | 4 cores | 8+ cores |
| RAM | 16 GB | 32 GB |
| Disk | 30 GB | 50 GB SSD (model + caches) |
No GPU? Serve Qwen via llama.cpp/Ollama in GGUF Q4 on CPU (slower, ~6–8 GB RAM for 7B). See §7.
# System deps
sudo apt update && sudo apt install -y python3.11 python3.11-venv git
# Project
git clone <your-repo> crisis-rag && cd crisis-rag
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Start Qdrant (Docker is easiest even for local dev)
docker run -d -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant:v1.11.3
# Serve the LLM (GPU). Installs vLLM and serves an OpenAI-compatible API on :8000
pip install vllm==0.6.2
vllm serve Qwen/Qwen2.5-7B-Instruct --max-model-len 8192 --quantization fp8
# In config.yaml set base_url + qdrant url to localhost when running bare metal:
# llm.base_url: http://localhost:8000/v1
# vector_store.url: http://localhost:6333
# Build the index, then run the API
python -m scripts.ingest
uvicorn app.server:app --host 0.0.0.0 --port 8080# Requires Docker + NVIDIA Container Toolkit for the GPU LLM service.
export HF_TOKEN=hf_xxx # optional, only if a gated model is used
docker compose up -d --build # starts qdrant + vLLM + app
# Wait for vLLM to finish loading (watch: docker compose logs -f llm)
# Build the knowledge index inside the app container:
docker compose exec app python -m scripts.ingest
# Test it:
curl -s http://localhost:8080/healthcurl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b # or qwen2.5:3b for low RAM
ollama serve # exposes http://localhost:11434
# Ollama is OpenAI-compatible at /v1. In config.yaml:
# llm.base_url: http://localhost:11434/v1
# llm.model_name: qwen2.5:7bNo GPU, no local LLM — use a hosted API. The code is already provider-agnostic
via env vars (app/rag.py:45-49). The easiest option is OpenRouter with free
models. The public deployment at https://crisis.archaon.ir/ currently uses this
OpenRouter path; local Ollama remains the offline development fallback.
| Model ID | Context | Notes |
|---|---|---|
google/gemma-4-31b-it:free |
262K | Recommended — multilingual, strong Persian |
nvidia/nemotron-3-super-120b-a12b:free |
1M | Strong reasoning, English-primary |
nvidia/nemotron-3-nano-30b-a3b:free |
256K | Lighter, faster |
Rate limits: 20 req/min, 50 req/day (bump to 1,000/day with a one-time $10 purchase).
# Set env vars
export LLM_BASE_URL="https://openrouter.ai/api/v1"
export LLM_API_KEY="sk-or-YOUR_KEY_HERE"
export LLM_MODEL="google/gemma-4-31b-it:free"
# Run (bare metal)
uvicorn app.server:app --host 0.0.0.0 --port 8080# Edit .env with your key, then:
docker compose -f docker-compose.yml -f docker-compose.openrouter.yml up -d --build
docker compose exec app python -m scripts.ingestThe docker-compose.openrouter.yml override disables the GPU llm service and
injects the three env vars into the app container.
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer sk-or-YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"model":"google/gemma-4-31b-it:free","messages":[{"role":"user","content":"test"}]}'| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 2 cores | 4+ cores |
| RAM | 4 GB | 8 GB |
| Disk | 5 GB | 10 GB |
| GPU | None | None |
Only Qdrant + embedder (~560 MB) + reranker (~1-2 GB) + FastAPI run locally.
POST /chat
{ "question": "خونریزی شدید را چطور کنترل کنم؟" }Response:
{
"answer": "ابتدا با پارچه تمیز فشار مستقیم و محکم روی زخم وارد کنید...",
"sources": [
{"title": "کمکهای اولیه: خونریزی شدید", "section": "فشار مستقیم",
"category": "کمکهای اولیه", "source": "first_aid_bleeding.md"}
]
}curl example:
curl -s -X POST http://localhost:8080/chat \
-H "Content-Type: application/json" \
-d '{"question":"در بسته اضطراری چه چیزهایی باید باشد؟"}' | jqGET /admin (also /panel) serves the RTL aggregate dashboard. GET /admin/stats
returns its data; when ADMIN_TOKEN is set, send it as X-Admin-Token. Browser analytics
accept only page_view, heartbeat, and pwa_install at POST /analytics/event. The
SQLite store hashes the anonymous browser ID and does not store IP addresses, device details,
questions, answers, or location. In Docker, analytics_data keeps counts across rebuilds.
| Query (fa) | Behavior |
|---|---|
| خونریزی شدید را چطور کنترل کنم؟ | Grounded first-aid steps + 115 reminder, cites bleeding doc |
| برای کاهش استرس در بحران چه کنم؟ | Slow comfortable breathing + grounding, cites stress doc |
| اگر بوی گاز حس کردم چه کار کنم؟ | Shut main valve, ventilate, no flames/switches, evacuate |
| پایتخت فرانسه کجاست؟ | Refuses — out of domain, redirects to crisis topics |
crisis-rag/
├── app/
│ ├── rag.py # retrieve → rerank → guard → generate
│ ├── server.py # FastAPI API + PWA root routes
│ └── static/ # RTL UI, manifest, service worker, icons, fonts
├── scripts/
│ └── ingest.py # build the vector index
├── data/raw/*.md # Persian knowledge base (edit me)
├── configs/config.yaml # all knobs in one place
├── .env # API keys (gitignored)
├── Dockerfile
├── docker-compose.yml # full stack (GPU required)
├── docker-compose.openrouter.yml # override: hosted API, no GPU
├── mobile/ # isolated Bubblewrap/TWA Android wrapper
└── requirements.txt
- Update knowledge: edit
data/raw/*.md, re-runpython -m scripts.ingest. No retraining. - Tune relevance: the primary out-of-domain guard is
domain.min_rerank_score(reranker score, default0.02). Raise it if it answers off-topic; lower if it over-refuses.domain.min_relevance_for_answer(vector score) is only the fallback when the reranker is disabled. - Evaluate: keep a
tests/qa.jsonlof question→expected-source pairs; assert the correctsourceappears insources(retrieval accuracy is the thing to measure for RAG).
Status (2026-07): functional end-to-end on master and publicly deployed at
https://crisis.archaon.ir/. Knowledge base = 25 topics / 248 chunks after the current heading-aware chunk pass. The production
chat uses an OpenRouter-hosted model; local qwen2.5:7b-instruct remains the development
fallback. The UI now uses matching light and rescue-night themes, a project logo, locally bundled
Vazirmatn Persian-digits fonts, pictorial high-contrast emergency flows, and an installable PWA shell.
What was built this round: adaptive light/dark appearance with saved/system preference, installable
PWA metadata and offline caching, Persian app shortcuts, branded launcher icons, a root-scoped service
worker, and an isolated Android Trusted Web Activity scaffold under mobile/. The production compose
override also pins Hugging Face Hub to offline mode so cached local retrieval models do not trigger downloads.
Known limitations (candidate backlog):
- LLM quality is the #1 lever. Local Qwen-7B is weak at Persian (occasional awkward/invented
phrasing). Fix = swap to a better model: finish
ollama pull aya-expanse:8b(local) or setLLM_BASE_URL/LLM_API_KEY/LLM_MODELto a hosted aggregator (Avalai/OpenRouter). One config flip — no code change. Gated on the user supplying a key or bandwidth. - No automated eval harness yet —
tests/qa.jsonlis proposed but not built. Regression testing is manual. - No bilingual (English) knowledge base — English questions are answered from Persian chunks via prompt-only language matching. Deferred until chunks are expanded/made more robust.
- Chat needs the backend online — the installed PWA can open offline and the 3 rescue tools remain usable, but generating a new RAG answer still needs FastAPI, Qdrant, and the configured LLM provider.
- Single-file frontend (
app/static/index.html, vanilla JS) — fine for now; would need componentizing if the UI grows. - No end-user chat authentication or application-level rate limiting — the public chat relies on its infrastructure and upstream provider limits. Aggregate admin stats can be protected with
ADMIN_TOKEN.
Deferred (decided not-this-round): bilingual KB, offline/hybrid chat fallback.