OncoRAG: Hybrid Retrieval for Oncology

Build a citation-grounded oncology RAG with BM25 and dense vector search.

Introduction

30 Second Summary

When a doctor searches medical literature for treatment options, they need exact evidence they can trace back to a specific paper and page. A wrong citation in oncology can mean the difference between the right treatment and a harmful one.

In this project, you will build a citation-grounded oncology research assistant that ingests cancer research PDFs, retrieves relevant passages using hybrid search, and generates answers where every claim maps to a specific source and page number.

What You'll Build

You upload an oncology PDF, ask a clinical question, and watch the system return a grounded answer with inline citations like [Source 1, p.4] alongside a confidence score telling you how well the evidence supports the response.

By the end of this project, you'll have:

  • A hybrid retrieval pipeline combining BM25 keyword search with dense vector embeddings, fused via Reciprocal Rank Fusion. You can query exact drug names and gene mutations just as easily as semantic paraphrases.
  • A citation-grounded generation system where every LLM claim traces back to a specific passage and page number. A confidence score warns you when the evidence is insufficient so you know if the model is speculating.
  • A FastAPI backend and Streamlit UI you can demo end-to-end: upload a PDF, ask a clinical question, and verify citations match the actual paper in a browser.
  • Secret Mission: Add a query decomposition layer that breaks complex clinical questions into sub-queries for better retrieval coverage.

Are there any prerequisites?

This project assumes you have Python experience and prior knowledge of how RAG (Retrieval-Augmented Generation) works at a high level. You will also need an OpenAI API key ready before you start.

Before We Start

Before we dive in, let's lock in what you're about to build and why hybrid retrieval is the right approach for medical literature.

Set Up the Project

The OncoRAG system depends on several specialised libraries: one for extracting text from PDFs, one for storing vector embeddings, one for sparse keyword retrieval, and one for calling the OpenAI API.

In this step you will create the project directory, install every dependency, and scaffold the folder structure the rest of the project builds on.

In this step, get ready to:
  • Create the project directory and activate a virtual environment.
  • Install all Python packages and configure your OpenAI API key.
  • Scaffold the project folder structure and download a sample oncology PDF.
Create the project directory and virtual environment

A virtual environment isolates this project's packages from the rest of your system. That prevents version conflicts between projects.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir oncorag
cd oncorag
  • Create a virtual environment by running:
python -m venv .venv
  • Activate the virtual environment:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the beginning. This confirms you are working inside the isolated environment.

What about Linux?

Linux uses the same activation command as macOS: source .venv/bin/activate.

Don't see (.venv) in your prompt?

Make sure you ran the activation command for your operating system. On macOS/Linux it starts with source. On Windows it uses the Scripts\activate path.

If you see a permissions error on Windows, try running PowerShell as Administrator and executing Set-ExecutionPolicy RemoteSigned first.

Help me troubleshoot this

Install all dependencies

Pinning package versions in a requirements.txt file ensures everyone working on this project gets the same library versions.

  • Create a file called requirements.txt in the root of your oncorag folder with the following contents:
pymupdf==1.28.0
chromadb==1.5.9
rank-bm25==0.2.2
openai
fastapi[standard]
streamlit
uvicorn
python-dotenv

What do these packages do?

  • pymupdf extracts text from PDF files while preserving page numbers.
  • chromadb stores vector embeddings with persistent local storage.
  • rank-bm25 provides the BM25 sparse keyword retrieval algorithm.
  • openai calls the OpenAI API for embeddings and text generation.
  • fastapi[standard] and uvicorn serve the backend API.
  • streamlit builds the demo UI.
  • python-dotenv loads environment variables from a .env file.
  • Install all packages by running:
pip install -r requirements.txt

You will see pip downloading and installing each package. This may take a minute or two depending on your connection speed.

  • Before you run the next command, what do you think will happen if one of the packages failed to install?
  • Verify all packages imported successfully by running:
python -c "import pymupdf, chromadb, rank_bm25, openai, fastapi, streamlit; print('All imports OK')"

You should see All imports OK printed with no errors.

Seeing an ImportError?

Confirm your virtual environment is still active. You should see (.venv) in your prompt. If not, re-run the activate command from earlier.

If a specific package failed, try reinstalling it individually, for example pip install pymupdf==1.28.0. Check that your Python version is 3.10 or higher with python --version.

Help me fix this import error

Create the project structure and configuration

The project uses a modular layout with separate folders for core logic, the API layer, and the UI.

  • Create all the required directories by running:

macOS

mkdir -p app/core app/api app/ui data/papers

Windows

mkdir app/core, app/api, app/ui, data/papers
  • Confirm the folders exist by running:
ls app

You should see api, core, and ui listed.

  • Create the Python package marker files and your .env file by running:

macOS

touch app/__init__.py app/core/__init__.py app/api/__init__.py
echo 'OPENAI_API_KEY=your-api-key-here' > .env

Windows

New-Item app/__init__.py, app/core/__init__.py, app/api/__init__.py -Force
Set-Content .env 'OPENAI_API_KEY=your-api-key-here'
  • Open .env in your code editor and replace your-api-key-here with your actual OpenAI API key.
  • Save the file.

Why a .env file?

Storing your API key in a .env file keeps it out of your source code. The python-dotenv library loads it into your environment at runtime so you never hardcode secrets.

  • Create the configuration file app/core/config.py with the following contents:
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# API keys and model configuration
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
EMBEDDING_MODEL = "text-embedding-3-small"
GENERATION_MODEL = "gpt-4o-mini"

# Chunking parameters for PDF text splitting
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50

# Reciprocal Rank Fusion parameter
RRF_K = 60

# Confidence scoring thresholds
CONFIDENCE_THRESHOLD_HIGH = 0.65
CONFIDENCE_THRESHOLD_MEDIUM = 0.4

# Storage paths
CHROMA_PERSIST_DIR = "./chroma_db"
DATA_DIR = "./data/papers"

What does this config do?

This file centralises every tuneable value in the project. CHUNK_SIZE controls how many tokens each text chunk holds. RRF_K is the standard Reciprocal Rank Fusion constant from the original Cormack et al. paper.

The confidence thresholds determine when the system labels an answer as High, Medium, or Low confidence. You will use all of these values in later steps.

  • Verify the config loads correctly by running:
python -c "from app.core.config import CHUNK_SIZE; print(f'Config loaded: CHUNK_SIZE={CHUNK_SIZE}')"

