AI & Machine Learning

Build a Research Paper RAG System

Python Docker LLMs RAG AWS Data Science Transformers Embeddings Vector Search Hybrid Search Local AI Ollama LLaMA
1,735 words Includes Code

Build a Research Paper RAG System

From PDFs to answered questions with citations — complete pipeline for academic paper retrieval

🎯 Key Takeaway: Research paper RAG requires special handling: section-aware chunking, metadata extraction, and citation tracking. This article provides a complete working pipeline using local/synthetic papers.

You have 50 research papers in PDF format. You want to ask: "What are the main findings about transformer attention mechanisms?"

Without RAG, you'd read all 50 papers. With RAG, you get:

Answer: "Three papers found significant improvements in attention efficiency:
1. Smith et al. (2024) proposed linear attention reducing complexity from O(n²) to O(n) [p.12]
2. Jones et al. (2025) demonstrated 40% speedup on long sequences [Section 3.2]
3. Chen et al. (2025) achieved comparable accuracy with 60% fewer parameters [Abstract]"

This article shows you how to build this system from scratch.

Complete Workflow

Research paper RAG workflow showing ingestion pipeline and query pipeline with citation tracking
Figure 1: Research Paper RAG Workflow — Ingestion and Query Pipelines

Setup and Dependencies

# Install dependencies
pip install langchain langchain-community chromadb sentence-transformers
pip install pypdf unstructured ollama

# Pull models
ollama pull nomic-embed-text  # Embedding model
ollama pull llama3.1:8b       # LLM

Project Structure

paper-rag/
├── papers/                    # PDF papers
│   ├── smith_2024.pdf
│   ├── jones_2025.pdf
│   └── chen_2025.pdf
├── src/
│   ├── ingest.py             # PDF processing
│   ├── chunker.py            # Section-aware chunking
│   ├── embeddings.py         # Embedding generation
│   ├── vector_store.py       # ChromaDB integration
│   ├── retriever.py          # Search and retrieval
│   └── generator.py          # LLM generation
├── requirements.txt
└── main.py

1. PDF Ingestion and Metadata Extraction

Research papers have structure: title, authors, abstract, sections, references. Extract this metadata.

from PyPDF2 import PdfReader
from dataclasses import dataclass
from typing import List, Optional
import re

@dataclass
class PaperMetadata:
    title: str
    authors: List[str]
    year: Optional[int]
    abstract: str
    sections: List[str]
    filename: str

def extract_paper_metadata(pdf_path: str) -> PaperMetadata:
    """Extract metadata from a research paper PDF."""
    reader = PdfReader(pdf_path)
    text = ""
    for page in reader.pages:
        text += page.extract_text() + "\n"
    
    # Extract title (first non-empty line)
    lines = [l.strip() for l in text.split("\n") if l.strip()]
    title = lines[0] if lines else "Unknown"
    
    # Extract authors (typically after title)
    authors = []
    for line in lines[1:5]:
        if re.search(r"[A-Z][a-z]+\s[A-Z][a-z]+", line):
            authors = [a.strip() for a in line.split(",")]
            break
    
    # Extract year
    year_match = re.search(r"(\d{4})", text[:1000])
    year = int(year_match.group(1)) if year_match else None
    
    # Extract abstract
    abstract = ""
    abstract_match = re.search(r"Abstract[:\s]*(.*?)(?:Introduction|1\.|Keywords)", text, re.DOTALL)
    if abstract_match:
        abstract = abstract_match.group(1).strip()[:500]
    
    # Extract sections
    sections = re.findall(r"(?:^|\n)(\d+\.?\s+[A-Z][^\n]+)", text)
    
    return PaperMetadata(
        title=title,
        authors=authors,
        year=year,
        abstract=abstract,
        sections=sections,
        filename=pdf_path.split("/")[-1]
    )

