MedIR-RAG - Milestone 1: BM25 lexical baseline for biomedical retrieval
A. Project overview
This repository is the completed Milestone 1 of a biomedical document retrieval system, written for the course Recuperação de Informação em Bases de Texto (RIBT) at the University of Évora.
The system answers a retrieval question, not a medical one: given a question in natural language, it returns the PubMed titles and abstracts of the corpus that are relevant to that question, ranked best first. It retrieves documents; it does not generate or summarise medical answers. The ranking model is BM25 over a Terrier inverted index, used through PyTerrier.
The work follows the four parts required by the assignment: preprocessing and tokenisation of
a biomedical corpus, indexing with a sparse retrieval library, a parameterised BM25 baseline,
and quantitative evaluation with pytrec_eval on NDCG@5, NDCG@10, MRR@10, MAP and Recall@100
using human relevance judgments.
The Terrier index is included in the repository, so the search command in section G works directly after cloning, without downloading the dataset or rebuilding anything.
Repository contents
README.md this file
requirements.txt pinned Python dependencies
src/prepare.py dataset download, validation and audit
src/experiment.py indexing, BM25 tuning, test evaluation, efficiency
src/search.py command-line search over the saved index
analysis/build_evidence.py tables, figures, statistics and failure cases
index/terrier_bm25/ the complete saved Terrier index (10 files)
results/raw/data_audit.json dataset validation and counts
results/raw/dev_grid.csv the 20 development configurations
results/raw/selected_config.json frozen BM25 parameters
results/raw/test_summary.json test metrics, cross-checks, sensitivity
results/raw/test_per_query.csv per-query metrics for all 1,145 test questions
results/raw/metric_validation.json metric self-tests on synthetic rankings
results/raw/experiment_manifest.json settings, versions, index statistics, timings
Not included, because they are generated locally or are large: the downloaded dataset
(data/), the TREC run files (about 70 MB each, produced by src/experiment.py), and the
evidence figures (produced by analysis/build_evidence.py). The Java runtime is not included
either; section F explains how to provide one.
B. Dataset
| Item | Value |
|---|---|
| Dataset | DinoStackAI/bioasq-rag-13b-resplit |
| Pinned revision | ade805f1df0b16dbf19385597861146bdb0d9904 |
| Licence | CC-BY-2.5 |
| Subsets used | corpus, queries, qrels |
python -m src.prepare downloads this dataset automatically from the Hugging Face Hub at the
pinned revision, validates its schema and identifier integrity, writes the Parquet splits to
data/, and records an audit in results/raw/data_audit.json. The answers and
retrieved_docs subsets are deliberately not used: this milestone ranks documents, it does
not generate answers.
Verified counts (from results/raw/data_audit.json):
| Item | Count |
|---|---|
| Published corpus records | 44,183 |
| Indexable records | 44,128 |
| Records without a usable title or abstract | 55 |
| Train questions | 3,668 |
| Development questions | 916 |
| Test questions | 1,145 |
| Test questions with relevance judgments used for the main evaluation | 1,073 |
| Positive relevance judgments in the test split | 9,478 |
The 55 excluded records have neither a title nor an abstract, so there is no text to index; nine test judgments point at them, which puts a small unavoidable ceiling on Recall@100.
What this corpus is and is not. The source is a community-prepared, BioASQ-derived fixed-corpus retrieval benchmark: PubMed titles and abstracts, with BioASQ question/answer pairs whose questions the dataset publisher re-merged and reshuffled into train, development and test splits. It is not the full PubMed collection, and the queries are not the official BioASQ challenge splits, so the scores reported here are internally consistent but not comparable to published BioASQ leaderboard results.
Attribution: the dataset was prepared by DinoStackAI and is distributed under CC-BY-2.5; the underlying questions and judgments come from the BioASQ challenge (Tsatsaronis et al., 2015, see section J), and the documents are PubMed records.
C. Preprocessing and indexing
Text preparation follows the standard Terrier English pipeline:
- Fields. The indexed text is
title + abstract. Title and abstract are also stored as metadata fields (400 and 4,000 characters) together with the document identifier, so search results can show titles and previews without opening the dataset. - Tokenisation. Terrier's
EnglishTokeniser(lowercasing, splitting on non-alphanumeric characters). - Stop-word removal. Terrier's English stop-word list.
- Stemming. Porter stemmer, so reducing, reduced and reduces collapse to one term and thyroid matches thyroids.
- Indexing. The terms are written to a Terrier inverted index with document lengths, term frequencies, a lexicon and a direct index, plus the metadata index.
Verified index statistics (from results/raw/experiment_manifest.json):
| Item | Value |
|---|---|
| Documents indexed | 44,128 |
| Distinct terms after stemming | 92,366 |
| Postings | 3,947,805 |
| Tokens indexed | 6,493,411 |
| Mean document length | 147 tokens |
| Index size on disk | 59.65 MB |
| Index build time | 12.05 s |
| Retrieval depth | 1,000 |
The complete index is included under index/terrier_bm25/: data.properties,
data.inverted.bf, data.direct.bf, data.lexicon.fsomapfile, data.lexicon.fsomaphash,
data.lexicon.fsomapid, data.document.fsarrayfile, data.meta.idx, data.meta.zdata and
data.meta-0.fsomapfile. All of them are required: the .bf files hold the compressed
postings, the lexicon files map terms to postings, and the data.meta* files hold the document
identifiers and the stored title/abstract fields.
D. BM25 retrieval and parameter tuning
BM25 scores a document by summing over the terms of the question:
- Term rarity. A term that appears in few documents is informative (metformin); a term that appears everywhere is not (patients).
- Term frequency, with saturation. More occurrences of a term in a document help, but with diminishing returns, so a document that repeats a word twenty times is not twenty times better. The parameter k1 controls how quickly this saturation sets in.
- Document length. Longer documents contain more words by accident, so their scores are
divided down. The parameter b controls how strong this penalty is (
b= 0: none,b= 1: full).
Two configurations are reported:
| Configuration | k1 | b |
|---|---|---|
| Standard BM25 default | 1.2 | 0.75 |
| Selected on the development split | 0.6 | 0.5 |
How the parameters were chosen. A grid of 5 values of k1 (0.6, 0.9, 1.2, 1.5, 1.8) and
4 values of b (0.3, 0.5, 0.75, 0.9) was evaluated on the 916 development questions, 872 of
which have judgments. The best development NDCG@10 was 0.8199 at k1 = 0.6, b = 0.5; the
default configuration scores 0.8097 on the same split. The selection is frozen in
results/raw/selected_config.json before the test split is touched, and the test split is
evaluated once. All 20 configurations are in results/raw/dev_grid.csv.
The chosen point sits on the edge of the grid (0.6 is the smallest k1 tested), so the tuned
gain is probably not exhausted; the grid was not extended after seeing test results, because
that would amount to tuning on the test split.
E. Experimental results
All numbers below are read from results/raw/test_summary.json and were produced by
pytrec_eval (the trec_eval bindings) on the 1,073 judged test questions, at retrieval
depth 1,000.
| Metric | Default BM25 (1.2, 0.75) | Tuned BM25 (0.6, 0.5) |
|---|---|---|
| NDCG@5 | 0.8117 | 0.8135 |
| NDCG@10 | 0.8171 | 0.8248 |
| MRR@10 | 0.8746 | 0.8766 |
| MAP@1000 | 0.7742 | 0.7839 |
| Recall@100 | 0.9664 | 0.9683 |
What the metrics measure:
- NDCG@5 / NDCG@10 - position-weighted accuracy of the first five and first ten results, normalised so that the ideal ordering of the judged relevant documents scores 1.0.
- MRR@10 - the reciprocal rank of the first relevant document, so 1.0 means it is always at rank 1 and 0.5 means it is usually at rank 2. It is computed on the run truncated at rank 10, not on the untruncated list.
- MAP@1000 - mean average precision with a 1,000-result cutoff; relevant documents ranked below 1,000 do not count.
- Recall@100 - the share of the judged relevant documents found in the first 100 results, ignoring their order.
The improvement is small. Tuning raises NDCG@10 by 0.0077 and MAP by 0.0097, and it is not
a uniform gain: across the 1,073 questions, 245 improve, 201 get slightly worse and 627 are
unchanged, which can be recomputed from results/raw/test_per_query.csv. A paired bootstrap
and a Wilcoxon signed-rank test on these per-query differences are reported in the accompanying
technical report. The honest reading is that the standard BM25 default is already a strong
baseline on this collection and that tuning k1 and b produces a modest, reliable gain.
These are retrieval scores, not clinical evidence. A high NDCG@10 means the ranking agrees with human judgments about which abstracts are relevant; it says nothing about whether any abstract is medically correct, current, or applicable to a patient.
Sensitivity check: 72 of the 1,145 published test questions have no relevance judgments. They
are excluded from the primary population above, and results/raw/test_summary.json also
records the variant in which they are retained and score zero (tuned NDCG@10 0.7729).
F. Requirements and installation
Tested with:
- Python 3.12.10 (Windows 11)
- Java: Eclipse Temurin OpenJDK 21.0.12 LTS (any Java 11+ runtime is sufficient for Terrier)
- Packages: see
requirements.txt(pyterrier 1.1.2, pytrec_eval-terrier 0.5.11, datasets 5.1.0, pandas 3.0.6, numpy 2.5.4, scipy 1.18.1, matplotlib 3.11.2, ir_measures 0.4.3, pyarrow 26.0.0, huggingface_hub 2.3.0)
Install from the repository root in Windows PowerShell:
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Terrier runs on the JVM, so a Java runtime must be visible to Python. If java -version
already works, nothing else is needed. Otherwise install a JDK (for example Temurin 21) and
point JAVA_HOME at it for the session:
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.1-hotspot"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
java -version
src/experiment.py and src/search.py use JAVA_HOME when it is set; if it is not, they look
for a portable JDK in a .jdk/ directory in the project root (a convenience used during
development, not included here). The first PyTerrier call starts the JVM, which takes a few
seconds; retrieval itself takes milliseconds per query.
No package is installed into the global Python environment, and no JDK is bundled with this repository.
G. Search using the saved index
From the repository root, with the virtual environment active:
.\.venv\Scripts\python.exe -m src.search --query "Does metformin reduce cardiovascular risk?" --top-k 10
The command loads index/terrier_bm25/ and the frozen parameters in
results/raw/selected_config.json (k1 = 0.6, b = 0.5) and prints, for each result, the rank,
the BM25 score, the PubMed identifier, the paper title, a preview of the abstract and a PubMed
link:
query: Does metformin reduce cardiovascular risk?
model: BM25 k1=0.6 b=0.5 (selected on dev)
results: 10
1. [25.7273] PMID 25954799
Type 2 diabetes and metformin. First choice for monotherapy: weak evidence of ...
Many guidelines recommend metformin as first-line therapy for patients with type 2 ...
https://pubmed.ncbi.nlm.nih.gov/25954799/
2. [25.2981] PMID 15529521
Avandamet: combined metformin-rosiglitazone treatment for insulin resistance ...
Insulin resistance is a major endocrinopathy underlying the development of ...
https://pubmed.ncbi.nlm.nih.gov/15529521/
(the output continues to result 10; two entries are shown here, with the titles and previews shortened for readability)
The other options are --top-k (default 10), and --k1 / --b to override the frozen
parameters in order to compare configurations by hand. The output is a ranked list of
documents, not a medical answer.
H. Reproducing the complete experiment
The full pipeline is three commands, run from the repository root in order:
.\.venv\Scripts\python.exe -m src.prepare # download and prepare the pinned dataset
.\.venv\Scripts\python.exe -m src.experiment # index, tune, evaluate, write raw results
.\.venv\Scripts\python.exe -m analysis.build_evidence # evidence tables, figures and statistics
src.preparedownloads the dataset at the pinned revision, validates it, writes the Parquet splits todata/and the audit toresults/raw/data_audit.json. This needs network access and roughly the disk space of the corpus parquet file.src.experimentvalidates its own metric implementation on synthetic rankings, then builds the index (or reusesindex/terrier_bm25/if it is present; use--rebuild-indexto force a rebuild), runs the 20-configuration grid on the development split, freezes the winner, evaluates both configurations on the test split, times retrieval, and writesresults/raw/dev_grid.csv,selected_config.json,test_default.trec,test_tuned.trec,test_per_query.csv,test_summary.jsonandexperiment_manifest.json. The two TREC run files are about 70 MB each and are not included in this repository; this command regenerates them.analysis.build_evidencereads the finished experiment outputs, including the TREC run files and the locally generated qrels, re-derives the reported metrics independently, and produces the tables, figures, bootstrap/Wilcoxon statistics and failure-case analysis inresults/report/. It cannot reproduce the analysis from the small JSON files alone: it needs the TREC runs and the qrels thatsrc.experimentandsrc.preparecreate locally.
In short: data/ and the TREC runs are download-and-run artefacts, while the index and the
seven result files in results/raw/ are included so that the reported numbers and the search
command can be inspected without repeating the full run. The evaluation results included here
were produced once and are not regenerated on upload.
I. Limitations
- Incomplete relevance judgments. The judgments are binary and sparse: about 79% of the corpus documents are never judged and are treated as non-relevant by TREC-style metrics, so every score in section E is a lower bound on true effectiveness.
- Restricted corpus. 44,128 indexable abstracts from a community-prepared BioASQ-derived benchmark, not the full PubMed collection, and with publisher-reshuffled questions. The numbers are not comparable to BioASQ leaderboard results.
- Missing records. 55 corpus records have no title and no abstract and cannot be indexed; some judgments point at them, which slightly caps Recall@100.
- Lexical retrieval only. BM25 matches words after stemming. It cannot handle synonyms it has not seen, and it breaks on identifiers: for example, the question "What is the mechanism of the drug CRT0066101?" fails because the tokeniser splits the alphanumeric code name, and the relevant abstracts are then never retrieved. Dense or hybrid retrieval is the subject of the following milestone.
- Not clinical advice. This is a research retrieval system evaluated with offline metrics. It must not be used to make medical decisions.
J. References
- DinoStackAI. bioasq-rag-13b-resplit [dataset], CC-BY-2.5, revision
ade805f1. https://huggingface.co/datasets/DinoStackAI/bioasq-rag-13b-resplit - Robertson, S. and Zaragoza, H. (2009). The probabilistic relevance framework: BM25 and beyond. Foundations and Trends in Information Retrieval, 3(4), 333-389.
- Macdonald, C., Tonellotto, N., MacAvaney, S. and Ounis, I. (2021). PyTerrier: declarative experimentation in Python. CIKM 2021.
- Terrier IR platform, version 5.11, University of Glasgow.
- Van Gysel, C. and de Rijke, M. (2018). Pytrec_eval: an extremely fast Python interface to trec_eval. SIGIR 2018.
- Tsatsaronis, G. et al. (2015). An overview of the BioASQ large-scale biomedical semantic indexing and question answering competition. BMC Bioinformatics, 16:138.