You should see Config loaded: CHUNK_SIZE=512 printed.

  • Download a sample oncology research paper into data/papers/. You need any open-access cancer research PDF from PubMed Central. Download one by running:
curl -L -o data/papers/sample.pdf "https://pmc.ncbi.nlm.nih.gov/articles/PMC12744472/pdf/41394_2025_Article_939.pdf"

Use any oncology PDF

If the download fails or you prefer a different paper, visit pmc.ncbi.nlm.nih.gov in your browser, search for any open-access oncology paper, and save the PDF into data/papers/ with the filename sample.pdf.

  • Verify the PDF loads with PyMuPDF by running:
python -c "import pymupdf; doc = pymupdf.open('data/papers/sample.pdf'); print(f'Pages: {len(doc)}')"

You should see something like Pages: 12 printed. The exact number depends on the paper you downloaded.

Seeing FileNotFoundError?

Check that the file exists at data/papers/sample.pdf. Run ls data/papers/ to confirm. If the curl download failed, open a PMC article directly in your browser and save the PDF manually.

Help me fix this file path error

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt, .env, and app/core/config.py.

ⓧ I'd like to double check the full code

Here is requirements.txt:

pymupdf==1.28.0
chromadb==1.5.9
rank-bm25==0.2.2
openai
fastapi[standard]
streamlit
uvicorn
python-dotenv

Here is .env:

OPENAI_API_KEY=your-api-key-here

Here is app/core/config.py:

import os
from dotenv import load_dotenv

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
EMBEDDING_MODEL = "text-embedding-3-small"
GENERATION_MODEL = "gpt-4o-mini"
CHUNK_SIZE = 512
CHUNK_OVERLAP = 50
RRF_K = 60
CONFIDENCE_THRESHOLD_HIGH = 0.65
CONFIDENCE_THRESHOLD_MEDIUM = 0.4
CHROMA_PERSIST_DIR = "./chroma_db"
DATA_DIR = "./data/papers"

The __init__.py files in app/, app/core/, and app/api/ are empty. They mark these directories as Python packages.

Your environment is ready and every dependency is confirmed working. Next up, you will build the PDF ingestion pipeline that extracts text and splits it into searchable chunks with page-level metadata.

Build the PDF Ingestion and Chunking Pipeline

Your environment is ready with all the packages installed and your project structure in place. Now it's time to solve the first real challenge: turning a raw PDF into searchable data.

Raw PDFs cannot be searched directly. To retrieve relevant passages later, you need to extract text while tracking which page each piece came from. Without this page-level metadata, you cannot provide page-number citations that make a medical RAG system trustworthy.

In this step, get ready to:
  • Extract text from PDF pages while preserving page-number metadata.
  • Split extracted text into overlapping chunks that maintain context at boundaries.
  • Run the full ingestion pipeline and verify chunks carry correct page references.
Build the PDF text extractor using PyMuPDF

The extractor opens a PDF, reads every page, and stores each page's text alongside its page number. This metadata travels with the text through the entire pipeline so you can cite exact pages later.

  • Create app/core/ingestion.py and add the imports and text extraction function by pasting this code:
import pymupdf
from typing import list

from app.core.config import CHUNK_SIZE, CHUNK_OVERLAP


# Extract text from a PDF while tracking which page each piece came from
def extract_text_with_pages(pdf_path: str) -> list[dict]:
    doc = pymupdf.open(pdf_path)
    pages = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        # Get all text content from this page
        text = page.get_text()
        # Only include pages that have actual text content
        if text.strip():
            pages.append({
                "text": text,
                "page_number": page_num + 1,  # 1-indexed for citations
                "source_file": pdf_path.split("/")[-1]
            })
    doc.close()
    return pages

What does this code do?

  • pymupdf.open() opens the PDF file and gives you access to each page as an object.
  • page.get_text() extracts all text content from a single page.
  • The if text.strip() check skips blank pages (like cover images or dividers) that have no useful text.
  • Each page's text is stored in a dictionary alongside its page_number (1-indexed so citations read naturally) and source_file name.
  • Save app/core/ingestion.py.
  • Verify the extractor works by running this command:
python -c "from app.core.ingestion import extract_text_with_pages; pages = extract_text_with_pages('data/papers/sample.pdf'); print('Extracted text from ' + str(len(pages)) + ' pages')"

You should see output like Extracted text from 12 pages (your number will vary depending on your PDF).

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • Confirm you are running the command from inside the oncorag/ project directory, not from app/core/.
  • help me debug this ModuleNotFoundError when importing from app.core.ingestion
Implement overlapping chunking

A 512-word chunk captures enough context for meaningful retrieval. The 50-word overlap ensures that sentences split across chunk boundaries still appear in at least one complete chunk.

  • In app/core/ingestion.py, add the chunking function below the extract_text_with_pages function:
# Split text into overlapping chunks to preserve context at boundaries
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
    words = text.split()
    chunks = []
    start = 0
    # Slide a window across the text, stepping back by overlap each time
    while start < len(words):
        end = start + chunk_size
        chunk = " ".join(words[start:end])
        chunks.append(chunk)
        # Move forward by (chunk_size - overlap) words
        start = end - overlap
    return chunks

Why overlapping chunks?

Imagine a key sentence sits right at the boundary between two chunks. Without overlap, half the sentence lands in chunk A and half in chunk B. Neither chunk alone retrieves the full meaning.

With a 50-word overlap, chunk B starts 50 words before where chunk A ended. That boundary sentence appears in full in at least one chunk, so the retriever can always find it.

  • Save app/core/ingestion.py.
  • Test the chunking logic by running:
python -c "from app.core.ingestion import chunk_text; chunks = chunk_text('word ' * 600); print('Created ' + str(len(chunks)) + ' chunks from 600 words')"

You should see Created 2 chunks from 600 words. 600 words split into a 512-word chunk and a second chunk starting 462 words in (512 minus 50 overlap).

Seeing 0 chunks or an error?

  • Make sure chunk_text is defined outside and below extract_text_with_pages. Python requires functions to be at the module level, not nested inside another function.
  • Check that CHUNK_SIZE and CHUNK_OVERLAP are imported from app.core.config at the top of your file.
  • help me debug why chunk_text is returning unexpected results
Run ingestion on your sample PDF and verify page references

