Skip to content

Repository files navigation

Document Q&A — a small RAG system

Upload a PDF or TXT document, ask questions about it in plain English, and get an answer generated only from the document's own contents — returned together with the exact passages and page numbers it was drawn from.

Measured on the bundled sample document: 0% hallucination rate, 100% retrieval recall@4, ~$0.004 per question. See Evaluation.


What it does

  PDF / TXT
      │
      ▼
  extract text per page  ──►  clean  ──►  split into overlapping chunks
                                                      │
                                                      ▼
                                        embed locally (all-MiniLM-L6-v2)
                                                      │
                                                      ▼
                                            store in ChromaDB
                                          (vector + text + page number)

  question
      │
      ├──► embed ──► vector search ────┐
      │                                 ├──► fuse (RRF) ──► top-k chunks ──► Claude ──► answer + sources
      └──► tokenize ──► BM25 search ───┘

The retrieved chunks are the only thing the model is allowed to answer from. If they do not contain the answer, the system says so instead of inventing one.


Screenshots

Answer with sources — every passage the answer was built from, with its page number and relevance score.

Answer with sources

Refusing rather than inventing — the funding figure is not in the document, so the system says so instead of producing a plausible number.

A question the document cannot answer

Follow-up questions — "Who proposed it?" is rewritten into a standalone question before retrieval runs, and the rewrite is shown rather than applied silently.

A follow-up question being resolved


Quick start with Docker

The shortest path — no Python, no virtualenv, no model download:

git clone https://github.com/DevnilMaster/RAG-Document-QA-System.git
cd RAG-Document-QA-System
cp .env.example .env      # then add your ANTHROPIC_API_KEY
docker compose up --build

Open http://localhost:8000

The first build takes a few minutes (it installs PyTorch and bakes the embedding model into the image so the container needs no network access to Hugging Face at runtime). Subsequent starts are immediate.

docker compose down          # stop
docker compose down -v       # stop and delete the indexed document

Setup without Docker

Assumes Python 3.10+ and git.

git clone https://github.com/DevnilMaster/RAG-Document-QA-System.git
cd RAG-Document-QA-System
python -m venv .venv
# Windows (PowerShell)
.venv\Scripts\Activate.ps1
# macOS / Linux
source .venv/bin/activate
pip install -r backend/requirements.txt
cp .env.example .env      # then add your ANTHROPIC_API_KEY
uvicorn backend.main:app --reload

Open http://127.0.0.1:8000 · interactive API docs at /docs

The first upload downloads the embedding model (all-MiniLM-L6-v2, ~90 MB) from Hugging Face and caches it locally. Once only.

Don't have an Anthropic API key?

Set RAG_DRY_RUN=1 in .env. Everything except the final generation step runs normally — the document is extracted, cleaned, chunked, embedded and searched, and the real retrieved passages are shown — with a placeholder in place of the model's answer. It verifies the retrieval half of the pipeline with no key and no cost.

Try it immediately

samples/sample-research-paper.pdf is a short fictional paper included for testing. Facts are spread deliberately across its three pages, so page citations can be checked at a glance:

Ask this Expect a citation to
"What kappa score did the model achieve?" Page 3
"How many fundus photographs were in the dataset?" Page 2
"Why did earlier handcrafted approaches fail?" Page 1
"What is the capital city of France?" a refusal — it is not in the document

Environment variables

Variable Required Default Purpose
ANTHROPIC_API_KEY Yes (unless RAG_DRY_RUN=1) Key for the Claude API. Get one at console.anthropic.com.
RAG_DRY_RUN No 0 1 skips the LLM call and returns a placeholder. Retrieval still runs in full.

.env is gitignored; .env.example is the committed template. Docker Compose reads the same .env and passes both values into the container — the key is never baked into an image layer.


How to use it

  1. Upload — choose a PDF or TXT file and press Upload. The status line reports how many chunks were indexed and from how many pages.
  2. Ask — type a question and press Ask (or hit Enter).
  3. Check the answer — the answer appears first, followed by the passages it was grounded in, each labelled with its page number and a relevance score.

Uploading a new document replaces the previous one.

API

POST /upload — index a document

Request: multipart/form-data with a single file field.

curl -F "file=@samples/sample-research-paper.pdf" http://127.0.0.1:8000/upload
{
  "filename": "sample-research-paper.pdf",
  "pages": 3,
  "chunk_count": 11,
  "message": "Indexed 11 chunks from 3 page(s). You can ask questions now."
}
POST /ask — ask a question
curl -X POST http://127.0.0.1:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "What are the three objectives of this study?"}'

