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 actually reads your PDF first — splitting it into overlapping chunks, converting each chunk to a vector embedding using nomic-embed-text, and storing them in ChromaDB. When you ask a question, it finds the most semantically relevant chunks and sends them as grounded context to phi3:mini running locally via Ollama. The LLM answers strictly from the paper — no hallucination, no guessing.
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}"]
)Chunking with overlap prevents important context from being split across boundaries — without the 50-token overlap, a sentence describing a key result could get cut exactly between two chunks and become unretrievable as a coherent thought. The embedding model maps each chunk to a dense vector in 768-dimensional semantic space, where chunks about similar ideas end up geometrically close together regardless of their exact 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