The final function wires together the extractor and chunker. It processes an entire PDF and returns a list of chunks, each carrying an ID, the text content, and metadata for citation.

  • In app/core/ingestion.py, add the ingest_pdf function below chunk_text:
# Orchestrate full PDF ingestion: extract pages, chunk text, attach metadata
def ingest_pdf(pdf_path: str) -> list[dict]:
    pages = extract_text_with_pages(pdf_path)
    all_chunks = []
    chunk_index = 0
    for page_data in pages:
        # Chunk each page's text independently
        chunks = chunk_text(page_data["text"])
        for chunk in chunks:
            # Each chunk carries its source file, page, and position
            all_chunks.append({
                "id": f"{page_data['source_file']}_chunk_{chunk_index}",
                "text": chunk,
                "metadata": {
                    "source_file": page_data["source_file"],
                    "page_number": page_data["page_number"],
                    "chunk_index": chunk_index
                }
            })
            chunk_index += 1
    return all_chunks

What does this code do?

  • ingest_pdf calls extract_text_with_pages to get page-level text, then passes each page through chunk_text.
  • Each chunk gets a unique id built from the filename and a global counter. This ID is used later as the document key in ChromaDB.
  • The metadata dict on each chunk stores source_file, page_number, and chunk_index. This metadata flows through retrieval into generation, enabling citations like [Source 1, p.4].
  • Save app/core/ingestion.py.

Before you run the full pipeline, what do you think the output will show? How many chunks would you expect from a 12-page research paper?

  • Run the full ingestion pipeline on your sample PDF:
python -c "
from app.core.ingestion import ingest_pdf
chunks = ingest_pdf('data/papers/sample.pdf')
pages = set(c['metadata']['page_number'] for c in chunks)
print('Extracted ' + str(len(chunks)) + ' chunks from ' + str(len(pages)) + ' pages')
for c in chunks[:3]:
    m = c['metadata']
    print('  Chunk ' + str(m['chunk_index']) + ': page ' + str(m['page_number']))
"

You should see output like:

Extracted 47 chunks from 12 pages
  Chunk 0: page 1
  Chunk 1: page 1
  Chunk 2: page 2

Your exact numbers will differ depending on your PDF's length. The key is that each chunk reports a correct page number tracing back to its source location in the document.

Seeing 0 chunks or a FileNotFoundError?

  • If you see FileNotFoundError, confirm your PDF is at data/papers/sample.pdf. Run ls data/papers/ to check the exact filename and adjust the path in the command.
  • If you see 0 chunks, your PDF might be image-based (scanned). PyMuPDF extracts text layers only. Try a different open-access paper from PubMed Central that has selectable text.
  • help me debug why my PDF ingestion is returning 0 chunks

✔️ Awesome, I've got everything!

Great. Double check you have saved app/core/ingestion.py and that the output confirms your chunks have page references.

ⓧ I'd like to double check the full code

Your app/core/ingestion.py should contain these sections in order. Scroll up and confirm each is present:

  • Imports at the top: pymupdf, typing, and CHUNK_SIZE/CHUNK_OVERLAP from config.
  • The extract_text_with_pages function that opens a PDF and returns page-level text dicts.
  • The chunk_text function that splits text into overlapping word-based chunks.
  • The ingest_pdf function that orchestrates extraction and chunking, attaching metadata to each chunk.

Your ingestion pipeline is working. Each chunk now carries the metadata needed for page-level citations. Next up, you'll build the hybrid retrieval engine that searches these chunks using both keyword matching and semantic similarity.

Implement Hybrid Retrieval with Reciprocal Rank Fusion

Your PDF chunks are ready, but they cannot answer questions yet. You need a retrieval system that finds the most relevant passages for a given query.

Pure semantic search (dense vectors) understands meaning but misses exact medical terms like drug names and gene mutations. Pure keyword search (BM25) catches those exact terms but misses paraphrases. In this step, you will build both approaches, see vector-only search fall short, then combine them using Reciprocal Rank Fusion to get the best of both worlds.

In this step, get ready to:
  • Build a dense retrieval component using ChromaDB and OpenAI embeddings.
  • Build a BM25 sparse retrieval component for exact keyword matching.
  • Implement Reciprocal Rank Fusion to merge both result sets into a single ranked list.
Build the dense retrieval foundation

Dense retrieval converts text into high-dimensional vectors, then finds passages whose vectors are closest to the query vector. You will use ChromaDB for vector storage and OpenAI's text-embedding-3-small model for embeddings.

  • Create the file app/core/retrieval.py and add the imports, OpenAI client, collection helper, and embedding function:
import chromadb
from openai import OpenAI
from rank_bm25 import BM25Okapi
from typing import list

from app.core.config import (
    OPENAI_API_KEY,
    EMBEDDING_MODEL,
    CHROMA_PERSIST_DIR,
    RRF_K
)

# Initialize the OpenAI client for embedding calls
client = OpenAI(api_key=OPENAI_API_KEY)


def get_chroma_collection():
    # Connect to persistent ChromaDB storage
    chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIR)
    collection = chroma_client.get_or_create_collection(
        name="oncology_papers",
        metadata={"hnsw:space": "cosine"}
    )
    return collection


def embed_text(text: str) -> list[float]:
    # Generate a vector embedding for a single text string
    response = client.embeddings.create(
        input=text,
        model=EMBEDDING_MODEL
    )
    return response.data[0].embedding

What does this code do?

The imports pull in ChromaDB for vector storage, the OpenAI SDK for embeddings, and BM25Okapi for keyword search (used later).

get_chroma_collection() creates a persistent vector database on disk at ./chroma_db/. The cosine metric measures similarity between embedding vectors. embed_text() calls the OpenAI API and returns a 1536-dimensional vector representing the input text.

  • Verify your embeddings are working by running this test:
python -c "from app.core.retrieval import embed_text; v = embed_text('test'); print(f'Embedding dimensions: {len(v)}')"

You should see Embedding dimensions: 1536 printed in your terminal.

Seeing an authentication error?

Make sure your .env file contains a valid OpenAI API key and that python-dotenv is installed. The config.py module loads the key via load_dotenv() at import time.

help me debug my OpenAI API authentication error

Add the chunk indexer and dense search function

Now you need two more functions: one to embed and store all your chunks in ChromaDB, and one to search them by vector similarity.

  • Add the index_chunks function below embed_text in app/core/retrieval.py:
