| # LoomChat RAG — Complete System Documentation |
|
|
| --- |
|
|
| ## 1. Architecture Overview |
|
|
| ``` |
| HF Space: idnameraj/rag-vs |
| (cpu-basic: 2 vCPU, 16 GB RAM) |
| +----------------------------------------------------------+ |
| | | |
| | FastAPI (uvicorn, port 7860) | |
| | +------------+ +--------------+ +----------------+ | |
| | | /upload | | /query | | /reset | | |
| | | (ingest) | | (RAG search) | | (clear DB) | | |
| | +-----+------+ +------+-------+ +----------------+ | |
| | | | | |
| | +-----v------+ +------v-------+ | |
| | | Sentence | | LanceDB | | |
| | | Transformer| | (vector DB) | | |
| | | MiniLM-L6 | | /tmp (fast) | | |
| | +------------+ +------+-------+ | |
| | | incremental sync | |
| | +------v-------+ | |
| | | /data bucket | | |
| | | (FUSE mount) | | |
| | +--------------+ | |
| | | |
| | +------------------+ | |
| | | Keep-Alive Loop | self-ping every 15 min | |
| | +------------------+ | |
| +----------------------------------------------------------+ |
| | |
| HTTP (httpx) |
| | |
| +------------v------------+ |
| | HF Space: | |
| | idnameraj/ | |
| | loomchat-ollama-new | |
| | | |
| | Ollama server | |
| | Model: qwen2.5:1.5b | |
| | Endpoint: /api/generate | |
| +-------------------------+ |
| ``` |
|
|
| --- |
|
|
| ## 2. Infrastructure (Two HF Spaces) |
|
|
| | Component | HF Space | Hardware | Role | |
| |-----------|----------|----------|------| |
| | **RAG API** | `idnameraj/rag-vs` | cpu-basic (2 vCPU, 16 GB RAM) | Embedding, vector search, document ingestion, API | |
| | **Ollama LLM** | `idnameraj/loomchat-ollama-new` | Separate Space | Hosts qwen2.5:1.5b for answer generation | |
| | **Storage Bucket** | `idnameraj/loomchat-rag-data` | FUSE-mounted at `/data` | Persistent vector DB storage across restarts | |
|
|
| --- |
|
|
| ## 2.1 Authentication — `RAG_API_SECRET` |
|
|
| Sensitive routes on this service (`/query`, `/upload`, `/reset`, deletes, `/debug-storage`, …) require HTTP header **`X-API-Key`** when the environment variable **`RAG_API_SECRET`** is set (non-empty). The LoomChat backend must use the **same** value in its **`RAG_API_SECRET`** setting so outbound `/query` and `/upload` succeed. |
|
|
| | Where | What to do | |
| |-------|------------| |
| | **Hugging Face Space** | **Settings → Repository secrets** → name `RAG_API_SECRET`, value = generated secret → reboot Space | |
| | **Backend** (Docker / `.env`) | `RAG_API_SECRET=<same string>` (see root `docker-compose.yml`, `.env.docker`, `backend/.env.example`) | |
| | **Local dev** | Leave **empty on both** the Space and the backend to disable auth (no-op guard in `main.py`) | |
|
|
| --- |
|
|
| ## 3. File Structure |
|
|
| ``` |
| hf-deploy/rag-api/ |
| ├── main.py # FastAPI app — all RAG logic (345 lines) |
| ├── Dockerfile # Docker build for HF Spaces deployment |
| ├── requirements.txt # Python dependencies (10 packages) |
| ├── test_ui.py # Streamlit validation UI (133 lines) |
| └── RAG_SYSTEM_DOCS.md # This documentation file |
| ``` |
|
|
| --- |
|
|
| ## 4. Module-by-Module Breakdown |
|
|
| --- |
|
|
| ### 4.1 Dockerfile |
|
|
| ```dockerfile |
| FROM python:3.11-slim |
| ``` |
|
|
| | Line | What | Why | |
| |------|------|-----| |
| | `FROM python:3.11-slim` | Base image | Minimal footprint (~150 MB vs ~900 MB full) | |
| | `ENV HF_HOME="/tmp/hf_cache"` | HuggingFace cache dir | Bucket FUSE mount doesn't support symlinks needed by HF hub cache; `/tmp` does | |
| | `pip install torch --index-url .../cpu` | CPU-only PyTorch | Saves ~2.5 GB by skipping CUDA libraries (no GPU on free tier) | |
| | `EXPOSE 7860` | HF Spaces port | Standard port required by HF Spaces Docker SDK | |
| | Runs as **root** (no `USER` directive) | | Bucket mount at `/data` requires root write permissions | |
|
|
| --- |
|
|
| ### 4.2 Dependencies (requirements.txt) |
|
|
| | Package | Version | Purpose | |
| |---------|---------|---------| |
| | `fastapi` | latest | Web framework for the API | |
| | `uvicorn[standard]` | latest | ASGI server to run FastAPI | |
| | `python-multipart` | latest | File upload parsing (`multipart/form-data`) | |
| | `lancedb>=0.20` | 0.20+ | Embedded vector database (columnar, Lance format) | |
| | `sentence-transformers` | latest | Embedding model loader (wraps HuggingFace transformers + PyTorch) | |
| | `pandas` | latest | DataFrame for LanceDB ingestion | |
| | `pypdf` | latest | PDF text extraction | |
| | `python-docx` | latest | DOCX text extraction | |
| | `httpx` | latest | Async HTTP client for Ollama calls | |
| | `huggingface-hub` | latest | Model download from HF Hub | |
|
|
| --- |
|
|
| ### 4.3 Main Application (main.py) |
|
|
| #### 4.3.1 Constants & Globals (Lines 24-41) |
|
|
| ```python |
| BUCKET_PATH = "/data" # FUSE-mounted HF Storage Bucket |
| LOCAL_DB_PATH = "/tmp/lancedb_store" # Fast ephemeral local disk |
| BUCKET_DB_PATH = "/data/lancedb_store" # Persistent bucket path |
| TABLE_NAME = "documents" # Single LanceDB table name |
| |
| OLLAMA_URL = "https://idnameraj-loomchat-ollama-new.hf.space" |
| OLLAMA_MODEL = "qwen2.5:1.5b" |
| ALLOWED_EXTENSIONS = {".pdf", ".txt", ".csv", ".docx"} |
| ``` |
|
|
| Both `OLLAMA_URL` and `OLLAMA_MODEL` are overridable via environment variables/secrets in HF Space settings. |
|
|
| #### 4.3.2 Persistence — Sync Functions (Lines 44-97) |
|
|
| **`sync_from_bucket()`** — Called once at startup |
| - Copies entire `/data/lancedb_store/` to `/tmp/lancedb_store/` using `shutil.copytree` |
| - Wrapped in `try/except OSError` — if the FUSE mount has I/O issues, it logs a warning and starts fresh instead of crashing |
| - Full copy is acceptable here since it runs only once |
|
|
| **`sync_to_bucket()`** — Called after every upload and on shutdown |
| - **Incremental sync** — walks the local directory, compares each file against the bucket copy |
| - Only copies files where: destination is missing, OR size differs, OR source mtime is newer |
| - Removes stale bucket files that no longer exist locally (handles LanceDB compaction) |
| - Much faster than the old `rmtree` + `copytree` approach as the DB grows |
|
|
| ``` |
| STARTUP: /data/lancedb_store/ --full copy--> /tmp/lancedb_store/ |
| RUNTIME: All reads/writes hit /tmp (fast local disk) |
| ON WRITE: /tmp/lancedb_store/ --incremental--> /data/lancedb_store/ |
| SHUTDOWN: Final incremental sync to /data |
| ``` |
|
|
| #### 4.3.3 Keep-Alive Loop (Lines 100-112) |
|
|
| ```python |
| KEEP_ALIVE_INTERVAL = 15 * 60 # 15 minutes |
| ``` |
|
|
| - Background `asyncio.Task` that pings `http://localhost:7860/` every 15 minutes |
| - Prevents HF free-tier inactivity sleep (which triggers after ~48h of no traffic) |
| - Non-blocking, failure-tolerant (silent `except`) |
| - Cancelled gracefully on shutdown |
|
|
| #### 4.3.4 Lifespan Manager (Lines 115-135) |
|
|
| FastAPI's `lifespan` context manager handles startup and shutdown: |
|
|
| **Startup sequence:** |
| 1. `sync_from_bucket()` — restore data from persistent storage |
| 2. `lancedb.connect(LOCAL_DB_PATH)` — open vector DB on fast local disk |
| 3. `SentenceTransformer("all-MiniLM-L6-v2")` — load embedding model (~80 MB, 384-dim) |
| 4. `asyncio.create_task(keep_alive_loop())` — start background ping |
|
|
| **Shutdown sequence:** |
| 1. Cancel keep-alive task |
| 2. `sync_to_bucket()` — final persistence |
| 3. Delete models, `gc.collect()` — free memory |
|
|
| #### 4.3.5 Text Extractors (Lines 169-197) |
|
|
| | Format | Extractor | Library | Method | |
| |--------|-----------|---------|--------| |
| | `.pdf` | `extract_text_from_pdf` | `pypdf.PdfReader` | Iterates all pages, extracts text | |
| | `.txt` | `extract_text_from_txt` | Built-in `open()` | Reads entire file with UTF-8, ignores errors | |
| | `.csv` | `extract_text_from_csv` | Built-in `csv.reader` | Joins each row with commas, joins rows with newlines | |
| | `.docx` | `extract_text_from_docx` | `python-docx` | Extracts text from all paragraphs | |
|
|
| Registered in `EXTRACTORS` dict, dispatched by file extension. |
|
|
| #### 4.3.6 Chunking (Lines 211-218) |
|
|
| ```python |
| def chunk_text(text, chunk_size=500, overlap=50): |
| ``` |
|
|
| - Splits extracted text into **500-character chunks** with **50-character overlap** |
| - Overlap ensures context isn't lost at chunk boundaries |
| - Strips whitespace, removes empty chunks |
| - A typical 10-page PDF produces ~40-80 chunks |
|
|
| #### 4.3.7 Embedding (Lines 261-267, inside upload) |
|
|
| ```python |
| embed_model.encode(batch) # -> 384-dim float vector per chunk |
| ``` |
|
|
| - Model: `sentence-transformers/all-MiniLM-L6-v2` (~22M params, ~80 MB) |
| - Output: 384-dimensional dense float vector per chunk |
| - Batch size: 16 chunks at a time (memory-defensive) |
| - Runs on CPU — ~50ms per batch of 16 |
|
|
| #### 4.3.8 Vector Storage — LanceDB |
|
|
| - **Embedded** database (no separate server process) |
| - Stores data in columnar Lance format (`.lance` files) |
| - Single table `"documents"` with columns: `text` (string), `vector` (float[384]) |
| - Search: approximate nearest neighbor (ANN) using L2 distance |
| - API compatibility helper `get_table_names()` handles both old (`table_names()`) and new (`list_tables()`) LanceDB APIs |
|
|
| #### 4.3.9 LLM Generation — Ollama (Lines 221-232) |
|
|
| ```python |
| async def ollama_generate(prompt: str) -> str: |
| ``` |
|
|
| - Sends prompt to `https://idnameraj-loomchat-ollama-new.hf.space/api/generate` |
| - Model: **qwen2.5:1.5b** (1.5 billion parameters, ~1 GB) |
| - Non-streaming mode (`"stream": False`) |
| - Timeout: 120 seconds |
| - Uses `httpx.AsyncClient` with `verify=False` (HF internal SSL) |
|
|
| --- |
|
|
| ### 4.4 API Endpoints |
|
|
| #### `GET /` — Health Check (Lines 141-163) |
|
|
| Returns system status: |
| ```json |
| { |
| "status": "ok", |
| "ollama": "connected", |
| "ollama_model": "qwen2.5:1.5b", |
| "accepted_formats": [".pdf", ".docx", ".txt", ".csv"], |
| "chunks_in_db": 6 |
| } |
| ``` |
| Pings Ollama (5s timeout) and counts rows in LanceDB. |
|
|
| #### `POST /upload` — Document Ingestion (Lines 238-295) |
|
|
| **Flow:** |
| 1. Validate file extension |
| 2. Save to temp file (`tempfile.NamedTemporaryFile`) |
| 3. Extract text using format-specific extractor |
| 4. Chunk text (500 chars, 50 overlap) |
| 5. Embed in batches of 16 -> 384-dim vectors |
| 6. Store in LanceDB (create table or append) |
| 7. `gc.collect()` to free memory |
| 8. Incremental sync to bucket |
| 9. Return `{"chunks_stored": N, "filename": "..."}` |
|
|
| #### `POST /query` — RAG Query (Lines 305-333) |
|
|
| **Flow:** |
| 1. Embed the query -> 384-dim vector |
| 2. Vector search in LanceDB -> top 3 nearest chunks |
| 3. Build prompt with retrieved context |
| 4. Send to Ollama for generation |
| 5. Return `{"answer": "...", "sources": [chunk1, chunk2, chunk3]}` |
|
|
| **Prompt template:** |
| ``` |
| Using only the context below, answer the question. |
| If the context does not contain the answer, say "I don't know." |
| |
| Context: |
| {top 3 chunks joined by newlines} |
| |
| Question: {user query} |
| |
| Answer: |
| ``` |
|
|
| #### `POST /reset` — Clear Database (Lines 339-344) |
|
|
| Drops all LanceDB tables, syncs empty state to bucket. |
|
|
| --- |
|
|
| ### 4.5 Test UI (test_ui.py) |
| |
| Streamlit app pointing at `https://idnameraj-rag-vs.hf.space`: |
| - Health dashboard (Ollama status, model name, chunk count) |
| - File upload widget (PDF, TXT, CSV, DOCX) |
| - Chat interface with message history (`st.session_state`) |
| - Source chunks displayed in expandable sections |
|
|
| --- |
|
|
| ## 5. Complete Data Flow |
|
|
| ``` |
| User uploads file.pdf |
| | |
| v |
| POST /upload (multipart) |
| | |
| v |
| Save to /tmp/tmpXXXX.pdf |
| | |
| v |
| pypdf extracts text --> "The quick brown fox..." |
| | |
| v |
| chunk_text(500, 50) --> ["The quick brown...", "brown fox jumped...", ...] |
| | |
| v |
| embed_model.encode() --> [[0.12, -0.34, ...], [0.56, 0.78, ...], ...] |
| (batches of 16) (384-dim each) |
| | |
| v |
| LanceDB table.add() --> /tmp/lancedb_store/documents.lance/ |
| | |
| v |
| sync_to_bucket() --> /data/lancedb_store/ (incremental, only changed files) |
| | |
| v |
| Return {"chunks_stored": 12} |
| |
| |
| User asks "What did the fox do?" |
| | |
| v |
| POST /query {"query": "What did the fox do?"} |
| | |
| v |
| embed_model.encode("What did the fox do?") --> [0.45, -0.12, ...] |
| | |
| v |
| LanceDB table.search(vector).limit(3) --> Top 3 nearest chunks |
| | |
| v |
| Build prompt with context + question |
| | |
| v |
| httpx POST to Ollama /api/generate --> qwen2.5:1.5b generates answer |
| | |
| v |
| Return {"answer": "The fox jumped over...", "sources": [...]} |
| ``` |
|
|
| --- |
|
|
| ## 6. Maximum Usage & Thresholds |
|
|
| ### RAG API Space (idnameraj/rag-vs — cpu-basic) |
|
|
| | Resource | Limit | Current Usage | Notes | |
| |----------|-------|---------------|-------| |
| | **vCPU** | 2 cores | ~0.1 idle, spikes to 2 during embed | Embedding is CPU-bound | |
| | **RAM** | 16 GB | ~2.5 GB baseline (PyTorch + model + LanceDB) | ~13 GB available for data | |
| | **Disk (`/tmp`)** | ~50 GB (shared ephemeral) | Minimal | LanceDB files grow here | |
| | **Bucket (`/data`)** | **20 GB** (free-tier HF Storage Bucket) | ~few MB | Hard limit for persistent data | |
| | **Max file upload** | ~100 MB (practical) | | FastAPI/uvicorn default body limits | |
| | **Concurrent requests** | 1 worker (uvicorn default) | | Async, but CPU-bound embedding blocks | |
|
|
| ### Ollama Space (idnameraj/loomchat-ollama-new) |
|
|
| | Resource | Limit | Notes | |
| |----------|-------|-------| |
| | **Model** | qwen2.5:1.5b (~1 GB RAM) | Small enough for free-tier CPU | |
| | **Context window** | **32,768 tokens** | qwen2.5 native context length | |
| | **Effective input** | ~1,500 chars (3 chunks x 500) + query + prompt template = **~600 tokens** | Well within limit | |
| | **Generation timeout** | 120 seconds | Set in httpx client | |
| | **Inference speed** | ~5-15 tokens/sec on CPU | Varies by load | |
| | **Max answer length** | ~500-1000 tokens typical | No explicit `num_predict` set (model default) | |
|
|
| ### Calculated Capacity Limits |
|
|
| | Metric | Estimate | How Calculated | |
| |--------|----------|----------------| |
| | **Max documents** | **~2,000-3,000 PDFs** (10 pages each) | 20 GB bucket / ~7-10 MB per 10-page PDF in LanceDB | |
| | **Max chunks in DB** | **~200,000-500,000** | 20 GB / (~40-100 KB per chunk with 384-dim vector) | |
| | **Upload speed** | ~5-10 seconds per 10-page PDF | Extract + chunk + embed (16 batch) + sync | |
| | **Query latency** | **3-15 seconds** total | ~50ms embed + ~10ms search + 3-15s Ollama generation | |
| | **Embedding throughput** | ~300 chunks/min | 16 chunks/batch, ~3s/batch on 2 vCPU | |
| | **Keep-alive duration** | Indefinite (with self-ping) | 15-min ping prevents sleep; HF may still restart for maintenance | |
|
|
| ### Hard Limits to Watch |
|
|
| | Limit | Threshold | What Happens | |
| |-------|-----------|--------------| |
| | **Bucket storage hits 20 GB** | `sync_to_bucket()` fails with `OSError` | New uploads fail to persist; data lost on restart | |
| | **RAM hits 16 GB** | OOM kill (exit code 137) | Container restarts, data in `/tmp` lost (bucket has last sync) | |
| | **Ollama Space sleeps** | Query returns 502 | First query after sleep takes ~30-60s (cold start) | |
| | **Both Spaces sleep** | Full cold start | RAG: ~60-90s (model download + bucket sync), Ollama: ~30-60s | |
| | **Concurrent heavy uploads** | RAM spike from embedding | 16-batch limit helps, but 5+ simultaneous large files could OOM | |
|
|
| ### Recommendations for Scaling Beyond Free Tier |
|
|
| | Bottleneck | Solution | Cost | |
| |------------|----------|------| |
| | Slow inference | Upgrade Ollama Space to GPU (T4) | ~$0.60/hr | |
| | More storage | Upgrade bucket or use external DB | $5-10/mo | |
| | Higher throughput | Upgrade RAG Space to cpu-upgrade (8 vCPU) | ~$0.03/hr | |
| | Always-on guarantee | Enable "Persistent" Space setting (paid) | Included with hardware upgrade | |
|
|