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.