embeddings_explorer.py— Voyage AI (voyage-3, 1024-dim) similarity playground.embed_text,cosine_similarity(pure Python, no numpy),compare.__main__batches all texts into one embed call and ranks 5 sentence pairs.
db.py— the store→retrieve core of the RAG pipeline.embed_and_store(text, source, chunk_index) -> str— embeds withvoyage-3and inserts into thedocumentstable; returns the new row id.search_similar(query, top_k=5) -> list[dict]— embeds the query and ranks via thematch_documentspgvector RPC; returnsid, content, source, chunk_index, similarity.delete_test_rows()— clears rows wheresource = 'test'; called at the top of__main__so the demo is idempotent (the 3 sample rows are taggedsource='test').- Keys from env:
VOYAGE_API_KEY,SUPABASE_URL,SUPABASE_KEY. Uses supabase-py only (no raw SQL from Python).
schema.sql—match_documents(query_embedding vector(1024), match_count int, match_threshold float), cosine similarity via pgvector<=>. Run in the Supabase SQL editor.- Added
supabasedependency (uv add supabase). chunker.py— turns a PDF into embed-ready chunks.chunk_pdf(pdf_path) -> list[dict]— extracts text withpypdf, splits into 512-token windows with 50-token overlap (token boundaries via tiktokencl100k_base), skips chunks < 50 tokens. Each dict:text, source (filename only), chunk_index (0-based), token_count.__main__takes the PDF path assys.argv[1]and prints total/avg/min/max chunk size + first/last 200-char previews.- Added deps
pypdf tiktoken. - Verified on a generated 14-chunk PDF: indices contiguous, interior chunks all 512 tokens, 13/13 adjacent pairs share an exact 50-token overlap.
- Hardened: validates the
%PDF-magic header and wrapsPdfReaderso a non-PDF (e.g. an HTML error page saved as.pdf) or corrupt file gives a cleanerror: ...+ exit 1 instead of a pypdf traceback.
ingestor.py— wires chunker + db into a PDF→vector-store pipeline.ingest_pdf(pdf_path) -> dict—chunk_pdf()→embed_and_store()per chunk; returns{source, total_chunks, stored_ids (list[int]), failed_chunks (list[int])}.- Paces embeds in batches of 3 with a 20s pause (Voyage 3 RPM); prints
Ingesting chunk X/total.... - Per-chunk errors are caught, logged, added to
failed_chunks, and the run continues — one bad chunk never crashes the ingest. __main__takes a PDF path, prints the summary, then runs a test query"what is constitutional AI?"viasearch_similar(top 3).- Logic verified with mocked deps (failure path, id collection, batch pauses, empty PDF). Progress prints use
flush=Trueso they stream live even when stdout is redirected to a file. - Real ingest done:
test.pdf→ 61/61 chunks stored, 0 failures (~20 min on the 3 RPM free tier).
rag.py— the generation layer (retrieve → ground → answer with citations).answer(question, top_k=5) -> dict→{answer, sources, question, chunks_used}. Callssearch_similar, injects chunks into a grounded system prompt, asks Claude to answer using ONLY that context and cite sources (or refuse if absent).- Model:
claude-haiku-4-5-20251001,max_tokens=1024, via the Anthropic SDK (ANTHROPIC_API_KEYfrom env). Addedanthropicdep. - Empty-retrieval guard returns a canned "not enough information" answer without calling Claude.
__main__runs 3 CAI questions. Verified: all 3 answered correctly and grounded intest.pdfchunks; the RLHF question did not trigger the refusal path because the paper genuinely covers RLHF.
main.py— FastAPI app over the pipeline (uv run uvicorn main:app, oruv run python main.py).POST /ingest— multipart PDF upload → temp file saved under the original filename (sosourceis the real name, not the temp path) →ingest_pdf()→ summary dict; non-PDF/unreadable → 400.POST /chat— JSON{question, top_k=5}→answer()→ full answer dict.GET /health—{status, model, db}; pings Supabase with alimit(1)query (returns 503 if unreachable).- Routes are sync
defso FastAPI offloads the blocking embed/Claude/ingest work to a threadpool. Added depsfastapi uvicorn python-multipart. - Verified via
TestClient: /health →ok / claude-haiku-4-5-20251001 / connected; /ingest stored a 3-chunk test PDF (cleaned up after) and rejected a non-PDF with 400; /chat returned a grounded answer (chunks_used=3).
ui.py— Streamlit front-end (uv run streamlit run ui.py); talks to the FastAPI server withrequestsonly.- Sidebar: health badge (green Connected / red Disconnected, re-checked each run via
/health), API base-URL input, PDF uploader + Ingest PDF button (spinner; success summary or red error), and a Clear chat history button. - Main:
Document Q&Achat withst.session_state-persisted history; each assistant turn has a "Sources (N chunks used)" expander (source/chunk_index/similarity). Empty state prompts to upload; the Ask button is disabled + a warning shown when the API is unreachable. - Verified headlessly with Streamlit
AppTestagainst the live server: connects, an Ask click returns a grounded answer with 5 sources, Clear empties the history. Addedstreamlitdep.
- Sidebar: health badge (green Connected / red Disconnected, re-checked each run via
- Deployment scaffolding (for Railway).
Dockerfile(FastAPI, EXPOSE 8000) andDockerfile.streamlit(UI, EXPOSE 8501) — bothpython:3.11-slim+ uv,uv sync --frozen --no-install-project, venv on PATH..dockerignore— keeps.venv/.git/.env/test.pdfout of the build context (esp..venv, soCOPY . .can't clobber the synced venv).railway.toml— FastAPI service: dockerfile build +startCommandbinds$PORT..env.example— placeholder values for the 4 env vars (no real keys).README.md— overview, local-run commands, Railway deploy steps, env-var table.- Pushed to GitHub (
fedeghiglio/rag-chatbot, branchmaster); deploy config iterated (Dockerfile CMD → Python entrypoint reading$PORT;railway.tomlstartCommand removed so the CMD is used).
- Project restructure — moved into a clean layout:
backend/app/(FastAPI package, relative imports likefrom .db import …),frontend/ui.py,db/schema.sql,scripts/embeddings_explorer.py.pyproject.toml/uv.locknow inbackend/. Docker builds use the repo-root context and COPY from the subdirs;railway.toml→backend/Dockerfile. Run locally frombackend/:uv run uvicorn app.main:app(oruv run python -m app.ingestor ../test.pdf). Verified with a dry-runimport app.main.
Stored 3 sentences; query "what do dogs eat?" → animals 0.730, science 0.215, finance 0.149. Correct ranking.
- Voyage free tier = 3 requests/min. Both scripts use retry/backoff (25s waits). Adding a payment method lifts the limit (free token allowance still applies).
- The originally-deployed
match_documentswas broken — only matched near-identical vectors and capped at 1 row, so real queries returned 0.schema.sqlis the corrected version; redeploy it if the function is ever reset. documentsschema:id bigint PK, content text, embedding vector(1024), source text, chunk_index int, created_at timestamptz.- pgvector embeddings are inserted as the JSON-array string form (
json.dumps(vector)). - Secrets live in
~/.bashrc(sourced into the shell before running). - Citation drift in
rag.py: Claude's inline[Source: …, chunk N]citations occasionally name a chunk that wasn't actually in the retrieved set — the answer stays grounded, only the prose citation drifts. The returnedsourceslist (the real retrieved chunks) is authoritative. Future fix: Anthropic's native citations API returns verifiable cited spans instead of prose-formatted citations. - Python version mismatch for Docker:
pyproject.tomlpinsrequires-python >=3.14but the Dockerfiles usepython:3.11-slim. It builds (uv provisions 3.14 inside the image), but to make the slim base the real runtime, switch the base topython:3.14-slimor relax the pin to>=3.11(the code runs fine on 3.11). .gitignorenegation:.env.*would ignore.env.exampletoo, so!.env.examplere-includes the template (it must be committed).
- Chunking —
chunker.py(512-token chunks, 50 overlap). Done. - Ingestion wiring —
ingestor.py(ingest_pdf): chunks →embed_and_store, batched for the 3 RPM limit, graceful per-chunk failures. Done. - Real ingest —
test.pdf→ 61/61 chunks stored, 0 failures (~20 min, 3 RPM free tier). Cleaned out 3 stalesource='test'demo rows afterward; table now holds 61test.pdfrows. Query"what is constitutional AI?"top 3 similarities: 0.572 / 0.571 / 0.547 — all on-topic Constitutional AI passages. Done. - RAG generation —
rag.py(answer): retrieve → grounded Claude (claude-haiku-4-5-20251001) → cited answer, with a refusal path. Done. - FastAPI layer —
main.py:POST /ingest,POST /chat,GET /health. Verified viaTestClient. Done. - Async refactor — CLAUDE.md mandates async-first (
asyncio+httpx). Move tovoyageai.AsyncClientand the async Supabase client; currentdb.pyis sync. - Verifiable citations — replace
rag.py's prose citations with Anthropic's citations API (see the citation-drift gotcha). - Streamlit UI —
ui.py(sidebar: health badge, PDF ingest, clear-chat; main: chat with source expanders). Verified viaAppTest. Done. - Deploy to Railway — Dockerfiles +
railway.toml+.env.example+ README ready. Remaining: push to GitHub, create backend (Dockerfile) + frontend (Dockerfile.streamlit) services, set the 4 env vars, point the UI's sidebar API URL at the backend's Railway URL. - Scale — add an HNSW/IVFFlat index on
documents.embeddingonce row counts grow.