afg1 commited on
Commit
8a0195c
·
verified ·
1 Parent(s): befcab6

Add retrieval comparison demo (BM25/Dense/Hybrid) + precomputed artifacts

Browse files
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ prompt.md
README.md CHANGED
@@ -1,14 +1,61 @@
1
  ---
2
- title: Vector Demo
3
- emoji: 🐢
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- short_description: Demo of vector database
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RAG Retrieval Compare
3
+ emoji: 🔍
4
+ colorFrom: indigo
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.17.3
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: BM25 vs Dense vs Hybrid retrieval, side by side, on ZeroGPU
11
  ---
12
 
13
+ # 🔍 Retrieval methods, side by side
14
+
15
+ A teaching demo for technical researchers learning vector-database fundamentals for RAG.
16
+ Given a query, it retrieves the top-k results from **three methods** and shows them
17
+ side by side over a corpus of ~4,000 Europe PMC abstracts (microRNA & disease):
18
+
19
+ 1. **BM25** (lexical) — `rank-bm25`
20
+ 2. **Dense** (vector) — exact cosine similarity over `all-MiniLM-L6-v2` embeddings
21
+ 3. **Hybrid** — Reciprocal Rank Fusion (RRF, k=60) over the BM25 and dense rankings
22
+
23
+ Plus a **metadata filter** (year / journal) that restricts the candidate pool *before*
24
+ retrieval — the distinctly vector-DB feature — and a **PCA scatter plot** for spatial
25
+ intuition, with connector lines from the query to the documents each method retrieved.
26
+
27
+ ## How it's built (read this before the talk)
28
+
29
+ - **Everything is precomputed offline** by `build_index.py`, which fetches the abstracts,
30
+ embeds the corpus, fits the PCA, and tokenises for BM25. The artifacts in `./data/` are
31
+ committed. The app **never** embeds the corpus or calls an external API at startup —
32
+ it only embeds your *live query*.
33
+ - **ZeroGPU:** GPU is allocated on demand only inside the `@spaces.GPU`-decorated query
34
+ embedding functions. The model is loaded on CPU at import; nothing touches CUDA at
35
+ startup. ⚠️ **The first GPU call after idle has a cold start of a few seconds — pre-warm
36
+ with a dummy query before presenting.**
37
+ - **Swappable model:** `EMBEDDING_MODEL` in `config.py` is the single source of truth for
38
+ both the offline build and the live query. Swap the one line (a biomedical
39
+ PubMedBERT sentence model is included as a comment) and re-run `build_index.py`.
40
+
41
+ ## Exact search, honestly
42
+
43
+ The corpus is tiny, so dense retrieval uses **exact** cosine similarity (a plain NumPy
44
+ dot product) — instant and correct. At production scale you'd reach for an **ANN index**
45
+ (e.g. HNSW) to keep search fast over millions of vectors. We deliberately don't show a
46
+ fake HNSW timing comparison here: at this scale the difference is invisible, so it would
47
+ mislead rather than teach.
48
+
49
+ ## Teaching caveat on the plot
50
+
51
+ The 2D PCA projection distorts true high-dimensional distances, so the retrieved points
52
+ may not be the visually-closest dots. **The ranked lists are authoritative; the plot is
53
+ only for intuition.**
54
+
55
+ ## Rebuild the index locally
56
+
57
+ ```bash
58
+ uv venv
59
+ uv pip install -r requirements.txt
60
+ uv run python build_index.py # writes ./data/
61
+ ```
app.py CHANGED
@@ -1,7 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
1
+ """Retrieval-methods comparison demo (BM25 vs Dense vs Hybrid/RRF).
2
+
3
+ A teaching app for EMBL-EBI researchers. Reads pre-built artifacts from ./data/ (made by
4
+ build_index.py) and only embeds the user's LIVE query at runtime. Read top-to-bottom:
5
+
6
+ artifacts -> embed query (GPU) -> filter -> 3 rankings -> RRF -> plot
7
+
8
+ ZeroGPU note: the embedding model is loaded on CPU at import. GPU is touched ONLY inside
9
+ the @spaces.GPU functions. Do not move the model to CUDA anywhere else.
10
+ """
11
+
12
+ import json
13
+ import os
14
+
15
  import gradio as gr