def index_chunks(chunks: list[dict]):
    # Embed all chunks in batches and store in ChromaDB
    collection = get_chroma_collection()
    ids = [chunk["id"] for chunk in chunks]
    documents = [chunk["text"] for chunk in chunks]
    metadatas = [chunk["metadata"] for chunk in chunks]
    embeddings = []
    batch_size = 100
    for i in range(0, len(documents), batch_size):
        batch = documents[i:i + batch_size]
        response = client.embeddings.create(input=batch, model=EMBEDDING_MODEL)
        embeddings.extend([item.embedding for item in response.data])
    collection.add(
        ids=ids,
        documents=documents,
        metadatas=metadatas,
        embeddings=embeddings
    )
    return len(ids)

What does this code do?

index_chunks() takes the list of chunk dictionaries from your ingestion pipeline, embeds them in batches of 100 (to stay within API limits), and stores the vectors plus metadata in ChromaDB. Batching reduces API calls from one-per-chunk to one-per-hundred.

  • Add the dense_search function below index_chunks:
def dense_search(query: str, k: int = 10) -> list[dict]:
    # Find the k most semantically similar chunks to the query
    collection = get_chroma_collection()
    query_embedding = embed_text(query)
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=k,
        include=["documents", "metadatas", "distances"]
    )
    search_results = []
    for i in range(len(results["ids"][0])):
        search_results.append({
            "id": results["ids"][0][i],
            "text": results["documents"][0][i],
            "metadata": results["metadatas"][0][i],
            "score": 1 - results["distances"][0][i]
        })
    return search_results

Why subtract distance from 1?

ChromaDB returns cosine distance (0 = identical, 1 = completely different). Subtracting from 1 converts it to a similarity score (1 = identical, 0 = completely different). This makes higher scores mean better matches, which is more intuitive when ranking results.

  • Save app/core/retrieval.py.
  • Index your sample PDF and test dense-only search by running:
python -c "
from app.core.ingestion import ingest_pdf
from app.core.retrieval import index_chunks, dense_search

chunks = ingest_pdf('data/papers/sample.pdf')
print(f'Indexing {len(chunks)} chunks...')
count = index_chunks(chunks)
print(f'Indexed {count} chunks into ChromaDB')

print('\n--- Dense search: EGFR T790M resistance mechanisms ---')
results = dense_search('EGFR T790M resistance mechanisms', k=5)
for i, r in enumerate(results):
    print(f'{i+1}. [p.{r["metadata"]["page_number"]}] score={r["score"]:.4f} | {r["text"][:80]}...')
"

Before you run this, what do you expect? Will vector search reliably surface passages containing the exact string "EGFR T790M"?

You will see results ranked by semantic similarity. Some may be topically related but not contain the exact terms "EGFR" or "T790M". Dense search understands meaning, but it treats specific gene mutations the same as general resistance concepts. Exact medical terminology gets diluted.

Why does this matter for medical search?

In oncology, the difference between "EGFR T790M" and "EGFR L858R" is clinically significant. They are different mutations with different treatment implications. A purely semantic model might rank them similarly because they are both EGFR mutations. Keyword-based search would correctly distinguish them.

Build BM25 sparse retrieval and fuse with Reciprocal Rank Fusion

BM25 is a classical information retrieval algorithm that scores documents by exact term frequency. It excels at finding passages containing specific drug names, gene symbols, and dosage values that vector search treats as interchangeable.

  • Add the sparse_search function below dense_search in app/core/retrieval.py:
def sparse_search(query: str, chunks: list[dict], k: int = 10) -> list[dict]:
    # Build a BM25 index and find chunks matching exact query terms
    tokenized_corpus = [chunk["text"].lower().split() for chunk in chunks]
    bm25 = BM25Okapi(tokenized_corpus)
    tokenized_query = query.lower().split()
    scores = bm25.get_scores(tokenized_query)
    top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
    search_results = []
    for idx in top_indices:
        search_results.append({
            "id": chunks[idx]["id"],
            "text": chunks[idx]["text"],
            "metadata": chunks[idx]["metadata"],
            "score": float(scores[idx])
        })
    return search_results

What does this code do?

sparse_search() tokenizes the entire corpus and the query into lowercase words, then uses the BM25Okapi algorithm to score each chunk by how many query terms it contains (weighted by term frequency and document length). Passages with the exact words "EGFR", "T790M", and "resistance" score highest.

  • Now add the reciprocal_rank_fusion function below sparse_search:
def reciprocal_rank_fusion(
    dense_results: list[dict],
    sparse_results: list[dict],
    k: int = RRF_K,
    top_n: int = 5
) -> list[dict]:
    # Merge dense and sparse results using RRF scoring
    rrf_scores = {}
    doc_map = {}
    for rank, result in enumerate(dense_results):
        doc_id = result["id"]
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)
        doc_map[doc_id] = result
    for rank, result in enumerate(sparse_results):
        doc_id = result["id"]
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)
        doc_map[doc_id] = result
    sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
    fused_results = []
    for doc_id, score in sorted_docs:
        result = doc_map[doc_id].copy()
        result["rrf_score"] = score
        fused_results.append(result)
    return fused_results

How does Reciprocal Rank Fusion work?

For each document, RRF computes: score = sum(1 / (k + rank)) across all result lists it appears in. The constant k=60 (from the original Cormack et al. paper) dampens the effect of high ranks so that documents appearing in both lists get a significant boost without being dominated by a single list.

A document ranked 1st in dense search AND 3rd in BM25 scores higher than a document ranked 1st in only one list. This rewards agreement between the two retrieval methods.

  • Save app/core/retrieval.py.
  • Test the full hybrid pipeline by running:
python -c "
from app.core.ingestion import ingest_pdf
from app.core.retrieval import dense_search, sparse_search, reciprocal_rank_fusion

chunks = ingest_pdf('data/papers/sample.pdf')

query = 'EGFR T790M resistance mechanisms'
print(f'Query: {query}\n')

dense_results = dense_search(query, k=10)
sparse_results = sparse_search(query, chunks, k=10)
fused = reciprocal_rank_fusion(dense_results, sparse_results)

print('--- Hybrid results (RRF) ---')
for i, r in enumerate(fused):
    in_dense = any(d['id'] == r['id'] for d in dense_results[:5])
    in_sparse = any(s['id'] == r['id'] for s in sparse_results[:5])
    source = 'BOTH' if (in_dense and in_sparse) else ('DENSE' if in_dense else 'BM25')
    print(f'{i+1}. [p.{r["metadata"]["page_number"]}] rrf={r["rrf_score"]:.4f} ({source}) | {r["text"][:80]}...')