mode is optional and defaults to hybrid; semantic and keyword run a single retrieval strategy, which is what makes the three directly comparable on the same question.

{
  "answer": "The three objectives are:\n\n1. To design a convolutional architecture that classifies retinal fundus images into five severity grades (Page 2).\n2. To constrain the model to fewer than twelve million parameters (Page 2).\n3. To quantify how much of the remaining accuracy gap is attributable to image acquisition variability (Page 2).",
  "sources": [
    {
      "page": 2,
      "snippet": "2. OBJECTIVES The objective of this study is threefold...",
      "chunk_id": "chunk-0004",
      "score": 0.032266
    }
  ],
  "mode": "hybrid",
  "resolved_question": null
}

score means different things per mode — cosine similarity for semantic, the raw BM25 score for keyword, the fused RRF score for hybrid — so compare scores within a mode, never across them.

POST /ask/stream — the same answer, streamed

Server-Sent Events. Same request body as /ask. Retrieval finishes before the stream opens, so the sources arrive first and the UI can show the evidence while the answer is still being written.

curl -N -X POST http://127.0.0.1:8000/ask/stream \
  -H "Content-Type: application/json" \
  -d '{"question": "What limitations do the authors acknowledge?"}'
event: sources
data: {"sources": [...], "mode": "hybrid", "resolved_question": null}

event: delta
data: {"text": "The authors acknowledge three limitations: (1) the dataset..."}

event: done
data: {}

Failures detectable before generation starts (no document indexed, empty question) still return a normal HTTP error code. Once the stream is open the status code has already been sent, so a later failure arrives as an error event instead.

The browser UI uses this endpoint; /ask is the non-streaming equivalent and is what the evaluation script calls.

POST /api/reset — forget the conversation

Clears follow-up context without re-uploading the document.

curl -X POST http://127.0.0.1:8000/api/reset
GET /api/status — what is currently indexed
{
  "indexed": true,
  "filename": "sample-research-paper.pdf",
  "chunk_count": 11,
  "model": "claude-sonnet-5",
  "dry_run": false,
  "conversation_turns": 2
}

Evaluation

python evaluation/run_eval.py

evaluation/questions.json holds 13 questions against the sample document: ten whose answer sits on a known page, and three that the document cannot answer at all — two of them deliberately plausible for a paper of this kind, which is exactly where a model is most tempted to invent something.

Results on the bundled sample (claude-sonnet-5, top_k=4, hybrid retrieval):

Metric Value
Retrieval recall@4 100% (10/10)
Retrieval precision@1 100% (10/10)
Answer keyword coverage 100% (10/10)
Correct refusal rate 100% (3/3)
Hallucination rate 0% (0/3)
False refusal rate 0% (0/10)
Retrieval latency median ~50 ms
Generation latency median 1.5–2.7 s across runs (network-dominated)
Cost $0.0044 per question

The full run writes evaluation/results.md with every question, its retrieved pages, and the generated answer.

Retrieval strategy comparison — same questions, same document, retrieval only (so it costs nothing to regenerate):

Mode Recall@4 Top-1 Returned nothing
hybrid 10/10 10/10 0/10
semantic 10/10 8/10 0/10
keyword 10/10 9/10 0/10

Q13 is the case worth reading. Asked "What are the three objectives of this study?", semantic-only retrieval never returns the chunk containing the objectives, and the model — correctly, given what it was handed — refuses. BM25 finds that chunk at rank 1 because the heading literally reads "OBJECTIVES", and hybrid recovers the full three-part answer. A false refusal caused by retrieval, not by generation, and a concrete reason for the extra machinery.


Project structure

RAG-Document-QA-System/
├── backend/
│   ├── main.py                # FastAPI app: /upload, /ask, /ask/stream, /api/*
│   ├── document_processor.py  # extraction, cleaning, chunking
│   ├── embeddings.py          # sentence-transformers wrapper
│   ├── vector_store.py        # ChromaDB wrapper
│   ├── keyword_search.py      # BM25, implemented directly
│   ├── retrieval.py           # semantic / keyword / hybrid + RRF fusion
│   ├── conversation.py        # follow-up detection + short-term memory
│   ├── llm.py                 # Claude call, streaming, follow-up rewriting
│   ├── prompts.py             # the prompt, isolated for inspection
│   ├── config.py              # every tunable value, in one place
│   └── requirements.txt
├── frontend/                  # index.html, app.js, style.css
├── evaluation/
│   ├── questions.json         # ground truth
│   ├── run_eval.py            # metrics + mode comparison
│   └── results.md             # generated
├── samples/                   # a sample document to test with
├── data/                      # local ChromaDB store (contents gitignored)
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md

Design decisions