16
+ import joblib
17
+ import numpy as np
18
+ import pandas as pd
19
+ import plotly.graph_objects as go
20
+ import spaces
21
+ import torch
22
+ from rank_bm25 import BM25Okapi
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ import config
26
+ from text_utils import tokenize
27
+
28
+ # ====================================================================================
29
+ # Load pre-built artifacts (fast; no embedding, no network)
30
+ # ====================================================================================
31
+ D = config.DATA_DIR
32
+ EMBEDDINGS = np.load(os.path.join(D, "embeddings.npy")) # (N, dim), L2-normalised
33
+ PCA_COORDS = np.load(os.path.join(D, "pca_coords.npy")) # (N, 2)
34
+ PCA = joblib.load(os.path.join(D, "pca.joblib"))
35
+ META = pd.read_parquet(os.path.join(D, "metadata.parquet"))
36
+ with open(os.path.join(D, "bm25_tokens.json")) as f:
37
+ BM25 = BM25Okapi(json.load(f))
38
+ with open(os.path.join(D, "config.json")) as f:
39
+ DATA_CFG = json.load(f)
40
+
41
+ TITLES = META["title"].tolist()
42
+ ABSTRACTS = META["abstract"].tolist()
43
+ YEARS = META["year"].fillna(0).astype(int).to_numpy()
44
+ JOURNALS = META["journal"].astype(str).to_numpy()
45
+
46
+ YEAR_MIN = int(YEARS[YEARS > 0].min())
47
+ YEAR_MAX = int(YEARS.max())
48
+ # 1300+ distinct journals — show only the most common ones in the dropdown.
49
+ TOP_JOURNALS = META["journal"].value_counts().head(30).index.tolist()
50
+
51
+ METHOD_COLORS = {"BM25": "#ff7f0e", "Dense": "#1f77b4", "Hybrid": "#2ca02c"}
52
+
53
+ # ====================================================================================
54
+ # Embedding model — loaded on CPU. GPU is used ONLY inside @spaces.GPU below.
55
+ # ====================================================================================
56
+ MODEL = SentenceTransformer(config.EMBEDDING_MODEL, device="cpu")
57
+ assert MODEL.get_sentence_embedding_dimension() == EMBEDDINGS.shape[1], (
58
+ "Model dim doesn't match committed embeddings — did you swap EMBEDDING_MODEL "
59
+ "without re-running build_index.py?"
60
+ )
61
+
62
+
63
+ def _encode(text: str) -> np.ndarray:
64
+ """Embed one string -> (dim,) float32, L2-normalised. Uses CUDA when available."""
65
+ device = "cuda" if torch.cuda.is_available() else "cpu" # safe: only called under @spaces.GPU
66
+ MODEL.to(device)
67
+ vec = MODEL.encode(
68
+ [text], normalize_embeddings=True, convert_to_numpy=True, device=device
69
+ )
70
+ return vec[0].astype(np.float32)
71
+
72
+
73
+ @spaces.GPU
74
+ def embed_query(text: str) -> np.ndarray:
75
+ """GPU-backed query embedding for retrieval."""
76
+ return _encode(text)
77
+
78
+
79
+ @spaces.GPU
80
+ def embed_text(text: str) -> np.ndarray:
81
+ """GPU-backed embedding for the 'embed your own text' feature."""
82
+ return _encode(text)
83
+
84
+
85
+ # ====================================================================================
86
+ # Retrieval
87
+ # ====================================================================================
88
+ def candidate_indices(year_lo: int, year_hi: int, journal: str) -> np.ndarray:
89
+ """Apply the metadata filter BEFORE retrieval. Returns indices of allowed docs.
90
+
91
+ This pre-filtering of the candidate pool is the distinctly vector-DB feature: all
92
+ three methods then search only within these documents.
93
+ """
94
+ mask = (YEARS >= int(year_lo)) & (YEARS <= int(year_hi))
95
+ if journal and journal != "All journals":
96
+ mask &= JOURNALS == journal
97
+ return np.flatnonzero(mask)
98
+
99
+
100
+ def top_k(scores: np.ndarray, cand: np.ndarray, k: int) -> list[tuple[int, float]]:
101
+ """Top-k (doc_index, score) among candidates, ranked by score descending."""
102
+ cand_scores = scores[cand]
103
+ order = np.argsort(-cand_scores)[:k]
104
+ return [(int(cand[i]), float(cand_scores[i])) for i in order]
105
+
106
+
107
+ def rrf_fuse(bm25: np.ndarray, dense: np.ndarray, cand: np.ndarray, k: int):
108
+ """Reciprocal Rank Fusion over the FULL candidate rankings, then take top-k.
109
+
110
+ Rank every candidate by each method, sum 1/(RRF_K + rank) across methods, and only
111
+ then truncate. (Truncating each method first would drop docs ranked low by one method
112
+ but high by the other.) No score normalisation.
113
+ """
114
+ def ranks(scores):
115
+ order = np.argsort(-scores[cand]) # candidate positions, best first
116
+ r = np.empty(len(cand), dtype=int)
117
+ r[order] = np.arange(len(cand)) # 0-based rank per candidate position
118
+ return r
119
+
120
+ rb, rd = ranks(bm25), ranks(dense)
121
+ fused = 1.0 / (config.RRF_K + rb) + 1.0 / (config.RRF_K + rd)
122
+ order = np.argsort(-fused)[:k]
123
+ return [(int(cand[i]), float(fused[i])) for i in order]
124
+
125
+
126
+ def format_results(method: str, results: list[tuple[int, float]], score_label: str) -> str:
127
+ """Render a ranked list as Markdown. This list is the SOURCE OF TRUTH for retrieval."""
128
+ if not results:
129
+ return f"### {method}\n\n_No results._"
130
+ lines = [f"### {method}"]
131
+ for rank, (doc, score) in enumerate(results, start=1):
132
+ snippet = ABSTRACTS[doc][:200].rsplit(" ", 1)[0] + "…"
133
+ lines.append(
134
+ f"**{rank}. {TITLES[doc]}** \n"
135
+ f"`{score_label}={score:.3f}` · {int(YEARS[doc])} · *{JOURNALS[doc]}* \n"
136
+ f"{snippet}\n"
137
+ )
138
+ return "\n".join(lines)
139
+
140
+
141
+ def make_plot(query_coord=None, retrieved=None, extra_point=None) -> go.Figure:
142
+ """PCA scatter of the whole corpus, plus query point + connector lines to hits."""
143
+ fig = go.Figure()
144
+ # Whole corpus as grey background — "the space the database searches through".
145
+ fig.add_trace(
146
+ go.Scatter(
147
+ x=PCA_COORDS[:, 0], y=PCA_COORDS[:, 1], mode="markers",
148
+ marker=dict(size=4, color="lightgrey"), text=TITLES, hoverinfo="text",
149
+ name="corpus",
150
+ )
151
+ )
152
+ if retrieved and query_coord is not None:
153
+ for method, results in retrieved.items():
154
+ color = METHOD_COLORS[method]
155
+ xs, ys = [], []
156
+ for doc, _ in results: # connector lines query -> each hit
157
+ xs += [query_coord[0], PCA_COORDS[doc, 0], None]
158
+ ys += [query_coord[1], PCA_COORDS[doc, 1], None]
159
+ fig.add_trace(
160
+ go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=color, width=1),
161
+ opacity=0.5, name=f"{method} links", hoverinfo="skip")
162
+ )
163
+ fig.add_trace(
164
+ go.Scatter(
165
+ x=[PCA_COORDS[d, 0] for d, _ in results],
166
+ y=[PCA_COORDS[d, 1] for d, _ in results],
167
+ mode="markers",
168
+ marker=dict(size=11, color=color, symbol="circle-open", line=dict(width=2)),
169
+ text=[TITLES[d] for d, _ in results], hoverinfo="text", name=f"{method} hits",
170
+ )
171
+ )
172
+ if query_coord is not None:
173
+ fig.add_trace(
174
+ go.Scatter(x=[query_coord[0]], y=[query_coord[1]], mode="markers",
175
+ marker=dict(size=18, color="red", symbol="star"),
176
+ text=["your query"], hoverinfo="text", name="query")
177
+ )
178
+ if extra_point is not None:
179
+ coord, label = extra_point
180
+ fig.add_trace(
181
+ go.Scatter(x=[coord[0]], y=[coord[1]], mode="markers",
182
+ marker=dict(size=15, color="purple", symbol="diamond"),
183
+ text=[label], hoverinfo="text", name="your text")
184
+ )
185
+ fig.update_layout(
186
+ margin=dict(l=10, r=10, t=30, b=10), height=520,
187
+ xaxis_title="PC 1", yaxis_title="PC 2",
188
+ legend=dict(orientation="h", yanchor="bottom", y=1.0),
189
+ )
190
+ return fig
191
+
192
+
193
+ # ====================================================================================
194
+ # Gradio handlers
195
+ # ====================================================================================
196
+ def run_search(query, k, year_lo, year_hi, journal):
197
+ query = (query or "").strip()
198
+ if not query:
199
+ return "Enter a query.", "", "", make_plot(), "—"
200
+
201
+ cand = candidate_indices(year_lo, year_hi, journal)
202
+ info = f"**Candidate pool: {len(cand)} / {len(META)} abstracts** after filtering."
203
+ if len(cand) == 0:
204
+ return "No documents match the filter.", "", "", make_plot(), info
205
+
206
+ k = int(k)
207
+ qvec = embed_query(query) # <-- the only GPU work in search
208
+ dense_scores = EMBEDDINGS @ qvec # exact cosine (vectors are normalised)
209
+ bm25_scores = np.asarray(BM25.get_scores(tokenize(query)))
210
+
211
+ bm25_top = top_k(bm25_scores, cand, k)
212
+ dense_top = top_k(dense_scores, cand, k)
213
+ hybrid_top = rrf_fuse(bm25_scores, dense_scores, cand, k)
214
+
215
+ query_coord = PCA.transform(qvec.reshape(1, -1))[0]
216
+ fig = make_plot(query_coord, {"BM25": bm25_top, "Dense": dense_top, "Hybrid": hybrid_top})
217
+
218
+ return (
219
+ format_results("BM25", bm25_top, "score"),
220
+ format_results("Dense", dense_top, "cosine"),
221
+ format_results("Hybrid", hybrid_top, "RRF"),
222
+ fig,
223
+ info,
224
+ )
225
+
226
+
227
+ def run_embed_own(text):
228
+ text = (text or "").strip()
229
+ if not text:
230
+ return make_plot()
231
+ vec = embed_text(text) # <-- GPU work
232
+ coord = PCA.transform(vec.reshape(1, -1))[0]
233
+ return make_plot(extra_point=(coord, text[:80]))
234
+
235
+
236
+ # ====================================================================================
237
+ # Example queries — TODO: the presenter fills these in.
238
+ # Each placeholder is meant to make ONE method visibly win; pick real strings against the
239
+ # current microRNA/disease corpus before the talk.
240
+ # ====================================================================================
241
+ # BM25 should win: a precise token/ID that appears VERBATIM in abstracts.
242
+ EXACT_ID_QUERY = "TODO_EXACT_ID: e.g. a specific miRNA like 'miR-21' or a gene symbol"
243
+ # Dense should win: a conceptual paraphrase using NONE of the corpus's exact words.
244
+ PARAPHRASE_QUERY = "TODO_PARAPHRASE: e.g. 'small RNAs that switch genes off in tumours'"
245
+ # Lexical vs semantic gap: an acronym whose expansion is what's written in the text.
246
+ ACRONYM_QUERY = "TODO_ACRONYM: e.g. an acronym vs its full form"
247
+ # BM25 strong on rare exact tokens.
248
+ RARE_TERM_QUERY = "TODO_RARE_TERM: e.g. a rare, very specific technical term"
249
+ # Hybrid should win: a broad topic where fusing lexical + semantic beats either alone.
250
+ BROAD_CONCEPT_QUERY = "TODO_BROAD: e.g. 'microRNA biomarkers for early cancer detection'"
251
+
252
+ EXAMPLES = [
253
+ ("Exact ID (BM25)", EXACT_ID_QUERY),
254
+ ("Paraphrase (Dense)", PARAPHRASE_QUERY),
255
+ ("Acronym", ACRONYM_QUERY),
256
+ ("Rare term (BM25)", RARE_TERM_QUERY),
257
+ ("Broad concept (Hybrid)", BROAD_CONCEPT_QUERY),
258
+ ]
259
+
260
+ PLOT_CAVEAT = (
261
+ "⚠️ **The 2D projection distorts true distances.** This PCA view captures only a "
262
+ "sliver of the 384-dimensional space, so retrieved points may *not* be the "
263
+ "visually-closest dots. **The ranked lists above are authoritative** — the plot is "
264
+ "only for spatial intuition."
265
+ )
266
+
267
+ # ====================================================================================
268
+ # UI
269
+ # ====================================================================================
270
+ with gr.Blocks(title="RAG retrieval: BM25 vs Dense vs Hybrid") as demo:
271
+ gr.Markdown(
272
+ "# 🔍 Retrieval methods, side by side\n"
273
+ "Compare **BM25** (lexical), **Dense** (vector cosine), and **Hybrid** "
274
+ "(Reciprocal Rank Fusion) over a corpus of biomedical abstracts. "
275
+ f"Corpus: {len(META):,} Europe PMC abstracts on microRNA & disease."
276
+ )
277
+
278
+ with gr.Row():
279
+ query = gr.Textbox(label="Query", placeholder="Type a search query…", scale=4)
280
+ k = gr.Slider(1, 10, value=5, step=1, label="Top-k", scale=1)
281
+
282
+ with gr.Row():
283
+ for label, text in EXAMPLES:
284
+ btn = gr.Button(label, size="sm")
285
+ btn.click(lambda t=text: t, outputs=query)
286
+
287
+ with gr.Group():
288
+ gr.Markdown("**Metadata filter** — restricts the candidate pool *before* retrieval (all 3 methods).")
289
+ with gr.Row():
290
+ year_lo = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MIN, step=1, label="Year from")
291
+ year_hi = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MAX, step=1, label="Year to")
292
+ journal = gr.Dropdown(["All journals"] + TOP_JOURNALS, value="All journals", label="Journal")
293
+
294
+ search_btn = gr.Button("Search", variant="primary")
295
+ filter_info = gr.Markdown("—")
296
+
297
+ with gr.Row():
298
+ bm25_out = gr.Markdown(label="BM25")
299
+ dense_out = gr.Markdown(label="Dense")
300
+ hybrid_out = gr.Markdown(label="Hybrid")
301
+
302
+ gr.Markdown("## Vector space (PCA projection)")
303
+ plot = gr.Plot(value=make_plot())
304
+ gr.Markdown(PLOT_CAVEAT)
305
+
306
+ with gr.Accordion("Embed your own text", open=False):
307
+ gr.Markdown(
308
+ "Type anything — it gets embedded with the same model and dropped onto the "
309
+ "map. This *is* the space the database searches through."
310
+ )
311
+ own_text = gr.Textbox(label="Your text", placeholder="e.g. a sentence about gene regulation…")
312
+ own_btn = gr.Button("Embed & plot")
313
+ own_btn.click(run_embed_own, inputs=own_text, outputs=plot)
314
+
315
+ inputs = [query, k, year_lo, year_hi, journal]
316
+ outputs = [bm25_out, dense_out, hybrid_out, plot, filter_info]
317
+ search_btn.click(run_search, inputs=inputs, outputs=outputs)
318
+ query.submit(run_search, inputs=inputs, outputs=outputs)
319
 
 
 
