Build a Full-Stack RAG Document Copilot with FastAPI, React, and Supabase

· 5 min read ai rag youtube

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 (50K50K–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:

  1. 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.

  2. 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

LayerTechnology
BackendPython 3.12+, FastAPI, Uvicorn
FrontendReact, TypeScript, Vite, Tailwind CSS, shadcn/ui
DatabaseSupabase (PostgreSQL + pgvector)
EmbeddingsOpenAI text-embedding-3-small
LLMOpenAI GPT-5.5
Agentic FrameworkPydantic AI
Document ProcessingDocling
HostingRailway
MonorepoFrontend 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:

  1. What exactly does it do that we can’t write in <30 lines of clear code?
  2. How often does it get used?
  3. 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:

data/download.py
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 API
submission = get_json(f"https://data.sec.gov/submissions/CIK{cik}.json")
# Fetches .htm files from SEC archives
source_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:

app/config.py
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:

TablePurpose
profilesOne row per authenticated user, keyed by Supabase auth.users.id
chat_threadsThread metadata, owner, title, timestamps
chat_messagesUser and assistant messages in order
message_citationsNormalized citation records linked to assistant messages
source_documentsOriginal SEC filings metadata + normalized Markdown content
document_chunksChunk text, embeddings, full-text search vectors

The document_chunks table includes a vector column for pgvector:

app/database/models/document_chunk.py
embedding = Column(Vector(1536)) # text-embedding-3-small dimension

Alembic 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:

app/auth/dependencies.py
async def get_current_user(request: Request) -> User:
token = request.headers.get("Authorization")
# Verify JWT with Supabase, return user or raise HTTPException

Phase 3 — Chat Vertical Slice

Backend API

We create REST endpoints for the chat system:

  • POST /api/chat — Create a new chat thread
  • POST /api/chat/{thread_id}/message — Send a message
  • GET /api/chat/{thread_id}/messages — Retrieve thread history
  • GET /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.

data/convert_to_markdown.py
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
result = converter.convert(html_path)
# Write structured markdown output

Step 2: Load Source Documents into Database

We ingest the markdown files into the source_documents table — 25 records (5 companies × 5 years):

app/ingest/load_source_documents.py
# Loads markdown files, stores ticker, form type, filing date, source URL, full text

Step 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:

app/ingest/chunking_strategy.py
from docling.chunking import HierarchicalChunker
chunker = HierarchicalChunker(
token_counter=token_counter,
max_tokens=300,
overlap=50
)
# Chunks by document structure, not arbitrary token boundaries

Step 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):

app/retrieval/retriever.py
# Semantic search via pgvector cosine similarity
semantic_results = await query_vector_search(query_embedding, top_k=60)
# Keyword search via PostgreSQL full-text search
keyword_results = await query_fulltext_search(keywords, top_k=60)
# Fuse with RRF
fused = reciprocal_rank_fusion([semantic_results, keyword_results], k=60)
# Return top 5
return fused[:5]

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 call
async def extract_keywords(query: str) -> list[str]:
# LLM extracts 3-5 search-relevant terms, removes filler words

Neighbor 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:

app/assistant/agent.py
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:

@dataclass
class 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:

  1. search_filings — Runs the hybrid retrieval pipeline
  2. read_chunk — Reads a specific chunk by ID
  3. read_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/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# frontend/Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Caddy serves the Vite build

Railway 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

flowchart LR user[Analyst] --> browser[Browser
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 queries

Key Takeaways

  1. Monorepo + AI agents — Having frontend and backend in one repo lets AI coding agents reason over the full context, dramatically speeding up development.

  2. 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.

  3. Hybrid search beats pure semantic — Combining pgvector with PostgreSQL full-text search and fusing via RRF produces more accurate retrieval than either alone.

  4. 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.

  5. The validator is the trust contract — A post-generation validation step ensures every answer is citable. Without it, you’re just another hallucinating chatbot.

  6. Document processing is the bottleneck — Converting raw documents to clean, chunkable text often takes more time than the retrieval pipeline. Invest here.

  7. Start with the setup — Environment variables, auth, database — this plumbing takes time but makes everything else faster. Don’t skip it.

  8. Minimize dependencies — Every dependency is a liability. Write it yourself unless the alternative is non-trivial, error-prone, or reinvention of a standard.


References

  1. 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
  2. Document Copilot Repository — Dave Ebbelaar, GitHub — https://github.com/daveebbelaar/document-copilot
  3. Docling Documentation — DS4SD — https://ds4sd.github.io/docling/
  4. Pydantic AI — Pydantic — https://ai.pydantic.dev/
  5. pgvector Documentation — pgvector — https://github.com/pgvector/pgvector
  6. Supabasehttps://supabase.com
  7. Railwayhttps://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