"

You should see ranked results with RRF scores. The BOTH label shows documents that appeared in both dense and sparse top results. These get the highest fused scores. BM25-only results contain exact terminology that vector search missed.

Why hybrid instead of just BM25?

BM25 alone fails on paraphrases. A query about "resistance mechanisms" would miss a passage discussing "treatment failure pathways" that means the same thing. Dense vectors catch that semantic overlap. The hybrid approach gives you both: exact term precision from BM25 and semantic coverage from dense embeddings.

Seeing empty results or zero scores?

If dense_search returns empty results, your chunks may not have been indexed. Delete the ./chroma_db/ folder and re-run the indexing step from the previous test. If BM25 scores are all zero, your query terms may not appear in the paper. Try a query using terms you can see in the PDF.

help me debug why my hybrid retrieval returns empty or zero-score results

✔️ Awesome, I've got everything!

Great. Double check you have saved app/core/retrieval.py with all five functions.

ⓧ I'd like to double check the full code

Your app/core/retrieval.py should contain these sections in order. Scroll up and confirm each is present:

  • Imports (chromadb, OpenAI, BM25Okapi, typing, config values) and the client = OpenAI(...) line.
  • get_chroma_collection() returning a PersistentClient collection named oncology_papers with cosine space.
  • embed_text(text) calling client.embeddings.create and returning response.data[0].embedding.
  • index_chunks(chunks) batching embeddings in groups of 100 and calling collection.add().
  • dense_search(query, k=10) querying ChromaDB and converting distances to similarity scores.
  • sparse_search(query, chunks, k=10) building a BM25Okapi index and returning top results.
  • reciprocal_rank_fusion(dense_results, sparse_results, k=RRF_K, top_n=5) computing RRF scores and returning the top 5 fused results.

Your hybrid retrieval pipeline is working. You can now find relevant passages using both semantic understanding and exact keyword matching. Next up, you will build the generation layer that takes these retrieved passages and produces citation-grounded answers with confidence scores.

Build Citation-Grounded Generation with Confidence Scoring

Your hybrid retrieval pipeline finds the right passages. But raw passages alone are not useful to a researcher or clinician.

In medical AI, an answer without a citation is dangerous. Hallucinated claims about drug interactions or treatment protocols can cause real harm. In this step, you will build a generation layer that forces the LLM to cite every factual claim back to its source passage and page number. You will also add a confidence score that tells the user when the evidence is strong and when the system is speculating.

In this step, get ready to:
  • Build a citation-grounding prompt that forces the LLM to cite every claim with source and page number.
  • Implement confidence scoring based on retrieval quality and page coverage.
  • Wire the full query pipeline together end-to-end.
Build the generation prompt that enforces citation grounding

The system prompt is the core safety mechanism. It tells the LLM exactly how to behave: only answer from provided sources, cite every claim in [Source N, p.X] format, and never provide medical diagnoses.

  • Create a new file app/core/generation.py and add the imports, OpenAI client, system prompt, and context builder by pasting this code:
from openai import OpenAI
from typing import list

from app.core.config import OPENAI_API_KEY, GENERATION_MODEL, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM

client = OpenAI(api_key=OPENAI_API_KEY)

SYSTEM_PROMPT = """You are OncoRAG, a citation-grounded oncology research assistant.

RULES:
1. ONLY answer based on the provided source passages. Do NOT use prior knowledge.
2. Cite EVERY factual claim using [Source N, p.X] format where N is the source number and X is the page number.
3. If the sources do not contain enough information, explicitly say so.
4. NEVER provide a medical diagnosis or treatment recommendation.
5. End every response with: "Disclaimer: This is an AI-generated summary for research purposes only. Always consult qualified healthcare professionals for clinical decisions."

SOURCES:
{context}"""


def build_context(passages: list[dict]) -> str:
    context_parts = []
    for i, passage in enumerate(passages, 1):
        page = passage["metadata"]["page_number"]
        source = passage["metadata"]["source_file"]
        context_parts.append(
            f"[Source {i}] (File: {source}, Page {page}):\n{passage['text']}\n"
        )
    return "\n".join(context_parts)

What does this code do?

The SYSTEM_PROMPT establishes five strict rules for the LLM. Rule 2 is the key safety mechanism: it forces every factual claim to include a citation in [Source N, p.X] format so a clinician can verify it.

The build_context() function formats the retrieved passages into numbered context blocks with their file name and page number. This gives the LLM the information it needs to produce accurate citations.

  • Save app/core/generation.py.
  • Verify the module loads and the context builder works by running this command:
python -c "from app.core.generation import build_context; print(build_context([{'text': 'EGFR mutations are common in NSCLC', 'metadata': {'page_number': 3, 'source_file': 'sample.pdf'}}]))"

You should see output like [Source 1] (File: sample.pdf, Page 3): followed by the passage text.

Seeing a ModuleNotFoundError?

Make sure you are running the command from the oncorag project root (the folder containing the app/ directory). Also confirm your virtual environment is activated.

help me debug a ModuleNotFoundError when importing from app.core.generation

Implement confidence scoring based on retrieval quality

A confidence score tells the clinician whether the system found strong evidence or is guessing. This is the difference between a useful research tool and a liability.

  • In app/core/generation.py, add the calculate_confidence function below build_context:
def calculate_confidence(passages: list[dict]) -> dict:
    if not passages:
        return {"level": "Low", "reason": "No relevant passages found"}
    rrf_scores = [p.get("rrf_score", 0) for p in passages]
    pages_covered = len(set(p["metadata"]["page_number"] for p in passages))
    avg_score = sum(rrf_scores) / len(rrf_scores) if rrf_scores else 0
    if avg_score >= CONFIDENCE_THRESHOLD_HIGH and pages_covered >= 2:
        return {"level": "High", "reason": f"Strong evidence from {pages_covered} pages"}
    elif avg_score >= CONFIDENCE_THRESHOLD_MEDIUM:
        return {"level": "Medium", "reason": "Moderate evidence found"}
    else:
        return {"level": "Low", "reason": "Limited evidence. Answer may be speculative."}

How does confidence scoring work?

