PaperMind
A local RAG-powered academic paper analyzer. Upload any PDF, ask questions, get structured summaries, running 100% privately on self-hosted hardware. Zero cloud, zero cost, zero data leakage.
PaperMind started as a frustration. I kept feeding papers to ChatGPT and getting confidently wrong answers. So I built something that actually reads the paper first, then answers from it.
PaperMind reads the PDF first: it splits the text into overlapping chunks, embeds each with nomic-embed-text, and stores them in ChromaDB. A question retrieves the most relevant chunks and sends them as grounded context to phi3:mini on local Ollama. The model answers strictly from the paper.
Both halves of the tool running against a real PDF. Nothing here leaves the machine: the retrieval index and the model both sit locally, so the same screens work with no network at all.
RAG has two phases: indexing (done once per PDF) and retrieval + generation (done per query). Here's the actual math:
1. Extract text from PDF (PyPDF2 / pdfplumber)
2. Split into overlapping chunks:
chunk_size = 500 tokens
chunk_overlap = 50 tokens
3. For each chunk c_i:
embedding_i = nomic-embed-text(c_i)
-- embedding_i is a 768-dim float vector
4. Store in ChromaDB:
collection.add(
documents = [c_i],
embeddings = [embedding_i],
ids = [f"doc_{paper_id}_chunk_{i}"]
)Overlap stops context being split at a boundary — without the 50-token overlap, a sentence describing a key result could be cut between two chunks and become unretrievable. Each chunk maps to a dense vector in 768-dimensional space, where similar ideas land close together regardless of wording.
# A 12-page paper, ~9,400 tokens of body text chunks = split(text, size=500, overlap=50) → 21 overlapping chunks produced # Chunk 7 (positions 2850–3350 tokens): embedding_7 = nomic-embed-text(chunk_7) → [0.0123, -0.0871, 0.1442...] (768 floats) collection.add( documents=[chunk_7], embeddings=[embedding_7], ids=["doc_42_chunk_7"] ) → stored, ready for retrieval
1. Embed the user's question:
q_embedding = nomic-embed-text(query)
2. Compute cosine similarity to all chunks:
similarity(q, c_i) = (q · c_i) / (|q| × |c_i|)
-- dot product of vectors / product of magnitudes
-- returns value in [-1, 1], higher = more similar
3. Retrieve top-k chunks by similarity:
top_chunks = sort(chunks, by=similarity, desc=True)[:5]
4. Build grounded prompt:
prompt = f"""
Context from the paper:
{join(top_chunks)}
Question: {query}
Answer strictly based on the context above:
"""
5. Send to phi3:mini via OllamaChromaDB uses an approximate nearest-neighbour index (HNSW) so retrieval stays fast even across thousands of chunks. The LLM sees only the retrieved context, it cannot access information outside those 5 chunks, which eliminates hallucination by construction rather than by prompting the model to "please be accurate." If the paper genuinely doesn't contain the answer, the top-5 chunks will simply have low similarity scores and the model says so.
# Query: "What dataset did the paper use for training?" q_embedding = nomic-embed-text(query) → [0.0091, -0.0654, 0.1308...] similarity(q, chunk_3) = 0.81 ← discusses dataset, high match similarity(q, chunk_7) = 0.74 ← mentions preprocessing steps similarity(q, chunk_12) = 0.69 ← results table, some overlap similarity(q, chunk_1) = 0.22 ← abstract, low match similarity(q, chunk_19) = 0.11 ← references section, irrelevant top_5 = [chunk_3, chunk_7, chunk_12, chunk_9, chunk_15] → sent to phi3:mini as grounding context