← José Leitão

Ask My Thesis

repo ↗

Purpose

Nobody reads a master's thesis cover to cover - not even the people who are asked to. So I built an assistant that does it for them: ask a question in Portuguese or English and get an answer in that same language, with the thesis sections it was drawn from cited underneath. If the thesis doesn't cover the question, the assistant says so rather than inventing an answer.

The project is built end to end: PDF preprocessing, chunking, embedding and indexing, retrieval, grounded generation, a full evaluation suite and a Streamlit chat UI. It was presented as the final project for the EDIT. - Deep Learning with TensorFlow bootcamp.

Highlights

Architecture

The pipeline has two halves. Preprocessing and indexing run once, offline: the thesis PDF is split into sections, cleaned, chunked and embedded into a vector database. Retrieval and generation run per question: the query is embedded, the closest chunks are retrieved, and the LLM writes an answer grounded only in them.

AskMyThesis architecture
More technical information
  • Preprocessing: pdfplumber extracts the text into structured sections with metadata, then a cleaning step dehyphenates, reflows paragraphs and normalizes the text.
  • Chunking: recursive, token-aware chunks of roughly 500 tokens, each with its section title prepended.
  • Embeddings and retrieval: bge-m3 (BAAI) in a persisted Chroma collection with cosine distance - strong multilingual embeddings for a PT/EN corpus. The app retrieves the top 4 chunks per question.
  • Generation: Qwen2.5-7B-Instruct, run locally as qwen2.5:7b on Ollama - the same model in the app and in the evaluation, with greedy decoding for reproducibility.
  • Safety nets: the index is stamped with its embedding model, and retrieval refuses to load an index built with a different one, so query and index can never silently diverge.
  • Streaming: answers appear in the chat word by word as the model writes them, instead of all at once after a spinner.
  • Traceable results: every evaluation report records the git commit and settings it was produced with, so any number can be traced back to the exact code behind it.

How grounding works

The system prompt binds the model to a few rules: answer only from the provided context, decline briefly when the context doesn't cover the question, and reply in the question's language.

That last rule turned out to be the tricky one. A Portuguese context pulls a small model toward answering in Portuguese, even when the question is in English. So the reply language is detected deterministically in code and forced via a directive placed right next to the question, instead of trusting the model to pick it.

Evaluation

Everything is scored against a hand-built golden set of 123 questions (84 PT / 39 EN):

Retrieval

Retrieval is evaluated entirely locally, sweeping k and search type (similarity vs MMR). It saturates at k=4, with a hit rate of 0.99 and an MRR of 0.88 - a fifth chunk adds nothing - so both the app and the generation eval retrieve 4 chunks per question. Plain similarity search beat MMR at every k above 1 on this corpus.

Retrieval quality vs k - recall@k and MRR@k for similarity and MMR search

Generation

Generation is judged by gemma3:12b running locally. On the answerable questions it scores 0.98 faithfulness, 0.85 answer relevancy and 0.82 semantic similarity. On the negatives, it correctly declined 27/27 out-of-scope and 23/23 unanswerable on-topic questions.

Generation results and best retrieval distance by question type
Why a judge from a different model family?

LLM judges tend to rate text from their own model family more favorably - a self-preference bias. Pombal et al. (2026) show it holds even for simple yes/no verdicts on objective criteria - a good and interesting read if you work with LLM judges.

Since the generator is a Qwen model, the judge is Google's gemma3:12b: free and deterministic when run locally, and from a different model family, so it has no reason to favor the answers it is scoring.

What I learned