The function uses two signals: the average Reciprocal Rank Fusion score (how well-matched the passages are) and page coverage (whether evidence comes from multiple pages).

  • High: average RRF score >= 0.65 AND passages from 2+ pages. Strong corroborating evidence.
  • Medium: at least one passage above the 0.4 threshold. Some supporting evidence.
  • Low: all passages below threshold. The model warns it may be speculating.
  • Save app/core/generation.py.
  • Verify confidence scoring works by testing the empty-passages edge case:
python -c "from app.core.generation import calculate_confidence; print(calculate_confidence([]))"

You should see {'level': 'Low', 'reason': 'No relevant passages found'} printed. When no passages exist, the system correctly reports low confidence.

Seeing a NameError for CONFIDENCE_THRESHOLD_HIGH?

Check that your import line at the top of generation.py includes both CONFIDENCE_THRESHOLD_HIGH and CONFIDENCE_THRESHOLD_MEDIUM from app.core.config.

help me fix a NameError in my confidence scoring function

Wire together the full pipeline

Now you need two more pieces: the generate_answer function that calls gpt-4o-mini with your citation-enforcing prompt, and a pipeline module that connects retrieval to generation.

  • In app/core/generation.py, add the generate_answer function below calculate_confidence:
def generate_answer(question: str, passages: list[dict]) -> dict:
    context = build_context(passages)
    confidence = calculate_confidence(passages)
    confidence_instruction = ""
    if confidence["level"] == "Low":
        confidence_instruction = "\nIMPORTANT: Evidence is limited. Clearly state uncertainty in your answer."
    response = client.chat.completions.create(
        model=GENERATION_MODEL,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT.format(context=context) + confidence_instruction},
            {"role": "user", "content": question}
        ],
        temperature=0.1,
        max_tokens=1024
    )
    return {
        "answer": response.choices[0].message.content,
        "confidence": confidence,
        "sources": [
            {
                "source_number": i + 1,
                "file": p["metadata"]["source_file"],
                "page": p["metadata"]["page_number"],
                "text": p["text"][:200] + "..."
            }
            for i, p in enumerate(passages)
        ]
    }

What does generate_answer do?

This function orchestrates the entire generation step. It formats the retrieved passages into numbered context, calculates confidence, and sends the question to gpt-4o-mini with a low temperature (0.1) for deterministic, grounded answers.

When confidence is Low, it injects an extra instruction telling the model to clearly state uncertainty. The return value bundles the answer, confidence level, and truncated source passages for display.

  • Save app/core/generation.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file. Your generation.py should have four main sections: imports/client, SYSTEM_PROMPT, build_context, calculate_confidence, and generate_answer.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/core/generation.py contains these sections in order:

  • Imports (OpenAI, list, config values) and client = OpenAI(api_key=OPENAI_API_KEY).
  • SYSTEM_PROMPT multi-line string with 5 rules and a {context} placeholder.
  • build_context(passages) function formatting passages with source numbers and page numbers.
  • calculate_confidence(passages) function returning High/Medium/Low based on RRF scores.
  • generate_answer(question, passages) function calling the OpenAI API and returning answer, confidence, and sources.

Now create the pipeline module that ties retrieval and generation together into a single function call.

  • Create a new file app/core/pipeline.py and paste the following code:
from app.core.retrieval import dense_search, sparse_search, reciprocal_rank_fusion, index_chunks
from app.core.ingestion import ingest_pdf
from app.core.generation import generate_answer

_chunks_cache: list = []


def ingest_document(pdf_path: str) -> int:
    global _chunks_cache
    chunks = ingest_pdf(pdf_path)
    count = index_chunks(chunks)
    _chunks_cache.extend(chunks)
    return count


def query_pipeline(question: str) -> dict:
    dense_results = dense_search(question, k=10)
    sparse_results = sparse_search(question, _chunks_cache, k=10)
    fused_results = reciprocal_rank_fusion(dense_results, sparse_results)
    response = generate_answer(question, fused_results)
    return response

What does the pipeline module do?

The _chunks_cache stores all ingested chunks in memory so BM25 can access them (BM25 needs the raw text, unlike ChromaDB which stores embeddings).

The query_pipeline function is the single entry point: question in, cited answer out. It runs dense search, sparse search, fuses results with RRF, then generates a grounded answer with citations.

  • Save app/core/pipeline.py.

✔️ Awesome, I've got everything!

Great. Confirm you have saved app/core/pipeline.py with both ingest_document and query_pipeline functions.

ⓧ I'd like to double check the full code

from app.core.retrieval import dense_search, sparse_search, reciprocal_rank_fusion, index_chunks
from app.core.ingestion import ingest_pdf
from app.core.generation import generate_answer

_chunks_cache: list = []


def ingest_document(pdf_path: str) -> int:
    global _chunks_cache
    chunks = ingest_pdf(pdf_path)
    count = index_chunks(chunks)
    _chunks_cache.extend(chunks)
    return count


def query_pipeline(question: str) -> dict:
    dense_results = dense_search(question, k=10)
    sparse_results = sparse_search(question, _chunks_cache, k=10)
    fused_results = reciprocal_rank_fusion(dense_results, sparse_results)
    response = generate_answer(question, fused_results)
    return response

Time to test the full pipeline end-to-end. Before you run this, what do you expect the output to contain? Think about what the system prompt demands from the LLM.

  • Run the full pipeline by executing this command (replace the question with something relevant to your uploaded PDF):
python -c "
from app.core.pipeline import ingest_document, query_pipeline
import json

# Re-ingest your PDF to populate the chunks cache
count = ingest_document('data/papers/sample.pdf')
print(f'Chunks indexed: {count}')

# Ask a question about your paper
result = query_pipeline('What are the key findings of this study?')
print(f'\nConfidence: {result[\"confidence\"][\"level\"]} - {result[\"confidence\"][\"reason\"]}')
print(f'\nAnswer:\n{result[\"answer\"]}')
print(f'\nSources:')
for s in result['sources']:
    print(f'  [Source {s[\"source_number\"]}] {s[\"file\"]}, Page {s[\"page\"]}')
"

You should see a confidence level (High, Medium, or Low) followed by an answer that includes inline citations like [Source 1, p.4]. The answer ends with the medical disclaimer. Below that, you see the source passages listed with their file and page numbers.

Why is this portfolio-worthy?

Every claim in the response traces to a specific passage and page number that a human can verify. The confidence score tells a clinician when the evidence is strong versus when the system is speculating. These are the exact safety patterns that companies like Tempus AI and Triomics build into their production medical AI systems.

