Appearance
Contract Q&A Backend Architecture
System Overview
This document details the backend architecture for a document Q&A system that enables developers to upload contracts and ask natural language questions against them. The backend is split into two specialized services:
- Go API Server — High-throughput query handler with SSE streaming
- Python Worker (Celery + FastAPI) — Document ingestion pipeline
These services communicate through Redis and shared infrastructure (Supabase, Pinecone), creating a scalable, independently deployable system.
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Developer Browser │
│ Angular UI (Tailwind + DaisyUI) │
└──────────────┬────────────────────────────────┬──────────────────┘
│ │
Upload PDF Ask Question
│ │
┌──────────▼────────────────┐ ┌──────────▼────────────────┐
│ │ │ │
│ Go API (Gin) │ │ Go API (Gin) │
│ PORT 7070 │ │ PORT 7070 │
│ │ │ │
│ POST /upload │ │ POST /ask (SSE stream) │
│ - Validate file │ │ - Embed question │
│ - Store to Supabase │ │ - Query Pinecone │
│ - Push Redis job │ │ - Stream Claude │
│ - Return 202 Accepted │ │ - Save to Postgres │
│ │ │ │
└───────────┬───────────────┘ └───────────┬────────────────┘
│ │
Job pushed to Query for vectors
Redis queue & stream response
│ │
┌───────────▼────────────────┐ ┌─────────▼────────────────┐
│ │ │ │
│ Redis Queue │ │ Pinecone (Vector DB) │
│ (Upstash) │ │ │
│ │ │ Namespaced by project │
│ "document_processing" │ │ - Top-K semantic search │
│ "document_delete" │ │ - Scored results │
│ │ │ │
└───────────┬────────────────┘ └──────────────────────────┘
│
Celery worker polls
│
┌───────────▼────────────────────────────────────────┐
│ │
│ Python Worker (Celery + FastAPI) │
│ PORT 7075 │
│ │
│ Document Ingestion Pipeline: │
│ 1. Download file from Supabase Storage │
│ 2. Extract text (PyMuPDF, python-docx) │
│ 3. Chunk with overlap (LangChain) │
│ 4. Embed chunks in batches (Voyage AI) │
│ 5. Upsert vectors to Pinecone │
│ 6. Update doc status in Postgres │
│ │
└────────┬──────────────┬─────────┬──────────────────┘
│ │ │
┌────────▼──┐ ┌────────▼──┐ ┌──▼──────────────┐
│ Supabase │ │ Voyage │ │ Pinecone │
│ Storage │ │ AI │ │ (vectors) │
└───────────┘ └───────────┘ └────────────────┘
│
┌────────▼────────────────┐
│ │
│ Supabase Postgres │
│ │
│ - mandates │
│ - documents │
│ - chat_messages │
│ - users │
│ │
│ RLS policies by user │
│ │
└─────────────────────────┘Service Breakdown
Go API Server
Purpose: Handle all synchronous, low-latency operations. Serve as the public API gateway for the frontend.
Technology Stack:
- Language: Go 1.26.2
- Framework: Gin (HTTP router)
- Port: 7070
- Concurrency: Goroutines (handles 1000s of concurrent connections)
Key Responsibilities:
Authentication & Authorization
- JWT token validation on all protected routes
- Extract user_id from claims
- Row-level security enforced via Postgres policies
Document Upload Handler
POST /api/mandates/:projectId/documents/upload Flow: - Validate JWT token - Validate file type (PDF, DOCX, TXT, MD) - Validate file size (<50MB) - Upload to Supabase Storage (path: projectId/docId.ext) - Create document record in Postgres (status: pending) - Push ProcessingJob to Redis queue - Return 202 Accepted immediatelyReturns immediately without waiting for processing. Processing happens async.
Q&A Handler (SSE Streaming)
POST /api/mandates/:projectId/ask Flow: - Validate project access - Embed question using Voyage AI - Query Pinecone for top-8 relevant chunks (scored) - Build context from chunks - Send context + question to Claude API - Stream response back as Server-Sent Events - Save Q&A to Postgres (async, non-blocking) SSE Events: - "status": Processing status updates - "sources": Referenced document chunks - "token": Streamed text tokens - "done": Completion signal - "error": Error messagesUses HTTP chunked transfer encoding for real-time streaming.
Rate Limiting
- Per-user rate limiting via Redis: 30 requests/minute
- Sliding window algorithm
- Returns 429 Too Many Requests if exceeded
CORS Handling
- Allow requests from Angular dev server + production domains
- Allow credentials for JWT in Authorization header
Dependencies:
github.com/gin-gonic/gin— HTTP routergithub.com/golang-jwt/jwt/v5— JWT parsinggithub.com/lib/pq— Postgres drivergithub.com/redis/go-redis/v9— Redis client- Custom services: Claude, Embedder, VectorDB
Python Worker (Celery + FastAPI)
Purpose: Heavy lifting — document parsing, embeddings, vector storage. Runs as background tasks independent of the synchronous API.
Technology Stack:
- Language: Python 3.13.4
- Task Queue: Celery 5.4
- Message Broker: Redis (via Upstash)
- API Server: FastAPI (for health checks, monitoring)
- Port: 7075 (FastAPI)
Architecture:
The Python layer has two components:
A. FastAPI HTTP Server (Port 7075)
Provides:
GET /health— Kubernetes health checkGET /stats— Queue depth, active workers, worker listPOST /jobs/process— Manual trigger for developmentDELETE /jobs/{doc_id}— Manual vector cleanup
Not the primary way jobs are triggered. Mostly for monitoring and manual intervention.
B. Celery Worker Process
Runs as persistent background process(es). Pulls jobs from Redis queue continuously.
Job Types:
document_processing (Main Task)
Input: ProcessingJob { "job_id": "uuid", "doc_id": "uuid", "project_id": "uuid", "storage_path": "projectId/docId.pdf", "file_type": ".pdf" } Steps: 1. Download file from Supabase Storage 2. Extract text - PDF: PyMuPDF (handles scanned + text PDFs) - DOCX: python-docx (preserves structure) - TXT/MD: Direct decode 3. Chunk text - Split on natural boundaries (paragraphs → sentences → words) - Maintain 50-token overlap for context continuity - Track character positions for source attribution 4. Embed chunks - Batch chunks (max 96 per call) - Call Voyage AI for each batch - Retry with exponential backoff on failure 5. Upsert vectors to Pinecone - Namespace = project_id (multi-tenant isolation) - Metadata: doc_id, doc_name, chunk_index, text, page_number - Text stored in metadata for retrieval (no separate lookup needed) 6. Update document status - Set status = "ready" - Store chunk_count and page_count - Broadcast to Postgres Output: Document marked ready, vectors in Pinecone Retry Policy: 3 retries, exponential backoff (30s → 60s → 120s) Timeout: None (runs as long as needed)document_delete (Cleanup Task)
Input: {"doc_id", "project_id"} Action: - Delete all vectors from Pinecone with matching doc_id - Delete document record from Postgres Used when user deletes a document or admin cleans up.
Celery Configuration:
python
app.conf.update(
task_acks_late=True, # Only ack after task completes
worker_prefetch_multiplier=1, # Process one job per worker at a time
task_reject_on_worker_lost=True, # Requeue if worker crashes
)This ensures at-least-once delivery — if a worker crashes mid-processing, the job goes back to the queue and retries. No lost documents.
Scaling:
- Start with 1 worker (4 concurrent tasks per worker)
- Scale horizontally by running more worker containers
- Each worker can handle 4 documents in parallel
- Queue depth monitoring determines if more workers needed
Data Flow Examples
Flow 1: Document Upload to Ready
User clicks "Upload" → Angular multipart form POST
↓
Go API validates file
- Check JWT token
- Check file type/size
- Verify project access
↓
Upload to Supabase Storage (projectId/docId.pdf)
↓
Create document record in Postgres
INSERT INTO documents (id, project_id, name, status)
VALUES (docId, projectId, 'contract.pdf', 'pending')
↓
Push job to Redis queue
LPUSH document_processing:queue {job_json}
↓
Return 202 Accepted to client
{doc_id: "...", status: "processing"}
↓
[Async] Celery worker polls Redis
↓
Worker pulls job from queue
↓
Download from Supabase Storage
→ file_bytes (50MB PDF in memory)
↓
Extract text with PyMuPDF
→ full_text (2000 pages × ~500 chars/page = 1M chars)
↓
Chunk with LangChain splitter
→ [chunk_1, chunk_2, ..., chunk_2000]
→ 500 tokens per chunk, 50 token overlap
↓
Embed in batches (96 chunks per API call)
→ Voyage AI /v1/embeddings
→ Returns [embedding_1, embedding_2, ...]
→ Each embedding: 1024 dimensions
↓
Upsert to Pinecone (namespace=projectId)
→ id: "docId-chunk-0", vector: [...], metadata: {...}
→ Batch 100 vectors per upsert call
↓
Update Postgres
UPDATE documents SET status='ready', chunk_count=2000
↓
[Frontend polls every 3s]
GET /api/mandates/{projectId}/documents
→ Returns document with status='ready'
↓
UI shows "✓ Ready" badge
User can now ask questionsTotal time: 2–5 minutes for 200-page contract No blocking: User gets 202 immediately, can upload more files while processing
Flow 2: User Asks a Question
User types "What are the payment terms?"
↓
Angular sends POST /api/mandates/{projectId}/ask
Body: {question: "What are the payment terms?"}
Header: Authorization: Bearer {token}
↓
Go API validates JWT, checks project access
↓
Voyage AI embedding
POST https://api.voyageai.com/v1/embeddings
{input: ["What are the payment terms?"], model: "voyage-3"}
← Returns embedding vector (1024 dims)
↓
Query Pinecone (namespace=projectId, top_k=8)
POST https://api.pinecone.io/query
{vector: [...], top_k: 8, namespace: projectId}
← Returns [
{id: "...", score: 0.92, metadata: {text: "Invoices due 30 days...", ...}},
{id: "...", score: 0.88, metadata: {text: "Payment terms: Net 30...", ...}},
...
]
↓
Build prompt context
context = "--- Document: contract.pdf (Section 3) ---\n" +
"Invoices due 30 days from receipt...\n" +
"\n--- Document: contract.pdf (Section 4) ---\n" +
"Payment terms: Net 30...\n"
↓
Set SSE headers (Content-Type: text/event-stream)
Send to client: event: "sources"
data: {sources: [{doc_name: "contract.pdf", excerpt: "..."}]}
↓
Stream Claude API
POST https://api.anthropic.com/v1/messages (stream=true)
{model: "claude-opus-4-6", messages: [...], stream: true}
↓
Claude streams response as SSE events
event: "token"
data: {text: "Based"}
event: "token"
data: {text: " on"}
event: "token"
data: {text: " the"}
[continues until done]
↓
Go API concatenates tokens, accumulates full response
↓
When Claude finishes (event: "message_stop")
Send: event: "done"
data: {message_id: "msg-uuid"}
↓
[Non-blocking] Async background task saves to Postgres
INSERT INTO chat_messages (id, project_id, user_id, question, answer, sources)
VALUES (msg_id, projectId, userId, "...", "...", [...])
↓
Angular receives all events, displays streaming response
1. Shows sources at top
2. Streams text in real-time
3. Marks complete when "done" event arrivesTotal time: 3–8 seconds (streaming in real-time) User experience: See the answer appear word-by-word, like ChatGPT
Database Schema
Core Tables
users
sql
id UUID PRIMARY KEY
email TEXT UNIQUE NOT NULL
name TEXT
created_at TIMESTAMPTZmandates
sql
id UUID PRIMARY KEY
name TEXT NOT NULL
description TEXT
owner_id UUID REFERENCES users(id)
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
INDEX: (owner_id, created_at)documents
sql
id UUID PRIMARY KEY
project_id UUID REFERENCES mandates(id) ON DELETE CASCADE
name TEXT NOT NULL
file_type TEXT (.pdf, .docx, .txt, .md)
storage_path TEXT (mandates/{projectId}/{docId}.ext)
status TEXT (pending, processing, ready, failed)
chunk_count INTEGER (number of vectors in Pinecone)
page_count INTEGER (pages in original document)
size_bytes BIGINT
error TEXT (if status=failed, why?)
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
INDEX: (project_id, status)
RLS: Users can only access docs in their mandateschat_messages
sql
id UUID PRIMARY KEY
project_id UUID REFERENCES mandates(id)
user_id UUID REFERENCES users(id)
question TEXT
answer TEXT
sources JSONB [{doc_id, doc_name, chunk_index, score, excerpt}, ...]
created_at TIMESTAMPTZ
INDEX: (project_id, created_at DESC)
RLS: Users can only access their own messagesRow-Level Security (Supabase)
All tables have RLS policies so users can only see their own data:
sql
-- Example: Users can only see mandates they own
CREATE POLICY "mandates_owner_access" ON mandates
FOR ALL USING (owner_id = auth.uid());
-- Documents: accessible only if in user's project
CREATE POLICY "documents_project_access" ON documents
FOR ALL USING (
project_id IN (
SELECT id FROM mandates WHERE owner_id = auth.uid()
)
);Enforced by Supabase Postgres — no application-level logic needed.
External Service Integrations
Anthropic Claude API
Endpoint: https://api.anthropic.com/v1/messages
Usage Pattern:
Go API → Streaming POST request → SSE events from ClaudeSystem prompt:
You are a precise contract and legal document analyst assistant.
Answer questions accurately based ONLY on provided context.
If information is not in context, say so clearly.
Cite specific sections when possible.Model: claude-opus-4-6 Max tokens: 2048 per response Cost: ~$0.015 per 1K output tokens
Voyage AI
Endpoint: https://api.voyageai.com/v1/embeddings
Usage Pattern:
Python worker → Batch embedding requests (96 texts at a time)
Go API → Query embedding (single question)Model: voyage-3 Embedding dimensions: 1024 Cost: Free tier (check current limits)
Example:
json
POST /v1/embeddings
{
"input": ["What are payment terms?", "..."],
"model": "voyage-3",
"input_type": "query"
}
Response:
{
"data": [
{"embedding": [0.123, 0.456, ...]},
...
]
}Pinecone
Index: contract-qa Dimension: 1024 (matches Voyage AI) Metric: cosine similarity
Data Structure:
Vector ID: "docId-chunk-0"
Values: [1024 floats]
Metadata:
- doc_id: UUID
- doc_name: "contract.pdf"
- project_id: UUID
- chunk_index: 0
- text: Full chunk text (for retrieval)
- page_number: 1
- char_start: 0Namespaces:
- Each project gets its own namespace (isolation)
- Queries restricted to project namespace
Query:
POST /query
{
"vector": [1024 floats],
"topK": 8,
"namespace": "project-uuid",
"includeMetadata": true
}
Response: Top-8 chunks scored by similarity (0.0–1.0)Supabase
Postgres Database:
- Connection pooling via pgBouncer
- Automatic backups
- RLS policies enforced by server
Storage:
- S3-compatible object storage
- PDF/DOCX files stored by project and doc ID
- Path:
documents/projectId/docId.pdf - Public read access via signed URLs (optional)
Error Handling & Resilience
Document Processing Failures
Scenario: Python worker crashes mid-embedding
Celery detects worker death → Job returned to queue
Next available worker picks it up → Retries from beginning
Task tracked by job_id → Idempotent (safe to retry)After 3 failed retries: Mark document as "failed" in Postgres, log error.
Scenario: Voyage AI API rate limit
Embedding call returns 429 (too many requests)
Worker catches exception → Applies exponential backoff
Wait 2^n seconds before retry
After 3 attempts, mark document failed with error messageGo API Failures
Scenario: Pinecone is down
Query to Pinecone fails → Go API returns error response
User sees: "Could not search documents, please try again"
Retry logic in Angular (exponential backoff)Scenario: Claude API timeout
SSE stream breaks → Go API sends event: {type: "error", data: {message: "..."}}
Angular receives error → Shows "Failed to get response"Redis Queue Failures
Scenario: Redis connection lost
Go API tries to push job → Connection error
Return error to user: "Upload failed, please try again"
User retries → Redis back online, job succeedsIdempotent design: If same file uploaded twice, creates two documents (not a problem).
Operational Considerations
Monitoring
Health Checks:
bash
# Go API
curl https://api.yourdomain.com/health
→ {status: "ok"}
# Python FastAPI
curl https://python-api.yourdomain.com/health
→ {status: "ok", worker: "ok"}Metrics to Watch:
- Go API response time (p99, p95, mean)
- Celery queue depth (documents waiting to process)
- Pinecone query latency
- Claude API streaming latency
- Postgres query performance
Logging:
- All errors logged with context (doc_id, user_id, service)
- Structured logging (JSON) for easy aggregation
- Centralized log storage (e.g., Datadog, Sentry)
Scaling
Go API:
- Stateless → Scale horizontally
- Add more containers/machines as request load increases
- Load balancer distributes traffic
Python Worker:
- Queue-driven → Scale by queue depth
- Monitor Redis queue size
- Add workers when queue > threshold (e.g., 100 jobs)
- Scale down when queue clears
Postgres:
- Read replicas for analytics queries
- Connection pooling (pgBouncer)
- Index optimization on (project_id, status) for fast lookups
Pinecone:
- Managed service → No scaling needed
- Upgrade pod type if query latency increases
Deployment Strategies
Local Development
docker-compose up
├── go-api:7070
├── python-api:7075
└── redis:6379Postgres, Pinecone, Claude/Voyage on external services.
UAT Environment
$2.50/month VPS (DigitalOcean/Linode)
├── Docker Compose running all services
├── Nginx reverse proxy
└── Let's Encrypt HTTPSProduction
Fly.io (or Railway)
├── go-api (auto-scaling, 1–5 machines)
├── python-api (always-on, 1–2 machines)
├── celery-worker (scale by queue depth, 1–10 machines)
└── Redis (Upstash managed)Security Considerations
Authentication
- JWT tokens issued by frontend auth (Supabase Auth)
- Validated on every Go API request
- Secrets stored in environment variables
Authorization
- Project-level access control (RLS in Postgres)
- Users can only see their own mandates/documents
- Cross-project access blocked at database level
Data Privacy
- Document content only stored in:
- Supabase Storage (encrypted at rest)
- Pinecone vectors (metadata, not full text)
- Postgres (project isolation)
- No data sent to third parties except:
- Claude API (context for processing)
- Voyage AI (text for embedding)
- Pinecone (vectors)
- All external connections use HTTPS
Rate Limiting
- 30 requests/minute per user (Go API)
- Prevents abuse, keeps costs predictable
Performance Characteristics
Upload Time
- 5–50MB file: 2–5 minutes (depends on size)
- Bottleneck: Embedding API (Voyage AI batch call)
Query Time
- User asks question → Response visible in 3–8 seconds
- Streaming: User sees first token in ~1 second
- Bottleneck: Claude API streaming latency
Concurrency
- Go API: Thousands of concurrent connections (goroutines)
- Python Worker: 4 documents in parallel per worker
- Database: Connection pool of 20–50 connections
Cost Per Document
- Voyage AI embedding: ~$0.01
- Pinecone storage: ~$0.001 (1M vectors = $1/month)
- Claude API queries: ~$0.05 per question
- Total recurring per month: Mostly Claude API usage
Future Enhancements
Short Term
- Caching: Cache frequent questions to reduce Claude API calls
- Async History: Load chat history in background while user types
- Progressive Enhancement: Show results as chunks arrive, not waiting for full context
Medium Term
- Document Summary: Auto-generate executive summaries on upload
- Batch Questions: Process multiple questions in parallel
- Search Index: Full-text search on document content (separate from vectors)
- Document Relationships: Link related documents based on content similarity
Long Term
- Multi-model Support: Support Claude 3.7, GPT-4, etc.
- Custom Embeddings: Fine-tune embeddings for legal/domain-specific language
- Knowledge Graph: Build graph of concepts/obligations across documents
- Compliance Checks: Auto-flag suspicious or unusual contract clauses
Conclusion
This architecture separates concerns efficiently:
- Go handles synchronous, low-latency requests and streaming
- Python handles heavy processing that doesn't need to block
- Redis queues asynchronous work reliably
- Postgres stores structured data with row-level security
- Pinecone indexes vectors for fast semantic search
- Supabase Storage manages files efficiently
- Claude API provides the intelligence
Each component scales independently. The system handles thousands of users, hundreds of documents, and millions of questions — all without overcomplicating the architecture.