Chunking: 800 characters, 150-character overlap, split within a page

800 characters is roughly 150–200 tokens — large enough to hold a complete thought, small enough that four of them still leave the prompt cheap. The 150-character overlap means a fact sitting on a chunk boundary is still retrievable: it appears complete in at least one chunk instead of being cut in half by both.

Chunking happens within each page, so every chunk belongs to exactly one page and the page number cited as evidence is always exact. The trade-off is that a sentence spanning a page break gets split; since page-accurate attribution is the whole point of the system, exactness was the priority.

The splitter is a small hand-written recursive splitter rather than LangChain's RecursiveCharacterTextSplitter. It tries the most natural boundary available first — paragraph, then line, then sentence, then word — and only falls back to a finer one for parts still too long. Writing it directly (~40 lines) was preferred over adding LangChain's dependency tree for one function.

Embeddings: all-MiniLM-L6-v2, running locally

Every chunk of every uploaded document has to be embedded, so a hosted embedding API would mean a per-upload cost and a second key to manage. This model is 22M parameters, produces 384-dimensional vectors, and embeds a query in ~15 ms on CPU — it never shows up next to the model's network round-trip.

requirements.txt pulls the CPU-only PyTorch wheel via an extra index. The default build bundles ~2 GB of CUDA libraries this project never uses, and a container has no GPU to use them with anyway.

A larger model (bge-large, e5-large) would retrieve somewhat better at roughly 10× the size and embedding time. Retrieval quality was not the bottleneck here.

Vector store: ChromaDB

ChromaDB stores the vector, the chunk text, and the metadata together and persists to disk with no server to run. With FAISS the index holds vectors only, so chunk text and page numbers live in a parallel structure kept in sync by hand. Since page-accurate attribution matters here, keeping metadata attached to the vector removes a whole category of bug.

Distance is cosine, which measures the angle between vectors and ignores their length. That matters for text: a long passage and a short question about it should count as similar because they point the same way, not be pushed apart because one has more words.

Retrieval: hybrid, top-4

Semantic search has a specific blind spot. An embedding compresses a passage into 384 numbers, and exact tokens — 0.847, AdamW, a section heading — do not survive that compression intact. BM25 is the mirror image: excellent on exact tokens, blind to paraphrase.

They are fused with Reciprocal Rank Fusion. The obvious alternative, adding the two scores, does not work off the shelf: cosine similarity lives in roughly 0–1 while BM25 is unbounded and corpus-dependent, so any fixed weighting is really a constant tuned to one document. RRF discards scores and uses only rank position, which is directly comparable between the two lists and needs no tuning. The measured effect is in the table above.

k=4 keeps the prompt at roughly 1,300 input tokens. Raising it improves recall on questions whose answer is spread across a document, at a linear cost in tokens and a real risk of diluting context with weakly-related passages. k is a single constant in config.py.

Generation: Claude Sonnet 5, thinking disabled, low effort

The task is extraction from supplied context, not open-ended reasoning, so the model is configured for exactly that:

  • thinking: {"type": "disabled"} — extended thinking is on by default on Claude Sonnet 5 and would spend tokens deliberating over a task that does not need it.
  • effort: "low" — the same reasoning applied to overall token spend.
  • max_tokens: 600 — a hard ceiling; an unbounded response is the easiest way to spend API credit by accident.
  • max_retries: 1 (SDK default is 2) — a persistent failure should surface quickly rather than be billed three times.

Measured result: $0.0044 per question, with generation latency dominated by the network round-trip rather than by anything on this side.

Anthropic is the only provider, with no fallback backend. A fallback path doubles the code that has to be understood and tested, and it hides configuration mistakes: with one provider, a missing key fails immediately and says why.

How hallucination is discouraged

The prompt lives in backend/prompts.py, on its own, so it can be read and argued about rather than buried inside an API call. Four things do the work:

  1. The system prompt states that the excerpts are the complete set of available information, not a helpful addition to what the model already knows.
  2. It supplies the exact sentence to emit when the answer is absent. Declining is much easier for a model handed the words to decline with.
  3. Each excerpt is labelled with its page number, so citing a source is copying a label rather than recalling a fact.
  4. When retrieval returns nothing, the API is not called at all — the "not enough information" answer is returned directly. An answer already determined should not cost a request.

Measured: 3/3 correct refusals on unanswerable questions, 0/10 false refusals on answerable ones. That is a real number on a small set, not a guarantee.

Follow-up questions: rewrite before retrieval, not after

Given the exchange

Q: What is this research about?
A: Automated retinal disease classification using RetinaNet-DR...
Q: Who proposed it?