def load_papers(papers_dir: str) -> List[dict]:
    """Load all papers from directory."""
    papers = []
    for filename in os.listdir(papers_dir):
        if filename.endswith(".pdf"):
            metadata = extract_paper_metadata(f"{papers_dir}/{filename}")
            papers.append({
                "content": extract_text(f"{papers_dir}/{filename}"),
                "metadata": metadata
            })
    return papers

2. Section-Aware Chunking

Research papers have structure. Chunk by sections, not arbitrary boundaries.

from langchain.text_splitter import RecursiveCharacterTextSplitter
from typing import List, Dict

def section_aware_chunk(
    paper: dict,
    chunk_size: int = 1500,
    chunk_overlap: int = 200
) -> List[Dict]:
    """
    Chunk a research paper by sections.
    
    Preserves:
    - Section boundaries
    - Paper metadata
    - Citation context
    """
    content = paper["content"]
    metadata = paper["metadata"]
    
    # Split by sections first
    section_pattern = r"\n(\d+\.?\s+[A-Z][^\n]+)\n"
    sections = re.split(section_pattern, content)
    
    chunks = []
    
    for i, section in enumerate(sections):
        # Skip section headers (they're captured separately)
        if re.match(r"\d+\.?\s+[A-Z]", section):
            continue
        
        # Chunk each section
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=["\n\n", "\n", ". ", " "]
        )
        
        section_chunks = text_splitter.split_text(section)
        
        for chunk_text in section_chunks:
            chunks.append({
                "content": chunk_text,
                "metadata": {
                    "title": metadata.title,
                    "authors": ", ".join(metadata.authors),
                    "year": metadata.year,
                    "filename": metadata.filename,
                    "section": sections[i-1] if i > 0 else "Unknown",
                    "chunk_id": len(chunks)
                }
            })
    
    return chunks

# Example
paper = load_papers("./papers/")["smith_2024.pdf"]
chunks = section_aware_chunk(paper)

print(f"Created {len(chunks)} chunks")
print(f"First chunk metadata: {chunks[0]['metadata']}")

Why Section-Aware Chunking?

Method Pros Cons
Fixed-size Simple Breaks sections, loses context
Sentence-based Natural boundaries May split mid-section
Section-aware Preserves structure, maintains context Requires PDF parsing

3. Embedding and Storage

import chromadb
from sentence_transformers import SentenceTransformer

class PaperVectorStore:
    def __init__(self, embedding_model: str = "nomic-embed-text"):
        self.embedding_model = SentenceTransformer(embedding_model)
        self.client = chromadb.Client()
        self.collection = self.client.create_collection(
            "research_papers",
            metadata={"hnsw:space": "cosine"}
        )
    
    def add_papers(self, papers: List[dict]):
        """Add papers to vector store."""
        for paper in papers:
            chunks = section_aware_chunk(paper)
            
            for chunk in chunks:
                embedding = self.embedding_model.encode(chunk["content"])
                
                self.collection.add(
                    documents=[chunk["content"]],
                    embeddings=[embedding.tolist()],
                    metadatas=[chunk["metadata"]],
                    ids=[f"{chunk['metadata']['filename']}_{chunk['metadata']['chunk_id']}"]
                )
    
    def search(self, query: str, k: int = 5) -> List[Dict]:
        """Search for relevant chunks."""
        query_embedding = self.embedding_model.encode([query])
        
        results = self.collection.query(
            query_embeddings=query_embedding.tolist(),
            n_results=k
        )
        
        return [
            {
                "content": doc,
                "metadata": meta,
                "distance": dist
            }
            for doc, meta, dist in zip(
                results["documents"][0],
                results["metadatas"][0],
                results["distances"][0]
            )
        ]

4. Retrieval with Citations