Getting an AuthenticationError from OpenAI?

Check that your .env file in the project root contains a valid API key and that python-dotenv is installed. The config.py file calls load_dotenv() which reads from .env automatically.

help me debug an OpenAI AuthenticationError in my OncoRAG pipeline

Your citation-grounded generation is working. Every claim now traces to a source, and the confidence score signals evidence quality. Next up, you will wrap this pipeline in a FastAPI backend and Streamlit UI so you can demo it as a complete application.

Build the FastAPI Backend and Streamlit Demo UI

Your hybrid retrieval pipeline and citation-grounded generation work as Python functions. However, a recruiter cannot run your Python script. They need a live demo they can interact with in a browser.

In this step, you will wrap your pipeline in a FastAPI backend with two endpoints, then build a Streamlit UI that lets anyone upload a PDF, ask a question, and see a cited answer with a confidence score.

In this step, get ready to:
  • Build a FastAPI backend with PDF ingestion and query endpoints.
  • Create a Streamlit UI with file upload, question input, and cited response display.
  • Run both servers and demo the complete end-to-end flow.
Create the FastAPI backend

The API gives your pipeline a standard HTTP interface. Any client (Streamlit, curl, a future React frontend) can call it.

  • Create a new file at app/api/main.py and paste the following imports and app setup:
import os
import shutil
from fastapi import FastAPI, UploadFile, File
from pydantic import BaseModel

from app.core.pipeline import ingest_document, query_pipeline
from app.core.config import DATA_DIR

# Initialize FastAPI app with a title and version
app = FastAPI(title="OncoRAG API", version="1.0.0")


# Define request/response schemas for the query endpoint
class QueryRequest(BaseModel):
    question: str


class QueryResponse(BaseModel):
    answer: str
    confidence: dict
    sources: list

What does this code do?

The FastAPI() call creates the API application. The QueryRequest and QueryResponse Pydantic models define the shape of data the API accepts and returns.

FastAPI uses these models to validate incoming requests and automatically generate interactive documentation at /docs.

  • Below the QueryResponse class, add the two endpoint functions:
@app.post("/ingest")
async def ingest_pdf_endpoint(file: UploadFile = File(...)):
    # Save uploaded PDF to disk, then ingest it
    os.makedirs(DATA_DIR, exist_ok=True)
    file_path = os.path.join(DATA_DIR, file.filename)
    with open(file_path, "wb") as f:
        shutil.copyfileobj(file.file, f)
    chunk_count = ingest_document(file_path)
    return {"filename": file.filename, "chunks_indexed": chunk_count}


@app.post("/query", response_model=QueryResponse)
async def query_endpoint(request: QueryRequest):
    # Run the full hybrid retrieval + generation pipeline
    result = query_pipeline(request.question)
    return QueryResponse(
        answer=result["answer"],
        confidence=result["confidence"],
        sources=result["sources"]
    )

What do these endpoints do?

The /ingest endpoint accepts a PDF file upload, saves it to the data/papers/ directory, then runs your full ingestion pipeline (extract, chunk, embed, index). It returns the filename and the number of chunks indexed.

The /query endpoint takes a question string and runs query_pipeline() which performs hybrid retrieval, Reciprocal Rank Fusion, and citation-grounded generation. It returns a structured response with the answer, confidence level, and source passages.

  • Save app/api/main.py.
  • Start the API server by running:
uvicorn app.api.main:app --port 8000

Before you check the result, what do you expect to see in your terminal?

You should see Uvicorn start and print a line containing Uvicorn running on http://127.0.0.1:8000.

  • Open http://127.0.0.1:8000/docs in your browser.
  • You should see the Swagger UI with two endpoints listed: POST /ingest and POST /query.

Server fails to start?

  • If you see ModuleNotFoundError, make sure your virtual environment is activated and you are running the command from the oncorag project root directory.
  • If the port is already in use, stop any other server on port 8000 or choose a different port with --port 8001.

Help me debug my FastAPI server startup error.

Build the Streamlit demo interface

The UI turns your API into something anyone can use. It handles file uploads, sends queries to your backend, and displays the cited response with a confidence badge.

  • Open a second terminal window (keep the uvicorn server running in the first one).
  • Activate your virtual environment in this new terminal.
  • Create the file app/ui/demo.py and paste the page configuration and header section:
import streamlit as st
import requests

# Point to the FastAPI backend
API_URL = "http://localhost:8000"

# Configure the page layout
st.set_page_config(page_title="OncoRAG", page_icon="\U0001f52c", layout="wide")

# Medical disclaimer banner at the top of every page
st.markdown(
    "\u26a0\ufe0f **Medical Disclaimer**: This tool is for research and educational purposes only. "
    "It does NOT provide medical diagnoses or treatment recommendations. "
    "Always consult qualified healthcare professionals for clinical decisions."
)

st.title("OncoRAG: Oncology Research Assistant")
st.markdown("Upload oncology research papers and ask citation-grounded questions.")

What does this code do?

This sets up the Streamlit page with a wide layout and a medical disclaimer banner. The API_URL variable points to your running FastAPI server so the UI knows where to send requests.

The disclaimer appears at the top of the page on every load. This is a safety pattern required for any medical AI system.

  • Below the title, add the sidebar section for PDF uploads:
# Sidebar for document upload
with st.sidebar:
    st.header("Upload Documents")
    uploaded_file = st.file_uploader("Upload PDF", type=["pdf"])
    if uploaded_file and st.button("Ingest Document"):
        with st.spinner("Processing PDF..."):
            files = {"file": (uploaded_file.name, uploaded_file.getvalue(), "application/pdf")}
            response = requests.post(f"{API_URL}/ingest", files=files)
            if response.status_code == 200:
                result = response.json()
                st.success(f"Indexed {result['chunks_indexed']} chunks from {result['filename']}")
            else:
                st.error("Failed to ingest document")

What does this code do?

The sidebar contains a file uploader widget filtered to PDF files only. When the learner clicks Ingest Document, it sends the PDF to your /ingest endpoint. A success message shows how many chunks were indexed.

  • Save app/ui/demo.py and start Streamlit to verify the page renders:
streamlit run app/ui/demo.py

Streamlit opens your browser automatically at http://localhost:8501. You should see the medical disclaimer banner, the title, and an Upload Documents section in the sidebar.

