A retrieval-augmented generation (RAG) system for question-answering over scientific papers. Ask natural-language questions and get grounded, cited answers backed by a multi-stage retrieval pipeline.
Browsing and querying the paper library is open to everyone. Uploading or deleting papers requires signing in with Google.
flowchart TD
U([User]) -->|question| FE[Next.js Frontend]
FE -->|POST /api/ask| API[FastAPI]
API -->|cache hit| FE
API --> R[Retriever]
R -->|embed query| OL[OpenAI\ntext-embedding-3-small]
R -->|cosine search top-20| PG[(PostgreSQL + pgvector)]
R -->|keyword ILIKE top-20| PG
R -->|RRF fusion + threshold filter| RR[Reranker]
RR -->|score 0-10 per chunk| LLM[OpenAI LLM]
RR -->|top-k chunks| GEN[Generator]
GEN -->|synthesise answer + sources| LLM
GEN --> CACHE[Redis Cache\n1-hr TTL]
CACHE -->|answer + citations| FE
Query pipeline:
- Check Redis cache (SHA-256 key, 1-hour TTL)
- Embed query β OpenAI
text-embedding-3-small, 1536-dim - Dense search β cosine similarity, top-20 candidates from pgvector
- Keyword search β
ILIKE, top-20 candidates - RRF fusion β merge both lists by reciprocal rank
- Similarity threshold filter (β₯ 0.3); fallback to top-k if none pass
- LLM reranker β score each chunk 0β10 for relevance
- LLM generator β synthesise grounded answer with inline source numbers
- Store in Redis, return with citations
| Layer | Technology |
|---|---|
| Frontend | Next.js, React 18, Tailwind CSS, TypeScript, Lora (serif) |
| Backend API | FastAPI + Uvicorn |
| Auth | Google OAuth 2.0 (ID token) + JWT (python-jose) |
| Embeddings | OpenAI text-embedding-3-small (1536-dim) |
| LLM | OpenAI gpt-4o-mini |
| Vector DB | PostgreSQL 16 + pgvector |
| Cache | Redis 7 |
| ORM | SQLAlchemy 2 |
| PDF parsing | PyMuPDF |
| Chunking | LangChain RecursiveCharacterTextSplitter |
- Docker Desktop
- Python 3.11+
- Node.js 18+
- OpenAI API key
- Google Cloud project with an OAuth 2.0 Web Client ID (see Auth Setup)
The app uses Google Sign-In. You need a Google OAuth 2.0 client ID before running.
- Go to Google Cloud Console β APIs & Services β Credentials
- Click Create Credentials β OAuth 2.0 Client ID, choose Web application
- Under Authorized JavaScript origins, add:
http://localhost:3000(Docker / local frontend)http://localhost:3001(if runningnext devon port 3001)http://YOUR_VM_IP:3000(for VM deployment)
- Copy the Client ID β it looks like
123456789-abc.apps.googleusercontent.com
Two ways to run: Docker Compose (recommended) or local dev (faster iteration).
git clone <repo-url>
cd scientific-rag-assistant
# 1. Configure secrets
cp .env.example .envEdit .env and fill in:
OPENAI_API_KEY=sk-...
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
JWT_SECRET_KEY=<generate: python -c "import secrets; print(secrets.token_hex(32))"># 2. Build and start all services
docker compose up --build| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| API | http://localhost:8000 |
| API docs | http://localhost:8000/docs |
| PostgreSQL | localhost:5732 |
| Redis | localhost:6380 |
To ingest pre-existing PDFs from data/raw/:
# Requires a valid JWT β use the Upload button in the UI instead,
# or call the endpoint with a Bearer token from a signed-in session.
curl -X POST http://localhost:8000/api/ingest \
-H "Authorization: Bearer <your-jwt>"git clone <repo-url>
cd scientific-rag-assistant
cp .env.example .env
# fill in OPENAI_API_KEY, GOOGLE_CLIENT_ID, JWT_SECRET_KEY in .envdocker compose up -d db redispython -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
pip install -r requirements.txtuvicorn main:app --reloadcd frontend
cp .env.example .env.local
# fill in NEXT_PUBLIC_GOOGLE_CLIENT_ID in .env.local
npm install
npm run devFrontend: http://localhost:3000 (or 3001 if 3000 is in use)
Place PDFs in data/raw/, then use the Upload Paper button in the UI, or run the script directly:
python scripts/ingest_all.py # ingest all new PDFs
python scripts/ingest_all.py --dry-run # preview what would be ingestedExtra steps needed when running on a server rather than localhost.
In your OAuth client's Authorized JavaScript origins, add:
http://YOUR_VM_IP:3000
# URL the browser uses to reach the API (baked into the Next.js bundle at build time)
NEXT_PUBLIC_API_URL=http://YOUR_VM_IP:8000
# Comma-separated list of CORS origins the backend will accept
ALLOWED_ORIGINS=http://YOUR_VM_IP:3000If your PostgreSQL volume already has data from before auth was added, the users table won't exist. Create it once:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
google_sub TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
name TEXT,
picture TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);docker compose up --build| Variable | Where | Required | Description |
|---|---|---|---|
OPENAI_API_KEY |
root .env |
Yes | OpenAI API key for embeddings and generation |
GOOGLE_CLIENT_ID |
root .env |
Yes | Google OAuth client ID (backend token verification) |
JWT_SECRET_KEY |
root .env |
Yes | Secret for signing JWTs β use a random 32-byte hex string |
NEXT_PUBLIC_GOOGLE_CLIENT_ID |
root .env + frontend/.env.local |
Yes | Same client ID, exposed to the browser bundle |
NEXT_PUBLIC_API_URL |
root .env |
VM only | URL the browser uses to reach the API (default: http://localhost:8000) |
ALLOWED_ORIGINS |
root .env |
VM only | Comma-separated CORS origins (default: localhost:3000/3001) |
JWT_EXPIRE_HOURS |
root .env |
No | JWT lifetime in hours (default: 168 = 1 week) |
CACHE_TTL_SECONDS |
root .env |
No | Redis answer cache TTL (default: 3600) |
GENERATION_MODEL |
root .env |
No | LLM for answer generation (default: gpt-4o-mini) |
RETRIEVAL_FINAL_K |
root .env |
No | Number of chunks to retrieve (default: 5) |
frontend/.env.localis not committed (git-ignored). Create it fromfrontend/.env.example.
Endpoints marked π Auth required expect a JWT in the Authorization header:
Authorization: Bearer <token>
A token is returned by POST /api/auth/google after a successful Google sign-in.
Exchange a Google ID token (from the browser's Google Sign-In flow) for a JWT.
Request body
{ "credential": "<Google ID token>" }Response
{
"access_token": "<jwt>",
"token_type": "bearer",
"user": { "email": "user@example.com", "name": "Jane Smith", "picture": "https://..." }
}Return the currently authenticated user's profile.
{ "id": 1, "email": "user@example.com", "name": "Jane Smith", "picture": "https://..." }Ask a question over the indexed papers. No authentication required.
Request body
{
"question": "How do transformer models handle long-range dependencies?",
"k": 5
}| Field | Type | Default | Description |
|---|---|---|---|
question |
string | required | Natural-language question |
k |
int 1β20 | 5 | Number of chunks to retrieve and cite |
Response
{
"answer": "Transformer models handle long-range dependencies through self-attention [1]...",
"unsupported": false,
"citations": [
{
"source_number": 1,
"chunk_id": "paper_003_chunk_0012",
"paper_id": "paper_003",
"file_name": "attention_is_all_you_need.pdf",
"preview": "The attention mechanism allows the model to..."
}
],
"from_cache": false,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}When unsupported: true, the papers did not contain sufficient evidence and citations will be empty.
Upload a PDF to be chunked, embedded, and indexed. Returns 200 if already indexed, 201 on success.
Request: multipart/form-data with a single file field (PDF only, max 50 MB).
Response (201)
{
"message": "'paper.pdf' uploaded and ingested successfully.",
"result": { "file": "paper.pdf", "paper_id": "paper_004", "chunks": 38 }
}List all papers currently indexed. No authentication required.
Response
[
{ "paper_id": "paper_001", "file_name": "sparse_autoencoders.pdf", "is_session_upload": false },
{ "paper_id": "paper_004", "file_name": "uploaded_draft.pdf", "is_session_upload": true }
]is_session_upload: true means the paper was uploaded this session and will be removed on next server restart.
Remove a paper and all its chunks from the database.
Response
{ "paper_id": "paper_004", "deleted_file": "uploaded_draft.pdf" }Trigger bulk ingestion of all PDFs in data/raw/. Already-indexed files are skipped.
Response
{
"message": "Ingested 3 paper(s), skipped 1, failed 0.",
"result": {
"ingested": [{ "file": "paper.pdf", "paper_id": "paper_002", "chunks": 54 }],
"skipped": ["already_indexed.pdf"],
"failed": []
}
}Delete all session-uploaded PDFs and remove their chunks. Called automatically on server startup.
Response
{ "deleted_files": ["uploaded_draft.pdf"], "count": 1 }Check liveness and dependency health. No authentication required.
Response
{
"status": "ok",
"uptime_seconds": 142.3,
"checks": {
"database": { "status": "ok" },
"openai": { "status": "ok" },
"redis": { "status": "ok" }
}
}status is "degraded" if any dependency check fails.
python scripts/eval_retrieval.py # Hit@K and MRR
python scripts/eval_reranker.py # baseline vs reranked comparison| Metric | Score |
|---|---|
| Hit@5 | β |
| MRR | β |
| Avg Faithfulness | β |
| Avg Answer Relevance | β |
| Avg Context Relevance | β |
pip install -r requirements-dev.txt
pytest tests/ -vscientific-rag-assistant/
βββ app/
β βββ api/
β β βββ ask.py # POST /api/ask (public)
β β βββ health.py # GET /health
β β βββ ingest.py # POST /api/ingest (auth required)
β β βββ upload.py # POST /api/upload, GET /api/papers, DELETE /api/papers/:id
β βββ auth/
β β βββ dependencies.py # get_current_user FastAPI dependency (JWT verification)
β β βββ router.py # POST /api/auth/google, GET /api/auth/me
β βββ core/
β β βββ config.py # Pydantic settings (env-driven, including auth + CORS)
β βββ db/
β β βββ session.py # SQLAlchemy engine + SessionLocal
β βββ schemas/
β β βββ ask.py # AskRequest / AskResponse / Citation
β βββ services/
β βββ cache.py # Redis answer cache (1-hr TTL)
β βββ chunker.py # PDF β text chunks via PyMuPDF + LangChain
β βββ embedder.py # OpenAI embedding client
β βββ evaluator.py # LLM-based RAG quality evaluator
β βββ generator.py # LLM answer synthesis with citations
β βββ pipeline.py # End-to-end ingestion pipeline
β βββ reranker.py # LLM chunk relevance scorer (0β10)
β βββ retriever.py # pgvector dense + keyword search + RRF fusion
βββ frontend/
β βββ app/
β βββ contexts/
β β βββ AuthContext.tsx # Google auth state, JWT storage, login/logout
β βββ login/
β β βββ page.tsx # Sign-in page (Google One Tap)
β βββ components/
β β βββ UploadModal.tsx # PDF upload modal (auth-gated)
β βββ page.tsx # Main chat UI
βββ data/
β βββ raw/ # Source PDFs for bulk ingest
β βββ uploads/ # Session uploads (auto-cleaned on restart)
βββ scripts/
β βββ eval_retrieval.py
β βββ eval_reranker.py
β βββ ingest_all.py
βββ tests/
βββ Dockerfile # FastAPI container image
βββ docker-compose.yml # Full stack orchestration
βββ .env.example # Environment variable reference
βββ init.sql # DB schema: chunks + users tables + pgvector indexes
βββ main.py # FastAPI entry point + CORS + startup cleanup
βββ requirements.txt
βββ requirements-dev.txt