File size: 8,981 Bytes
fc3b3c4
 
 
 
 
 
5959a78
fc3b3c4
 
f35185e
5959a78
fc3b3c4
5959a78
 
f35185e
5959a78
f35185e
5959a78
 
f35185e
5959a78
f35185e
5959a78
 
f35185e
 
5959a78
 
f35185e
5959a78
 
 
 
 
 
f35185e
 
5959a78
f35185e
 
5959a78
f35185e
 
5959a78
f35185e
 
5959a78
f35185e
 
 
5959a78
 
f35185e
5959a78
f35185e
 
5959a78
 
 
 
 
f35185e
 
5959a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f35185e
 
5959a78
 
 
 
f35185e
 
 
5959a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f35185e
 
5959a78
f35185e
 
5959a78
f35185e
5959a78
 
 
f35185e
 
5959a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
---
title: DocuMind AI
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: docker
app_port: 8000
pinned: false
---

# DocuMind AI

Ask questions about your PDFs and get answers with page-level citations.
FastAPI + PyMuPDF + sentence-transformers + **FAISS** + ChromaDB + LangGraph + Groq.

## Quick start

**You need Python 3.10, 3.11 or 3.12** (3.13/3.14 do not work: `torch 2.5.1` has no wheels for them).
Get a free API key at <https://console.groq.com>.

### Windows (PowerShell)

```powershell
.\run.ps1
```

The script creates `.venv`, installs everything, creates `.env` (and opens it so you can paste
`GROQ_API_KEY`), then starts the app. Or do it manually:

```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1          # if blocked: Set-ExecutionPolicy -Scope Process RemoteSigned
pip install -r requirements-dev.txt
Copy-Item .env.example .env           # then edit .env and set GROQ_API_KEY
python main.py
```

### Linux / macOS

```bash
./run.sh        # first run creates .env; add GROQ_API_KEY, then run it again
```

### Docker

```bash
cp .env.example .env     # Windows: Copy-Item .env.example .env   (then set GROQ_API_KEY)
docker-compose up --build
```

Open <http://localhost:8000> (API docs at `/docs`). The first upload/question loads the
embedding model (~90 MB download when running outside Docker).

## How it works

```
Upload:  PDF -> PyMuPDF text (per page, reading order, OCR fallback) -> clean -> token-based
         chunks (200 tokens, 30 overlap) -> MiniLM embeddings (384-d, normalised) -> ChromaDB (text + metadata) and FAISS (vectors)
Query:   question -> (condense follow-up using chat history) -> embed -> top-K cosine search
         -> weak evidence? -> "no evidence" answer
         -> otherwise Groq LLM answers with [Source N] citations -> grounding check
```

* LangGraph nodes: `condense_question` (only with history) -> `retrieve` -> gate -> `generate` -> `check_evidence`.
* **Vector search:** FAISS (`IndexFlatIP` on L2-normalised vectors = exact cosine similarity) finds the
  nearest chunk ids; ChromaDB stores the chunk text, metadata and the document list (one collection
  `documents`, filters on `doc_id` / `owner_id`). Chroma is the source of truth: the FAISS index lives
  in memory and is rebuilt from Chroma at startup (about 0.5 s per 10,000 chunks) and automatically
  whenever the two disagree. `VECTOR_BACKEND=chroma` switches to Chroma's own HNSW search; if
  `faiss-cpu` is missing the app falls back to it with a warning.
* Why FAISS here: exact search (100% recall) and faster than Chroma's approximate index at this size
  (`python scripts/benchmark_search.py`). A flat index scans every vector, so past roughly a million
  chunks switch to an approximate FAISS index (IVF/HNSW).
* `confidence` (HIGH/MEDIUM/LOW) comes from retrieval similarity; `is_grounded` is a lexical-overlap
  heuristic. **Neither proves the answer is correct** - always check the cited pages.