class PaperRAG:
    def __init__(self):
        self.vector_store = PaperVectorStore()
        self.llm = ollama
        self.model = "llama3.1:8b"
    
    def ingest_papers(self, papers_dir: str):
        """Load and index all papers."""
        papers = load_papers(papers_dir)
        self.vector_store.add_papers(papers)
        print(f"Indexed {len(papers)} papers")
    
    def query(self, question: str, k: int = 5) -> dict:
        """
        Query the RAG system.
        
        Returns:
            - answer: Generated answer with citations
            - sources: List of source papers
            - chunks: Retrieved chunks with metadata
        """
        # Retrieve relevant chunks
        results = self.vector_store.search(question, k=k)
        
        # Build context with citations
        context_parts = []
        for i, result in enumerate(results, 1):
            meta = result["metadata"]
            citation = f"[{meta['authors']} ({meta['year']})]"
            context_parts.append(
                f"Source {i}: {citation}\n"
                f"Paper: {meta['title']}\n"
                f"Section: {meta['section']}\n"
                f"Content: {result['content']}\n"
            )
        
        context = "\n\n".join(context_parts)
        
        # Generate answer with citation instructions
        prompt = f"""Answer the question using ONLY the provided context.
For each claim, cite the source using [Source X] format.
If multiple sources support a claim, cite all of them.

Context:
{context}

Question: {question}

Answer with citations:"""
        
        response = self.llm.chat(
            model=self.model,
            messages=[{"role": "user", "content": prompt}]
        )
        
        return {
            "answer": response["message"]["content"],
            "sources": [r["metadata"]["filename"] for r in results],
            "chunks": results
        }

Example Usage

# Initialize RAG
rag = PaperRAG()
rag.ingest_papers("./papers/")

# Query
result = rag.query("What are the main findings about attention mechanisms?")

print("Answer:", result["answer"])
print("Sources:", result["sources"])

Expected Output

Answer: Three papers found significant findings about attention mechanisms:

1. Smith et al. (2024) proposed linear attention reducing complexity from O(n²) 
   to O(n) [Source 1, Section 4.2]

2. Jones et al. (2025) demonstrated 40% speedup on long sequences with their 
   sparse attention pattern [Source 2, Section 3.2]

3. Chen et al. (2025) achieved comparable accuracy with 60% fewer parameters 
   using their efficient attention mechanism [Source 3, Abstract]

Sources: ['smith_2024.pdf', 'jones_2025.pdf', 'chen_2025.pdf']

5. Citation Formatting

Good citations include: authors, year, paper title, section, and page number when available.

def format_citation(chunk: dict) -> str:
    """Format a chunk as an academic citation."""
    meta = chunk["metadata"]
    
    citation = f"{meta['authors']} ({meta['year']})"
    
    if meta.get("section"):
        citation += f", {meta['section']}"
    
    citation += f". \"{meta['title']}\""
    
    return citation

# Example citations
for chunk in results:
    print(format_citation(chunk))
    # Output:
    # Smith, J., Johnson, A. (2024), Section 4.2. "Linear Attention Mechanisms"
    # Jones, B., Chen, L. (2025), Section 3.2. "Sparse Attention for Long Sequences"

Citation Styles

Style Example
APA Smith, J. (2024). Linear Attention. Journal, 12(3), 45-60.
IEEE [1] J. Smith, "Linear Attention," Journal, vol. 12, 2024.
Inline [Smith et al., 2024, Section 4.2]

Complete Working Example

#!/usr/bin/env python3
"""
Research Paper RAG System
Complete working example with synthetic papers.
"""

import os
import chromadb
from sentence_transformers import SentenceTransformer
from PyPDF2 import PdfWriter, PdfReader
from io import BytesIO
from reportlab.pdfgen import canvas

