Skip to content

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:

  1. Authentication & Authorization

    • JWT token validation on all protected routes
    • Extract user_id from claims
    • Row-level security enforced via Postgres policies
  2. 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 immediately

    Returns immediately without waiting for processing. Processing happens async.

  3. 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 messages

    Uses HTTP chunked transfer encoding for real-time streaming.

  4. Rate Limiting

    • Per-user rate limiting via Redis: 30 requests/minute
    • Sliding window algorithm
    • Returns 429 Too Many Requests if exceeded
  5. CORS Handling

    • Allow requests from Angular dev server + production domains
    • Allow credentials for JWT in Authorization header

Dependencies:

  • github.com/gin-gonic/gin — HTTP router
  • github.com/golang-jwt/jwt/v5 — JWT parsing
  • github.com/lib/pq — Postgres driver
  • github.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 check
  • GET /stats — Queue depth, active workers, worker list
  • POST /jobs/process — Manual trigger for development
  • DELETE /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:

  1. 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)
  2. 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 questions

Total 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 arrives

Total 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  TIMESTAMPTZ

mandates

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 mandates

chat_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 messages

Row-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 Claude

System 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:    0

Namespaces:

  • 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 message

Go 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 succeeds

Idempotent 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:6379

Postgres, 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 HTTPS

Production ​

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.