A full-stack, deployed app for document ingestion, parsing, classification, indexing, and grounded Q&A with citations - containerized for local development and running in production on Render, Vercel, and Chroma Cloud.
Live demo: https://doc-intel-mu.vercel.app
This repository gives you a complete pipeline:
- Upload PDFs, text files, and images
- Extract and normalize content (PyMuPDF text + table extraction)
- Classify documents with Groq (llama-3.1-8b-instant)
- Index chunks for semantic (vector) + lexical (BM25) retrieval
- Ask questions and get answers with inline citations and page previews
- FastAPI backend with clear route separation
- Next.js 14 frontend with upload, chat, and document preview workflows
- Chroma Cloud vector store (persistent, not tied to a single server's disk) plus BM25 keyword retrieval, fused via Reciprocal Rank Fusion
- Postgres-backed metadata - document status, classification, and chunk counts persist independently of any single server instance
- Backblaze B2 object storage - uploaded originals and rendered page images persist independently of any single server's local disk, surviving redeploys
- JWT authentication - email/password accounts with bcrypt-hashed passwords; uploads are scoped per-user all the way through retrieval (chat only ever surfaces a user's own documents plus public/demo documents), while anonymous visitors can still use the public demo set with no login
- Redis-backed job queue (arq) - document processing runs in a separate worker process so an in-flight upload survives an API server restart/redeploy, rather than being silently lost like with in-process background tasks (fully implemented and tested locally; see Roadmap for production status)
- Dockerized backend + frontend with
docker-composefor one-command local orchestration matching production architecture - Streaming (SSE) and non-streaming chat endpoints
- Deployed end-to-end: FastAPI on Render, Next.js on Vercel, vectors on Chroma Cloud, metadata on Render Postgres, files on Backblaze B2
Use this section for quick visual orientation when you share the project.
Suggested captures:
- Landing page (
frontend/app/page.tsx) - Upload workflow with progress states
- Chat response showing inline citations
- Document preview modal with page navigation
flowchart LR
U[Browser] -->|JWT| A[Frontend Next.js - Vercel]
A --> B[FastAPI Routers - Render]
B --> AUTH[Auth: JWT + bcrypt]
B --> C[Upload Pipeline]
C -->|enqueue| Q[(Redis - arq)]
Q --> W[Worker process]
W --> D[Parser - PyMuPDF]
W --> E[Classifier - Groq LLM]
D --> F[Chunking + ONNX Embeddings]
F --> G[Chroma Cloud]
F --> H[BM25 Index]
W --> P[(Postgres - Render)]
W --> S[(Backblaze B2)]
A --> I[Chat API]
I --> J[RAG Agent]
J -->|scoped by user_id| G
J --> H
J --> A
Local dev runs the full diagram above, including the Redis/worker path. Production currently skips the Redis/worker box — uploads there fail gracefully with a clear message instead (see Roadmap).
Deployment topology:
| Layer | Local Dev | Production |
|---|---|---|
| Frontend | Docker container (localhost:3000) |
Vercel |
| Backend | Docker container (localhost:8000) |
Render Web Service |
| Metadata DB | Docker Postgres container | Render Managed Postgres |
| Vector store | Chroma Cloud (shared with prod) | Chroma Cloud |
| File storage | Backblaze B2 (shared with prod) | Backblaze B2 |
| Job queue | Redis + arq worker (Docker containers) | Not provisioned - uploads return a clear 503 (see Roadmap) |
backend/
main.py FastAPI app entrypoint
worker.py arq worker settings/entrypoint (separate process from the API)
tasks.py Background job functions run by the worker (parse->classify->index->persist)
Dockerfile Backend/worker container build (shared image, different start command)
limiter.py Shared rate limiter
requirements.txt Python dependencies
models/
document.py Request/response Pydantic models
db.py SQLAlchemy models (User, Document) + Postgres session/engine
routers/ upload, chat, documents, auth endpoints
services/
parser.py PDF/text parsing (PyMuPDF)
classifier.py LLM-based document classification
embedder.py ONNX embedding function (Chroma's built-in MiniLM)
vector_store.py Chroma Cloud client + hybrid search (auth-scoped via doc_id filter)
document_repo.py Postgres data-access layer (documents + users)
object_storage.py Backblaze B2 (S3-compatible) client for files + page images
job_queue.py arq/Redis pool + job enqueue helper, used by the API process
rag_agent.py Retrieval orchestration + answer synthesis (auth-scoped retrieval)
bm25_index.py In-memory BM25 keyword index
reranker.py Cross-encoder re-ranking
auth.py Password hashing (bcrypt) + JWT create/decode
auth_deps.py FastAPI dependencies for required/optional current-user extraction
sample_docs/ Sample documents for demoing
storage/ Local scratch space during processing (uploaded files and
rendered page images are persisted to Backblaze B2 after
successful indexing, then removed from local disk)
frontend/
Dockerfile Frontend container build
package.json Next scripts and deps
app/ App Router pages (including login/, register/)
components/ Reusable UI modules
context/
AuthContext.tsx React context: current user, login/register/logout
lib/ API client (JWT auto-attached via Axios interceptor) and helpers
docker-compose.yml Orchestrates backend + worker + frontend + postgres + redis locally
- Backend: FastAPI, Uvicorn, Pydantic, SlowAPI, SQLAlchemy
- Auth: JWT (python-jose), bcrypt password hashing (passlib)
- Job queue: arq (Redis-backed async job queue) - separate worker process from the API
- Parsing: PyMuPDF, pdfplumber (table extraction)
- Retrieval: Chroma Cloud (ONNX MiniLM embeddings), BM25, cross-encoder re-ranking
- Metadata storage: PostgreSQL (Render Managed Postgres in prod, Dockerized Postgres locally)
- File storage: Backblaze B2 (S3-compatible, via
boto3) - LLM: Groq (
groqSDK) - llama-3.3-70b-versatile (RAG) + llama-3.1-8b-instant (classification) - Frontend: Next.js 14, React 18, TypeScript, Tailwind CSS
- Infra: Docker, docker-compose, Render, Vercel, Chroma Cloud, Backblaze B2, Redis
- Docker Desktop (recommended path - see Quickstart below)
- Or, for a non-Docker setup: Python 3.11+, Node.js 18+
- Groq API key (free at https://console.groq.com)
- Chroma Cloud account (free tier - https://trychroma.com)
- Backblaze B2 account (free tier, 10GB - https://www.backblaze.com/sign-up/cloud-storage)
This is the fastest way to get the full stack running locally, identical to production architecture.
git clone https://github.com/ankitnegi-dev/DocIntel.git
cd DocIntelCreate backend/.env:
GROQ_API_KEY=your_groq_api_key_here
CHROMA_API_KEY=your_chroma_cloud_api_key
CHROMA_TENANT=your_chroma_tenant_id
CHROMA_DATABASE=your_chroma_database_name
B2_ENDPOINT_URL=https://s3.<region>.backblazeb2.com
B2_ACCESS_KEY_ID=your_b2_key_id
B2_SECRET_ACCESS_KEY=your_b2_application_key
B2_BUCKET_NAME=your_b2_bucket_name
JWT_SECRET_KEY=generate_a_random_64_char_hex_string
ALLOWED_ORIGINS=http://localhost:3000
STORAGE_DIR=storage
SAMPLE_DOCS_DIR=sample_docsGenerate a JWT_SECRET_KEY with: python -c "import secrets; print(secrets.token_hex(32))"
Then:
docker-compose up --buildThis starts five containers:
docintel-postgres- Postgres 16, healthcheck-gateddocintel-redis- Redis 7, backs the job queuedocintel-backend- FastAPI on:8000, waits for Postgres + Redis to be healthy, auto-creates tables on startupdocintel-worker- arq worker process; runs the actual parse->classify->index->persist pipeline, separate from the API processdocintel-frontend- Next.js on:3000
Open:
- Frontend: http://localhost:3000
- Backend docs: http://localhost:8000/docs
- Backend health: http://localhost:8000/health
cd backend
python -m venv .venv
powershell -ExecutionPolicy Bypass -File .venv\Scripts\Activate.ps1 # Windows
pip install -r requirements.txt
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reloadWithout a DATABASE_URL set, the backend falls back to a local SQLite file (local_fallback.db) so it still runs - but production and Docker both use Postgres.
cd frontend
npm install
npm run devIf PowerShell blocks npm.ps1, use npm.cmd install / npm.cmd run dev instead.
- Start the stack (
docker-compose up --build, or both servers individually). - Register an account at
/register, or log in if you already have one. - Open the Upload page and upload one of
backend/sample_docs/*. The Processing Queue shows Queued, then flips to Indexed once the worker finishes (see Notes for Maintainers on the live-progress trade-off). - Open the Chat page and ask a question about the uploaded document - while logged in, this checks your own documents plus the public/demo set; log out and the same question should no longer find your private upload.
- Confirm the response contains inline citations like
[DocName, Page N]with a page thumbnail.
GROQ_API_KEY=your_groq_api_key_here
CHROMA_API_KEY=your_chroma_cloud_api_key
CHROMA_TENANT=your_chroma_tenant_id
CHROMA_DATABASE=your_chroma_database_name
B2_ENDPOINT_URL=https://s3.<region>.backblazeb2.com
B2_ACCESS_KEY_ID=your_b2_key_id
B2_SECRET_ACCESS_KEY=your_b2_application_key
B2_BUCKET_NAME=your_b2_bucket_name
JWT_SECRET_KEY=generate_a_random_64_char_hex_string
ALLOWED_ORIGINS=http://localhost:3000
MAX_FILE_SIZE_MB=20
STORAGE_DIR=storage
SAMPLE_DOCS_DIR=sample_docs
# Optional - only needed outside Docker/Render where it's injected automatically:
# DATABASE_URL=postgresql://user:password@host:5432/dbname
# REDIS_URL=redis://localhost:6379Get a free Groq API key at https://console.groq.com -> API Keys -> Create API Key.
Get free Chroma Cloud credentials at https://trychroma.com -- create a project, then generate an API key from the Settings page.
Get free Backblaze B2 credentials at https://www.backblaze.com -- create a private bucket, then generate an Application Key scoped to that bucket.
Generate JWT_SECRET_KEY with python -c "import secrets; print(secrets.token_hex(32))" -- use a different value per environment (local vs. production).
NEXT_PUBLIC_API_URL=http://localhost:8000| Method | Endpoint | Purpose |
|---|---|---|
| POST | /auth/register |
Create a new account, returns a JWT |
| POST | /auth/login |
Log in, returns a JWT |
| GET | /auth/me |
Get the current logged-in user (requires token) |
| POST | /upload |
Upload one document (requires login) |
| POST | /bulk-upload |
Upload multiple documents (requires login) |
| GET | /status/{doc_id} |
Check processing status |
| POST | /chat |
Ask question and get full response (works logged-out on public docs; scoped to own+public docs when logged in) |
| POST | /chat/stream |
Stream response with SSE (same auth-aware scoping as /chat) |
| GET | /documents |
List documents visible to the caller (own + public, or public only if logged out) |
| GET | /documents/{doc_id} |
Get document metadata (must be public or owned by caller) |
| DELETE | /documents/{doc_id} |
Delete document (owner only; Postgres row + Chroma vectors + B2 files) |
| POST | /documents/{doc_id}/reindex |
Re-index document (owner only) |
| GET | /page-image |
Get rendered page image |
| GET | /health |
Service health and Chroma index count |
- Uses SHA-256 content hashing for safe, deduplicated storage
- Validates extension, Content-Type, and byte-level MIME when available
- Scans PDFs for suspicious markers before processing
- Documents with zero extractable text (e.g. scanned PDFs without OCR support) are correctly marked
status: errorwith a clear reason, rather than falsely reporting success
- Email/password accounts, bcrypt-hashed passwords, JWT access tokens (7-day expiry)
- Uploading requires login. Every document has a nullable
user_id:NULLmeans public/demo, otherwise it's owned by that user /documents,/documents/{id},/page-image, and/chatuse optional auth -- anonymous visitors can browse and ask questions about the public/demo document set with no login; logged-in users additionally see and can query their own private documentsDELETEand/reindexrequire login and ownership (or the document being public)- Retrieval is scoped at the vector-search layer, not just the listing layer:
rag_agent.pyasks Postgres for the caller's visibledoc_ids and passes that as a Chromawherefilter plus a BM25 post-filter, so a logged-out or different user genuinely cannot get an answer grounded in someone else's private document through chat -- this was verified directly (same question, with and without a token, against a private document) rather than assumed
- Uploads are enqueued as
arqjobs (Redis-backed) and processed by a separate worker process (worker.py/tasks.py), not by the API process'sBackgroundTasks - This means an in-flight upload survives an API server restart or redeploy -- verified by restarting the backend mid-processing and confirming the worker completed the job independently and Postgres reflected the correct final state
- Trade-off: because processing now happens out-of-process, the fine-grained live progress the old in-process approach had (parsing 20% -> classifying 50% -> indexing 75%) is no longer tracked live. Status now reads
queued->indexed/error, sourced from Postgres. Durability was prioritized over granular live progress; see Roadmap for the frontend polish this leaves open
- All document metadata (filename, status, classification, chunk count, error messages) is stored in Postgres, not flat JSON files
- A
services/document_repo.pyrepository layer mediates all reads/writes -- routers never touch SQLAlchemy directly - Postgres tables are auto-created on startup via
init_db()-- safe to call on every boot
- Local disk (
storage/uploads/,storage/pages/) is treated as scratch space only during processing -- the parser needs local file paths, so files land there first - After successful indexing, the original file and all rendered page images are uploaded to Backblaze B2 (S3-compatible) via
services/object_storage.py, then deleted from local disk /page-imagetries B2 first and falls back to local disk, so documents indexed before this migration still work- This design means
parser.pyneeded no changes -- B2 wraps around the existing local-disk-based parsing logic rather than replacing it
- Semantic retrieval from Chroma Cloud (ONNX MiniLM embeddings -- no GPU/torch dependency, keeps the backend lightweight enough for free-tier hosting)
- BM25 lexical retrieval complement, fused via Reciprocal Rank Fusion
- Cross-encoder reranking for final context quality
- Inline citation extraction and page linkage
- Relevance threshold tuned empirically against Chroma Cloud's distance scale (see Notes for Maintainers)
- Upload flow with per-file progress states (Upload -> Parse -> Classify -> Index -> Done)
- Chat with citation cards, page thumbnails, and follow-up prompts
- Document sidebar with delete, reindex, and preview actions
- Markdown export for chat transcripts
- Rate limits applied to upload and chat routes
- File size constraints enforced during upload
- Page image endpoint validates
doc_idand resolved path (prevents directory traversal) - Stored files use SHA-256 hashed filenames -- original filenames never touch the filesystem path
- Security headers added on backend responses
- CORS behavior controlled by environment settings
Known limitations / what's next (see Roadmap):
- Production has no Redis/worker provisioned -- uploads in production return a clear
503explaining the job queue isn't available there, rather than hanging or crashing (this was a deliberate cost decision, not an oversight; see Roadmap) - The frontend upload UI still expects granular live-progress stages that the job-queue architecture no longer produces -- functionally the upload completes correctly, but the "Waiting to start..." state can persist longer than expected before flipping to done
- Background jobs run at
max_jobs = 4concurrency on a single local worker; no horizontal scaling or dead-letter/retry handling beyond arq's defaults
Docker Desktop isn't running. Launch it from the Start Menu and wait for the tray icon to settle before running docker-compose commands.
Check docker-compose logs backend --tail 50. The most common cause during development is a Python IndentationError/TabError from mixed tabs/spaces after a manual edit -- open the file in VS Code and run "Convert Indentation to Spaces".
Use npm.cmd:
npm.cmd install
npm.cmd run dev- Ensure backend is running and reachable on the port
NEXT_PUBLIC_API_URLpoints to - Confirm CORS (
ALLOWED_ORIGINS) allows the frontend's origin - In Docker, the frontend talks to the backend via
http://localhost:8000from the browser (not the Docker network alias), sinceNEXT_PUBLIC_API_URLis baked in at build time for the browser to use directly
Check chunk_count for that document via /documents or directly in Postgres -- if it's 0, the document had no extractable text (commonly a scanned PDF; OCR support was removed to keep the backend within free-tier memory limits -- see Notes for Maintainers).
Render's free tier caps at 512MB RAM. Avoid loading heavy ML libraries (torch, sentence-transformers, easyocr) at import time or on startup -- load them lazily on first use, or avoid them entirely (this project uses Chroma's built-in ONNX embedding function instead of sentence-transformers for this reason).
This is almost always the ONNX embedding model re-downloading (~80MB from Chroma's CDN) rather than an actual B2/network failure -- check docker-compose logs worker for an onnx.tar.gz download progress line (the model loads in the worker container, not the backend, since that's where indexing runs). In Docker, make sure the onnx_cache volume is mounted to /root/.cache so the model persists across container rebuilds instead of re-downloading every time.
Uploading requires login. Register/log in via /register or /login, or check that the frontend is sending the Authorization: Bearer <token> header (the Axios interceptor in lib/api.ts does this automatically once a token is stored). /chat and /documents work without login too, but only against public/demo documents.
Expected in production, where no Redis instance is provisioned (see Roadmap). Locally, this means the redis container isn't running or REDIS_URL isn't reaching it -- check docker-compose ps and docker-compose logs redis.
Check docker-compose logs worker (or the Render worker service logs, if one is provisioned) -- the API process only enqueues the job, it doesn't process it. If the worker container isn't running, jobs will sit in Redis indefinitely.
This project is deployed across three platforms:
- Root Directory:
backend - Build Command:
pip install -r requirements.txt - Start Command:
uvicorn main:app --host 0.0.0.0 --port $PORT - Environment variables:
GROQ_API_KEY,CHROMA_API_KEY,CHROMA_TENANT,CHROMA_DATABASE,B2_ENDPOINT_URL,B2_ACCESS_KEY_ID,B2_SECRET_ACCESS_KEY,B2_BUCKET_NAME,JWT_SECRET_KEY,ALLOWED_ORIGINS,DATABASE_URL(auto-populated by linking a Render Postgres instance) - A separate Render PostgreSQL instance provides
DATABASE_URLvia its Internal Database URL - No
REDIS_URLis currently set -- production does not have a Redis/worker provisioned (see Roadmap). Uploads return a503with a clear explanation rather than failing silently or crashing.
- Root Directory:
frontend - Environment variables:
NEXT_PUBLIC_API_URLpointing to the Render backend URL
- Free-tier hosted vector database, shared between local Docker dev and production so indexed documents persist across redeploys and across environments
- Free-tier S3-compatible object storage (10GB), shared between local Docker dev and production
- Original files and rendered page images are written here after successful indexing; local disk is scratch space only
- Redis + a dedicated worker service were built, deployed, and verified working on Render (a paid Key Value instance plus a free Background Worker service), then deliberately torn down after confirming the local implementation was solid -- the ongoing $7/month cost wasn't justified for a portfolio project at this stage
- The production API degrades gracefully:
services/job_queue.pycatches the Redis connection failure androuters/upload.pyreturns a503with a clear message, instead of hanging or throwing an unhandled exception - Re-provisioning production support is a config-only change (create a Render Key Value instance + a Background Worker service running
arq worker.WorkerSettings, addREDIS_URLto both) -- no code changes needed
- Secrets via environment variables (never committed)
- Explicit CORS origin handling
- Persistent vector storage (Chroma Cloud) surviving redeploys
- Persistent metadata storage (Postgres) surviving redeploys
- Persistent file storage (Backblaze B2) surviving redeploys
- User authentication and per-user document scoping (enforced through retrieval, not just listing)
- Background job queue implemented and tested for restart-durability (local only; graceful degradation in production)
- Structured logging / error monitoring (e.g. Sentry)
- Automated tests + CI
This project is being actively extended beyond the original assignment scope as a learning exercise in production-grade system design:
- Dockerize backend + frontend, orchestrate with
docker-compose - Migrate vector storage from local ChromaDB to Chroma Cloud
- Migrate document metadata from JSON files to Postgres (local + Render)
- Migrate file storage (uploads, page images) to Backblaze B2 (S3-compatible)
- Add authentication and per-user document scoping (JWT + bcrypt; enforced through chat retrieval, not just document listing)
- Replace synchronous background tasks with a real job queue (arq + Redis) -- built, deployed, and verified on Render, then scoped back to local-only after weighing the ongoing cost against project stage
- Provision production Redis + worker again once there's a concrete reason to (e.g. real users, or as a paid-tier demo)
- Restore granular live upload progress in the frontend (currently shows queued/indexed only, since progress tracking moved out-of-process with the worker)
- Add a retrieval evaluation harness (precision/recall against a labeled Q&A set)
- Add automated tests and CI
Docker (recommended):
docker-compose up --build # build + start everything (postgres, redis, backend, worker, frontend)
docker-compose down # stop everything
docker-compose logs backend # tail API logs
docker-compose logs worker # tail background job processing logs
docker exec -it docintel-postgres psql -U docintel -d docintel # connect to local PostgresBackend (without Docker):
cd backend
python -m uvicorn main:app --reload --port 8000
# In a separate terminal, run the worker (requires a local Redis):
arq worker.WorkerSettingsFrontend (without Docker):
cd frontend
npm run dev
npm run build
npm run lintbackend/main.pyno longer auto-indexes sample documents or warms up the embedding model on startup -- both were removed after causing out-of-memory crashes on Render's free tier. Usebackend/create_samples.pyor the/uploadendpoint to seed sample documents instead.backend/services/embedder.pyuses Chroma's built-inONNXMiniLM_L6_V2embedding function rather thansentence-transformers, specifically to avoid pulling intorch(~1.5GB), which does not fit in Render's 512MB free-tier memory limit.RELEVANCE_THRESHOLDinbackend/services/rag_agent.pywas empirically retuned after migrating to Chroma Cloud +chromadb==1.5.9, since the cosine-distance scale shifted compared to the original local ChromaDB 0.5.x setup. If retrieval quality seems off after a ChromaDB version bump, check this value against real logged distances before assuming a code bug.backend/routers/upload.pyandbackend/routers/documents.pyare fully migrated to Postgres viaservices/document_repo.py.backend/create_samples.pystill writes to the legacy JSON metadata format -- it is a standalone seeding script, not part of the live request path, so this inconsistency is tracked but not urgent.- File persistence to Backblaze B2 happens in
tasks.py(moved there fromupload.pywhen processing became a separate worker job). It is best-effort: a B2 failure logs a warning but does not fail the job, since the document is already indexed and usable from Postgres + Chroma Cloud alone. - The ONNX embedding model (~80MB) is downloaded on first use and cached at
/root/.cache/chroma/onnx_models/. Indocker-compose.yml, this path is mounted to a named volume (onnx_cache) on both thebackendandworkerservices, since indexing (and therefore the model load) now happens in the worker container. RELEVANCE_THRESHOLDinbackend/services/rag_agent.pywas empirically retuned after migrating to Chroma Cloud +chromadb==1.5.9, since the cosine-distance scale shifted compared to the original local ChromaDB 0.5.x setup. If retrieval quality seems off after a ChromaDB version bump, check this value against real logged distances before assuming a code bug.services/auth_deps.pyprovides two dependencies:get_current_user_required(401s if no valid token) andget_current_user_optional(returnsNoneinstead of raising). Routes that should work for both anonymous and logged-in users --/documents,/documents/{id},/page-image,/chat,/chat/stream-- use the optional variant and then filter byuser_idthemselves.- Auth-scoped retrieval is implemented at the vector-search layer (
vector_store.search(..., doc_ids=...)builds a Chromawhere: {"doc_id": {"$in": [...]}}filter) plus a BM25 post-filter inrag_agent._hybrid_search, not just at the document-listing layer. This was a deliberate choice: filtering only in/documentswould have left/chatable to answer questions using content from documents the caller can't even see listed. worker.py/tasks.pyrun as a separate Docker service (docintel-workerlocally) from the API (docintel-backend), sharing the same image but with a different start command (arq worker.WorkerSettingsvsuvicorn main:app). This is intentional -- an API process restart (e.g. on every Render redeploy) does not affect an in-flight job, because the job lives in Redis and the worker is a wholly separate process.frontend/lib/api.tsis the central API client used by UI components; it attaches the JWT to every request via an Axios request interceptor, so individual API functions don't need to handle auth headers manually.
No license file is currently included in this repository. Add one before public distribution.