Medical Literature RAG: PubMed + GPT-5 Research Assistant (2026)
The short answer
A PubMed research assistant built on RAG ingests biomedical article abstracts, chunks them by section (background, methods, results, conclusion), filters by PubMed metadata (study type, publication date, journal), and answers researcher queries with explicit citations. This tutorial builds the full assistant using the PubMed API, OpenAI embeddings, Qdrant storage, and GPT-5 for answer generation. Safety guardrails prevent off-label medical advice generation.
Medical literature doubles every nine years. PubMed indexes over 36 million citations. No clinician reads even a thin slice of it. A medical literature RAG system, done right, surfaces the specific evidence that answers a specific question, with every claim traceable to a specific paper and specific line.
Done wrong, the same system invents studies, confuses correlation with causation, and quietly ships evidence from retracted papers. This guide covers how to build the right version: ingestion from PubMed, section-aware chunking, retrieval that respects publication date and study type, generation that cites papers precisely, and the retraction-check layer that keeps the system honest.
Why medical RAG differs from everything else
Three structural problems medical literature introduces:
- Study type matters as much as content. A randomized controlled trial carries weight an observational study does not. A systematic review carries weight a case report does not. The retrieval system has to surface and the generator has to respect this hierarchy.
- Dates matter for safety. A 2015 paper on COVID-19 therapeutics would have been shocking, since the virus did not exist. More subtly, pre-2020 guidance on anticoagulants is often superseded. Date filtering is a hard requirement.
- Retractions exist and matter. About 1 in 500 published medical papers gets retracted. Many stay citeable in corpora long after the retraction notice. The system has to know.
Everything that follows is in service of these three constraints.
Ingest from PubMed with structured metadata
The NCBI E-utilities API returns PubMed records with abstract, authors, publication year, study type (MeSH descriptors), journal, and more. The Bio.Entrez package in Biopython is the easiest access path.
from Bio import Entrez
Entrez.email = "research@example.com"
def fetch_pubmed(query, max_results=100):
search = Entrez.esearch(
db="pubmed", term=query, retmax=max_results
)
ids = Entrez.read(search)["IdList"]
fetch = Entrez.efetch(
db="pubmed", id=",".join(ids), rettype="xml", retmode="xml"
)
records = Entrez.read(fetch)
results = []
for article in records["PubmedArticle"]:
md = article["MedlineCitation"]
pmid = str(md["PMID"])
art = md["Article"]
title = str(art.get("ArticleTitle", ""))
abstract = ""
if "Abstract" in art and "AbstractText" in art["Abstract"]:
abstract = " ".join(str(x) for x in art["Abstract"]["AbstractText"])
year = art["Journal"]["JournalIssue"]["PubDate"].get("Year", "unknown")
journal = str(art["Journal"]["Title"])
mesh = [
str(m["DescriptorName"])
for m in md.get("MeshHeadingList", [])
]
pub_types = [str(pt) for pt in art.get("PublicationTypeList", [])]
results.append({
"pmid": pmid,
"title": title,
"abstract": abstract,
"year": year,
"journal": journal,
"mesh_terms": mesh,
"publication_types": pub_types,
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
})
return resultsThe publication_types field is the one that drives evidence ranking. Values include “Randomized Controlled Trial”, “Meta-Analysis”, “Systematic Review”, “Case Reports”, “Review”, and dozens more. Store this as a metadata field; use it to filter and rank at query time.
Chunk by abstract section
Modern PubMed abstracts are often structured with labeled sections: Background, Methods, Results, Conclusions. These labels are retrieval gold.
import re
SECTION_PATTERN = re.compile(
r"(BACKGROUND|OBJECTIVE|METHODS|RESULTS|CONCLUSIONS?|DISCUSSION)[:\.]\s*",
re.IGNORECASE,
)
def chunk_abstract(abstract, pmid, meta):
parts = SECTION_PATTERN.split(abstract)
if len(parts) < 3:
return [{"text": abstract, "section": "full", "pmid": pmid, **meta}]
chunks = []
for i in range(1, len(parts) - 1, 2):
label = parts[i].upper()
text = parts[i + 1].strip()
if text:
chunks.append({
"text": f"{label}: {text}",
"section": label.lower(),
"pmid": pmid,
**meta,
})
return chunksA query like “metformin dose for prediabetes” benefits from retrieving the METHODS and RESULTS sections, not CONCLUSIONS. The section label in metadata lets the system filter or re-rank by section when the query signals a specific need.
For full-text papers (through PMC Open Access), chunk by heading structure similar to the legal clause pattern: Introduction, Methods, Results, Discussion each as separate chunks with the paper’s title and PMID attached.
Store evidence-rank metadata on every chunk
Translate publication_types into a numeric evidence rank that reflects the standard hierarchy of medical evidence.
EVIDENCE_RANK = {
"Meta-Analysis": 1,
"Systematic Review": 2,
"Randomized Controlled Trial": 3,
"Clinical Trial": 4,
"Cohort Studies": 5,
"Case-Control Studies": 6,
"Cross-Sectional Studies": 7,
"Observational Study": 8,
"Case Reports": 9,
"Review": 10,
"Editorial": 11,
}
def evidence_rank(pub_types):
best = 99
for pt in pub_types:
best = min(best, EVIDENCE_RANK.get(pt, 99))
return bestLower number is stronger evidence. A chunk from a Meta-Analysis (rank 1) will be weighted ahead of a Case Report (rank 9) when the generator chooses what to cite.
Retrieve with evidence-aware re-ranking
Standard dense + sparse retrieval gets the top candidates. A re-rank pass weights by evidence rank and recency.
def evidence_rerank(matches, now_year=2026):
def score(m):
base = m["score"]
md = m["metadata"]
rank = md.get("evidence_rank", 99)
rank_boost = max(0, 1.0 - rank / 10)
year = int(md.get("year", 0)) if str(md.get("year", "")).isdigit() else 0
age = now_year - year if year else 20
recency_boost = max(0, 1.0 - age / 15)
return base + 0.2 * rank_boost + 0.1 * recency_boost
return sorted(matches, key=score, reverse=True)The weights are tunable. A system aimed at acute clinical decisions might boost recency harder. A system for pathophysiology research might flatten the recency weight because 10-year-old mechanistic papers still matter.
Check retractions before serving the response
Retraction Watch publishes a free retraction database. Keep a local copy, refresh weekly.
import json
RETRACTED_PMIDS = set()
with open("retraction_watch.jsonl") as f:
for line in f:
row = json.loads(line)
if row.get("pmid"):
RETRACTED_PMIDS.add(str(row["pmid"]))
def filter_retracted(chunks):
live = [c for c in chunks if c["metadata"]["pmid"] not in RETRACTED_PMIDS]
retracted = [c for c in chunks if c["metadata"]["pmid"] in RETRACTED_PMIDS]
return live, retractedIf a retrieved chunk is from a retracted paper, drop it from the context passed to the model AND surface a warning to the user: “A retracted paper (PMID 123456) was in your query results. See retraction notice: [link].” Clinicians need to know what did not make it into the answer and why.
Generation prompt for medical RAG
Precision over fluency. Strict grounding. Explicit uncertainty.
SYSTEM = """You are a medical literature research assistant. You answer clinical research questions using ONLY the provided PubMed abstracts and papers. Rules: 1. Every clinical claim MUST cite a specific paper by PMID and study type: "(PMID 12345678, Randomized Controlled Trial, 2024)". 2. Do NOT cite any paper not in the provided sources. 3. Prefer evidence from higher-ranked study types (Meta-Analysis > Systematic Review > RCT > Observational > Case Report). 4. If evidence is conflicting, state the conflict explicitly and provide both citations. 5. If the sources do not answer the question, respond: "The provided sources do not address this clinical question." 6. Describe effect sizes with confidence intervals when the source reports them. Do not round or paraphrase statistics. 7. Flag populations: "This evidence is from adults; pediatric extrapolation is unsupported." 8. Do NOT provide clinical advice. Describe what the literature reports. 9. If any retrieved source is retracted, mention this: "Note: PMID X was retracted; it is excluded from this analysis." """
Rules 6, 7, 8 are the ones that separate a medical literature assistant from a glossy summary machine. Clinicians need numbers, not vibes.
Query interface for clinicians
Expose the metadata filters as UI controls so researchers can scope by evidence type, date range, population, and intervention. Dropdowns beat buried JSON every time.
class ResearchQuery(BaseModel):
question: str
study_types: list[str] | None = None # e.g. ["Randomized Controlled Trial"]
year_min: int | None = None
year_max: int | None = None
mesh_filters: list[str] | None = None # e.g. ["Diabetes Mellitus, Type 2"]
exclude_retracted: bool = True
@app.post("/research")
async def research(req: ResearchQuery):
filters = {}
if req.study_types:
filters["publication_types"] = {"$in": req.study_types}
if req.year_min or req.year_max:
filters["year_int"] = {}
if req.year_min: filters["year_int"]["$gte"] = req.year_min
if req.year_max: filters["year_int"]["$lte"] = req.year_max
retrieved = hybrid_retrieve(req.question, filters=filters, k=10)
if req.exclude_retracted:
retrieved, dropped_retracted = filter_retracted(retrieved)
ranked = evidence_rerank(retrieved)
answer = generate(req.question, ranked[:5])
return {
"answer": answer,
"sources": [...],
"retracted_papers_excluded": dropped_retracted if req.exclude_retracted else [],
}The retracted_papers_excluded field is non-cosmetic. Researchers deserve to know what was filtered and why.
Scope and safety limits
Four hard limits worth coding in:
- Not a diagnostic tool. The system informs research. It is not a clinical decision support system cleared by any regulator.
- Not for direct patient use. UI should be scoped to healthcare professionals. End-user patient-facing systems carry very different compliance requirements.
- Rate limits per user. Prevent large-scale scraping of your indexed corpus through the retrieval endpoint.
- Audit log for every query. Research misconduct investigations sometimes trace back through literature-search tools. Keep searchable logs for at least seven years.
Frequently asked questions
Official documentation
How do I prevent a medical RAG system from citing retracted papers?
Maintain a local copy of the Retraction Watch database (available in CSV and JSONL). Refresh weekly. Store retracted PMIDs in a set. After retrieval, filter out any chunk whose PMID is in that set, and surface a message to the user that retracted sources were excluded.
What is the right evidence ranking for medical RAG?
The GRADE / Oxford CEBM hierarchy is the standard: meta-analyses and systematic reviews at the top, randomized controlled trials next, then cohort and case-control studies, then observational and case reports. Weighting the re-rank pass by this hierarchy ensures the model cites the strongest available evidence.
How do I handle full-text papers vs abstracts?
PubMed Central Open Access gives you full-text XML for roughly 25% of papers. Index both: a short chunk per abstract section, and section-level chunks (Methods, Results, Discussion) from the full text. Retrieve from both pools and let the reranker surface what matters. For papers behind paywall, the structured abstract is often enough.
What embedding model works best for medical literature?
BioBERT, PubMedBERT, and sci-bert outperform general-purpose embeddings on biomedical retrieval. BGE-M3 is a strong open-source baseline. For API-based options, Voyage voyage-3-large is competitive. For top quality, fine-tune on PubMed-domain query-chunk pairs; a small dataset of 2,000-5,000 pairs is enough to beat off-the-shelf models by 10-15%.
Can a medical literature RAG system give treatment recommendations?
It should not. The system should describe what the literature reports, not what the user should do. Treatment decisions involve patient-specific factors a literature system cannot know. Clear UI messaging and system prompt rules keep the output in “evidence summary” territory, not clinical advice.
How much does a PubMed RAG system cost to run?
Index-side costs for 1 million recent abstracts: embedding at Voyage voyage-3 is around $100 one-time, Pinecone storage around $70/mo. Query-side: Cohere Rerank around $50/mo at moderate volumes, Claude Opus 5.5 generation around $400-800/mo at 10,000 queries per month. Total $600-1,000/mo for a mid-volume research assistant.
Medical RAG rewards discipline. The system is only useful if clinicians trust it, and trust comes from precision, honest handling of retractions and conflicts, and never pretending to be a clinical decision tool. Build the citation-validation and retraction-filter layers from day one.
