benroshan Claude Sonnet 4.6 commited on
Commit
d281212
·
1 Parent(s): 72bfed4

docs: update all references Render → HF Spaces (Stage 12)

Browse files

- README.md: deployment row, design decisions, architecture diagram
- docs/architecture.md: component table, key decisions, known limits, future work
- docs/decisions.md: Render decision superseded, HF Spaces decision added
- docs/evolution.md: Stage 12 added, Current State Snapshot updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +6 -5
  2. docs/architecture.md +12 -10
  3. docs/decisions.md +4 -3
  4. docs/evolution.md +34 -5
README.md CHANGED
@@ -88,7 +88,7 @@ If retrieval degrades, you see it before the user does.
88
  | **Eval** | RAGAS (pre-computed JSON) + LLM-as-Judge per turn |
89
  | **Observability** | LangSmith |
90
  | **Frontend** | React 19 + Vite + Tailwind CSS v4 |
91
- | **Deployment** | Render (Docker backend) + Vercel (frontend) |
92
 
93
  ---
94
 
@@ -232,10 +232,11 @@ prism/
232
 
233
  ## Design Decisions Worth Noting
234
 
235
- - **Singleton retriever cache per workspace** — without cache, every request rebuilt the Chroma instance (full embedding reload) → OOM after 2–3 queries. Cache invalidated after ingest.
236
- - **CPU-only torch in Dockerfile** — sentence-transformers pulls CUDA torch (~2GB) by default, OOMing Render 512MB before uvicorn binds port. Pre-installing CPU torch (~200MB) is mandatory.
237
- - **RAGAS pre-computed locally** — `nest_asyncio` cannot patch `uvloop` (uvicorn's event loop on Linux). Live RAGAS eval on Render always 500s. Run locally, commit JSON, Vercel reads file.
238
- - **TinyBERT-L-2-v2 reranker** — MiniLM-L-6-v2 (~85MB) + base memory exceeded 512MB on web queries. TinyBERT (~17MB) saves 68MB permanently.
 
239
  - **Web search bypasses chain** — `ConversationalRetrievalChain` condensation step strips prepended Tavily context before LLM sees it. Web path uses direct LLM call with chat history.
240
  - **Idempotent ingestion** — chunk IDs are `md5(source + page + text)`. Re-ingesting same doc does not duplicate chunks.
241
 
 
88
  | **Eval** | RAGAS (pre-computed JSON) + LLM-as-Judge per turn |
89
  | **Observability** | LangSmith |
90
  | **Frontend** | React 19 + Vite + Tailwind CSS v4 |
91
+ | **Deployment** | HF Spaces Docker (backend, 16GB RAM) + Vercel (frontend) |
92
 
93
  ---
94
 
 
232
 
233
  ## Design Decisions Worth Noting
234
 
235
+ - **Singleton retriever cache per workspace** — without cache, every request rebuilt the Chroma instance (full embedding reload). Cache invalidated after ingest.
236
+ - **CPU-only torch in Dockerfile** — sentence-transformers pulls CUDA torch (~2GB) by default. Pre-installing CPU torch keeps the image lean.
237
+ - **HF Spaces UID 1000** — HF runs Docker containers as user 1000. Reranker weights baked under `HF_HOME=/app/.cache/huggingface` as user 1000 at build time; `HF_HUB_OFFLINE=1` set after download to block runtime Hub calls.
238
+ - **RAGAS pre-computed locally** — `nest_asyncio` cannot patch `uvloop` (uvicorn's event loop on Linux). Live RAGAS eval always 500s. Run locally, commit JSON, Vercel reads file.
239
+ - **TinyBERT-L-2-v2 reranker** — same ranking quality as MiniLM-L-6-v2 at demo corpus scale; ~17MB vs ~85MB.
240
  - **Web search bypasses chain** — `ConversationalRetrievalChain` condensation step strips prepended Tavily context before LLM sees it. Web path uses direct LLM call with chat history.
241
  - **Idempotent ingestion** — chunk IDs are `md5(source + page + text)`. Re-ingesting same doc does not duplicate chunks.
242
 
docs/architecture.md CHANGED
@@ -13,8 +13,8 @@ Query → hybrid retrieval (ChromaDB dense + BM25 sparse) → weighted RRF fusio
13
  | Vector store | ChromaDB (persistent) | Dense embedding storage and retrieval |
14
  | Sparse retrieval | rank_bm25 (BM25Okapi) | Keyword-match retrieval for regulatory text |
15
  | Hybrid fusion | Weighted RRF (dense 0.7 + sparse 0.3) | Merge dense + sparse result lists |
16
- | Reranker | cross-encoder/ms-marco-TinyBERT-L-2-v2 | Re-score top-10 → return top-5 (~17MB vs 85MB; OOM prevention on 512MB Render) |
17
- | Embeddings | Euron API (text-embedding-3-small) | API-based; avoids OOM on Render free tier. Groq has no embeddings endpoint. |
18
  | LLM | Groq (llama-3.3-70b-versatile) via langchain-groq | Fast open-weight inference; OpenAI-compatible |
19
  | Chunking | LangChain ParentDocumentRetriever | Child 200-char indexed, parent 800-char sent to LLM |
20
  | Memory | ConversationBufferWindowMemory (k=10) | Last 10 conversation turns |
@@ -28,7 +28,7 @@ Query → hybrid retrieval (ChromaDB dense + BM25 sparse) → weighted RRF fusio
28
  | Document parsing | LlamaParse (primary), pypdf (fallback) | PDF extraction |
29
  | Backend | FastAPI + Uvicorn | REST API |
30
  | Frontend | React 19 + Vite + Tailwind CSS v4 | Chat / Upload tabs |
31
- | Deployment | Render (Docker backend) + Vercel (frontend) + Vercel (eval-dashboard) | Production |
32
 
33
  ## Data flow
34
 
@@ -49,24 +49,26 @@ Query → hybrid retrieval (ChromaDB dense + BM25 sparse) → weighted RRF fusio
49
  7. Response includes: answer, sources (with scores), retrieval_method
50
 
51
  ## Key design decisions
52
- - **API embeddings over local**: sentence-transformers ~400MB OOMs on Render 512MB free tier; Euron API ~0MB. Groq used for LLM; Euron retained for embeddings (Groq exposes no embeddings endpoint).
53
  - **ParentDocumentRetriever**: small chunks improve retrieval precision; large parent chunks improve answer faithfulness
54
  - **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-20 pool
55
  - **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses
56
- - **RAGAS benchmark pre-computed locally**: `nest_asyncio` cannot patch `uvloop` (used by uvicorn on Render Linux), making live RAGAS eval impossible on prod. Run `scripts/run_ragas_local.py` locally, commit JSON results, Vercel builds dashboard from file.
57
- - **TinyBERT-L-2-v2 reranker**: MiniLM-L-6-v2 (~85MB) + base memory (~250MB) + Tavily content + LLM call exceeded Render 512MB on web queries. TinyBERT-L-2-v2 is ~17MB — same ranking quality at demo corpus scale.
58
- - **Singleton vectorstore/retriever cache**: each workspace caches its Chroma vectorstore + HybridRetriever in a module-level dict. Without cache, every chat request created a new Chroma instance (full embedding reload), causing OOM on repeated queries. Cache is invalidated after ingest.
59
  - **Multi-workspace isolation**: each workspace maps to one ChromaDB collection. Frontend workspace switcher passes `workspace_id` on every request; backend resolves the correct collection before retrieval.
60
- - **URL size guard**: `url_loader.py` enforces a max content size before embedding URL content, preventing OOM from large external pages.
61
  - **Eval dashboard separate site**: eval runs offline, results versioned as JSON. Separates eval tooling from user-facing app; no live eval endpoint on prod backend. Per-message faithfulness badge removed from UI — moved to dedicated dashboard.
62
  - **answer_correctness over faithfulness**: faithfulness (LLM judge vs retrieved chunks) is circular — inflates when eval pairs are corpus-aligned. answer_correctness (LLM judge vs ground_truth reference) is an independent signal.
 
63
 
64
  ## Known limitations
65
  - InMemoryStore for parent chunks: does not survive server restart (re-ingest required)
66
  - BM25 index rebuilt in memory on each startup (not persisted to disk)
67
- - Render free tier: 512MB RAM, cold starts after inactivity
 
68
 
69
  ## Future improvements
70
  - Persist BM25 index to disk (pickle)
71
- - Multi-collection ChromaDB (one per document set)
72
  - Streaming responses from LLM
 
13
  | Vector store | ChromaDB (persistent) | Dense embedding storage and retrieval |
14
  | Sparse retrieval | rank_bm25 (BM25Okapi) | Keyword-match retrieval for regulatory text |
15
  | Hybrid fusion | Weighted RRF (dense 0.7 + sparse 0.3) | Merge dense + sparse result lists |
16
+ | Reranker | cross-encoder/ms-marco-TinyBERT-L-2-v2 | Re-score top-10 → return top-5 (~17MB; chosen over MiniLM-L-6-v2 ~85MB for lower memory footprint) |
17
+ | Embeddings | Euron API (text-embedding-3-small) | API-based; Groq has no embeddings endpoint. |
18
  | LLM | Groq (llama-3.3-70b-versatile) via langchain-groq | Fast open-weight inference; OpenAI-compatible |
19
  | Chunking | LangChain ParentDocumentRetriever | Child 200-char indexed, parent 800-char sent to LLM |
20
  | Memory | ConversationBufferWindowMemory (k=10) | Last 10 conversation turns |
 
28
  | Document parsing | LlamaParse (primary), pypdf (fallback) | PDF extraction |
29
  | Backend | FastAPI + Uvicorn | REST API |
30
  | Frontend | React 19 + Vite + Tailwind CSS v4 | Chat / Upload tabs |
31
+ | Deployment | HF Spaces (Docker backend, 16GB RAM) + Vercel (frontend) + Vercel (eval-dashboard) | Production |
32
 
33
  ## Data flow
34
 
 
49
  7. Response includes: answer, sources (with scores), retrieval_method
50
 
51
  ## Key design decisions
52
+ - **API embeddings over local**: Euron API embeddings ~0MB RAM; Groq has no embeddings endpoint so Euron is retained for embeddings.
53
  - **ParentDocumentRetriever**: small chunks improve retrieval precision; large parent chunks improve answer faithfulness
54
  - **Cross-encoder reranker**: bi-encoder (ChromaDB) is fast but approximate; cross-encoder is slower but more accurate on top-20 pool
55
  - **BM25 weight 0.3**: regulatory text has exact keyword matches (section numbers); sparse retrieval catches what dense misses
56
+ - **RAGAS benchmark pre-computed locally**: `nest_asyncio` cannot patch `uvloop` (used by uvicorn on Linux), making live RAGAS eval impossible on prod. Run `scripts/run_ragas_local.py` locally, commit JSON results, Vercel builds dashboard from file.
57
+ - **TinyBERT-L-2-v2 reranker**: MiniLM-L-6-v2 (~85MB) vs TinyBERT-L-2-v2 (~17MB) — same ranking quality at demo corpus scale with lower memory footprint.
58
+ - **Singleton vectorstore/retriever cache**: each workspace caches its Chroma vectorstore + HybridRetriever in a module-level dict. Without cache, every chat request created a new Chroma instance (full embedding reload). Cache is invalidated after ingest.
59
  - **Multi-workspace isolation**: each workspace maps to one ChromaDB collection. Frontend workspace switcher passes `workspace_id` on every request; backend resolves the correct collection before retrieval.
60
+ - **URL size guard**: `url_loader.py` enforces a max content size before embedding URL content, preventing memory spikes from large external pages.
61
  - **Eval dashboard separate site**: eval runs offline, results versioned as JSON. Separates eval tooling from user-facing app; no live eval endpoint on prod backend. Per-message faithfulness badge removed from UI — moved to dedicated dashboard.
62
  - **answer_correctness over faithfulness**: faithfulness (LLM judge vs retrieved chunks) is circular — inflates when eval pairs are corpus-aligned. answer_correctness (LLM judge vs ground_truth reference) is an independent signal.
63
+ - **HF Spaces Docker (UID 1000)**: model weights baked into image under `HF_HOME=/app/.cache/huggingface` as user 1000 at build time; `HF_HUB_OFFLINE=1` set after download to block runtime network calls.
64
 
65
  ## Known limitations
66
  - InMemoryStore for parent chunks: does not survive server restart (re-ingest required)
67
  - BM25 index rebuilt in memory on each startup (not persisted to disk)
68
+ - HF Spaces free tier: ephemeral filesystem — chroma_db lost on cold start (re-upload required)
69
+ - Euron embedding API sequential: ~5s/chunk — 30 chunks = ~150s blocking upload
70
 
71
  ## Future improvements
72
  - Persist BM25 index to disk (pickle)
73
+ - Move Euron embedding to background task (return 202, poll for ready)
74
  - Streaming responses from LLM
docs/decisions.md CHANGED
@@ -4,15 +4,16 @@
4
 
5
  | Date | Decision | Rationale | Status |
6
  |------|----------|-----------|--------|
7
- | 2026-05 | Euron API for embeddings (not local sentence-transformers) | Local model ~400MB OOM on Render 512MB free tier | Active |
8
  | 2026-05-24 | Groq (llama-3.3-70b-versatile) for LLM; Euron retained for embeddings | Groq: faster inference, open-weight model. Euron kept for embeddings — Groq has no embeddings endpoint. | Active |
9
- | 2026-05 | Render (Docker) over Railway for backend | Free tier RAM fit confirmed: embeddings ~150MB + reranker ~17MB = ~230MB total | Active |
 
10
  | 2026-05 | ParentDocumentRetriever (child 200 / parent 800) | Better faithfulness: small chunks retrieved precisely, large chunks give LLM full context | Active |
11
  | 2026-05 | BM25 weight 0.3 in RRF fusion | Regulatory text has exact keyword matches; sparse recall is complementary, not dominant | Active |
12
  | 2026-05-30 | RAGAS pre-computed locally, dashboard JSON-driven | `nest_asyncio` cannot patch `uvloop` on Render — live eval endpoint always 500s. Run `scripts/run_ragas_local.py` (8B judge model) → commit `ragas_benchmark.json` → Vercel builds dashboard. | Active |
13
  | 2026-05-30 | RAGAS judge uses `llama-3.1-8b-instant` not `llama-3.3-70b` | 70B model exhausts Groq free-tier 100k TPD in one eval run. 8B has 500k TPD and is sufficient for statement-level faithfulness checks. Answer generation still uses 70B. | Active |
14
  | 2026-05 | LangSmith tracing via env var (no code changes) | LangChain reads LANGCHAIN_TRACING_V2 automatically; zero instrumentation cost | Active |
15
- | 2026-05-30 | Reranker switched to TinyBERT-L-2-v2 (~17MB) from MiniLM-L-6-v2 (~85MB) | MiniLM + base memory + Tavily content + LLM call exceeded 512MB on web queries; TinyBERT saves 68MB permanently with acceptable ranking quality at demo scale | Active |
16
  | 2026-05 | Cross-encoder reranker pre-downloaded at Docker build time | Avoids cold-start latency on first request in production | Active |
17
  | 2026-05 | Idempotent ingestion via md5(source+page+text) chunk IDs | Re-running ingest does not duplicate chunks in ChromaDB | Active |
18
  | 2026-06-14 | Multi-workspace: one ChromaDB collection per workspace | Isolated document sets per workspace; `workspace_id` passed on every request; `list_collections()` normalised for chromadb ≥0.5.4 (returns `list[str]`) and <0.5 (returns `list[Collection]`) | Active |
 
4
 
5
  | Date | Decision | Rationale | Status |
6
  |------|----------|-----------|--------|
7
+ | 2026-05 | Euron API for embeddings (not local sentence-transformers) | Groq has no embeddings endpoint; Euron API keeps RAM footprint near zero vs local model | Active |
8
  | 2026-05-24 | Groq (llama-3.3-70b-versatile) for LLM; Euron retained for embeddings | Groq: faster inference, open-weight model. Euron kept for embeddings — Groq has no embeddings endpoint. | Active |
9
+ | 2026-05 | Render (Docker) over Railway for backend | Free tier RAM fit confirmed: embeddings ~150MB + reranker ~17MB = ~230MB total | **Superseded 2026-06-22** |
10
+ | 2026-06-22 | HF Spaces Docker over Render for backend | Render 512MB free tier caused repeated OOM under contextual refresh + concurrent chat. HF Spaces CPU Basic = 16GB RAM free. No memory guards needed. Port 7860, user UID 1000, HF_HOME=/app/.cache/huggingface baked at build time. | Active |
11
  | 2026-05 | ParentDocumentRetriever (child 200 / parent 800) | Better faithfulness: small chunks retrieved precisely, large chunks give LLM full context | Active |
12
  | 2026-05 | BM25 weight 0.3 in RRF fusion | Regulatory text has exact keyword matches; sparse recall is complementary, not dominant | Active |
13
  | 2026-05-30 | RAGAS pre-computed locally, dashboard JSON-driven | `nest_asyncio` cannot patch `uvloop` on Render — live eval endpoint always 500s. Run `scripts/run_ragas_local.py` (8B judge model) → commit `ragas_benchmark.json` → Vercel builds dashboard. | Active |
14
  | 2026-05-30 | RAGAS judge uses `llama-3.1-8b-instant` not `llama-3.3-70b` | 70B model exhausts Groq free-tier 100k TPD in one eval run. 8B has 500k TPD and is sufficient for statement-level faithfulness checks. Answer generation still uses 70B. | Active |
15
  | 2026-05 | LangSmith tracing via env var (no code changes) | LangChain reads LANGCHAIN_TRACING_V2 automatically; zero instrumentation cost | Active |
16
+ | 2026-05-30 | Reranker switched to TinyBERT-L-2-v2 (~17MB) from MiniLM-L-6-v2 (~85MB) | Lower memory footprint with acceptable ranking quality at demo corpus scale | Active |
17
  | 2026-05 | Cross-encoder reranker pre-downloaded at Docker build time | Avoids cold-start latency on first request in production | Active |
18
  | 2026-05 | Idempotent ingestion via md5(source+page+text) chunk IDs | Re-running ingest does not duplicate chunks in ChromaDB | Active |
19
  | 2026-06-14 | Multi-workspace: one ChromaDB collection per workspace | Isolated document sets per workspace; `workspace_id` passed on every request; `list_collections()` normalised for chromadb ≥0.5.4 (returns `list[str]`) and <0.5 (returns `list[Collection]`) | Active |
docs/evolution.md CHANGED
@@ -1,7 +1,7 @@
1
  # Prism — Project Evolution
2
 
3
  > End-to-end record of what was broken at each stage, what was built to fix it, and what is planned next.
4
- > Updated as the project evolves. Last updated: 2026-06-20 (Stage 11).
5
 
6
  ---
7
 
@@ -18,6 +18,7 @@
18
  9. [Stage 8 — Eval Dashboard + Rigorous Metrics](#stage-8--eval-dashboard--rigorous-metrics-2026-06-17)
19
  10. [Stage 9 — Multi-Query Retrieval](#stage-9--multi-query-retrieval-2026-06-19)
20
  11. [Stage 10 — Contextual Retrieval (Eval)](#stage-10--contextual-retrieval-eval-2026-06-20)
 
21
  12. [Stage 11 — Contextual Retrieval in Production + Dashboard Polish](#stage-11--contextual-retrieval-in-production--dashboard-polish-2026-06-20)
22
  13. [Current State Snapshot](#current-state-snapshot)
23
  13. [Roadmap — Retrieval & Answer Quality](#roadmap--retrieval--answer-quality)
@@ -487,15 +488,43 @@ Eval: Separate eval-dashboard/ static site → https://askprism-eval.ver
487
  Versioning: MAJOR.MINOR.PATCH — name changes on MAJOR only (v1.x.x=Violet, v2.x.x=Indigo)
488
  Frontend: Violet v1.3 badge in sidebar footer. Maintenance banner config-driven (frontend/src/config.js).
489
  Workspaces: Per-workspace ChromaDB collection, singleton retriever cache
490
- Infra: Render (backend, ephemeral FS — re-upload required after cold start) +
491
  https://askprism.vercel.app/ (frontend) + https://askprism-eval.vercel.app/ (eval)
492
- Known limits: Euron embed ~52s for 30 chunks (sync, blocking). Next: move to background.
493
- Render ephemeral FS: chroma_db lost on restart. Fix: Render persistent disk.
 
494
  Observability: LangSmith traces all LLM + retrieval calls (optional, env var)
495
  ```
496
 
497
  ---
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  ## Roadmap — Retrieval & Answer Quality
500
 
501
  ### Phase 1 — Quick wins (no infra change, measurable RAGAS lift)
@@ -528,7 +557,7 @@ Observability: LangSmith traces all LLM + retrieval calls (optional, env var)
528
  ### Phase 3 — UX + trust
529
 
530
  #### Streaming Responses
531
- - **Problem:** User submits question → 8–15s wait → full answer appears. On Render free vCPU this feels broken.
532
  - **How:** Backend: `chain.astream_events()` → `StreamingResponse` yielding SSE tokens. Frontend: `EventSource` or `fetch` + `ReadableStream` — append tokens as they arrive. Faithfulness scoring runs as background task after full answer assembled.
533
  - **Effort:** High — both backend and frontend change. `ConversationalRetrievalChain` supports `astream_events()` in LangChain ≥0.2.
534
  - **Impact:** Perceived latency drops from 10s to ~1s. Single biggest UX improvement.
 
1
  # Prism — Project Evolution
2
 
3
  > End-to-end record of what was broken at each stage, what was built to fix it, and what is planned next.
4
+ > Updated as the project evolves. Last updated: 2026-06-22 (Stage 12).
5
 
6
  ---
7
 
 
18
  9. [Stage 8 — Eval Dashboard + Rigorous Metrics](#stage-8--eval-dashboard--rigorous-metrics-2026-06-17)
19
  10. [Stage 9 — Multi-Query Retrieval](#stage-9--multi-query-retrieval-2026-06-19)
20
  11. [Stage 10 — Contextual Retrieval (Eval)](#stage-10--contextual-retrieval-eval-2026-06-20)
21
+ 12. [Stage 12 — HF Spaces Migration](#stage-12--hf-spaces-migration-2026-06-22)
22
  12. [Stage 11 — Contextual Retrieval in Production + Dashboard Polish](#stage-11--contextual-retrieval-in-production--dashboard-polish-2026-06-20)
23
  13. [Current State Snapshot](#current-state-snapshot)
24
  13. [Roadmap — Retrieval & Answer Quality](#roadmap--retrieval--answer-quality)
 
488
  Versioning: MAJOR.MINOR.PATCH — name changes on MAJOR only (v1.x.x=Violet, v2.x.x=Indigo)
489
  Frontend: Violet v1.3 badge in sidebar footer. Maintenance banner config-driven (frontend/src/config.js).
490
  Workspaces: Per-workspace ChromaDB collection, singleton retriever cache
491
+ Infra: HF Spaces CPU Basic (backend, 16GB RAM, ephemeral FS — re-upload required after cold start) +
492
  https://askprism.vercel.app/ (frontend) + https://askprism-eval.vercel.app/ (eval)
493
+ Backend URL: https://benroshan-prism.hf.space
494
+ Known limits: Euron embed ~5s/chunk sequential — 30 chunks = ~150s blocking upload. Next: move embed to background.
495
+ HF Spaces ephemeral FS: chroma_db lost on cold start. Fix: mount HF persistent storage bucket.
496
  Observability: LangSmith traces all LLM + retrieval calls (optional, env var)
497
  ```
498
 
499
  ---
500
 
501
+ ## Stage 12 — HF Spaces Migration (2026-06-22)
502
+
503
+ ### What was wrong
504
+ Render free tier (512MB RAM) caused repeated OOM crashes under contextual retrieval:
505
+ - Base RSS after upload = 524MB (over the 512MB limit)
506
+ - `gc.collect()` had no effect — ChromaDB HNSW index + torch runtime held by native allocators, not Python heap
507
+ - Contextual refresh (3 async Groq coroutines) + simultaneous chat (Tavily + LLM + CrossEncoder) = peak exceeded 512MB
508
+ - Workarounds (RSS guard skipping contextual retrieval, web search suppression during refresh) negated the +18% recall improvement
509
+
510
+ ### What we built
511
+
512
+ | File | Change |
513
+ |------|--------|
514
+ | `Dockerfile` | Port 8000 → 7860 (HF convention). Add `useradd -m -u 1000 user` + `chown -R user /app` (HF runs containers as UID 1000). Set `HF_HOME=/app/.cache/huggingface` BEFORE pre-download so user 1000 owns cached weights. Set `HF_HUB_OFFLINE=1` AFTER download. |
515
+ | `README.md` | Added HF Spaces frontmatter (`sdk: docker`, `app_port: 7860`). Updated deploy instructions. |
516
+ | `server/routes/chat.py` | Removed `is_contextualizing` web search suppression guard (Render-specific). |
517
+ | `server/routes/upload.py` | Removed `RSS > 460MB` contextual retrieval skip guard (Render-specific). |
518
+ | `docs/`, `decisions.md` | Render → HF Spaces across all infra references. |
519
+
520
+ ### Key discoveries
521
+ - HF_HUB_OFFLINE must be set AFTER the pre-download RUN step — setting it before blocks the download itself
522
+ - Docker build runs pre-download as root by default; must `USER 1000` first then set `HF_HOME` under `/app` so runtime user 1000 can read the cached weights
523
+ - HF Spaces free CPU Basic: 2 vCPUs, 16GB RAM — resolves all Render OOM issues permanently
524
+ - Contextual retrieval now runs fully in production (was silently skipped by RSS guard on Render)
525
+
526
+ ---
527
+
528
  ## Roadmap — Retrieval & Answer Quality
529
 
530
  ### Phase 1 — Quick wins (no infra change, measurable RAGAS lift)
 
557
  ### Phase 3 — UX + trust
558
 
559
  #### Streaming Responses
560
+ - **Problem:** User submits question → 8–15s wait → full answer appears. Feels broken even on fast hardware.
561
  - **How:** Backend: `chain.astream_events()` → `StreamingResponse` yielding SSE tokens. Frontend: `EventSource` or `fetch` + `ReadableStream` — append tokens as they arrive. Faithfulness scoring runs as background task after full answer assembled.
562
  - **Effort:** High — both backend and frontend change. `ConversationalRetrievalChain` supports `astream_events()` in LangChain ≥0.2.
563
  - **Impact:** Perceived latency drops from 10s to ~1s. Single biggest UX improvement.