0
stars
16
commits
JavaScript
primary language
Aug 22, 2026
updated
Upload a PDF and chat with it through an agentic Retrieval-Augmented Generation (RAG) pipeline. DocuMind grounds every answer in your document with citations, falls back to web search when the PDF doesn't have the answer, self-corrects when retrieval is weak, and caches by meaning so repeat questions return instantly.
LangGraph ยท Qdrant ยท local embeddings (no embedding API key) ยท OpenRouter ยท Langfuse
userId + pdfId; all retrieval, caching, and history reads are filtered so users only touch their own PDFs.PROCESSING โ READY / FAILED) tracked in Postgres with a failure reason.| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite 5, Tailwind CSS, Clerk, react-markdown, sonner |
| Backend | Node.js (ESM) + Express 5 |
| Agent | LangGraph StateGraph + LangChain |
| LLM | OpenRouter (default meta-llama/llama-3.1-8b-instruct, configurable) |
| Embeddings | Local on-device Xenova/all-MiniLM-L6-v2 โ 384-dim, no API key |
| Reranker | Local cross-encoder Xenova/ms-marco-MiniLM-L-6-v2 |
| Retrieval | Qdrant (dense) + in-process BM25 (sparse) + RRF + cross-encoder rerank |
| Web search | Tavily if TAVILY_API_KEY is set, else keyless DuckDuckGo |
| Cache | Semantic cache in Qdrant (qa_cache_local) |
| Queue | BullMQ + Valkey/Redis |
| Database | PostgreSQL + Prisma |
| File storage | AWS S3 (required) |
| Observability | Langfuse |
| Infra | Docker Compose |
Client โ POST /upload/pdf (auth, 5/min, โค15MB, PDF-only)
โ upload buffer to S3 (uploads/<userId>/<uuid>-<name>.pdf)
โ create Pdf(status=PROCESSING) in Postgres
โ enqueue job on "file-upload-queue" (BullMQ / Valkey)
โ
Worker (concurrency 5)
โ download from S3 to temp โ PDFLoader โ split (1000 chars / 200 overlap)
โ reject scanned/image-only PDFs (no OCR yet) โ status=FAILED + reason
โ tag every chunk {userId, pdfId} โ embed locally (MiniLM)
โ upsert to Qdrant in batches of 50 โ status=READY โ delete temp file
Client โ POST /chat/stream {query, pdfId} (auth, 20/min) โโ streams SSE โโโบ
โโโโโโโโโโโโโโโโโโโโโโโ Semantic Cache โโโโโโโโโโโโโโโโโโโโโโโโ
โ embed(query) โ search qa_cache_local (filtered userId+pdfId) โ
โ score โฅ 0.85 & within TTL โ return cached answer โก โ
โ miss / error (fails open) โ run the agent โผ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
LangGraph agent:
rewrite โโบ retrieve (dense + BM25 โ RRF โ rerank, top 4)
โโบ grade relevance (LLM yes/no)
โโ relevant โโบ generate (cited) [document]
โโ weak & tries < 2 โโบ rewrite & retry โบ
โโ still weak โโบ webSearch โโบ generate [web]
โโ no results โโบ noAnswer
โ cache the answer (skipped when noAnswer)
โ persist USER + AI turns to Postgres
โ trace the run to Langfuse
pdf_embeddings_local, filtered by metadata.userId + metadata.pdfId (payload indexes created on demand โ Qdrant Cloud requires them to filter). Pulls a 10-doc candidate pool.k1=1.5, b=0.75) computed in-process over all chunks of that PDF, scrolled from Qdrant in pages of 128.k=60) merges both ranked lists, deduping on a content prefix. Falls back to the dense list if fusion yields nothing.Document relevance shown in the UI is the rerank logit squashed through a sigmoid.
POST /chat/stream emits named events:
| Event | Payload | Meaning |
|---|---|---|
trace | {step, detail} | one agent reasoning step |
token | {token} | answer token from the generate node |
final | {answer, sources, sourceType, cached} | complete result |
done | {} | stream finished |
error | {error} | failure mid-stream |
Turns are persisted once (idempotent saved guard) โ including when the client disconnects mid-generation or the stream errors, so a stopped generation is never lost.
User 1โโโn Pdf 1โโโn Chat
โโ status: PROCESSING | READY | FAILED (+ statusMessage)
โโโโn Chat role: USER | AI, sources Json?
All relations cascade on delete. Deleting a PDF also removes its Qdrant vectors and the S3 object.
docker compose up -d
| Service | Port |
|---|---|
| Postgres | 5432 (admin / secret / pdf_rag) |
| Qdrant | 6333 |
| Valkey (Redis) | 6379 |
| Adminer (DB UI) | 8080 |
cd server
cp .env.example .env # see configuration below โ S3 + Clerk + OpenRouter are required
npm install
npm run prisma:generate
npm run prisma:migrate
npm start # API on :8000
npm run start:worker # ingestion worker (separate terminal)
# or run both together:
npm run start:all
The API throws at boot if S3 isn't configured, and the worker throws if
OPENROUTER_API_KEY,QDRANT_URL, orDATABASE_URLare missing โ fail fast instead of failing mid-upload.
First run downloads the embedding + reranker models (~120MB) to a local cache; startup is slower once.
cd frontend
cp .env.example .env # VITE_CLERK_PUBLISHABLE_KEY + VITE_API_URL
npm install
npm run dev # app on :5173
cd server
npm test # Jest (ESM) โ rate limiter + request validation
npm run eval # LLM-as-judge faithfulness / relevance
For npm run eval, set EVAL_USER_ID and EVAL_PDF_ID in .env to an already-ingested document.
server/.env)Required
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY | LLM access |
DATABASE_URL | PostgreSQL connection string |
QDRANT_URL | Vector DB URL (http://localhost:6333 locally) |
CLERK_SECRET_KEY | Verifies API Bearer tokens |
CLERK_WEBHOOK_SECRET | Verifies the Svix-signed user-sync webhook |
AWS_REGION, S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | PDF storage (all four needed) |
Optional
| Variable | Purpose | Default |
|---|---|---|
OPENROUTER_MODEL | Chat model | meta-llama/llama-3.1-8b-instruct |
QDRANT_API_KEY | For Qdrant Cloud | โ |
REDIS_HOST, REDIS_PORT | Queue + rate-limit backend | localhost, 6379 |
REDIS_USERNAME, REDIS_PASSWORD | Redis auth (managed Redis / Valkey) | โ |
TAVILY_API_KEY | Better web search (else DuckDuckGo) | โ |
CORS_ORIGINS | Comma-separated allowlist | http://localhost:5173,http://127.0.0.1:5173 |
PORT | API port | 8000 |
SEMANTIC_CACHE_ENABLED | Toggle the semantic cache | true |
SEMANTIC_CACHE_THRESHOLD | Min cosine similarity for a hit | 0.85 |
SEMANTIC_CACHE_TTL_HOURS | Ignore entries older than this | 168 |
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASEURL | Observability | disabled |
All routes except / and /webhook/clerk require Authorization: Bearer <clerk-token>.
| Method | Route | Description | Limit |
|---|---|---|---|
GET | / | Health check | โ |
POST | /upload/pdf | Upload a PDF (multipart pdf, โค15MB), queues ingestion | 5/min |
GET | /pdfs | List the current user's PDFs | 120/min |
GET | /pdfs/:pdfId/file | Stream the PDF back for in-app viewing | 120/min |
DELETE | /pdfs/:pdfId | Delete PDF + its vectors + its S3 object | 5/min |
POST | /chat | Ask {query, pdfId} (non-streaming) | 20/min |
POST | /chat/stream | Ask {query, pdfId}, stream trace + tokens (SSE) | 20/min |
GET | /chat/:pdfId | Full chat history for a PDF | 120/min |
POST | /webhook/clerk | Clerk user-sync webhook (Svix-verified raw body) | โ |
Chat returns 409 while a PDF is still PROCESSING, and 404 if the PDF isn't yours.
server/
index.js Express app, routes, CORS, rate limits, error handling
worker.js BullMQ ingestion worker
start-all.js runs API + worker in one process
controllers/ pdf, chat (incl. SSE), clerk webhook
lib/
agent.js LangGraph state machine (the agentic RAG loop)
ai.js embeddings, chat model, hybrid retrieval, BM25, RRF
rerank.js local cross-encoder reranker
semanticCache.js meaning-based answer cache in Qdrant
webSearch.js Tavily โ DuckDuckGo fallback
rateLimit.js Redis Lua fixed-window limiter
observability.js Langfuse tracing
auth.js db.js redis.js s3.js validate.js
prisma/ schema + migrations
eval/evaluate.js LLM-as-judge eval harness
tests/ Jest tests
frontend/src/
App.jsx layout, PDF list, upload, auth gate
components/ ChatArea (SSE + trace UI), FileUpload, PdfViewer, โฆ
lib/api.js axios client + Clerk token injection
โก CACHE HIT (similarity 0.9xx) โ skipped RAG+LLM pipeline.pdfId โ 404, never a leaked chunk.FAILED reason.16 commits
JavaScript
97.5%
CSS
1.4%
HTML
1.1%
0
stars
16
commits
JavaScript
primary language
Aug 22, 2026
updated
Upload a PDF and chat with it through an agentic Retrieval-Augmented Generation (RAG) pipeline. DocuMind grounds every answer in your document with citations, falls back to web search when the PDF doesn't have the answer, self-corrects when retrieval is weak, and caches by meaning so repeat questions return instantly.
LangGraph ยท Qdrant ยท local embeddings (no embedding API key) ยท OpenRouter ยท Langfuse
userId + pdfId; all retrieval, caching, and history reads are filtered so users only touch their own PDFs.PROCESSING โ READY / FAILED) tracked in Postgres with a failure reason.| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite 5, Tailwind CSS, Clerk, react-markdown, sonner |
| Backend | Node.js (ESM) + Express 5 |
| Agent | LangGraph StateGraph + LangChain |
| LLM | OpenRouter (default meta-llama/llama-3.1-8b-instruct, configurable) |
| Embeddings | Local on-device Xenova/all-MiniLM-L6-v2 โ 384-dim, no API key |
| Reranker | Local cross-encoder Xenova/ms-marco-MiniLM-L-6-v2 |
| Retrieval | Qdrant (dense) + in-process BM25 (sparse) + RRF + cross-encoder rerank |
| Web search | Tavily if TAVILY_API_KEY is set, else keyless DuckDuckGo |
| Cache | Semantic cache in Qdrant (qa_cache_local) |
| Queue | BullMQ + Valkey/Redis |
| Database | PostgreSQL + Prisma |
| File storage | AWS S3 (required) |
| Observability | Langfuse |
| Infra | Docker Compose |
Client โ POST /upload/pdf (auth, 5/min, โค15MB, PDF-only)
โ upload buffer to S3 (uploads/<userId>/<uuid>-<name>.pdf)
โ create Pdf(status=PROCESSING) in Postgres
โ enqueue job on "file-upload-queue" (BullMQ / Valkey)
โ
Worker (concurrency 5)
โ download from S3 to temp โ PDFLoader โ split (1000 chars / 200 overlap)
โ reject scanned/image-only PDFs (no OCR yet) โ status=FAILED + reason
โ tag every chunk {userId, pdfId} โ embed locally (MiniLM)
โ upsert to Qdrant in batches of 50 โ status=READY โ delete temp file
Client โ POST /chat/stream {query, pdfId} (auth, 20/min) โโ streams SSE โโโบ
โโโโโโโโโโโโโโโโโโโโโโโ Semantic Cache โโโโโโโโโโโโโโโโโโโโโโโโ
โ embed(query) โ search qa_cache_local (filtered userId+pdfId) โ
โ score โฅ 0.85 & within TTL โ return cached answer โก โ
โ miss / error (fails open) โ run the agent โผ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
LangGraph agent:
rewrite โโบ retrieve (dense + BM25 โ RRF โ rerank, top 4)
โโบ grade relevance (LLM yes/no)
โโ relevant โโบ generate (cited) [document]
โโ weak & tries < 2 โโบ rewrite & retry โบ
โโ still weak โโบ webSearch โโบ generate [web]
โโ no results โโบ noAnswer
โ cache the answer (skipped when noAnswer)
โ persist USER + AI turns to Postgres
โ trace the run to Langfuse
pdf_embeddings_local, filtered by metadata.userId + metadata.pdfId (payload indexes created on demand โ Qdrant Cloud requires them to filter). Pulls a 10-doc candidate pool.k1=1.5, b=0.75) computed in-process over all chunks of that PDF, scrolled from Qdrant in pages of 128.k=60) merges both ranked lists, deduping on a content prefix. Falls back to the dense list if fusion yields nothing.Document relevance shown in the UI is the rerank logit squashed through a sigmoid.
POST /chat/stream emits named events:
| Event | Payload | Meaning |
|---|---|---|
trace | {step, detail} | one agent reasoning step |
token | {token} | answer token from the generate node |
final | {answer, sources, sourceType, cached} | complete result |
done | {} | stream finished |
error | {error} | failure mid-stream |
Turns are persisted once (idempotent saved guard) โ including when the client disconnects mid-generation or the stream errors, so a stopped generation is never lost.
User 1โโโn Pdf 1โโโn Chat
โโ status: PROCESSING | READY | FAILED (+ statusMessage)
โโโโn Chat role: USER | AI, sources Json?
All relations cascade on delete. Deleting a PDF also removes its Qdrant vectors and the S3 object.
docker compose up -d
| Service | Port |
|---|---|
| Postgres | 5432 (admin / secret / pdf_rag) |
| Qdrant | 6333 |
| Valkey (Redis) | 6379 |
| Adminer (DB UI) | 8080 |
cd server
cp .env.example .env # see configuration below โ S3 + Clerk + OpenRouter are required
npm install
npm run prisma:generate
npm run prisma:migrate
npm start # API on :8000
npm run start:worker # ingestion worker (separate terminal)
# or run both together:
npm run start:all
The API throws at boot if S3 isn't configured, and the worker throws if
OPENROUTER_API_KEY,QDRANT_URL, orDATABASE_URLare missing โ fail fast instead of failing mid-upload.
First run downloads the embedding + reranker models (~120MB) to a local cache; startup is slower once.
cd frontend
cp .env.example .env # VITE_CLERK_PUBLISHABLE_KEY + VITE_API_URL
npm install
npm run dev # app on :5173
cd server
npm test # Jest (ESM) โ rate limiter + request validation
npm run eval # LLM-as-judge faithfulness / relevance
For npm run eval, set EVAL_USER_ID and EVAL_PDF_ID in .env to an already-ingested document.
server/.env)Required
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY | LLM access |
DATABASE_URL | PostgreSQL connection string |
QDRANT_URL | Vector DB URL (http://localhost:6333 locally) |
CLERK_SECRET_KEY | Verifies API Bearer tokens |
CLERK_WEBHOOK_SECRET | Verifies the Svix-signed user-sync webhook |
AWS_REGION, S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | PDF storage (all four needed) |
Optional
| Variable | Purpose | Default |
|---|---|---|
OPENROUTER_MODEL | Chat model | meta-llama/llama-3.1-8b-instruct |
QDRANT_API_KEY | For Qdrant Cloud | โ |
REDIS_HOST, REDIS_PORT | Queue + rate-limit backend | localhost, 6379 |
REDIS_USERNAME, REDIS_PASSWORD | Redis auth (managed Redis / Valkey) | โ |
TAVILY_API_KEY | Better web search (else DuckDuckGo) | โ |
CORS_ORIGINS | Comma-separated allowlist | http://localhost:5173,http://127.0.0.1:5173 |
PORT | API port | 8000 |
SEMANTIC_CACHE_ENABLED | Toggle the semantic cache | true |
SEMANTIC_CACHE_THRESHOLD | Min cosine similarity for a hit | 0.85 |
SEMANTIC_CACHE_TTL_HOURS | Ignore entries older than this | 168 |
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASEURL | Observability | disabled |
All routes except / and /webhook/clerk require Authorization: Bearer <clerk-token>.
| Method | Route | Description | Limit |
|---|---|---|---|
GET | / | Health check | โ |
POST | /upload/pdf | Upload a PDF (multipart pdf, โค15MB), queues ingestion | 5/min |
GET | /pdfs | List the current user's PDFs | 120/min |
GET | /pdfs/:pdfId/file | Stream the PDF back for in-app viewing | 120/min |
DELETE | /pdfs/:pdfId | Delete PDF + its vectors + its S3 object | 5/min |
POST | /chat | Ask {query, pdfId} (non-streaming) | 20/min |
POST | /chat/stream | Ask {query, pdfId}, stream trace + tokens (SSE) | 20/min |
GET | /chat/:pdfId | Full chat history for a PDF | 120/min |
POST | /webhook/clerk | Clerk user-sync webhook (Svix-verified raw body) | โ |
Chat returns 409 while a PDF is still PROCESSING, and 404 if the PDF isn't yours.
server/
index.js Express app, routes, CORS, rate limits, error handling
worker.js BullMQ ingestion worker
start-all.js runs API + worker in one process
controllers/ pdf, chat (incl. SSE), clerk webhook
lib/
agent.js LangGraph state machine (the agentic RAG loop)
ai.js embeddings, chat model, hybrid retrieval, BM25, RRF
rerank.js local cross-encoder reranker
semanticCache.js meaning-based answer cache in Qdrant
webSearch.js Tavily โ DuckDuckGo fallback
rateLimit.js Redis Lua fixed-window limiter
observability.js Langfuse tracing
auth.js db.js redis.js s3.js validate.js
prisma/ schema + migrations
eval/evaluate.js LLM-as-judge eval harness
tests/ Jest tests
frontend/src/
App.jsx layout, PDF list, upload, auth gate
components/ ChatArea (SSE + trace UI), FileUpload, PdfViewer, โฆ
lib/api.js axios client + Clerk token injection
โก CACHE HIT (similarity 0.9xx) โ skipped RAG+LLM pipeline.pdfId โ 404, never a leaked chunk.FAILED reason.16 commits
JavaScript
97.5%
CSS
1.4%
HTML
1.1%