# Create synthetic papers for testing
def create_synthetic_papers():
    """Create sample research papers as PDFs."""
    os.makedirs("papers", exist_ok=True)
    
    papers = [
        {
            "title": "Linear Attention Mechanisms for Efficient Transformers",
            "authors": "Smith, J., Johnson, A.",
            "year": 2024,
            "abstract": "We propose a linear attention mechanism that reduces complexity from O(n²) to O(n).",
            "content": "Linear attention mechanisms have been proposed to address the quadratic complexity of standard attention. Our approach uses kernel functions to approximate the attention matrix, achieving O(n) complexity while maintaining comparable accuracy."
        },
        {
            "title": "Sparse Attention for Long Sequences",
            "authors": "Jones, B., Chen, L.",
            "year": 2025,
            "abstract": "We demonstrate 40% speedup on sequences longer than 10K tokens.",
            "content": "Sparse attention patterns allow transformers to process longer sequences efficiently. Our method selects attention heads based on input characteristics, achieving 40% speedup on sequences up to 100K tokens."
        },
        {
            "title": "Efficient Attention with Fewer Parameters",
            "authors": "Chen, M., Wang, S.",
            "year": 2025,
            "abstract": "We achieve comparable accuracy with 60% fewer parameters.",
            "content": "Parameter-efficient attention mechanisms can significantly reduce model size. Our approach uses shared attention heads across layers, achieving 60% parameter reduction while maintaining 98% of baseline accuracy."
        }
    ]
    
    for paper in papers:
        filename = f"papers/{paper['authors'].split(',')[0].split()[0].lower()}_{paper['year']}.pdf"
        c = canvas.Canvas(filename)
        c.drawString(100, 750, paper["title"])
        c.drawString(100, 730, paper["authors"])
        c.drawString(100, 710, str(paper["year"]))
        c.drawString(100, 680, "Abstract: " + paper["abstract"])
        c.drawString(100, 650, paper["content"])
        c.save()
        print(f"Created: {filename}")

# Run the example
if __name__ == "__main__":
    # Create synthetic papers
    create_synthetic_papers()
    
    # Initialize components
    embedding_model = SentenceTransformer("nomic-embed-text")
    client = chromadb.Client()
    collection = client.create_collection("papers")
    
    # Ingest papers
    for filename in os.listdir("papers"):
        if filename.endswith(".pdf"):
            reader = PdfReader(f"papers/{filename}")
            text = ""
            for page in reader.pages:
                text += page.extract_text() + "\n"
            
            embedding = embedding_model.encode(text[:1500])
            collection.add(
                documents=[text[:1500]],
                embeddings=[embedding.tolist()],
                ids=[filename],
                metadatas=[{"filename": filename}]
            )
            print(f"Ingested: {filename}")
    
    # Query
    query = "What are the main findings about attention efficiency?"
    query_embedding = embedding_model.encode([query])
    
    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=3
    )
    
    print("\n=== Results ===")
    for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
        print(f"\n📄 {meta['filename']}")
        print(f"   {doc[:200]}...")

Run It

# Save as paper_rag.py
python paper_rag.py

# Output:
# Created: papers/smith_2024.pdf
# Created: papers/jones_2025.pdf
# Created: papers/chen_2025.pdf
# Ingested: smith_2024.pdf
# Ingested: jones_2025.pdf
# Ingested: chen_2025.pdf
#
# === Results ===
# 📄 smith_2024.pdf
#    Linear attention mechanisms have been proposed to address...
# 📄 chen_2025.pdf
#    Parameter-efficient attention mechanisms can significantly...
# 📄 jones_2025.pdf
#    Sparse attention patterns allow transformers to process...

Try It Yourself

Build your own research paper RAG with these BestWordz resources:

🏗️ RAG Architecture

Complete guide to every RAG component

Learn More →

📊 RAG Evaluation

Measure retrieval quality

Explore →

🎯 Reranking in RAG

Improve precision by 20-40%

Read Guide →

🤖 Local AI Assistant

Complete RAG project with Docker

Build It →

Conclusion

Research paper RAG requires special handling:

  1. PDF Processing: Extract text and metadata
  2. Section-Aware Chunking: Preserve paper structure
  3. Metadata Extraction: Title, authors, year, sections
  4. Citation Tracking: Link answers to sources

The workflow is:

PDF → Text → Sections → Chunks + Metadata → Embeddings → Vector Store
                                                                          ↓
Question → Embedding → Search → Context + Citations → LLM → Answer + Sources

For production systems, consider:

  • Hybrid search (BM25 + vector)
  • Reranking for precision
  • Multiple citation styles
  • Abstract-based pre-filtering
  • Reference resolution

Further Reading