Spaces:
Running
Running
| # Julia β WinnCare RAG Assistant: Technical Documentation | |
| **Stack:** Python 3.12 Β· FastAPI Β· Qdrant Β· BGE-M3 Β· BGE Reranker Β· Groq (LLaMA 3.3-70b) Β· React Β· Vite | |
| **Deployment:** HuggingFace Spaces (Docker, 2 vCPU / 16 GB RAM, no GPU) | |
| **Live URL:** `https://esra2001-winncare.hf.space` | |
| --- | |
| ## Table of Contents | |
| 1. [System Overview](#1-system-overview) | |
| 2. [Architecture](#2-architecture) | |
| 3. [Request Lifecycle](#3-request-lifecycle) | |
| 4. [Ingestion Pipeline](#4-ingestion-pipeline) | |
| 5. [Retrieval Pipeline](#5-retrieval-pipeline) | |
| 6. [Answer Generation](#6-answer-generation) | |
| 7. [API Reference](#7-api-reference) | |
| 8. [Configuration Reference](#8-configuration-reference) | |
| 9. [Frontend](#9-frontend) | |
| 10. [Authentication](#10-authentication) | |
| 11. [Conversation Persistence](#11-conversation-persistence) | |
| 12. [Evaluation Harness (RAGAS)](#12-evaluation-harness-ragas) | |
| 13. [Deployment](#13-deployment) | |
| 14. [Local Development](#14-local-development) | |
| 15. [Operational Notes](#15-operational-notes) | |
| 16. [Known Limitations and Trade-offs](#16-known-limitations-and-trade-offs) | |
| --- | |
| ## 1. System Overview | |
| Julia is a multilingual Retrieval-Augmented Generation (RAG) chatbot built for WinnCare Tunisia. It answers questions about internal documents β product specifications, procedures, compliance reports, price lists, capacity data β by retrieving relevant content from an indexed corpus and generating grounded, cited answers. | |
| **Supported document types:** PDF (any layout including scans, screenshots, tables, charts, forms), Excel (.xlsx, .xls, .xlsm) | |
| **Supported languages:** French, Arabic, English (any mix per message) | |
| **Deployment model:** Single-tenant, shared corpus. All authenticated users query the same document set. | |
| ### What Julia does (and doesn't do) | |
| | Does | Does not | | |
| |------|----------| | |
| | Answer factual questions grounded in uploaded documents | Make up information not in documents | | |
| | Extract data from scanned PDFs using vision LLM | Answer from general internet knowledge | | |
| | Query Excel sheets using generated SQL | Execute arbitrary SQL or modify data | | |
| | Hold a 5-turn conversation with context | Maintain separate per-user memory | | |
| | Abstain when evidence is weak or absent | Guess when uncertain | | |
| | Answer in the user's language | Force a fixed language | | |
| --- | |
| ## 2. Architecture | |
| ``` | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β BROWSER β | |
| β React SPA (Vite) β | |
| β ChatView ββ SSE token stream βββββββββββββββββββββββββββ β | |
| β UploadView ββ multipart POST βββββββββββββββββββββββ β β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β | |
| ββββββββββββββββββββββββββββββ FASTAPI (port 7860) ββββββββββββββββββββββ | |
| β β β β | |
| β /ingest/* ββββ upload.py ββββββββββββββββββββββββββββ β β β | |
| β β β β | |
| β ββββββΌβββββββββββββββββββββββββββββββ β β | |
| β β INGESTION PIPELINE β β β | |
| β β parsers.py (PDF β pages) β β β | |
| β β chunking.py (pages β chunks) β β β | |
| β β embeddings.py (BGE-M3) β β β | |
| β β store.py (β Qdrant) β β β | |
| β β tabular.py (Excel β DuckDB) β β β | |
| β ββββββββββββββββββββββββββββββββββββ β β | |
| β β β | |
| β /query/ask/stream ββ router.py ββββββββββββββββββββββββββββ β β | |
| β β β | |
| β ββββββββΌβββββββββββββββββββββββββββββββββββββββ β | |
| β β QUERY PIPELINE β β | |
| β β condense.py (history-aware rewrite) β β | |
| β β classify.py (casual / descriptive / SQL) β β | |
| β β retrieval.py (BGE-M3 + Qdrant + reranker) β β | |
| β β generate.py (Groq LLaMA / GPT-4o-mini) β β | |
| β β text_to_sql.py (DuckDB SQL path) β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β | |
| β /auth/* auth/router.py (JWT, 30-day tokens) β | |
| β /conversations conversations/ (SQLite / Turso) β | |
| β /health system health check β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| External services: | |
| Qdrant Cloud β vector + sparse index | |
| Groq API β primary LLM (LLaMA 3.3-70b, free tier 100K TPD) | |
| OpenAI API β vision LLM for PDF parsing (VISION_API_KEY) | |
| fallback LLM when Groq quota exhausted (FALLBACK_API_KEY) | |
| Turso β cloud SQLite for conversation storage (optional) | |
| ``` | |
| --- | |
| ## 3. Request Lifecycle | |
| A complete streaming chat request from browser to response: | |
| ``` | |
| User sends message | |
| β | |
| βΌ | |
| [1] condense.py β history-aware query rewrite | |
| If history exists, rewrite follow-up ("et la clause ?") | |
| into a standalone query ("Quelle clause ISO 13485 concerne X ?"). | |
| Uses Groq (cheap, 60 max_tokens). Skipped on first turn. | |
| β | |
| βΌ | |
| [2] classify.py β intent classification | |
| Labels the condensed query as: casual | descriptive | numeric | |
| Β· casual β skip retrieval, Julia answers from personality | |
| Β· descriptive β vector search path | |
| Β· numeric β SQL path (if tabular data exists) | |
| Uses Groq (5 max_tokens, one word output). | |
| β | |
| ββββ΄βββββββββββββββββββββββββββ | |
| β casual path β descriptive/numeric path | |
| βΌ βΌ | |
| [3a] stream_answer() [3b] retrieve() | |
| chunks = [] BGE-M3 embed query + keyword variant | |
| Julia replies from Qdrant hybrid search (dense + sparse RRF) | |
| system personality BGE cross-encoder rerank | |
| MMR deduplication | |
| Source-window expansion | |
| Abstain if best score < 0.08 | |
| β β | |
| β ββββββββββββ | |
| β β descriptive β numeric + tabular data | |
| β βΌ βΌ | |
| β [4] generate.py [4b] text_to_sql.py | |
| β Build context Generate SQL β DuckDB | |
| β [S1][S2] IDs Render result as text | |
| β Groq streaming Single LLM call | |
| β β SSE tokens | |
| β β | |
| ββββββββββββββββββββββ | |
| β | |
| βΌ | |
| [5] SSE done event | |
| { citations, source_map, chunks_used, path, provider } | |
| Citations verified against source_map (no hallucinated filenames) | |
| ``` | |
| --- | |
| ## 4. Ingestion Pipeline | |
| ### Entry point: `POST /ingest/upload` | |
| The upload endpoint saves the file, creates a job record, and runs ingestion in a thread-pool executor (non-blocking). PDF and Excel follow separate paths. | |
| ### PDF path | |
| ``` | |
| File on disk | |
| β | |
| βΌ | |
| parsers.parse_pdf() β iterates pages with PyMuPDF | |
| β | |
| βββ For each page: | |
| β PyMuPDF text extraction (fast, free) | |
| β has_images = bool(page.get_images()) | |
| β | |
| β if has_images OR text < 2000 chars: | |
| β Vision LLM path (GPT-4o-mini) | |
| β render page to PNG at 200 DPI β base64 | |
| β send to OpenAI-compatible vision API | |
| β returns markdown: headings, tables, form fields, chart data | |
| β if vision_text > pymupdf_text β use vision result | |
| β | |
| β elif text < 50 chars: | |
| β Tesseract OCR (ara+fra+eng) as last resort | |
| β | |
| β else: | |
| β PyMuPDF text used as-is | |
| β | |
| βΌ | |
| chunking.chunk_pages() | |
| β | |
| βββ Tables from PyMuPDF path β atomic chunks (never split) | |
| β prefix "[TABLE]\n" + markdown rows | |
| β | |
| βββ Body text β paragraph-boundary splitting | |
| _CHUNK_CHARS = 2500 chars | |
| _OVERLAP_CHARS = 300 chars (last paragraph repeated at next chunk start) | |
| Each chunk gets: chunk_id (UUID), content_hash (SHA-256[:16]), | |
| chunk_index (doc-wide 0-based position), source_language (langdetect) | |
| β | |
| βΌ | |
| embeddings.embed_texts() β BGE-M3 (BAAI/bge-m3) | |
| Each chunk β dense vector (1024-dim) + sparse vector (lexical weights) | |
| Batch size 12, max_length 1024 tokens, CPU inference | |
| β | |
| βΌ | |
| store.upsert_chunks() β Qdrant upsert | |
| Payload per point: doc_id, filename, page, source_language, | |
| category, text, content_hash, chunk_index | |
| Indexed fields: doc_id, filename, category, source_language | |
| ``` | |
| ### Excel path | |
| ``` | |
| File on disk | |
| β | |
| βΌ | |
| tabular.load_excel_to_duckdb() | |
| For each sheet: | |
| _find_header_row() β detects actual header (not always row 0) | |
| _coerce_numeric_columns() β object β numeric when β₯80% convertible | |
| CREATE TABLE tbl_<doc_id_prefix>_<sheet_name> AS SELECT * FROM df | |
| Register in _table_registry | |
| β | |
| βΌ | |
| tabular.get_prose_chunks_for_qdrant() | |
| Converts each sheet to "col: value | col: value" text lines | |
| Chunks and embeds into Qdrant (same pipeline as PDF body text) | |
| page = sheet_name (string) so citations read as "[file, sheet SheetName]" | |
| ``` | |
| ### Duplicate detection | |
| Before any ingestion, `store.delete_by_filename(filename)` removes all existing Qdrant points for that filename. Re-uploading the same document replaces, not duplicates, the previous chunks. | |
| --- | |
| ## 5. Retrieval Pipeline | |
| File: `backend/app/query/retrieval.py` | |
| ```python | |
| # Score thresholds | |
| _MIN_SCORE = 0.05 # drop chunks with reranker score below this | |
| _ABSTAIN_SCORE = 0.08 # if best surviving chunk < this, return [] β model abstains | |
| # MMR diversity | |
| _MMR_OVERLAP_THRESHOLD = 0.70 # Jaccard similarity above this = duplicate β drop | |
| # Window expansion | |
| _EXPAND_WINDOW = True # fetch same-doc adjacent-page chunks for top result | |
| ``` | |
| ### Step-by-step | |
| **1. Query expansion** | |
| Two variants are embedded: the original query and a stop-word-stripped keyword form. | |
| Example: "quelle est la composition du PB2050683" β also embeds "composition PB2050683" | |
| **2. BGE-M3 embedding** | |
| Both variants embedded in one batch. Results cached in a 256-entry LRU dict keyed by query string. | |
| Returns: `dense` (1024-float list) + `sparse` (token_id β weight dict) | |
| **3. Qdrant hybrid search** | |
| One `Prefetch` pair (dense + sparse) per query variant. Qdrant fuses all prefetches using Reciprocal Rank Fusion (RRF). Candidate pool = `top_k Γ 6` when reranking is enabled. | |
| **4. BGE reranker** | |
| Cross-encoder `BAAI/bge-reranker-v2-m3` scores every (query, chunk) pair. `normalize=True` maps scores to [0, 1]. | |
| **5. Abstention check** | |
| If the highest reranker score is below `_ABSTAIN_SCORE = 0.08`, return an empty list. The generator then tells the model "no relevant information found" and the model correctly abstains. | |
| **6. MMR deduplication** | |
| After sorting by score, walk the list. Drop any chunk whose character k-gram Jaccard similarity with a higher-ranked already-selected chunk exceeds 0.70. Prevents 3 near-identical paragraphs consuming context budget. | |
| **7. Source-window expansion** | |
| For the top-ranked chunk, fetch all other chunks from the same `doc_id` on adjacent pages (page Β± 1) from Qdrant. Append them to the context. Gives the model surrounding paragraphs without full parent-child retrieval. | |
| --- | |
| ## 6. Answer Generation | |
| File: `backend/app/query/generate.py` | |
| ### Context construction | |
| Retrieved chunks are formatted as: | |
| ``` | |
| [S1] procedure_ISO13485.pdf, page 12 | |
| <chunk text> | |
| --- | |
| [S2] procedure_ISO13485.pdf, page 13 | |
| <chunk text> | |
| --- | |
| [S3] fiche_produit_PB2050683.pdf, page 2 | |
| <chunk text> | |
| ``` | |
| The source IDs `[S1]`, `[S2]`, `[S3]` are stable β the model can only reference IDs it sees in the context. It cannot invent a filename. `extract_citations()` maps `[S1]` back to the real `{filename, page, snippet}` after generation. | |
| Context budget: **10,000 chars** total (~2,500 tokens). Per-chunk ceiling: **2,000 chars**. | |
| ### System prompt structure | |
| The system prompt is structured in sections: | |
| - **Identity** β Julia, WinnCare Tunisia assistant | |
| - **Conversation style** β casual messages need no citations | |
| - **Document Q&A rules** β ground claims in context; cite with [S1]; abstain when missing; no cross-product contamination; ask clarifying questions when ambiguous | |
| - **Formatting** β Markdown, bold, bullet lists | |
| - **Language rule** (non-negotiable) β reply in the EXACT language of the LATEST user message, ignoring history language | |
| ### Groq streaming + fallback | |
| ``` | |
| Primary: Groq API (LLaMA 3.3-70b-versatile) | |
| stream=True β token-by-token SSE | |
| temperature=0.0, max_tokens=1200 | |
| If 429 / rate_limit / quota / 5xx: | |
| βΌ | |
| Fallback: FALLBACK_API_KEY provider (default gpt-4o-mini) | |
| stream=False β complete response | |
| Simulated streaming: word-by-word with 12ms delay | |
| If fallback also fails: | |
| βΌ | |
| Error message: "β οΈ Limite quotidienne atteinte..." with retry-after time | |
| ``` | |
| Done event payload: | |
| ```json | |
| { | |
| "type": "done", | |
| "citations": [{"filename": "...", "page": 12, "snippet": "..."}], | |
| "source_map": [...], | |
| "chunks_used": 6, | |
| "path": "vector", | |
| "provider": "groq" | |
| } | |
| ``` | |
| ### Conversation memory | |
| Last **10 messages (5 turns)** are included in every request as `history`. The condensation step (`condense.py`) also uses 5 turns to rewrite follow-up queries. | |
| --- | |
| ## 7. API Reference | |
| All routes require `Authorization: Bearer <token>` except `/auth/login` and `/health`. | |
| ### Authentication | |
| | Method | Path | Description | | |
| |--------|------|-------------| | |
| | `POST` | `/auth/login` | `{email, password}` β `{token, email}`. Token is HS256 JWT, 30-day expiry. | | |
| | `GET` | `/auth/me` | Returns `{email}` for the bearer token. | | |
| ### Ingestion | |
| | Method | Path | Description | | |
| |--------|------|-------------| | |
| | `POST` | `/ingest/upload` | Multipart: `file` (PDF/Excel), `category` (string, optional). Returns `{job_id, doc_id, status}`. Ingestion is async. | | |
| | `GET` | `/ingest/status/{job_id}` | Returns job status: `queued \| processing \| done \| failed`, `chunks_stored`, `error`. | | |
| | `GET` | `/ingest/documents` | Lists all indexed documents from Qdrant + DuckDB. | | |
| | `DELETE` | `/ingest/documents/{doc_id}` | Delete all chunks for a document from Qdrant and DuckDB. | | |
| | `DELETE` | `/ingest/documents` | Drop and recreate the Qdrant collection; clear DuckDB; delete uploaded files. | | |
| ### Query | |
| | Method | Path | Description | | |
| |--------|------|-------------| | |
| | `POST` | `/query/ask/stream` | **Primary endpoint.** SSE stream. Body: `{question, history, top_k, category}`. Emits `status`, `token`, `done`, `error` events. | | |
| | `POST` | `/query/ask` | Non-streaming fallback. Same body, returns full `AskResponse`. Used by RAGAS eval. | | |
| #### SSE event types | |
| ``` | |
| data: {"type": "status", "status": "searching"} | |
| data: {"type": "status", "status": "generating"} | |
| data: {"type": "token", "content": "La composition"} | |
| data: {"type": "token", "content": " est 49%"} | |
| data: {"type": "done", "citations": [...], "source_map": [...], "chunks_used": 6, "path": "vector", "provider": "groq"} | |
| data: {"type": "error", "message": "..."} | |
| ``` | |
| SSE headers prevent proxy buffering: | |
| ``` | |
| Cache-Control: no-cache | |
| X-Accel-Buffering: no | |
| Connection: keep-alive | |
| ``` | |
| #### AskResponse schema | |
| ```typescript | |
| { | |
| answer: string | |
| citations: Array<{ filename: string; page: number | string; snippet?: string }> | |
| source_map: Array<{ filename: string; page: number | string; snippet: string }> | |
| chunks_used: number | |
| path: "vector" | "sql" | |
| sql: string | null | |
| contexts: string[] // raw chunk texts (used by RAGAS eval harness only) | |
| } | |
| ``` | |
| ### Conversations | |
| | Method | Path | Description | | |
| |--------|------|-------------| | |
| | `GET` | `/conversations` | List user's conversations (id, title, timestamps). | | |
| | `POST` | `/conversations` | Create: `{title, messages}`. Returns conversation object. | | |
| | `GET` | `/conversations/{id}` | Get conversation with full message array. | | |
| | `PUT` | `/conversations/{id}` | Update title and messages. | | |
| | `DELETE` | `/conversations/{id}` | Delete conversation. | | |
| ### System | |
| | Method | Path | Description | | |
| |--------|------|-------------| | |
| | `GET` | `/health` | Returns env, qdrant_url, embedding_model, groq_model. No auth required. | | |
| --- | |
| ## 8. Configuration Reference | |
| All settings live in `backend/app/config.py` and are read from environment variables (or `backend/.env` for local development). **Never commit `backend/.env`** β it contains real API keys. | |
| ### Required in production | |
| | Variable | Description | | |
| |----------|-------------| | |
| | `GROQ_API_KEY` | Groq API key. Free tier: 100K tokens/day. | | |
| | `JWT_SECRET` | Random string for HS256 JWT signing. Use `openssl rand -hex 32`. | | |
| | `AUTH_USERS` | JSON object `{"email@example.com": "password"}`. Supports multiple users. | | |
| | `QDRANT_URL` | Qdrant server URL. E.g. `https://xxx.cloud.qdrant.io:6333` | | |
| | `QDRANT_API_KEY` | Qdrant Cloud API key (blank for local). | | |
| ### Optional but recommended | |
| | Variable | Default | Description | | |
| |----------|---------|-------------| | |
| | `GROQ_MODEL` | `llama-3.3-70b-versatile` | Primary LLM. LLaMA 3.3-70b is free on Groq and supports 128K context. | | |
| | `VISION_API_KEY` | `` | API key for vision LLM (PDF parsing). Uses `VISION_BASE_URL` endpoint. If blank, vision path is disabled and PyMuPDF + Tesseract are used. | | |
| | `VISION_BASE_URL` | `https://api.openai.com/v1` | Vision API endpoint (OpenAI-compatible). | | |
| | `VISION_MODEL` | `gpt-4o-mini` | Vision model. gpt-4o-mini: ~$0.01/40-page document. | | |
| | `FALLBACK_API_KEY` | `` | API key for text-generation fallback when Groq quota is exhausted. | | |
| | `FALLBACK_BASE_URL` | `https://api.openai.com/v1` | Fallback LLM endpoint. | | |
| | `FALLBACK_MODEL` | `gpt-4o-mini` | Fallback model name. | | |
| | `RERANK_ENABLED` | `true` | Set `false` to skip cross-encoder reranking (faster, lower precision). | | |
| | `RERANKER_MODEL` | `BAAI/bge-reranker-v2-m3` | Cross-encoder model. | | |
| | `TURSO_URL` | `` | Turso database URL for persistent conversation storage. If blank, uses local SQLite (lost on container restart). | | |
| | `TURSO_TOKEN` | `` | Turso auth token. | | |
| ### Less commonly changed | |
| | Variable | Default | Description | | |
| |----------|---------|-------------| | |
| | `QDRANT_COLLECTION` | `rag_documents` | Qdrant collection name. | | |
| | `EMBEDDING_MODEL` | `BAAI/bge-m3` | Embedding model. Do not change without re-ingesting all documents. | | |
| | `EMBEDDING_DEVICE` | `cpu` | `cpu`, `cuda`, `mps`. HF Spaces has no GPU β always `cpu`. | | |
| | `DUCKDB_PATH` | `data/tabular.duckdb` | DuckDB file path (relative to working dir). | | |
| | `UPLOAD_DIR` | `data/uploads` | Uploaded source files directory. | | |
| | `LOG_LEVEL` | `INFO` | Python logging level. | | |
| | `APP_ENV` | `development` | Visible in `/health`. | | |
| --- | |
| ## 9. Frontend | |
| **Stack:** React 18, TypeScript, Vite, react-markdown with remark-gfm | |
| ### Component structure | |
| ``` | |
| App.tsx | |
| βββ LoginPage.tsx β email + password form, token stored in localStorage | |
| β | |
| βββ (authenticated) | |
| βββ Sidebar.tsx β conversation list, new chat, sign out | |
| β | |
| βββ main content | |
| βββ ChatView.tsx β message thread, SSE streaming, citation chips | |
| βββ UploadView.tsx β file drop zone, ingestion status polling | |
| ``` | |
| ### Key frontend behaviors | |
| **Streaming:** `askQuestionStream()` in `api.ts` reads the SSE response using `ReadableStream`. Status events update the thinking indicator ("Recherche dans les documentsβ¦" / "RΓ©daction de la rΓ©ponseβ¦"). Token events append to the message in real time. | |
| **Citations:** Citation chips appear below each assistant message. `page` can be `number` (PDF page) or `string` (Excel sheet name). Hovering a chip shows the first 300 chars of that source chunk as a tooltip. | |
| **Markdown:** `ReactMarkdown` with `remark-gfm` renders tables, bold, code, blockquotes. Vision LLM pages return markdown tables which render correctly. | |
| **Conversation persistence:** After each assistant response, the full message array is saved via `PUT /conversations/{id}`. On sidebar click, `GET /conversations/{id}` restores the thread. | |
| **Languages:** UI strings are in `i18n.ts`, supporting `en` and `fr`. Language preference stored in `localStorage`. | |
| ### Build output | |
| Vite builds to `frontend/dist/`. The Dockerfile copies this to `backend/static/`. FastAPI serves `static/assets/` and falls back to `static/index.html` for any path not matched by an API route (SPA client-side routing). | |
| --- | |
| ## 10. Authentication | |
| **Mechanism:** HS256 JWT, 30-day expiry. | |
| **User management:** Users are defined in the `AUTH_USERS` environment variable as a JSON object: | |
| ```json | |
| {"alice@winncare.tn": "password1", "bob@winncare.tn": "password2"} | |
| ``` | |
| There is no self-registration or password reset. Add/remove users by updating the secret. | |
| **Token storage:** Stored in `localStorage` under key `julia_token`. Auto-cleared on 401 response and page reloads to the login screen. | |
| **Authorization on API routes:** All query, ingestion, and conversation routes depend on `get_current_user()`. A missing or invalid token returns HTTP 401 immediately. | |
| > **Note:** This is a simple single-tenant auth model. For multi-tenant deployments with data isolation, each user would need their own Qdrant namespace or collection, and the `doc_id` / `filename` payload indexes would need a `tenant_id` field. | |
| --- | |
| ## 11. Conversation Persistence | |
| File: `backend/app/conversations/store.py` | |
| Conversations are stored in SQLite (local dev) or Turso (production). | |
| **Schema:** | |
| ```sql | |
| CREATE TABLE conversations ( | |
| id TEXT PRIMARY KEY, | |
| user_email TEXT NOT NULL, | |
| title TEXT NOT NULL, | |
| messages TEXT NOT NULL DEFAULT '[]', -- JSON array | |
| created_at TEXT NOT NULL, | |
| updated_at TEXT NOT NULL | |
| ) | |
| ``` | |
| Messages are stored as a JSON blob. Each message: `{role: "user"|"assistant", text: string, meta?: AskResponse}`. | |
| **Turso:** Turso is a cloud SQLite service with a free tier. Set `TURSO_URL` and `TURSO_TOKEN` in secrets. Without it, conversations are stored in `data/conversations.db` which is ephemeral on HF Spaces (lost on container restart). | |
| --- | |
| ## 12. Evaluation Harness (RAGAS) | |
| File: `backend/app/eval/ragas_eval.py` | |
| Questions: `backend/app/eval/data/questions.jsonl` | |
| Results: `backend/app/eval/results/report.json` | |
| ### Metrics | |
| | Metric | What it measures | Target | | |
| |--------|-----------------|--------| | |
| | `faithfulness` | Fraction of Julia's claims that are verifiable from retrieved chunks. 1.0 = zero hallucination. | β₯ 0.85 | | |
| | `context_precision` | Were retrieved chunks actually useful? Penalizes noise at the top of the ranking. | β₯ 0.75 | | |
| | `context_recall` | Did retrieval cover everything needed to answer? Requires `ground_truth`. | β₯ 0.80 | | |
| ### Running the eval | |
| The backend must be running. Run in a separate virtual environment (dependency conflict between FlagEmbedding and RAGAS on `datasets` version): | |
| ```bash | |
| cd backend | |
| python -m venv .venv-eval | |
| .venv-eval\Scripts\activate # Windows | |
| # or: source .venv-eval/bin/activate # Mac/Linux | |
| pip install -r requirements-eval.txt | |
| python -m app.eval.ragas_eval | |
| ``` | |
| ### Questions file format | |
| ```jsonl | |
| {"question": "Quelle est la composition du PB2050683 ?", "ground_truth": "49% PE - 51% PU"} | |
| {"question": "Quel est le seuil AQL pour les dΓ©fauts critiques ?", "ground_truth": "AQL 0,065"} | |
| ``` | |
| Lines starting with `#` are comments. | |
| ### Interpreting results | |
| ``` | |
| faithfulness < 0.80 β Julia hallucinates β tighten the system prompt or reduce context noise | |
| context_precision < 0.70 β Retrieval is noisy β tune top_k, chunking, or reranker threshold | |
| context_recall < 0.70 β Retrieval misses info β check chunking size, vision threshold, re-ingest | |
| ``` | |
| The report surfaces the 3 lowest-scoring questions by combined faithfulness + recall. Fix these first. | |
| --- | |
| ## 13. Deployment | |
| ### HuggingFace Spaces (current) | |
| The app runs as a Docker container on HF Spaces. Push to the `main` branch of the HF Space repository triggers a rebuild. | |
| **Remote:** `https://huggingface.co/spaces/esra2001/winncare` (git remote: `origin`) | |
| **Build:** Two-stage Docker build. Stage 1 builds the React SPA. Stage 2 is Python 3.12-slim, installs backend deps, copies the Vite `dist/` into `backend/static/`, pre-downloads BGE-M3 (~2 GB baked into image layer so the first request is fast). | |
| **Exposed port:** 7860 (HF Spaces requirement). | |
| **Secrets (set in HF Space dashboard, not in git):** | |
| ``` | |
| GROQ_API_KEY | |
| JWT_SECRET | |
| AUTH_USERS | |
| QDRANT_URL | |
| QDRANT_API_KEY | |
| VISION_API_KEY | |
| VISION_BASE_URL | |
| VISION_MODEL | |
| FALLBACK_API_KEY | |
| FALLBACK_BASE_URL | |
| FALLBACK_MODEL | |
| RERANK_ENABLED | |
| RERANKER_MODEL | |
| GROQ_MODEL | |
| TURSO_URL | |
| TURSO_TOKEN | |
| ``` | |
| **Persistent storage:** HF Spaces ephemeral disk β `data/uploads/` and `data/conversations.db` are lost on restarts. Use Qdrant Cloud for vectors and Turso for conversations to achieve full persistence across restarts. | |
| **Triggering a rebuild without code changes:** | |
| ```bash | |
| git commit --allow-empty -m "trigger rebuild" && git push origin HEAD:main | |
| ``` | |
| ### Production (self-hosted) | |
| Use `docker-compose.yml` for local Qdrant and Redis, then run the app container separately: | |
| ```bash | |
| docker-compose up -d # starts Qdrant (port 6333) and Redis (port 6379) | |
| docker build -t julia-rag . | |
| docker run -p 7860:7860 \ | |
| -e GROQ_API_KEY=... \ | |
| -e JWT_SECRET=... \ | |
| -e AUTH_USERS='{"admin@company.com":"password"}' \ | |
| -e QDRANT_URL=http://host.docker.internal:6333 \ | |
| julia-rag | |
| ``` | |
| --- | |
| ## 14. Local Development | |
| ### Backend | |
| ```bash | |
| cd backend | |
| # Create and activate virtual environment | |
| python -m venv .venv | |
| .venv\Scripts\activate # Windows | |
| # source .venv/bin/activate # Mac/Linux | |
| pip install -r requirements.txt | |
| # Start infrastructure | |
| docker-compose up -d # Qdrant + Redis | |
| # Configure | |
| cp .env.example .env # fill in GROQ_API_KEY at minimum | |
| # Run | |
| uvicorn app.main:app --reload --port 8000 | |
| ``` | |
| Minimal `.env` for local dev: | |
| ```env | |
| GROQ_API_KEY=gsk_... | |
| JWT_SECRET=any-random-string | |
| AUTH_USERS={"dev@local.com": "dev"} | |
| QDRANT_URL=http://localhost:6333 | |
| ``` | |
| ### Frontend | |
| ```bash | |
| cd frontend | |
| npm install | |
| npm run dev # Vite dev server on http://localhost:5173 | |
| ``` | |
| Vite proxies `/query`, `/ingest`, `/auth`, `/conversations` to `http://localhost:8000` (configured in `vite.config.ts`). The SPA runs on its own port; no CORS issues in dev. | |
| ### Building for production (manual) | |
| ```bash | |
| cd frontend && npm run build # β frontend/dist/ | |
| cp -r frontend/dist backend/static | |
| cd backend && uvicorn app.main:app --port 7860 | |
| ``` | |
| --- | |
| ## 15. Operational Notes | |
| ### After changing chunking or parsing logic | |
| Re-ingest all documents. Old chunks in Qdrant do not reflect new chunk sizes, overlap, content_hash, or chunk_index. Steps: | |
| 1. Go to the Upload tab β "Clear all documents" | |
| 2. Re-upload each document | |
| ### After changing the embedding model | |
| The new model produces incompatible vectors. You must recreate the collection: | |
| 1. Delete collection in Qdrant dashboard (or clear all via UI) | |
| 2. Re-ingest all documents | |
| 3. Update `EMBEDDING_MODEL` in secrets before re-ingesting | |
| ### Groq daily quota (free tier) | |
| Groq free tier: **100,000 tokens/day**. At typical usage (question + 5-turn history + 10K context + 1200 token answer β 14K tokens/request), that's ~7 full requests before hitting the daily limit. | |
| Options: | |
| - Set `FALLBACK_API_KEY` + `FALLBACK_MODEL` to automatically failover to OpenAI/Gemini | |
| - Upgrade Groq plan | |
| - Switch `GROQ_MODEL` to `llama-3.1-8b-instant` (6K TPM limit but uses fewer tokens) | |
| The 429 error message shown to users includes a retry-after time extracted from the Groq error response. | |
| ### Vision LLM costs | |
| At GPT-4o-mini pricing (~$0.075 per 1M input tokens, ~$0.30 per 1M output tokens), a 40-page document where 20 pages go through vision costs roughly $0.02β$0.05. Budget accordingly for large corpora. | |
| ### Reranker memory | |
| `BAAI/bge-reranker-v2-m3` loads ~550 MB into RAM on first query. On HF Spaces (16 GB), this is fine alongside BGE-M3 (~2 GB). Total model footprint: ~2.6 GB. | |
| ### SSE and proxy buffering | |
| The `/query/ask/stream` endpoint sends three headers to prevent proxy buffering: | |
| - `X-Accel-Buffering: no` β disables nginx buffering (critical for HF Spaces) | |
| - `Cache-Control: no-cache` β tells intermediate proxies not to cache the stream | |
| - `Connection: keep-alive` β prevents idle-timeout drops on long responses | |
| If responses appear cut or arrive all at once on a specific network, a local proxy is the cause. These headers resolve it for standard nginx-based setups. | |
| --- | |
| ## 16. Known Limitations and Trade-offs | |
| ### Free-tier constraints | |
| | Constraint | Impact | Workaround | | |
| |-----------|--------|------------| | |
| | Groq: 100K tokens/day | ~7 full requests before quota | Set FALLBACK_API_KEY | | |
| | HF Spaces: 2 vCPU, no GPU | BGE-M3 embed ~3-5s per chunk batch; reranker ~2-4s per request | Pre-download models in Dockerfile (already done) | | |
| | HF Spaces: ephemeral disk | Uploads and local conversations lost on restart | Use Qdrant Cloud + Turso | | |
| ### Architecture trade-offs | |
| **No parent-child retrieval.** Chunks are 2500 chars with 300 chars overlap. Source-window expansion (fetch adjacent page chunks for top result) partially compensates. True parent-child would require dual-index ingestion at different granularities. | |
| **Single tenant.** All users share one Qdrant collection and one DuckDB file. No per-user document isolation. Adding `tenant_id` to every chunk payload and filtering on it is the path to multi-tenancy. | |
| **Vision LLM at ingestion time only.** PDF parsing with vision happens when a document is uploaded, not at query time. Changing the vision model requires re-ingesting documents to benefit. | |
| **SQL path for aggregations only.** `classify.py` routes to DuckDB only for `numeric` questions (sum, count, average, max, min across many rows). Single-value lookups ("what is the price of X?") go through the vector path even when the value is in an Excel sheet. | |
| **No streaming fallback.** When Groq fails and the fallback provider responds, the answer is simulated as word-by-word streaming (12ms delay per word) rather than true token streaming. The experience is slightly less responsive but visually similar. | |
| ### Accuracy targets (current RAGAS baseline) | |
| | Metric | Baseline | Target | | |
| |--------|----------|--------| | |
| | faithfulness | 0.50 | β₯ 0.85 | | |
| | context_precision | 0.67 | β₯ 0.75 | | |
| | context_recall | 0.71 | β₯ 0.80 | | |
| The main drivers of low faithfulness are: weak context (already addressed by abstention threshold) and the model summarizing across documents rather than citing specific claims. Running RAGAS after re-ingestion with the Phase 1 changes should show measurable improvement in all three metrics. | |