320
 
321
+ if __name__ == "__main__":
322
+ demo.launch()
build_index.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline index builder — runs LOCALLY, never on the HF Space.
2
+
3
+ Fetches abstracts from Europe PMC, embeds them, fits a 2D PCA, tokenises for BM25, and
4
+ writes everything to ./data/. The Space then just loads these artifacts at startup; it
5
+ never embeds the corpus or calls an external API at runtime.
6
+
7
+ Run: uv run python build_index.py
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import time
13
+
14
+ import joblib
15
+ import numpy as np
16
+ import pandas as pd
17
+ import requests
18
+ from sentence_transformers import SentenceTransformer
19
+ from sklearn.decomposition import PCA
20
+
21
+ import config
22
+ from text_utils import tokenize
23
+
24
+ EUROPE_PMC_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
25
+
26
+
27
+ def fetch_abstracts(query: str, target_n: int) -> list[dict]:
28
+ """Page through the Europe PMC REST API (cursorMark) collecting records with abstracts."""
29
+ records = []
30
+ cursor = "*"
31
+ session = requests.Session()
32
+ while len(records) < target_n:
33
+ params = {
34
+ "query": query,
35
+ "format": "json",
36
+ "resultType": "core", # includes abstractText, journalInfo, pubYear
37
+ "pageSize": 1000,
38
+ "cursorMark": cursor,
39
+ }
40
+ resp = session.get(EUROPE_PMC_URL, params=params, timeout=60)
41
+ resp.raise_for_status()
42
+ data = resp.json()
43
+
44
+ results = data.get("resultList", {}).get("result", [])
45
+ if not results:
46
+ print(" no more results from Europe PMC; stopping early")
47
+ break
48
+
49
+ for r in results:
50
+ abstract = r.get("abstractText")
51
+ title = r.get("title")
52
+ if not abstract or not title:
53
+ continue # skip records missing the text we need
54
+ records.append(
55
+ {
56
+ "id": r.get("id") or r.get("pmid") or "",
57
+ "title": title.strip(),
58
+ "abstract": abstract.strip(),
59
+ "year": int(r["pubYear"]) if r.get("pubYear") else None,
60
+ "journal": (
61
+ r.get("journalInfo", {}).get("journal", {}).get("title")
62
+ or "Unknown"
63
+ ),
64
+ }
65
+ )
66
+ if len(records) >= target_n:
67
+ break
68
+
69
+ next_cursor = data.get("nextCursorMark")
70
+ print(f" fetched {len(records)} / {target_n} abstracts...")
71
+ if not next_cursor or next_cursor == cursor:
72
+ break # reached the end of the result set
73
+ cursor = next_cursor
74
+ time.sleep(0.2) # be polite to the API
75
+
76
+ return records
77
+
78
+
79
+ def main():
80
+ os.makedirs(config.DATA_DIR, exist_ok=True)
81
+
82
+ # 1. Fetch ----------------------------------------------------------------------
83
+ print(f"Fetching abstracts for query:\n {config.EUROPE_PMC_QUERY}")
84
+ records = fetch_abstracts(config.EUROPE_PMC_QUERY, config.TARGET_N)
85
+ if not records:
86
+ raise SystemExit("No records fetched — check the query / network.")
87
+ df = pd.DataFrame(records)
88
+ # Drop the occasional exact-duplicate abstract.
89
+ df = df.drop_duplicates(subset="abstract").reset_index(drop=True)
90
+ print(f"Kept {len(df)} unique abstracts.")
91
+
92
+ # The text we both embed AND tokenise for BM25 — keep them identical for a fair fight.
93
+ docs = (df["title"] + ". " + df["abstract"]).tolist()
94
+
95
+ # 2. Embed ----------------------------------------------------------------------
96
+ print(f"Loading embedding model: {config.EMBEDDING_MODEL}")
97
+ model = SentenceTransformer(config.EMBEDDING_MODEL)
98
+ print("Embedding corpus (this is the slow part)...")
99
+ embeddings = model.encode(
100
+ docs,
101
+ batch_size=64,
102
+ show_progress_bar=True,
103
+ normalize_embeddings=True, # so cosine similarity == dot product
104
+ convert_to_numpy=True,
105
+ ).astype(np.float32)
106
+ np.save(os.path.join(config.DATA_DIR, "embeddings.npy"), embeddings)
107
+ print(f"Saved embeddings: {embeddings.shape}")
108
+
109
+ # 3. PCA ------------------------------------------------------------------------
110
+ print("Fitting 2D PCA...")
111
+ pca = PCA(n_components=2, random_state=0)
112
+ coords = pca.fit_transform(embeddings).astype(np.float32)
113
+ joblib.dump(pca, os.path.join(config.DATA_DIR, "pca.joblib"))
114
+ np.save(os.path.join(config.DATA_DIR, "pca_coords.npy"), coords)
115
+ print(f"Saved PCA + corpus coords: {coords.shape}")
116
+
117
+ # 4. BM25 tokens ----------------------------------------------------------------
118
+ print("Tokenising for BM25...")
119
+ tokens = [tokenize(doc) for doc in docs]
120
+ with open(os.path.join(config.DATA_DIR, "bm25_tokens.json"), "w") as f:
121
+ json.dump(tokens, f)
122
+ print(f"Saved {len(tokens)} token lists.")
123
+
124
+ # 5. Metadata -------------------------------------------------------------------
125
+ df.to_parquet(os.path.join(config.DATA_DIR, "metadata.parquet"), index=False)
126
+ print(f"Saved metadata.parquet: {df.shape}")
127
+
128
+ # 6. Config record (startup sanity check in app.py) -----------------------------
129
+ with open(os.path.join(config.DATA_DIR, "config.json"), "w") as f:
130
+ json.dump(
131
+ {"embedding_model": config.EMBEDDING_MODEL, "dim": int(embeddings.shape[1])},
132
+ f,
133
+ indent=2,
134
+ )
135
+
136
+ print("\nDone. Artifacts written to", config.DATA_DIR)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
config.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared configuration — the SINGLE SOURCE OF TRUTH for both build_index.py and app.py.
2
+
3
+ Keeping these constants in one module guarantees the offline corpus and the live query
4
+ are embedded with the *same* model. If they ever diverge, the query vector and the corpus
5
+ vectors live in different spaces and every retrieval result is silently wrong.
6
+ """
7
+
8
+ # --- Embedding model -----------------------------------------------------------------
9
+ # Small, fast, 384-dim. The default for this demo.
10
+ EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
11
+ # Biomedical alternative (768-dim) — swap the line above for this one and rebuild to use a
12
+ # domain model. ZeroGPU gives us the headroom for it.
13
+ # EMBEDDING_MODEL = "pritamdeka/S-PubMedBert-MS-MARCO"
14
+
15
+ # --- Corpus (offline build only) -----------------------------------------------------
16
+ # Europe PMC search query. See https://europepmc.org/Help for the query syntax.
17
+ EUROPE_PMC_QUERY = (
18
+ "microRNA AND disease AND HAS_ABSTRACT:Y AND LANG:eng "
19
+ "AND (FIRST_PDATE:[2018-01-01 TO 2025-12-31])"
20
+ )
21
+ TARGET_N = 4000 # number of abstracts to fetch & index
22
+
23
+ # --- Retrieval -----------------------------------------------------------------------
24
+ RRF_K = 60 # Reciprocal Rank Fusion constant (standard default)
25
+
26
+ # --- Paths ---------------------------------------------------------------------------
27
+ DATA_DIR = "./data"
data/bm25_tokens.json ADDED
The diff for this file is too large to render. See raw diff
 
data/config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
3
+ "dim": 384
4
+ }
data/embeddings.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2f81a7b345de3c3173cd6b8219789968f061980d7d5d6dffe8445e82d84298c6
3
+ size 6133376
data/metadata.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d002fef7b9475d2794670200a5ec53092d7816d769583330c6ec7dedfabc24a1
3
+ size 3825335
data/pca.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f13f7a85bd6fe24bf75709961bcdefb4e2ca4587e3e377cf67f29a7b765baefd
3
+ size 5583
data/pca_coords.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ca78a6240daa3c539a57f76faf6efdaf2419b7031d683f780adc112e5eef9746
3
+ size 32072
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ spaces
3
+ sentence-transformers
4
+ rank-bm25
5
+ scikit-learn
6
+ numpy
7
+ pandas
8
+ pyarrow
9
+ plotly
10
+ joblib
11
+ requests
text_utils.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny shared text helper.
2
+
3
+ Lives in its own module so the offline BM25 index (build_index.py) and the live query
4
+ tokenisation (app.py) use the EXACT same tokeniser. If these drift, BM25 scoring becomes
5
+ inconsistent between corpus and query.
6
+ """
7
+
8
+ import re
9
+
10
+ _WORD_RE = re.compile(r"\w+")
11
+
12
+
13
+ def tokenize(text: str) -> list[str]:
14
+ """Lowercase + split on word characters. Simple and good enough for a teaching demo."""
15
+ return _WORD_RE.findall(text.lower())