* Only the top-K chunks are shown to the LLM, so questions about the **whole** document ("how many
  sections are there?") can be wrong while still showing `is_grounded=true`.
* Reasoning models such as `openai/gpt-oss-*` need a large answer budget (`LLM_MAX_TOKENS`, default 4096).

## Configuration (`.env`)

| Variable | Default | Meaning |
|---|---|---|
| `GROQ_API_KEY` | - | Required for answers. Placeholder values are treated as "not set". |
| `LLM_MODEL` | `openai/gpt-oss-20b` | Any chat model available to your Groq key (see Troubleshooting). |
| `LLM_MAX_TOKENS` | `4096` | Answer budget. Reasoning models (gpt-oss) need a large value. |
| `API_KEY` | empty | If set, clients must send `X-API-Key`; data is isolated per key. Empty = open (dev). |
| `ALLOWED_ORIGINS` | `http://localhost:8000` | Comma-separated CORS origins. |
| `CHUNK_SIZE` / `CHUNK_OVERLAP` / `CHUNK_UNIT` | `200` / `30` / `tokens` | Keep chunks <= 256 tokens: the embedding model ignores the rest. |
| `TOP_K_RESULTS` | `5` | Chunks sent to the LLM. |
| `RETRIEVAL_THRESHOLD` | `0.20` | Below this top similarity the LLM is not called. |
| `GROUNDING_OVERLAP_THRESHOLD` | `0.30` | Word-overlap needed for `is_grounded=true`. |
| `VECTOR_BACKEND` | `faiss` | `faiss` or `chroma` - which engine runs the similarity search. |
| `HYBRID_SEARCH` | `false` | Re-rank the top vector candidates with BM25 (RRF fusion). |
| `ENABLE_RERANK` | `false` | Cross-encoder re-ranking (downloads another model). |
| `ENABLE_FAITHFULNESS_CHECK` | `false` | Extra LLM call that judges whether the answer is supported. |
| `TORCH_THREADS` | `1` | CPU threads for embeddings. |
| `DEBUG` | `false` | `true` enables auto-reload. |

## API

| Method | Path | Notes |
|---|---|---|
| GET | `/api/health` | Public. |
| POST | `/api/upload` | PDF, max 50 MB. >= 10 MB is indexed in the background and returns a `job_id`. |
| GET | `/api/jobs/{id}` | Background upload status (`queued/processing/done/failed`). |
| GET | `/api/documents` | List indexed documents. |
| DELETE | `/api/documents/{doc_id}` | Remove vectors and the stored PDF. |
| POST | `/api/query` | `{"question": "...", "top_k": 5, "doc_ids": [], "history": [{"role":"user","content":"..."}]}` (history: max 10 turns, 4000 chars each). |
| POST | `/api/query/stream` | Same input, Server-Sent Events: `token` events, then one `final` event. |

Query response:

```json
{
  "answer": "The report identifies three findings [Source 1].",
  "sources": [{"doc_name": "report.pdf", "page_number": 4, "text": "Relevant excerpt", "similarity": 0.82}],
  "query": "What are the findings?", "model_used": "openai/gpt-oss-20b",
  "latency_ms": 1200.0, "chunks_retrieved": 1, "is_grounded": true, "confidence": "HIGH"
}
```

Errors: `400` bad upload, `401` bad API key, `404` nothing indexed / unknown id, `409` content owned by
another key, `422` unreadable or scanned PDF without OCR / invalid body, `429` rate limit
(10 uploads/min, 30 queries/min per IP), `500` generic message with a `request_id`.

## Project structure

```text
main.py                         app, CORS, rate limiting, request ids
backend/api/{routes,schemas}.py HTTP layer
backend/core/{config,logger}.py settings + logging
backend/services/
    pdf_processor.py            extraction, OCR fallback, chunking
    embeddings.py               sentence-transformers wrapper (lazy singleton)
    vector_store.py             ChromaDB storage + indexing/search/delete (+ optional BM25/rerank)
    faiss_index.py              FAISS exact-cosine index with delete and doc/owner filters
    rag_graph.py                LangGraph workflow, prompts, grounding check
    rag_engine.py               adapts graph output to the API response
frontend/                       single-page UI (vanilla JS)
tests/  scripts/                pytest suite, retrieval evaluation script
```

## Development

```bash
pip install -r requirements-dev.txt
pytest tests/ -v
ruff check .
python scripts/eval_retrieval.py scripts/eval_example.json   # hit-rate@k / MRR on your own PDFs
python scripts/benchmark_search.py                           # FAISS vs Chroma search speed + recall
```

## Troubleshooting

| Problem | Fix |
|---|---|
| `No matching distribution found for torch==2.5.1` | You are on Python 3.13/3.14. Use Python 3.12 (`py -3.12 -m venv .venv`). |
| `running scripts is disabled` (PowerShell) | `Set-ExecutionPolicy -Scope Process RemoteSigned` |
| Answers say "Answer generation failed" / `model_not_found` | Your key cannot use that model. List the ones it can: `$k=(Select-String -Path .env -Pattern '^GROQ_API_KEY=(.*)$').Matches.Groups[1].Value; (Invoke-RestMethod https://api.groq.com/openai/v1/models -Headers @{Authorization="Bearer $k"}).data.id` and set `LLM_MODEL` to a chat model from that list. |
| Scanned PDF returns 422 | Install [Tesseract](https://github.com/UB-Mannheim/tesseract/wiki) and put it on `PATH` (Docker image includes it). |
| `VECTOR_BACKEND=faiss but faiss-cpu is not installed` | `pip install -r requirements.txt` again (the app keeps working on Chroma meanwhile). |
| Port 8000 busy | Set `PORT=8001` in `.env`. |

## Known limitations

* `confidence` / `is_grounded` are heuristics, not guarantees of correctness.
* Rate limits and background jobs live in process memory: run **one** worker (Chroma's local
  client is also single-process). Use a real queue / server database to scale out.
* "Hybrid" search re-ranks the top vector candidates with BM25; it is not a full-corpus keyword search.
* OCR needs Tesseract. Tables and multi-column layouts are extracted as plain text.
* English-focused embedding model (`all-MiniLM-L6-v2`).
* The FAISS index is in-memory (rebuilt from Chroma at startup); it is exact, not approximate, so very
  large corpora (millions of chunks) need an IVF/HNSW index instead.
* Data written by older versions (one Chroma collection per document) is not migrated: delete
  `data/vectorstore` and re-upload your PDFs.
* Default mode has no authentication - set `API_KEY` before exposing the service.