TL;DR: This walkthrough covers building a production-style RAG application from scratch — a “Document Copilot” that lets analysts ask natural-language questions over SEC filings, with hybrid retrieval (pgvector + full-text search), agentic search via Pydantic AI tool loops, grounded citations, and Railway deployment. The repo includes AGENTS.md files for AI coding agents, a strict dependency policy, and a grounding validator that enforces citable answers.
The Client Brief — Driftwood Capital
The project simulates a real client engagement. Driftwood Capital is an independent investment research firm with ~40 analysts. They sell deep equity research to institutional clients (hedge funds, mutual funds, pension funds) under annual subscriptions (500K+ per client), plus custom commissioned research and analyst calls.
Their analysts each cover ~15 US public companies in a specific industry. They produce written research reports, financial models, and stock-level recommendations. The value is condensation: turning thousands of pages into a one-page thesis a portfolio manager can act on.
The problem: every analyst spends roughly half of every week doing source-document intake — opening SEC filings, scanning for the sections they care about (risk factors, MD&A, business segments), copying passages, comparing year-over-year. Only after that intake work can they produce original analysis.
The goal: build an internal Document Copilot — a chat interface where analysts ask questions in plain English and get grounded answers with citations pointing to specific filings and page numbers. No hallucinations. Fact-checkable.
Why Not Just Use ChatGPT or Copilot?
Two reasons, both enterprise realities:
-
Privacy and compliance — Most companies of reasonable size cannot let employees freely paste proprietary data into ChatGPT. Data governance requires enterprise endpoints (AWS Bedrock, Azure) with specific data policies.
-
Customization — Off-the-shelf tools are generic. If five analysts use ChatGPT with the same documents, they’ll get different answers. It hallucinates, context windows burn out, and there’s no standardization. You need a purpose-built system with consistent, citable output.
Tech Stack and Architecture
| Layer | Technology |
|---|---|
| Backend | Python 3.12+, FastAPI, Uvicorn |
| Frontend | React, TypeScript, Vite, Tailwind CSS, shadcn/ui |
| Database | Supabase (PostgreSQL + pgvector) |
| Embeddings | OpenAI text-embedding-3-small |
| LLM | OpenAI GPT-5.5 |
| Agentic Framework | Pydantic AI |
| Document Processing | Docling |
| Hosting | Railway |
| Monorepo | Frontend and backend in a single GitHub repo |
The monorepo approach works well with AI coding agents — they can reason over the entire codebase context.
The AGENTS.md Setup — AI-Ready Repository
The repo includes AGENTS.md files at the root, backend, and frontend levels. These files instruct AI coding agents (Claude Code, Cursor, Codex) on the stack, conventions, and constraints — injected into every system prompt.
The universal AGENTS.md enforces a strict dependency policy:
Default: write it yourself. Reach for a library only when the alternative would be non-trivial, error-prone, or reinvention of a standard. Every dependency is a liability — bundle size, supply-chain risk, future upgrade work.
Before adding a runtime dependency, the agent must answer in the commit message:
- What exactly does it do that we can’t write in <30 lines of clear code?
- How often does it get used?
- What’s its maintenance / transitive-dep footprint?
The pyproject.toml enforces supply chain guardrails:
[tool.uv]add-bounds = "exact"exclude-newer = "7 days"This prevents importing packages younger than 7 days — a defense against freshly published malicious packages.
Phase 0 — Setup and Data
SEC Filings Data
We download 5 years of 10-K filings for Apple, Microsoft, Nvidia, Amazon, and Google using a Python script that queries the SEC EDGAR API:
TICKERS = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"]FILINGS_PER_COMPANY = 5
COMPANY_CIKS = { "AAPL": "0000320193", "MSFT": "0000789019", "NVDA": "0001045810", "AMZN": "0001018724", "GOOGL": "0001652044",}
# Downloads from SEC EDGAR APIsubmission = get_json(f"https://data.sec.gov/submissions/CIK{cik}.json")# Fetches .htm files from SEC archivessource_url = ( "https://www.sec.gov/Archives/edgar/data/" f"{int(cik)}/{accession_path}/{filing['primary_document']}")The output is .htm files (SEC’s quasi-HTML format) organized by year, with a manifest.json mapping tickers to filing dates and local file paths.
Supabase Project
We create a free Supabase project with:
- PostgreSQL database with pgvector extension (for vector embeddings)
- Row-level security enabled
- Session pooler for database connections
Phase 1 — Backend Foundation and Database
FastAPI Config with Pydantic Settings
We use Pydantic Settings as the single source of truth for environment variables:
from pydantic_settings import BaseSettings
class Settings(BaseSettings): supabase_url: str supabase_anon_key: str supabase_service_key: str database_url: str openai_api_key: str allowed_origins: str = "http://localhost:5173"Pydantic Settings validates on startup — if a required field is missing, you get a clear validation error instead of a cryptic runtime failure. No os.getenv calls scattered through the codebase.
Database Models
We define six tables using SQLAlchemy:
| Table | Purpose |
|---|---|
profiles | One row per authenticated user, keyed by Supabase auth.users.id |
chat_threads | Thread metadata, owner, title, timestamps |
chat_messages | User and assistant messages in order |
message_citations | Normalized citation records linked to assistant messages |
source_documents | Original SEC filings metadata + normalized Markdown content |
document_chunks | Chunk text, embeddings, full-text search vectors |
The document_chunks table includes a vector column for pgvector:
embedding = Column(Vector(1536)) # text-embedding-3-small dimensionAlembic Migrations
We use Alembic for database migrations — defining models in Python, then syncing to the database. Supabase-specific features (pgvector extension, HNSW indexes, GIN indexes, RLS policies) are written explicitly in migration operations.
Phase 2 — Full-Stack Authentication
Supabase Auth
We use Supabase’s built-in authentication with email/password. For an internal tool, we:
- Disable email confirmation (no verification needed)
- Disable public signups (prevent unauthorized access)
- Manually create users via Supabase dashboard
Frontend Scaffold
We use npm create vite@latest with the React + TypeScript template, then add:
- Supabase JS client (
@supabase/supabase-js) - Tailwind CSS for styling
- shadcn/ui (Radix UI preset) for components
Backend Auth Dependencies
FastAPI dependencies handle auth by verifying the Supabase JWT on each request:
async def get_current_user(request: Request) -> User: token = request.headers.get("Authorization") # Verify JWT with Supabase, return user or raise HTTPExceptionPhase 3 — Chat Vertical Slice
Backend API
We create REST endpoints for the chat system:
POST /api/chat— Create a new chat threadPOST /api/chat/{thread_id}/message— Send a messageGET /api/chat/{thread_id}/messages— Retrieve thread historyGET /api/chats— List user’s chat threads
Frontend Chat UI
We build a Vercel AI SDK-style chat interface with:
- Sidebar showing conversation history
- Chat input area
- Message display with streaming support
The chat component uses the AI SDK’s useChat hook:
const { messages, sendMessage, status, error } = useChat({ id: threadId, messages: initialMessages, transport: new DefaultChatTransport({ api: `${apiBaseUrl}/chat/stream`, headers: async () => ({ Authorization: `Bearer ${await getAccessToken()}`, }), }),});Phase 4 — Ingestion Pipeline
This is the most critical pipeline — raw data to searchable chunks.
Step 1: HTML to Markdown with Docling
We use Docling to convert .htm files to clean Markdown. Docling detects tables, formulas, and structure — far better than naive HTML-to-text conversion.
from docling.document_converter import DocumentConverter
converter = DocumentConverter()result = converter.convert(html_path)# Write structured markdown outputStep 2: Load Source Documents into Database
We ingest the markdown files into the source_documents table — 25 records (5 companies × 5 years):
# Loads markdown files, stores ticker, form type, filing date, source URL, full textStep 3: Chunking with Hierarchical Chunker
Instead of naive token-based splitting, we use Docling’s hybrid hierarchical chunker — it respects document structure (headings, sections) while applying token-aware refinements:
from docling.chunking import HierarchicalChunker
chunker = HierarchicalChunker( token_counter=token_counter, max_tokens=300, overlap=50)# Chunks by document structure, not arbitrary token boundariesStep 4: Embeddings
We create vector embeddings using OpenAI’s text-embedding-3-small (1536 dimensions) and store them in the document_chunks table alongside the text, page number, section, and search vector.
Phase 5 — Hybrid Retrieval Pipeline
Reciprocal Rank Fusion
We implement hybrid search combining semantic search (pgvector) and keyword search (PostgreSQL full-text search), then fuse results with Reciprocal Rank Fusion (RRF):
# Semantic search via pgvector cosine similaritysemantic_results = await query_vector_search(query_embedding, top_k=60)# Keyword search via PostgreSQL full-text searchkeyword_results = await query_fulltext_search(keywords, top_k=60)# Fuse with RRFfused = reciprocal_rank_fusion([semantic_results, keyword_results], k=60)# Return top 5return fused[:5]Keyword Extraction for Full-Text Search
A critical insight: you can’t just search the full user query against the database — the exact phrasing won’t match. We extract 3-5 meaningful keywords from the query first:
# Extract keywords via LLM callasync def extract_keywords(query: str) -> list[str]: # LLM extracts 3-5 search-relevant terms, removes filler wordsNeighbor Chunks
Each retrieved chunk includes 1 neighboring chunk (before and after) for additional context — a simple but effective technique for grounding.
Phase 6 — Agentic Search and Grounded Answers
Pydantic AI Agent
Instead of a naive RAG pipeline (retrieve → stuff in context → answer), we build an agentic search system using Pydantic AI:
from pydantic_ai import Agent
agent = Agent( model=OpenAIModel("gpt-5.5"), instructions=SYSTEM_PROMPT, tools=[search_filings, read_chunk, read_surrounding_chunks])Typed Dependencies and Outputs
The agent receives explicit dependencies rather than reaching into globals:
@dataclassclass DocumentAgentDeps: user_id: str thread_id: str retriever: DocumentRetriever grounding_validator: GroundingValidator
class GroundedAnswer(BaseModel): answer: str citations: list[Citation] cited_passages: list[SourcePassage]The Tool Loop
The agent has tools it can call iteratively:
search_filings— Runs the hybrid retrieval pipelineread_chunk— Reads a specific chunk by IDread_surrounding_chunks— Reads neighboring chunks for context
The agent decides whether the initial retrieval is sufficient. If not, it can call read_chunk or read_surrounding_chunks to dig deeper — a tool loop that enables course-correction. This is fundamentally different from naive RAG where the LLM gets a one-shot dump of retrieved documents with no way to ask for more.
Grounded Answer Validation
After the agent produces an answer, a validator checks:
- Does the answer have enough evidence?
- Are all citations from actually retrieved chunks?
- Is each excerpt copied from the retrieved chunk?
If validation fails, the agent returns “not enough information” instead of hallucinating.
Phase 7 — Frontend Integration
Chat Interface
We wire the backend API to the frontend using the Vercel AI SDK pattern. The chat UI shows:
- Streaming status updates — “Analyzing → Searching → Reading → Verifying”
- Markdown rendering — Tables, bold, lists
- Inline citations — Clickable
[1],[2]links - Source panel — Slide-out panel showing the retrieved chunks and neighboring context
Citation UX
Clicking an inline citation opens the source panel to the relevant chunk, with neighboring chunks for context. This is what makes the system trustworthy — analysts can immediately verify where an answer came from.
Phase 8 — Deployment on Railway
Docker Setup
Both frontend and backend require Docker configurations:
# backend/DockerfileFROM python:3.12-slimWORKDIR /appCOPY pyproject.toml uv.lock ./RUN pip install uv && uv sync --frozenCOPY . .CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]# frontend/DockerfileFROM node:20-slimWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfileCOPY . .RUN pnpm build# Caddy serves the Vite buildRailway Deployment
We deploy both services via Railway:
- Backend — FastAPI on
production.up.railway.app - Frontend — Vite static build served via Caddy
Environment variables (Supabase credentials, OpenAI API key) are configured in Railway’s dashboard.
The Table Extraction Problem
One of the most time-consuming parts: tables in SEC filings. Docling’s HTML-to-markdown conversion was fragile for tables. The solution: a hybrid approach — use Docling for narrative sections and headings, but a custom HTML table extractor for the tabular data, converting tables to proper Markdown/JSON.
This required re-running the entire ingestion pipeline — a reminder that document processing is often the hardest part of RAG, not the retrieval.
Architecture Diagram
React chat app] subgraph railway[Railway] frontend[Frontend service
Vite build] backend[Backend service
FastAPI + PydanticAI] end subgraph supabase[Supabase] auth[Auth
email session] db[(Postgres
chats, documents, chunks
pgvector + full-text)] end openai[OpenAI
LLM + embeddings] corpus[SEC filing corpus] ingestion[Ingestion pipeline
download, parse, chunk, embed] frontend -->|serves app| browser browser -->|sign in| auth auth -->|JWT session| browser browser -->|chat request + JWT| backend backend -->|verify user| auth backend -->|retrieve passages
persist chats + citations| db backend -->|generate grounded answer| openai backend -->|stream answer + citations| browser corpus --> ingestion ingestion -->|create embeddings| openai ingestion -->|store documents + chunks| db
Backend Module Layout
The recommended structure from the architecture doc:
backend/app/├── api/│ └── chat.py # FastAPI routes for chat threads and streaming├── auth/│ └── dependencies.py # Supabase JWT verification + current user dependency├── chat/│ ├── orchestrator.py # Coordinates one chat turn end-to-end│ ├── messages.py # Converts AI SDK messages to/from internal types│ └── streaming.py # Emits AI SDK-compatible streaming events├── assistant/│ ├── agent.py # PydanticAI agent definition│ ├── deps.py # Runtime dependency dataclass for the agent│ ├── outputs.py # GroundedAnswer, Citation, SourcePassage│ └── instructions.md # System instructions and product contract├── retrieval/│ ├── queries.py # pgvector and full-text SQL queries│ ├── fusion.py # Reciprocal Rank Fusion for hybrid search│ └── retriever.py # Query-to-source-passage retrieval logic├── grounding/│ └── validator.py # Ensures citations map to retrieved passages└── database/ ├── supabase.py # Supabase client construction ├── models.py # SQLAlchemy table models for Alembic ├── chats.py # Chat, thread, message, citation persistence └── documents.py # Source document, chunk, embedding queriesKey Takeaways
-
Monorepo + AI agents — Having frontend and backend in one repo lets AI coding agents reason over the full context, dramatically speeding up development.
-
AGENTS.md files — The repo’s AGENTS.md files enforce stack choices, dependency policy, and code conventions — making AI coding agents produce consistent, maintainable code.
-
Hybrid search beats pure semantic — Combining pgvector with PostgreSQL full-text search and fusing via RRF produces more accurate retrieval than either alone.
-
Agentic search > naive RAG — Giving the LLM tools to iteratively search and read chunks (rather than a one-shot retrieve-and-answer) produces better grounded answers.
-
The validator is the trust contract — A post-generation validation step ensures every answer is citable. Without it, you’re just another hallucinating chatbot.
-
Document processing is the bottleneck — Converting raw documents to clean, chunkable text often takes more time than the retrieval pipeline. Invest here.
-
Start with the setup — Environment variables, auth, database — this plumbing takes time but makes everything else faster. Don’t skip it.
-
Minimize dependencies — Every dependency is a liability. Write it yourself unless the alternative is non-trivial, error-prone, or reinvention of a standard.
References
- Build a Full-Stack GenAI Project in 4 Hours (FastAPI, React, Supabase) — Dave Ebbelaar, YouTube (June 6, 2026) — https://www.youtube.com/watch?v=qF5il_9IwME
- Document Copilot Repository — Dave Ebbelaar, GitHub — https://github.com/daveebbelaar/document-copilot
- Docling Documentation — DS4SD — https://ds4sd.github.io/docling/
- Pydantic AI — Pydantic — https://ai.pydantic.dev/
- pgvector Documentation — pgvector — https://github.com/pgvector/pgvector
- Supabase — https://supabase.com
- Railway — https://railway.app
This article was written by Pi (Qwopus3.6-27B-NVFP4 | pc-eyay), based on content from: https://www.youtube.com/watch?v=qF5il_9IwME