Streamlit not opening?

  • Make sure you are running the command from the oncorag project root, not from inside the app/ folder.
  • If the browser does not open automatically, navigate to http://localhost:8501 manually.

Help me troubleshoot why my Streamlit app is not loading.

  • Stop Streamlit with Ctrl+C. Now add the question input and response display section below the sidebar block in app/ui/demo.py:
# Main area: question input and response display
st.header("Ask a Question")
question = st.text_input("Enter your oncology research question:")

if question and st.button("Search & Generate"):
    with st.spinner("Retrieving and generating..."):
        response = requests.post(f"{API_URL}/query", json={"question": question})
        if response.status_code == 200:
            result = response.json()
            confidence = result["confidence"]
            # Color-code the confidence badge
            confidence_colors = {"High": "green", "Medium": "orange", "Low": "red"}
            color = confidence_colors.get(confidence["level"], "gray")
            st.markdown(f"**Confidence**: :{color}[{confidence['level']}] - {confidence['reason']}")
            st.markdown("---")
            st.markdown(result["answer"])
            st.markdown("---")
            # Expandable source passages for verification
            with st.expander("View Source Passages"):
                for source in result["sources"]:
                    st.markdown(
                        f"**[Source {source['source_number']}]** "
                        f"{source['file']}, Page {source['page']}"
                    )
                    st.text(source["text"])
                    st.markdown("---")
        else:
            st.error("Query failed. Is the API running?")

What does this code do?

The main area shows a text input for the clinical question. When the learner clicks Search & Generate, it calls the /query endpoint and displays three things: a color-coded confidence badge (green for High, orange for Medium, red for Low), the generated answer with inline citations, and an expandable section showing the exact source passages with page numbers.

  • Save app/ui/demo.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/api/main.py and app/ui/demo.py.

ⓧ I'd like to double check the full code

Your app/api/main.py should contain these sections in order: imports, app setup, QueryRequest/QueryResponse models, /ingest endpoint, /query endpoint. Scroll up and confirm each chunk is present.

Your app/ui/demo.py should contain these sections in order: imports and API_URL, page config and disclaimer, title, sidebar with file uploader, header with question input, and the response display block. Scroll up and confirm each chunk is present.

Run both services and demo the full flow

Time to see the full system in action. You need both servers running at once.

  • In your first terminal, make sure the API server is still running. If you stopped it, restart it:
uvicorn app.api.main:app --port 8000
  • In your second terminal, start the Streamlit UI:
streamlit run app/ui/demo.py
  • In the Streamlit sidebar, click Browse files and select your sample oncology PDF from data/papers/.
  • Click Ingest Document.
  • Wait for the success message showing the number of chunks indexed.

Before you ask a question, predict what will happen. Do you think the system will return an answer with High, Medium, or Low confidence for a question that matches your paper's content?

  • In the Enter your oncology research question field, type a question relevant to your uploaded paper.
  • Click Search & Generate.

You should see a color-coded confidence badge, followed by an answer containing inline citations like [Source 1, p.4]. Expand View Source Passages to see the exact text and page numbers the answer drew from.

Query returns an error?

  • If you see "Query failed. Is the API running?", confirm your uvicorn server is still active in the first terminal. Check for error output there.
  • If you get a timeout, the OpenAI API call may be slow on first use. Wait up to 30 seconds.
  • If confidence is "Low" for every query, make sure you ingested the PDF before querying. The chunks cache must be populated first.

Help me debug why my OncoRAG query is failing.

You now have a full end-to-end oncology RAG system with a working API and demo UI. Next up, you will tackle the Secret Mission and add query decomposition for complex multi-faceted clinical questions.

Secret mission

Query Decomposition for Complex Clinical Questions

Simple questions work fine with a single retrieval pass. But real clinical questions are multi-faceted. Can you add a query decomposition layer that breaks complex questions into focused sub-queries for better retrieval coverage?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, with the exception of OpenAI API calls you made during development (approximately $2-4 total). There are no ongoing costs once you stop the FastAPI and Streamlit servers.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • oncorag project folder on your Desktop (contains all source code, .env file, and sample PDF).
  • Python virtual environment in oncorag/.venv/.
  • ChromaDB persistent database in oncorag/chroma_db/.
  • OpenAI API usage (approximately $2-4 spent during development for embeddings and generation).

Keep everything running

No action needed. Choose this if you plan to keep building on the project or running more queries.

  • The local ChromaDB database persists on disk in ./chroma_db/. No ongoing cost.
  • Your virtual environment and all installed packages remain in .venv/.
  • Future queries to the OpenAI API will incur additional charges per request (approximately $0.001-0.01 per query depending on passage length).

Pause - I'll come back to this later

Stop the running servers to free up memory. Your files and data remain on disk for when you return.

  • Switch to the terminal running the FastAPI server and press Ctrl+C to stop it.
  • Switch to the terminal running Streamlit and press Ctrl+C to stop it.
  • When you come back, reactivate your virtual environment and restart both servers.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the local database to free up disk space.

  • Stop both servers by pressing Ctrl+C in each terminal window.
  • Navigate to your Desktop and delete the entire project directory by running:

macOS

cd ~/Desktop
rm -rf oncorag

Windows

cd ~\Desktop
Remove-Item -Recurse -Force oncorag

This removes the project source code, virtual environment, ChromaDB database, downloaded PDFs, and your .env file.

  • Your OpenAI API key still exists in your OpenAI account. No action needed unless you want to revoke it from the API keys page at platform.openai.com.

Nice Work!

Nice Work!

Nice work! You just built a production-grade oncology research assistant that ingests cancer research PDFs, retrieves relevant passages with hybrid search, and generates citation-grounded answers with confidence scoring.

You've learned how to:

  • Build a hybrid retrieval system combining BM25 sparse search with dense vector embeddings, fused via Reciprocal Rank Fusion to catch both exact medical terminology and semantic meaning.
  • Implement citation-grounded generation where every LLM claim traces back to a specific source passage and page number, preventing dangerous hallucinations in medical AI.
  • Add confidence scoring that quantifies retrieval quality and warns when evidence is insufficient, then wrap it all in a FastAPI backend and Streamlit UI for a demo-ready end-to-end system.
  • Secret Mission: Added query decomposition for complex multi-faceted clinical questions, demonstrating agentic retrieval patterns that companies like Amgen and J&J list in their AI/ML engineer requirements.

Ready to quiz yourself?