the obvious fix — hand the model the chat history — does not work, because the problem is upstream of the model. "Who proposed it?" embeds to a vector about proposing and things; nothing in it points at retinal imaging, so the vector search returns the wrong chunks and no amount of history helps the model reason over passages it was never given.

So the follow-up is rewritten into a standalone question before retrieval: "Who proposed it?" becomes "Who proposed the RetinaNet-DR approach for automated diabetic retinopathy screening?", which the index can actually match.

The rewrite costs an API call, so it is gated behind a cheap regex heuristic — does the question contain a referring word, or is it very short? A self-contained question skips the rewrite entirely and costs nothing extra. The heuristic is deliberately biased towards false positives: rewriting a question that did not need it returns something equivalent and wastes a fraction of a cent, while missing a real follow-up produces a wrong answer.

The rewrite is shown in the UI rather than applied silently, because a system that quietly answers a different question than the one typed is hard to trust.

Streaming: sources first, then the answer

The browser UI reads /ask/stream, which sends the retrieved passages as the first event and then the answer in pieces. Retrieval is already finished by the time generation starts, so there is no reason to make the reader wait for the whole response before showing them the evidence — most of the perceived improvement comes from that reordering rather than from the token-by-token text.

EventSource would handle the framing, but it only supports GET and the question belongs in a request body, so the SSE records are parsed from the fetch stream directly (~25 lines in app.js). Network chunks split records arbitrarily, so anything after the last blank-line separator is held back until more arrives.

/ask remains as the non-streaming equivalent — simpler for API consumers, and it is what the evaluation script calls, since a script has nothing to gain from partial output.

Frontend: plain HTML and vanilla JavaScript

All the interesting work here is backend and retrieval, and the UI exists to demonstrate that the pipeline works. A zero-build frontend is servable as static files by FastAPI itself: no npm toolchain, no build step, no second server or CORS configuration, and a single-stage Dockerfile instead of a multi-stage one. That effort went into retrieval quality and evaluation instead.

Docker

Single-stage, because there is no frontend build to stage separately. requirements.txt is copied and installed before the application code so that editing a .py file reuses the cached PyTorch install instead of re-downloading it. The embedding model is baked in at build time, so the container needs no network access to Hugging Face at runtime. The app runs as a non-root user, and the ChromaDB store lives in a named volume so the index survives docker compose down.

The image is ~2.75 GB, essentially all of it PyTorch, ChromaDB, and their transitive scientific-Python dependencies. The CPU-only PyTorch wheel already removes about 2 GB of unused CUDA libraries from that figure.


Known limitations

  • One document at a time. Uploading a new file replaces the previous index. Supporting several would mean a collection or metadata filter per document plus a document picker — mechanically simple, but out of scope here.
  • Scanned PDFs do not work. Extraction reads a PDF's text layer. A scanned page has none, so the upload fails with a message saying exactly that. Handling it would require an OCR pass.
  • Chunks do not span page boundaries. A sentence continuing across a page break is split between two chunks — a deliberate trade for exact page attribution that can cost recall on a question whose answer sits on the break.
  • The evaluation measures recall at page level, not chunk level. A page number is what gets cited, so that is the unit reported — but it is coarser than the chunk the answer actually needs. Q13 is the proof: semantic-only retrieval scores as a page-level hit there and still produces a false refusal. Read page-level recall as an upper bound.
  • The evaluation set is small and single-document. Thirteen questions against one synthetic paper. It is enough to catch regressions and to compare retrieval strategies against each other; it is not enough to claim a general accuracy figure.
  • Chunk size, overlap and k were chosen by reasoning, not tuned. The harness to tune them exists — change a constant in config.py and re-run the evaluation — but no sweep was run.
  • No authentication or rate limiting. Anyone who can reach the port can use the API and spend the configured key's credit.
  • Conversation memory is process-global and in-memory. There is one active document and no authentication, so there is exactly one conversation. It holds the last two exchanges and is cleared on upload or via POST /api/reset. A multi-user version would key it by session and persist it — a different design, not a bigger version of this one.
  • The follow-up heuristic is a regex, not a classifier. It catches referring words and very short questions. A follow-up phrased without either ("and the authors of the second paper") would be missed and searched as-is.
  • Complex PDF layouts degrade extraction. Multi-column papers, tables, and figure captions come out of the text layer in a reading order that is sometimes wrong, which shows up as slightly incoherent chunks.
  • English-centric embeddings. all-MiniLM-L6-v2 is trained predominantly on English; retrieval quality in other languages will be noticeably worse.

About

A retrieval-augmented Q&A system for PDF/TXT documents : hybrid semantic + keyword search, page-cited answers, and zero hallucination on out-of-scope questions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages