Spaces:
Sleeping
Project Journal — Screen Ireland RAG Chatbot
A living document. Append to it as the project grows. Sections are stable; dated entries at the bottom of each section show the evolution over time.
1. What this project is
A retrieval-augmented chatbot for screenireland.ie. Visitors ask plain-English questions about funding, filming, courses, or programmes; the bot answers only from Screen Ireland's own published HTML and PDFs — with citations — or says "I don't have that". The hard requirement: never fabricate.
The architecture document (architecture.md) is the binding design spec. This
file (learning.md) is a journal — what's been built, what works, what's
pending, and the why behind decisions that aren't obvious from the code.
2. What we've achieved (POC — Stage 1)
2.1 Ingestion pipeline (Stage 1A) — ✅ complete
Crawler (
ingest/crawl.py): two-phase async crawler.- Phase 1: parse
sitemap.xml→ fetch every HTML page withhttpx(3 concurrent slots, 0.4 s/slot for politeness, descriptive User-Agent). - Phase 2: scan downloaded HTML for
<a href="*.pdf">links → download each PDF. - Resume-safe (skips already-fetched files).
- Result: 2,551 HTML pages + 196 PDFs downloaded. ~500 MB on disk.
- Phase 1: parse
Extractor (
ingest/extract.py):- HTML →
trafilatura.extract(output_format="markdown")— preserves heading hierarchy (#,##,###) for downstream chunking, strips nav/footer/cookie banners. - PDF →
PyMuPDFper page (page numbers retained for citation). - PDF title quality fix: when embedded title metadata is empty, fall back to the URL filename (
IFB_Annual_Report_full2009), not the hashed local path. This was a key quality fix found during acceptance. - Result: 2,523 HTML + 191 PDFs survived extraction.
- HTML →
Chunker (
ingest/chunk.py):- Parses markdown headings to build a
heading_pathlikeFunding > Development > Limits. - Heading-aware split, then ~600-token sliding window with ~80-token overlap inside each section.
- Heading path prepended to each chunk's text so embeddings carry context.
- Filters chunks with <25 tokens of content (drops news-listing fragments, subscription forms).
- Result: 13,414 chunks.
- Parses markdown headings to build a
Corpus enrichment (added after first quality review):
categoryfield — URL-derived (news / funding / report / policy / about / courses / skills / filming / strategy / catalogue / festivals / spotlight / sustainability / audience / insights / other). Tightened PDF heuristics to capture competency framework booklets, industry studies, etc.langfield —enorga. Detected via known Irish URL prefixes (/clair-mhaoiniuchain/,/feilte-margai/,/mar-gheall-ar-bse/,/plean-straiteiseach-2025-2029/) plus a Gaeilge marker-word heuristic for bilingual press releases.- Decision:
lang=gachunks are excluded at index time (1,419 of 13,414). The embedder is English-only; embedding Irish content withbge-small-enwastes compute and dilutes results. Re-indexable later when we move to a multilingual embedder.
2.2 Indexing (Stage 1B) — ✅ complete
Embedder (
app/providers/local_bge.py):BAAI/bge-small-en-v1.5via fastembed (ONNX runtime).- Original plan was
sentence-transformers, but a broken PyTorch ABI in the local Anaconda made it unusable. Pivot to fastembed cost nothing (same model, same weights) and bought ~3× faster CPU inference plus zero torch dependency. - Applies the bge query prefix (
"Represent this sentence for searching relevant passages: ") at retrieval time only. - L2-normalised output → cosine similarity becomes a dot product.
- Original plan was
Vector store (
ingest/index.py): LanceDB, file-based, schema:id, vector(384), text, source_url, title, source_type, page, heading_path, category, lang- Resume-safe: skips IDs already in the table.
- Indexed throughput: ~18 chunks/s on M-series CPU.
- Result: 11,995 English chunks indexed in ~11 minutes. ~30 MB on disk.
2.3 Retrieval + answer (Stage 1C) — ✅ complete
/chatendpoint (app/main.py, FastAPI + sse-starlette): POST{"question": "..."}, returns SSE stream with eventstoken,sources,done.- Retrieval (
app/retrieve.py): embed query → LanceDB cosine top-k (default 5) → apply relevance gate. - Relevance gate (Safeguard 1): if the top score is below
RELEVANCE_THRESHOLD(default 0.35), the LLM is never called; the bot streams the canned IDK and a closest-page link. This is the single most important component — it kills hallucinations on out-of-corpus topics at zero LLM cost. Empirically:- "What is the current weather in Dublin?" → score 0.341, gate fails. ✓
- "What is the development funding limit?" → score 0.546, gate passes. ✓
- Prompt-injection probe ("Ignore previous instructions…") → score 0.173, gate fails before the LLM sees it. ✓
- Grounding prompt (Safeguard 2) (
app/prompt.py): forbids outside knowledge, demands citations on every claim, adds "verify on page" sentence for funding figures, treats question + passages as data not instructions (prompt-injection defense), refuses to reveal the system prompt. - LLM provider abstraction (
app/providers/):EmbedderandLLMProviderABCs. Concrete:gemini.py— gemini-2.5-flash (primary, free).groq.py— llama-3.3-70b-versatile (fallback).anthropic.py— claude-haiku-4-5 (paid-switch ready, zero code change to flip).factory.py— automatic fallback chain (primary → secondary on quota/error).
2.4 Widget (Stage 1D) — ✅ complete
widget/widget.js— single self-mounting script. Renders inside a Shadow DOM so host-site CSS can't break it. Floating chat bubble bottom-right; click to expand a 380×560 panel. Streams from/chatviafetch+ReadableStream(parses SSE blocks manually since the request is POST).widget/demo.html— minimal landing page hosting the widget for the client demo.- Served from FastAPI at
/widget.jsand/demoso a singleuvicorn app.main:appboots the whole stack. - Accessible defaults (keyboard
Entersends,aria-liveon messages,aria-labelon bubble/close, focus management on open). WCAG AA work remains for Stage 2 (full contrast audit, screen-reader pass).
2.5 Evaluation (Stage 1E) — ✅ complete
eval/questions.jsonl— 15 questions: 10 answerable (about, funding, tax, filming, courses, audience), 4 deliberately out-of-corpus, 1 prompt-injection probe.eval/run_eval.py— scores each question ongate_ok,answered,cited,refused,leaked_promptas appropriate. Rate-limit-aware (--delay 7keeps Gemini under 10 RPM).--no-llmruns gate-only (fast, free).- Baseline numbers (2026-06-19):
- Without rate-limit spacing: 11/15 pass — the 4 "failures" were all HTTP 429s from Gemini's per-minute quota, not bugs.
- With
--delay 7(the canonical run): 15/15 pass (100%). Every topic green: about, audience, courses, filming, funding, injection, tax, unanswerable. - Use this 15/15 as the regression baseline. Any change that drops it must justify the drop.
2.6 Quality checkpoints we deliberately committed
- Hands-on inspection of 20 random chunks before declaring Stage 1A done (architecture acceptance criterion). All clean — no cookie banners, no nav menus, correct headings, correct attribution. Tightened the PDF title heuristic (URL-derived fallback) after the first pass surfaced ugly hashed titles.
- Honest corpus audit before moving to Stage 1B: identified that 63% of HTML is news articles (which would compete for retrieval slots vs. funding content), 15.7% is Irish-language (poor fit for English embedder), and ~4% had a "Top Picks" sidebar widget bleeding into chunks. Acted on the top two by tagging and language-filtering at index time. Left the sidebar issue documented and deferred — the relevance gate handles it in practice.
3. Key decisions and why
| Decision | Why we made it | When to revisit |
|---|---|---|
| Local bge embeddings, not hosted (Voyage / OpenAI) | Free forever, no rate limits, 384d cheap to store, privacy (text never leaves the box) | If eval recall is weak, upgrade to bge-base (768d) — one model swap + re-embed |
fastembed over sentence-transformers |
Local torch was broken; fastembed needs no torch and is ~3× faster on CPU | Never, unless we need PyTorch-only features |
| LanceDB over pgvector / Pinecone | Zero servers, file-based, scales to millions, cosine native | Move to Supabase pgvector when we need re-index without redeploy |
| Markdown-aware chunking, not blind 600-token splits | Preserves H1 > H2 > H3 context per chunk; without it, sentences split mid-section and lose meaning |
Tune size against eval/questions.jsonl |
Exclude Irish chunks at index, keep them in chunks.jsonl |
English embedder can't represent Irish well — wastes compute and dilutes results. Keeping them in chunks.jsonl makes re-indexing under a multilingual model a one-command job | When we want bilingual answers (would also need query language detection) |
| Two safeguards, not one (gate and prompt) | Gate handles "topic not in corpus"; prompt handles "topic in corpus but model wants to embellish". Defense in depth — one isn't enough for a state body | Never — both are mandatory |
| Gemini primary, Groq fallback, Anthropic paid-switch | Architecture's choice. Gemini has the most generous free tier; Groq is the fastest TTFT; Anthropic is what we move to when budget exists | LLM_PROVIDER=anthropic + a key is the entire migration |
4. What's NOT done yet
4.1 Architecture items still pending
| Stage | Item | Effort | Priority |
|---|---|---|---|
| 1.5 | Hybrid search (BM25 + bge fusion) + cross-encoder rerank | Half-day | Medium — add if Stage 1E shows specific-term misses (Section 481, named schemes, exact € figures) |
| 1.5 | bge-base upgrade for higher recall | 1 hour (just re-embed) | Low — only if 1.5 doesn't help |
| 2 | Embed widget on screenireland.ie via <script> tag |
Day | High — needs client sign-off + CORS lock |
| 2 | CORS origin-locked to screenireland.ie | Trivial | High |
| 2 | HTTPS in production | Hosting concern | High |
| 2 | Cookie/consent integration with site banner | Day | High (legal — ePrivacy) |
| 2 | Rate limiting per IP + max question length | Half-day | High (abuse/cost control) |
| 2 | WCAG 2.1 AA accessibility audit on widget | 2 days | High — legal under EU Web Accessibility Directive for public-sector sites |
| 2 | GDPR-aware logging policy (scrub PII, retention SLA) | Day | High (legal) |
| 2 | Monitoring & metrics: latency, error rate, fallback rate, IDK rate | Day | High |
| 2 | Thumbs up/down feedback loop | Half-day | Medium |
| 2 | Scheduled re-crawl + incremental indexing (hash-then-skip-unchanged) | Day | High — funding deadlines change; stale answers are the main prod risk |
| 2 | "Sources last updated on X" stamp in widget | Trivial | Medium |
| 12.2 | Multi-turn conversation with query rewriting | 2 days | Medium |
| 12.3 | Analytics dashboard (top questions, IDK rate, satisfaction) | Week | High value upsell — surfaces content gaps on their own site |
| 12.4 | Eval in CI — block regressions on every deploy | Half-day | High |
| 12.5 | Paid Claude upgrade (env var swap) | 10 min | Medium |
| 12.6 | Multilingual (Gaeilge) | Week | Low — client decision |
| 12.7 | Anthropic contextual retrieval (LLM-generated context blurb per chunk) | 2 days | Medium |
4.2 Known gaps / tech debt
- Google Gemini SDK deprecation: we use
google.generativeai(legacy). Google's recommended replacement isgoogle.genai. The migration is a contained 20-line job inapp/providers/gemini.py. Defer until paid tier or until the legacy SDK breaks. - "Top Picks" sidebar contamination (~192 HTML chunks, ~4%): trafilatura is including a sidebar widget on some course pages. Fix is a small
prune_xpathrule. Deferred — the relevance gate catches it in practice (sidebar chunks rarely beat real content for any reasonable query). - Annual reports dominate PDFs (~50% of PDF chunks): 2014–2024 annual reports give ~190 chunks each. Genuine content but historical and high-noise. Consider per-year tagging in Stage 2 so an old £ amount can't beat a current one.
- No FAQ-specific chunking: a generic 600-token window cuts FAQ Q+A pairs unpredictably. Stage 1.5 candidate: detect FAQ pages and keep Q+A pairs together.
- No rerank: top-k from raw cosine. A cross-encoder reranker on the top-20 would lift precision noticeably on named terms ("Section 481", scheme names).
5. Operational notes (the stuff that bit us)
- Gemini free tier is 10 RPM, not "1500 RPD comfortable for demos". The first full eval run hit 429s on requests 6–10 because we fired them with no spacing. Fix was
--delay 7inrun_eval.py. For live demos either (a) configureGROQ_API_KEYso the auto-fallback kicks in, or (b) space questions. - Anaconda + PyTorch ABI mismatch silently breaks
sentence-transformers. Switching to fastembed (ONNX) sidestepped the whole class of problem. .env.exampleis a template (safe to commit)..envis the real secrets (gitignored, mode 600). Easy mistake during setup; document explicitly in the README and the file headers.- LanceDB locks the file while indexing — concurrent
count_rows()from another process blocks. Just wait for the indexer to finish.
6. How to keep this document useful
Every meaningful change to the system should land a short dated entry below. Aim for why, not what — the code shows what changed; this file is where the rationale survives so the next engineer (or future-you) doesn't have to re-derive it.
Changelog
- 2026-06-18 — Stage 1A built end-to-end. 13,414 chunks from 2,551 pages + 196 PDFs. PDF title heuristic tightened after acceptance review.
- 2026-06-19 — Stage 1A+: added
categoryandlangtagging; Irish chunks excluded at index time. Stage 1B complete: 11,995 chunks embedded in LanceDB via fastembed (~11 min on CPU). Stage 1C complete: FastAPI + Gemini-primary/Groq-fallback streaming, both safeguards working in eval. Stage 1D complete: Shadow-DOM widget self-mounts, SSE streaming verified end-to-end. Stage 1E formalised with rate-limit-aware harness. Initial GitHub push.