Spaces:
Runtime error
Runtime error
Add cross-encoder rerank button + dual embedding models (general/biomedical) with live switch
Browse files- README.md +32 -13
- app.py +163 -102
- build_index.py +33 -38
- config.py +17 -6
- data/bm25_tokens.json +0 -0
- data/config.json +8 -2
- data/embeddings_biomedical.npy +3 -0
- data/{embeddings.npy → embeddings_general.npy} +1 -1
- data/metadata.parquet +2 -2
- data/umap_biomedical.joblib +3 -0
- data/{umap_coords.npy → umap_coords_biomedical.npy} +1 -1
- data/{umap.joblib → umap_coords_general.npy} +2 -2
- data/umap_general.joblib +3 -0
README.md
CHANGED
|
@@ -7,37 +7,56 @@ sdk: gradio
|
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
short_description: BM25 vs Dense vs Hybrid
|
| 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
|
| 17 |
-
side
|
| 18 |
|
| 19 |
1. **BM25** (lexical) — `rank-bm25`
|
| 20 |
-
2. **Dense** (vector) — exact cosine similarity over
|
| 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 **UMAP scatter plot** for spatial
|
| 25 |
intuition, with connector lines from the query to the documents each method retrieved.
|
| 26 |
Click any result to expand its full abstract.
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
## How it's built (read this before the talk)
|
| 29 |
|
| 30 |
- **Everything is precomputed offline** by `build_index.py`, which fetches the abstracts,
|
| 31 |
-
embeds the corpus, fits
|
| 32 |
-
committed. The app **never** embeds the corpus or calls
|
| 33 |
-
it only embeds your *live query*
|
|
|
|
| 34 |
- **ZeroGPU:** GPU is allocated on demand only inside the `@spaces.GPU`-decorated query
|
| 35 |
-
embedding functions.
|
| 36 |
-
startup. ⚠️ **The first GPU call after idle has a cold start of a few
|
| 37 |
-
with a dummy query before presenting.**
|
| 38 |
-
- **Swappable
|
| 39 |
-
|
| 40 |
-
|
| 41 |
|
| 42 |
## Exact search, honestly
|
| 43 |
|
|
|
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: BM25 vs Dense vs Hybrid vs Rerank, 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 several methods and shows them side by
|
| 17 |
+
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 sentence-transformer embeddings
|
| 21 |
3. **Hybrid** — Reciprocal Rank Fusion (RRF, k=60) over the BM25 and dense rankings
|
| 22 |
+
4. **Reranked** (optional, on-demand button) — a **cross-encoder** that scores
|
| 23 |
+
`(query, document)` pairs jointly and reorders the hybrid shortlist. It can't be
|
| 24 |
+
precomputed, so it runs live and the UI **times it** to make the cost visible.
|
| 25 |
+
|
| 26 |
+
Two embedding models are shipped — **general** (`all-MiniLM-L6-v2`, 384-dim) and
|
| 27 |
+
**biomedical** (`pritamdeka/S-PubMedBert-MS-MARCO`, 768-dim) — and a radio switches which
|
| 28 |
+
one Dense, Hybrid and the plot use. BM25 is lexical, so it's unaffected by the switch.
|
| 29 |
|
| 30 |
Plus a **metadata filter** (year / journal) that restricts the candidate pool *before*
|
| 31 |
retrieval — the distinctly vector-DB feature — and a **UMAP scatter plot** for spatial
|
| 32 |
intuition, with connector lines from the query to the documents each method retrieved.
|
| 33 |
Click any result to expand its full abstract.
|
| 34 |
|
| 35 |
+
## Retrieve-then-rerank, and how it differs from RRF
|
| 36 |
+
|
| 37 |
+
- **RRF (Hybrid)** is a *fusion* of rankings — it only uses BM25's and Dense's rank
|
| 38 |
+
positions, no model, essentially free. It improves **recall** by combining lexical +
|
| 39 |
+
semantic signal.
|
| 40 |
+
- **The reranker** is a *second-stage precision* step. A **bi-encoder** (Dense) embeds
|
| 41 |
+
query and document separately; a **cross-encoder** runs them through a transformer
|
| 42 |
+
*together*, so it judges relevance far more accurately — but at one forward pass per
|
| 43 |
+
candidate, so it only runs on a shortlist (here, the hybrid top-30). It can only reorder
|
| 44 |
+
what stage 1 surfaced, so recall still matters.
|
| 45 |
+
|
| 46 |
## How it's built (read this before the talk)
|
| 47 |
|
| 48 |
- **Everything is precomputed offline** by `build_index.py`, which fetches the abstracts,
|
| 49 |
+
embeds the corpus with **each** model, fits a UMAP per model, and tokenises for BM25.
|
| 50 |
+
The artifacts in `./data/` are committed. The app **never** embeds the corpus or calls
|
| 51 |
+
an external API at startup — it only embeds your *live query* (and, on demand, runs the
|
| 52 |
+
reranker).
|
| 53 |
- **ZeroGPU:** GPU is allocated on demand only inside the `@spaces.GPU`-decorated query
|
| 54 |
+
embedding and reranking functions. Every model is loaded on CPU at import; nothing
|
| 55 |
+
touches CUDA at startup. ⚠️ **The first GPU call after idle has a cold start of a few
|
| 56 |
+
seconds — pre-warm with a dummy query (and a dummy rerank) before presenting.**
|
| 57 |
+
- **Swappable models:** `config.py` holds the `EMBEDDING_MODELS` registry and
|
| 58 |
+
`RERANKER_MODEL` — the single source of truth for both the offline build and the live
|
| 59 |
+
app. Add/swap a model there and re-run `build_index.py`.
|
| 60 |
|
| 61 |
## Exact search, honestly
|
| 62 |
|
app.py
CHANGED
|
@@ -1,17 +1,23 @@
|
|
| 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 -> UMAP plot
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import html
|
| 13 |
import json
|
| 14 |
import os
|
|
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
import joblib
|
|
@@ -21,7 +27,7 @@ import plotly.graph_objects as go
|
|
| 21 |
import spaces
|
| 22 |
import torch
|
| 23 |
from rank_bm25 import BM25Okapi
|
| 24 |
-
from sentence_transformers import SentenceTransformer
|
| 25 |
|
| 26 |
import config
|
| 27 |
from text_utils import tokenize
|
|
@@ -30,14 +36,11 @@ from text_utils import tokenize
|
|
| 30 |
# Load pre-built artifacts (fast; no embedding, no network)
|
| 31 |
# ====================================================================================
|
| 32 |
D = config.DATA_DIR
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
REDUCER = joblib.load(os.path.join(D, "umap.joblib")) # projects new points via .transform
|
| 36 |
META = pd.read_parquet(os.path.join(D, "metadata.parquet"))
|
| 37 |
with open(os.path.join(D, "bm25_tokens.json")) as f:
|
| 38 |
BM25 = BM25Okapi(json.load(f))
|
| 39 |
-
with open(os.path.join(D, "config.json")) as f:
|
| 40 |
-
DATA_CFG = json.load(f)
|
| 41 |
|
| 42 |
TITLES = META["title"].tolist()
|
| 43 |
ABSTRACTS = META["abstract"].tolist()
|
|
@@ -49,50 +52,74 @@ YEAR_MAX = int(YEARS.max())
|
|
| 49 |
# 1300+ distinct journals — show only the most common ones in the dropdown.
|
| 50 |
TOP_JOURNALS = META["journal"].value_counts().head(30).index.tolist()
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
# ====================================================================================
|
| 57 |
-
MODEL = SentenceTransformer(config.EMBEDDING_MODEL, device="cpu")
|
| 58 |
-
# Prefer the new method name, fall back to the deprecated one across versions.
|
| 59 |
-
_get_dim = getattr(MODEL, "get_embedding_dimension", None) or MODEL.get_sentence_embedding_dimension
|
| 60 |
-
assert _get_dim() == EMBEDDINGS.shape[1], (
|
| 61 |
-
"Model dim doesn't match committed embeddings — did you swap EMBEDDING_MODEL "
|
| 62 |
-
"without re-running build_index.py?"
|
| 63 |
-
)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
return vec[0].astype(np.float32)
|
| 74 |
|
| 75 |
|
| 76 |
@spaces.GPU
|
| 77 |
-
def embed_query(text: str) -> np.ndarray:
|
| 78 |
"""GPU-backed query embedding for retrieval."""
|
| 79 |
-
return _encode(text)
|
| 80 |
|
| 81 |
|
| 82 |
@spaces.GPU
|
| 83 |
-
def embed_text(text: str) -> np.ndarray:
|
| 84 |
"""GPU-backed embedding for the 'embed your own text' feature."""
|
| 85 |
-
return _encode(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
# ====================================================================================
|
| 89 |
-
# Retrieval
|
| 90 |
# ====================================================================================
|
| 91 |
def candidate_indices(year_lo: int, year_hi: int, journal: str) -> np.ndarray:
|
| 92 |
"""Apply the metadata filter BEFORE retrieval. Returns indices of allowed docs.
|
| 93 |
|
| 94 |
-
This pre-filtering of the candidate pool is the distinctly vector-DB feature:
|
| 95 |
-
|
| 96 |
"""
|
| 97 |
mask = (YEARS >= int(year_lo)) & (YEARS <= int(year_hi))
|
| 98 |
if journal and journal != "All journals":
|
|
@@ -128,8 +155,7 @@ def rrf_fuse(bm25: np.ndarray, dense: np.ndarray, cand: np.ndarray, k: int):
|
|
| 128 |
|
| 129 |
def format_results(method: str, results: list[tuple[int, float]], score_label: str) -> str:
|
| 130 |
"""Render a ranked list as HTML. Each result is a <details> — click the title to
|
| 131 |
-
expand the FULL abstract
|
| 132 |
-
This list is the SOURCE OF TRUTH for retrieval.
|
| 133 |
"""
|
| 134 |
if not results:
|
| 135 |
return f"<h3>{method}</h3><p><em>No results.</em></p>"
|
|
@@ -149,47 +175,40 @@ def format_results(method: str, results: list[tuple[int, float]], score_label: s
|
|
| 149 |
return "".join(parts)
|
| 150 |
|
| 151 |
|
| 152 |
-
def make_plot(query_coord=None, retrieved=None, extra_point=None, cand=None) -> go.Figure:
|
| 153 |
-
"""UMAP scatter
|
| 154 |
|
| 155 |
-
`cand` (optional) =
|
| 156 |
-
|
| 157 |
"""
|
|
|
|
| 158 |
fig = go.Figure()
|
| 159 |
-
bg = np.arange(len(
|
| 160 |
-
# Grey background — "the space the database searches through" (after filtering).
|
| 161 |
fig.add_trace(
|
| 162 |
go.Scatter(
|
| 163 |
-
x=
|
| 164 |
marker=dict(size=4, color="lightgrey"),
|
| 165 |
text=[TITLES[i] for i in bg], hoverinfo="text", name=f"corpus ({len(bg)})",
|
| 166 |
)
|
| 167 |
)
|
| 168 |
if retrieved and query_coord is not None:
|
| 169 |
-
# Each method is its own trace. A doc retrieved by several methods has ONE
|
| 170 |
-
# coordinate, so markers/lines land on the same spot. We tier marker size and
|
| 171 |
-
# line width per method (BM25 biggest, Hybrid smallest) so overlaps nest into
|
| 172 |
-
# concentric rings instead of hiding each other — and "found by all three" is
|
| 173 |
-
# then visually obvious. No position jitter (that would misrepresent distance).
|
| 174 |
-
SIZES = {"BM25": 20, "Dense": 15, "Hybrid": 10}
|
| 175 |
-
WIDTHS = {"BM25": 4, "Dense": 2.5, "Hybrid": 1}
|
| 176 |
for method, results in retrieved.items():
|
| 177 |
color = METHOD_COLORS[method]
|
| 178 |
xs, ys = [], []
|
| 179 |
for doc, _ in results: # connector lines query -> each hit
|
| 180 |
-
xs += [query_coord[0],
|
| 181 |
-
ys += [query_coord[1],
|
| 182 |
fig.add_trace(
|
| 183 |
go.Scatter(x=xs, y=ys, mode="lines",
|
| 184 |
-
line=dict(color=color, width=
|
| 185 |
opacity=0.5, name=f"{method} links", hoverinfo="skip")
|
| 186 |
)
|
| 187 |
fig.add_trace(
|
| 188 |
go.Scatter(
|
| 189 |
-
x=[
|
| 190 |
-
y=[
|
| 191 |
mode="markers",
|
| 192 |
-
marker=dict(size=
|
| 193 |
line=dict(width=2)),
|
| 194 |
text=[TITLES[d] for d, _ in results], hoverinfo="text", name=f"{method} hits",
|
| 195 |
)
|
|
@@ -209,7 +228,7 @@ def make_plot(query_coord=None, retrieved=None, extra_point=None, cand=None) ->
|
|
| 209 |
)
|
| 210 |
fig.update_layout(
|
| 211 |
margin=dict(l=10, r=10, t=30, b=10), height=520,
|
| 212 |
-
xaxis_title="
|
| 213 |
legend=dict(orientation="h", yanchor="bottom", y=1.0),
|
| 214 |
)
|
| 215 |
return fig
|
|
@@ -218,67 +237,97 @@ def make_plot(query_coord=None, retrieved=None, extra_point=None, cand=None) ->
|
|
| 218 |
# ====================================================================================
|
| 219 |
# Gradio handlers
|
| 220 |
# ====================================================================================
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
query = (query or "").strip()
|
| 223 |
if not query:
|
| 224 |
-
return "Enter a query.", "", "", make_plot(), "—"
|
| 225 |
|
| 226 |
cand = candidate_indices(year_lo, year_hi, journal)
|
| 227 |
info = f"**Candidate pool: {len(cand)} / {len(META)} abstracts** after filtering."
|
| 228 |
if len(cand) == 0:
|
| 229 |
-
return "No documents match the filter.", "", "", make_plot(), info
|
| 230 |
|
| 231 |
k = int(k)
|
| 232 |
-
qvec = embed_query(query)
|
| 233 |
-
dense_scores =
|
| 234 |
bm25_scores = np.asarray(BM25.get_scores(tokenize(query)))
|
| 235 |
|
| 236 |
bm25_top = top_k(bm25_scores, cand, k)
|
| 237 |
dense_top = top_k(dense_scores, cand, k)
|
| 238 |
hybrid_top = rrf_fuse(bm25_scores, dense_scores, cand, k)
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
)
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
return (
|
| 246 |
format_results("BM25", bm25_top, "score"),
|
| 247 |
format_results("Dense", dense_top, "cosine"),
|
| 248 |
format_results("Hybrid", hybrid_top, "RRF"),
|
| 249 |
-
|
| 250 |
-
info,
|
| 251 |
)
|
| 252 |
|
| 253 |
|
| 254 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
text = (text or "").strip()
|
| 256 |
if not text:
|
| 257 |
-
return make_plot()
|
| 258 |
-
vec = embed_text(text)
|
| 259 |
-
coord = REDUCER.transform(vec.reshape(1, -1))[0]
|
| 260 |
-
return make_plot(extra_point=(coord, text[:80]))
|
| 261 |
|
| 262 |
|
| 263 |
# ====================================================================================
|
| 264 |
# Example queries — tuned to the cardiovascular slice of this microRNA/disease corpus.
|
| 265 |
# Each is chosen (and verified against the corpus) to make ONE method visibly win.
|
| 266 |
# ====================================================================================
|
| 267 |
-
# BM25 wins:
|
| 268 |
-
#
|
| 269 |
-
EXACT_ID_QUERY = "miR-208a"
|
| 270 |
-
# Dense wins: a plain-English paraphrase using NONE of the corpus jargon (no "microRNA",
|
| 271 |
-
# "fibrosis", "myocardial"). BM25 has almost no tokens to match; dense maps the meaning
|
| 272 |
-
# to miRNA-in-cardiac-fibrosis papers.
|
| 273 |
PARAPHRASE_QUERY = "small RNA molecules that worsen scarring after a heart attack"
|
| 274 |
-
# BM25 wins:
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
# BM25 wins: a specific molecule + mechanism. BM25 pins the exact miR-499 papers; dense
|
| 278 |
-
# returns topically-similar cardiomyocyte-apoptosis papers about *other* miRNAs.
|
| 279 |
-
RARE_TERM_QUERY = "miR-499 cardiomyocyte apoptosis"
|
| 280 |
-
# Hybrid wins: a broad real query where lexical and semantic each surface good-but-
|
| 281 |
-
# different papers, and RRF fuses them into the strongest combined ranking.
|
| 282 |
BROAD_CONCEPT_QUERY = "circulating microRNA biomarkers for cardiovascular disease"
|
| 283 |
|
| 284 |
EXAMPLES = [
|
|
@@ -300,12 +349,12 @@ PLOT_CAVEAT = (
|
|
| 300 |
# ====================================================================================
|
| 301 |
# UI
|
| 302 |
# ====================================================================================
|
| 303 |
-
with gr.Blocks(title="RAG retrieval: BM25 vs Dense vs Hybrid") as demo:
|
| 304 |
gr.Markdown(
|
| 305 |
"# 🔍 Retrieval methods, side by side\n"
|
| 306 |
-
"Compare **BM25** (lexical), **Dense** (vector cosine),
|
| 307 |
-
"
|
| 308 |
-
f"
|
| 309 |
)
|
| 310 |
|
| 311 |
with gr.Row():
|
|
@@ -317,8 +366,13 @@ with gr.Blocks(title="RAG retrieval: BM25 vs Dense vs Hybrid") as demo:
|
|
| 317 |
btn = gr.Button(label, size="sm")
|
| 318 |
btn.click(lambda t=text: t, outputs=query)
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
with gr.Group():
|
| 321 |
-
gr.Markdown("**Metadata filter** — restricts the candidate pool *before* retrieval (all
|
| 322 |
with gr.Row():
|
| 323 |
year_lo = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MIN, step=1, label="Year from")
|
| 324 |
year_hi = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MAX, step=1, label="Year to")
|
|
@@ -332,24 +386,31 @@ with gr.Blocks(title="RAG retrieval: BM25 vs Dense vs Hybrid") as demo:
|
|
| 332 |
bm25_out = gr.HTML(label="BM25")
|
| 333 |
dense_out = gr.HTML(label="Dense")
|
| 334 |
hybrid_out = gr.HTML(label="Hybrid")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
gr.Markdown("## Vector space (UMAP projection)")
|
| 337 |
-
plot = gr.Plot(value=make_plot())
|
| 338 |
gr.Markdown(PLOT_CAVEAT)
|
| 339 |
|
| 340 |
with gr.Accordion("Embed your own text", open=False):
|
| 341 |
gr.Markdown(
|
| 342 |
-
"Type anything — it gets embedded with the
|
| 343 |
"map. This *is* the space the database searches through."
|
| 344 |
)
|
| 345 |
own_text = gr.Textbox(label="Your text", placeholder="e.g. a sentence about gene regulation…")
|
| 346 |
own_btn = gr.Button("Embed & plot")
|
| 347 |
-
own_btn.click(run_embed_own, inputs=own_text, outputs=plot)
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
| 353 |
|
| 354 |
|
| 355 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Retrieval-methods comparison demo (BM25 vs Dense vs Hybrid/RRF, + optional reranking).
|
| 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 -> UMAP plot
|
| 7 |
+
|
|
| 8 |
+
optional: cross-encoder rerank (GPU)
|
| 9 |
|
| 10 |
+
Two embedding models are shipped (general MiniLM + biomedical PubMedBERT); a radio switches
|
| 11 |
+
which one Dense/Hybrid/plot use (BM25 is lexical, so it's unaffected).
|
| 12 |
+
|
| 13 |
+
ZeroGPU note: every model is loaded on CPU at import. GPU is touched ONLY inside the
|
| 14 |
+
@spaces.GPU functions. Do not move a model to CUDA anywhere else.
|
| 15 |
"""
|
| 16 |
|
| 17 |
import html
|
| 18 |
import json
|
| 19 |
import os
|
| 20 |
+
import time
|
| 21 |
|
| 22 |
import gradio as gr
|
| 23 |
import joblib
|
|
|
|
| 27 |
import spaces
|
| 28 |
import torch
|
| 29 |
from rank_bm25 import BM25Okapi
|
| 30 |
+
from sentence_transformers import CrossEncoder, SentenceTransformer
|
| 31 |
|
| 32 |
import config
|
| 33 |
from text_utils import tokenize
|
|
|
|
| 36 |
# Load pre-built artifacts (fast; no embedding, no network)
|
| 37 |
# ====================================================================================
|
| 38 |
D = config.DATA_DIR
|
| 39 |
+
|
| 40 |
+
# Shared, model-independent artifacts.
|
|
|
|
| 41 |
META = pd.read_parquet(os.path.join(D, "metadata.parquet"))
|
| 42 |
with open(os.path.join(D, "bm25_tokens.json")) as f:
|
| 43 |
BM25 = BM25Okapi(json.load(f))
|
|
|
|
|
|
|
| 44 |
|
| 45 |
TITLES = META["title"].tolist()
|
| 46 |
ABSTRACTS = META["abstract"].tolist()
|
|
|
|
| 52 |
# 1300+ distinct journals — show only the most common ones in the dropdown.
|
| 53 |
TOP_JOURNALS = META["journal"].value_counts().head(30).index.tolist()
|
| 54 |
|
| 55 |
+
# Per-model artifacts + models, keyed by model_key (see config.EMBEDDING_MODELS).
|
| 56 |
+
EMB, COORDS, REDUCER, MODELS = {}, {}, {}, {}
|
| 57 |
+
for _key, (_label, _name) in config.EMBEDDING_MODELS.items():
|
| 58 |
+
EMB[_key] = np.load(os.path.join(D, f"embeddings_{_key}.npy")) # (N, dim), L2-normalised
|
| 59 |
+
COORDS[_key] = np.load(os.path.join(D, f"umap_coords_{_key}.npy")) # (N, 2)
|
| 60 |
+
REDUCER[_key] = joblib.load(os.path.join(D, f"umap_{_key}.joblib")) # .transform new points
|
| 61 |
+
MODELS[_key] = SentenceTransformer(_name, device="cpu") # CPU only at import!
|
| 62 |
+
_gd = getattr(MODELS[_key], "get_embedding_dimension", None) or MODELS[_key].get_sentence_embedding_dimension
|
| 63 |
+
assert _gd() == EMB[_key].shape[1], f"Model/embedding dim mismatch for '{_key}' — rebuild?"
|
| 64 |
|
| 65 |
+
# Cross-encoder reranker — also CPU at import; moved to GPU inside @spaces.GPU.
|
| 66 |
+
RERANKER = CrossEncoder(config.RERANKER_MODEL, device="cpu")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
# UI label <-> internal key.
|
| 69 |
+
MODEL_LABELS = {k: lbl for k, (lbl, _) in config.EMBEDDING_MODELS.items()}
|
| 70 |
+
LABEL_TO_KEY = {lbl: k for k, lbl in MODEL_LABELS.items()}
|
| 71 |
+
DEFAULT_LABEL = MODEL_LABELS[config.DEFAULT_MODEL_KEY]
|
| 72 |
|
| 73 |
+
# Per-method plot styling. Sizes/widths are tiered so that a doc retrieved by several
|
| 74 |
+
# methods nests into concentric rings instead of one trace hiding another.
|
| 75 |
+
METHOD_COLORS = {"BM25": "#ff7f0e", "Dense": "#1f77b4", "Hybrid": "#2ca02c", "Reranked": "#111111"}
|
| 76 |
+
METHOD_SIZES = {"BM25": 20, "Dense": 15, "Hybrid": 10, "Reranked": 13}
|
| 77 |
+
METHOD_WIDTHS = {"BM25": 4, "Dense": 2.5, "Hybrid": 1, "Reranked": 2.5}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ====================================================================================
|
| 81 |
+
# GPU functions — the ONLY place CUDA is touched
|
| 82 |
+
# ====================================================================================
|
| 83 |
+
def _encode(model_key: str, text: str) -> np.ndarray:
|
| 84 |
+
"""Embed one string with the chosen model -> (dim,) float32, L2-normalised."""
|
| 85 |
+
device = "cuda" if torch.cuda.is_available() else "cpu" # safe: only under @spaces.GPU
|
| 86 |
+
model = MODELS[model_key]
|
| 87 |
+
model.to(device)
|
| 88 |
+
vec = model.encode([text], normalize_embeddings=True, convert_to_numpy=True, device=device)
|
| 89 |
return vec[0].astype(np.float32)
|
| 90 |
|
| 91 |
|
| 92 |
@spaces.GPU
|
| 93 |
+
def embed_query(model_key: str, text: str) -> np.ndarray:
|
| 94 |
"""GPU-backed query embedding for retrieval."""
|
| 95 |
+
return _encode(model_key, text)
|
| 96 |
|
| 97 |
|
| 98 |
@spaces.GPU
|
| 99 |
+
def embed_text(model_key: str, text: str) -> np.ndarray:
|
| 100 |
"""GPU-backed embedding for the 'embed your own text' feature."""
|
| 101 |
+
return _encode(model_key, text)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@spaces.GPU
|
| 105 |
+
def rerank_scores(query: str, texts: list[str]) -> np.ndarray:
|
| 106 |
+
"""Cross-encoder relevance scores for (query, doc) pairs. One transformer forward
|
| 107 |
+
pass PER candidate — this is the expensive, can't-precompute step."""
|
| 108 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 109 |
+
RERANKER.model.to(device) # CrossEncoder.device follows the underlying model
|
| 110 |
+
pairs = [(query, t) for t in texts]
|
| 111 |
+
scores = RERANKER.predict(pairs, batch_size=16, convert_to_numpy=True, show_progress_bar=False)
|
| 112 |
+
return np.asarray(scores, dtype=np.float32)
|
| 113 |
|
| 114 |
|
| 115 |
# ====================================================================================
|
| 116 |
+
# Retrieval (pure CPU / numpy)
|
| 117 |
# ====================================================================================
|
| 118 |
def candidate_indices(year_lo: int, year_hi: int, journal: str) -> np.ndarray:
|
| 119 |
"""Apply the metadata filter BEFORE retrieval. Returns indices of allowed docs.
|
| 120 |
|
| 121 |
+
This pre-filtering of the candidate pool is the distinctly vector-DB feature: every
|
| 122 |
+
method then searches only within these documents.
|
| 123 |
"""
|
| 124 |
mask = (YEARS >= int(year_lo)) & (YEARS <= int(year_hi))
|
| 125 |
if journal and journal != "All journals":
|
|
|
|
| 155 |
|
| 156 |
def format_results(method: str, results: list[tuple[int, float]], score_label: str) -> str:
|
| 157 |
"""Render a ranked list as HTML. Each result is a <details> — click the title to
|
| 158 |
+
expand the FULL abstract. This list is the SOURCE OF TRUTH for retrieval.
|
|
|
|
| 159 |
"""
|
| 160 |
if not results:
|
| 161 |
return f"<h3>{method}</h3><p><em>No results.</em></p>"
|
|
|
|
| 175 |
return "".join(parts)
|
| 176 |
|
| 177 |
|
| 178 |
+
def make_plot(model_key, query_coord=None, retrieved=None, extra_point=None, cand=None) -> go.Figure:
|
| 179 |
+
"""UMAP scatter for the chosen model, plus query point + connector lines to hits.
|
| 180 |
|
| 181 |
+
`cand` (optional) = indices that passed the metadata filter; when given, only those
|
| 182 |
+
points form the grey background, so the plot reflects the filter.
|
| 183 |
"""
|
| 184 |
+
coords = COORDS[model_key]
|
| 185 |
fig = go.Figure()
|
| 186 |
+
bg = np.arange(len(coords)) if cand is None else np.asarray(cand)
|
|
|
|
| 187 |
fig.add_trace(
|
| 188 |
go.Scatter(
|
| 189 |
+
x=coords[bg, 0], y=coords[bg, 1], mode="markers",
|
| 190 |
marker=dict(size=4, color="lightgrey"),
|
| 191 |
text=[TITLES[i] for i in bg], hoverinfo="text", name=f"corpus ({len(bg)})",
|
| 192 |
)
|
| 193 |
)
|
| 194 |
if retrieved and query_coord is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
for method, results in retrieved.items():
|
| 196 |
color = METHOD_COLORS[method]
|
| 197 |
xs, ys = [], []
|
| 198 |
for doc, _ in results: # connector lines query -> each hit
|
| 199 |
+
xs += [query_coord[0], coords[doc, 0], None]
|
| 200 |
+
ys += [query_coord[1], coords[doc, 1], None]
|
| 201 |
fig.add_trace(
|
| 202 |
go.Scatter(x=xs, y=ys, mode="lines",
|
| 203 |
+
line=dict(color=color, width=METHOD_WIDTHS[method]),
|
| 204 |
opacity=0.5, name=f"{method} links", hoverinfo="skip")
|
| 205 |
)
|
| 206 |
fig.add_trace(
|
| 207 |
go.Scatter(
|
| 208 |
+
x=[coords[d, 0] for d, _ in results],
|
| 209 |
+
y=[coords[d, 1] for d, _ in results],
|
| 210 |
mode="markers",
|
| 211 |
+
marker=dict(size=METHOD_SIZES[method], color=color, symbol="circle-open",
|
| 212 |
line=dict(width=2)),
|
| 213 |
text=[TITLES[d] for d, _ in results], hoverinfo="text", name=f"{method} hits",
|
| 214 |
)
|
|
|
|
| 228 |
)
|
| 229 |
fig.update_layout(
|
| 230 |
margin=dict(l=10, r=10, t=30, b=10), height=520,
|
| 231 |
+
xaxis_title="UMAP-1", yaxis_title="UMAP-2",
|
| 232 |
legend=dict(orientation="h", yanchor="bottom", y=1.0),
|
| 233 |
)
|
| 234 |
return fig
|
|
|
|
| 237 |
# ====================================================================================
|
| 238 |
# Gradio handlers
|
| 239 |
# ====================================================================================
|
| 240 |
+
RERANK_HINT = "<p><em>Press <b>Rerank</b> to reorder the hybrid shortlist with the cross-encoder.</em></p>"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def run_search(query, k, year_lo, year_hi, journal, model_label):
|
| 244 |
+
"""Stage 1: BM25 + Dense + Hybrid. Fast. Stashes a shortlist for optional reranking."""
|
| 245 |
+
key = LABEL_TO_KEY[model_label]
|
| 246 |
query = (query or "").strip()
|
| 247 |
if not query:
|
| 248 |
+
return "Enter a query.", "", "", "", make_plot(key), "—", None, ""
|
| 249 |
|
| 250 |
cand = candidate_indices(year_lo, year_hi, journal)
|
| 251 |
info = f"**Candidate pool: {len(cand)} / {len(META)} abstracts** after filtering."
|
| 252 |
if len(cand) == 0:
|
| 253 |
+
return "No documents match the filter.", "", "", "", make_plot(key, cand=cand), info, None, ""
|
| 254 |
|
| 255 |
k = int(k)
|
| 256 |
+
qvec = embed_query(key, query) # <-- GPU work (live query only)
|
| 257 |
+
dense_scores = EMB[key] @ qvec # exact cosine (vectors are normalised)
|
| 258 |
bm25_scores = np.asarray(BM25.get_scores(tokenize(query)))
|
| 259 |
|
| 260 |
bm25_top = top_k(bm25_scores, cand, k)
|
| 261 |
dense_top = top_k(dense_scores, cand, k)
|
| 262 |
hybrid_top = rrf_fuse(bm25_scores, dense_scores, cand, k)
|
| 263 |
+
# Shortlist for the reranker = hybrid's top-N (first-stage retrieval feeds stage 2).
|
| 264 |
+
shortlist = [d for d, _ in rrf_fuse(bm25_scores, dense_scores, cand, config.RERANK_CANDIDATES)]
|
| 265 |
+
|
| 266 |
+
query_coord = REDUCER[key].transform(qvec.reshape(1, -1))[0]
|
| 267 |
+
fig = make_plot(key, query_coord, {"BM25": bm25_top, "Dense": dense_top, "Hybrid": hybrid_top}, cand=cand)
|
| 268 |
+
|
| 269 |
+
state = {
|
| 270 |
+
"key": key, "query": query,
|
| 271 |
+
"query_coord": [float(query_coord[0]), float(query_coord[1])],
|
| 272 |
+
"cand": cand.tolist(), "shortlist": shortlist,
|
| 273 |
+
"hybrid_top": [[int(d), float(s)] for d, s in hybrid_top],
|
| 274 |
+
}
|
| 275 |
return (
|
| 276 |
format_results("BM25", bm25_top, "score"),
|
| 277 |
format_results("Dense", dense_top, "cosine"),
|
| 278 |
format_results("Hybrid", hybrid_top, "RRF"),
|
| 279 |
+
RERANK_HINT,
|
| 280 |
+
fig, info, state, "",
|
| 281 |
)
|
| 282 |
|
| 283 |
|
| 284 |
+
def run_rerank(state, k):
|
| 285 |
+
"""Stage 2: cross-encoder reranks the hybrid shortlist. Slow — and we time it."""
|
| 286 |
+
if not state:
|
| 287 |
+
return "<p><em>Run a search first.</em></p>", gr.update(), ""
|
| 288 |
+
|
| 289 |
+
key, query, shortlist = state["key"], state["query"], state["shortlist"]
|
| 290 |
+
texts = [f"{TITLES[i]}. {ABSTRACTS[i]}" for i in shortlist]
|
| 291 |
+
|
| 292 |
+
t0 = time.time()
|
| 293 |
+
scores = rerank_scores(query, texts) # <-- GPU work, one pass per candidate
|
| 294 |
+
dt = time.time() - t0
|
| 295 |
+
|
| 296 |
+
order = np.argsort(-scores)[: int(k)]
|
| 297 |
+
reranked = [(int(shortlist[i]), float(scores[i])) for i in order]
|
| 298 |
+
|
| 299 |
+
hybrid_top = [(int(d), float(s)) for d, s in state["hybrid_top"]]
|
| 300 |
+
fig = make_plot(key, state["query_coord"], {"Hybrid": hybrid_top, "Reranked": reranked},
|
| 301 |
+
cand=np.asarray(state["cand"]))
|
| 302 |
+
timer = (
|
| 303 |
+
f"⏱️ Reranked **{len(shortlist)}** candidates with the cross-encoder in "
|
| 304 |
+
f"**{dt:.2f}s**. It ran a transformer on every (query, document) pair at query "
|
| 305 |
+
f"time — contrast the instant BM25/Dense/Hybrid. The plot shows Hybrid (green) "
|
| 306 |
+
f"vs Reranked (black)."
|
| 307 |
+
)
|
| 308 |
+
return format_results("Reranked", reranked, "cross-enc"), fig, timer
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def run_embed_own(text, model_label):
|
| 312 |
+
key = LABEL_TO_KEY[model_label]
|
| 313 |
text = (text or "").strip()
|
| 314 |
if not text:
|
| 315 |
+
return make_plot(key)
|
| 316 |
+
vec = embed_text(key, text) # <-- GPU work
|
| 317 |
+
coord = REDUCER[key].transform(vec.reshape(1, -1))[0]
|
| 318 |
+
return make_plot(key, extra_point=(coord, text[:80]))
|
| 319 |
|
| 320 |
|
| 321 |
# ====================================================================================
|
| 322 |
# Example queries — tuned to the cardiovascular slice of this microRNA/disease corpus.
|
| 323 |
# Each is chosen (and verified against the corpus) to make ONE method visibly win.
|
| 324 |
# ====================================================================================
|
| 325 |
+
EXACT_ID_QUERY = "miR-208a" # BM25 wins: exact identifier token, matched verbatim.
|
| 326 |
+
# Dense wins: plain-English paraphrase using none of the corpus jargon.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
PARAPHRASE_QUERY = "small RNA molecules that worsen scarring after a heart attack"
|
| 328 |
+
ACRONYM_QUERY = "AMI" # BM25 wins: bare acronym is a lexical token; dense has little to grip.
|
| 329 |
+
RARE_TERM_QUERY = "miR-499 cardiomyocyte apoptosis" # BM25 pins the specific molecule.
|
| 330 |
+
# Hybrid wins: broad query where lexical + semantic each surface good-but-different papers.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
BROAD_CONCEPT_QUERY = "circulating microRNA biomarkers for cardiovascular disease"
|
| 332 |
|
| 333 |
EXAMPLES = [
|
|
|
|
| 349 |
# ====================================================================================
|
| 350 |
# UI
|
| 351 |
# ====================================================================================
|
| 352 |
+
with gr.Blocks(title="RAG retrieval: BM25 vs Dense vs Hybrid vs Rerank") as demo:
|
| 353 |
gr.Markdown(
|
| 354 |
"# 🔍 Retrieval methods, side by side\n"
|
| 355 |
+
"Compare **BM25** (lexical), **Dense** (vector cosine), **Hybrid** (Reciprocal "
|
| 356 |
+
"Rank Fusion), and an optional **cross-encoder rerank** over a corpus of "
|
| 357 |
+
f"{len(META):,} Europe PMC abstracts on microRNA & disease."
|
| 358 |
)
|
| 359 |
|
| 360 |
with gr.Row():
|
|
|
|
| 366 |
btn = gr.Button(label, size="sm")
|
| 367 |
btn.click(lambda t=text: t, outputs=query)
|
| 368 |
|
| 369 |
+
model_radio = gr.Radio(
|
| 370 |
+
choices=list(LABEL_TO_KEY.keys()), value=DEFAULT_LABEL,
|
| 371 |
+
label="Embedding model — used by Dense, Hybrid and the plot (BM25 is lexical, so it never changes)",
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
with gr.Group():
|
| 375 |
+
gr.Markdown("**Metadata filter** — restricts the candidate pool *before* retrieval (all methods).")
|
| 376 |
with gr.Row():
|
| 377 |
year_lo = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MIN, step=1, label="Year from")
|
| 378 |
year_hi = gr.Slider(YEAR_MIN, YEAR_MAX, value=YEAR_MAX, step=1, label="Year to")
|
|
|
|
| 386 |
bm25_out = gr.HTML(label="BM25")
|
| 387 |
dense_out = gr.HTML(label="Dense")
|
| 388 |
hybrid_out = gr.HTML(label="Hybrid")
|
| 389 |
+
reranked_out = gr.HTML(label="Reranked")
|
| 390 |
+
|
| 391 |
+
with gr.Row():
|
| 392 |
+
rerank_btn = gr.Button("Rerank hybrid shortlist (cross-encoder)", variant="secondary")
|
| 393 |
+
rerank_timer = gr.Markdown("")
|
| 394 |
|
| 395 |
gr.Markdown("## Vector space (UMAP projection)")
|
| 396 |
+
plot = gr.Plot(value=make_plot(config.DEFAULT_MODEL_KEY))
|
| 397 |
gr.Markdown(PLOT_CAVEAT)
|
| 398 |
|
| 399 |
with gr.Accordion("Embed your own text", open=False):
|
| 400 |
gr.Markdown(
|
| 401 |
+
"Type anything — it gets embedded with the selected model and dropped onto the "
|
| 402 |
"map. This *is* the space the database searches through."
|
| 403 |
)
|
| 404 |
own_text = gr.Textbox(label="Your text", placeholder="e.g. a sentence about gene regulation…")
|
| 405 |
own_btn = gr.Button("Embed & plot")
|
| 406 |
+
own_btn.click(run_embed_own, inputs=[own_text, model_radio], outputs=plot)
|
| 407 |
+
|
| 408 |
+
search_state = gr.State()
|
| 409 |
+
search_inputs = [query, k, year_lo, year_hi, journal, model_radio]
|
| 410 |
+
search_outputs = [bm25_out, dense_out, hybrid_out, reranked_out, plot, filter_info, search_state, rerank_timer]
|
| 411 |
+
search_btn.click(run_search, inputs=search_inputs, outputs=search_outputs)
|
| 412 |
+
query.submit(run_search, inputs=search_inputs, outputs=search_outputs)
|
| 413 |
+
rerank_btn.click(run_rerank, inputs=[search_state, k], outputs=[reranked_out, plot, rerank_timer])
|
| 414 |
|
| 415 |
|
| 416 |
if __name__ == "__main__":
|
build_index.py
CHANGED
|
@@ -92,51 +92,46 @@ def main():
|
|
| 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 |
-
#
|
| 96 |
-
|
| 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. UMAP -----------------------------------------------------------------------
|
| 110 |
-
# UMAP shows local cluster structure better than PCA. It also supports projecting
|
| 111 |
-
# new/out-of-sample points via .transform() — we use that to place the live query.
|
| 112 |
-
# Caveat: single-point transform is an approximate fit, so query placement is rough.
|
| 113 |
-
print("Fitting 2D UMAP (cosine metric)...")
|
| 114 |
-
reducer = umap.UMAP(
|
| 115 |
-
n_components=2, metric="cosine", n_neighbors=15, min_dist=0.1, random_state=42
|
| 116 |
-
)
|
| 117 |
-
coords = reducer.fit_transform(embeddings).astype(np.float32)
|
| 118 |
-
joblib.dump(reducer, os.path.join(config.DATA_DIR, "umap.joblib"))
|
| 119 |
-
np.save(os.path.join(config.DATA_DIR, "umap_coords.npy"), coords)
|
| 120 |
-
print(f"Saved UMAP reducer + corpus coords: {coords.shape}")
|
| 121 |
-
|
| 122 |
-
# 4. BM25 tokens ----------------------------------------------------------------
|
| 123 |
print("Tokenising for BM25...")
|
| 124 |
tokens = [tokenize(doc) for doc in docs]
|
| 125 |
with open(os.path.join(config.DATA_DIR, "bm25_tokens.json"), "w") as f:
|
| 126 |
json.dump(tokens, f)
|
| 127 |
-
print(f"Saved {len(tokens)} token lists.")
|
| 128 |
-
|
| 129 |
-
# 5. Metadata -------------------------------------------------------------------
|
| 130 |
df.to_parquet(os.path.join(config.DATA_DIR, "metadata.parquet"), index=False)
|
| 131 |
-
print(f"Saved metadata.parquet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
#
|
| 134 |
with open(os.path.join(config.DATA_DIR, "config.json"), "w") as f:
|
| 135 |
-
json.dump(
|
| 136 |
-
{"embedding_model": config.EMBEDDING_MODEL, "dim": int(embeddings.shape[1])},
|
| 137 |
-
f,
|
| 138 |
-
indent=2,
|
| 139 |
-
)
|
| 140 |
|
| 141 |
print("\nDone. Artifacts written to", config.DATA_DIR)
|
| 142 |
|
|
|
|
| 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 |
+
# --- Shared (model-independent) artifacts --------------------------------------
|
| 96 |
+
# BM25 is lexical and the metadata is just text, so both are shared across models.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
print("Tokenising for BM25...")
|
| 98 |
tokens = [tokenize(doc) for doc in docs]
|
| 99 |
with open(os.path.join(config.DATA_DIR, "bm25_tokens.json"), "w") as f:
|
| 100 |
json.dump(tokens, f)
|
|
|
|
|
|
|
|
|
|
| 101 |
df.to_parquet(os.path.join(config.DATA_DIR, "metadata.parquet"), index=False)
|
| 102 |
+
print(f"Saved bm25_tokens.json and metadata.parquet ({df.shape}).")
|
| 103 |
+
|
| 104 |
+
# --- Per-model artifacts: embeddings + UMAP ------------------------------------
|
| 105 |
+
cfg_record = {}
|
| 106 |
+
for key, (label, model_name) in config.EMBEDDING_MODELS.items():
|
| 107 |
+
print(f"\n[{key}] Loading embedding model: {model_name}")
|
| 108 |
+
model = SentenceTransformer(model_name)
|
| 109 |
+
print(f"[{key}] Embedding corpus (slow)...")
|
| 110 |
+
embeddings = model.encode(
|
| 111 |
+
docs,
|
| 112 |
+
batch_size=64,
|
| 113 |
+
show_progress_bar=True,
|
| 114 |
+
normalize_embeddings=True, # so cosine similarity == dot product
|
| 115 |
+
convert_to_numpy=True,
|
| 116 |
+
).astype(np.float32)
|
| 117 |
+
np.save(os.path.join(config.DATA_DIR, f"embeddings_{key}.npy"), embeddings)
|
| 118 |
+
print(f"[{key}] Saved embeddings: {embeddings.shape}")
|
| 119 |
+
|
| 120 |
+
# UMAP shows local cluster structure better than PCA, and projects new query
|
| 121 |
+
# points via .transform(). Caveat: single-point transform is an approximate fit.
|
| 122 |
+
print(f"[{key}] Fitting 2D UMAP (cosine metric)...")
|
| 123 |
+
reducer = umap.UMAP(
|
| 124 |
+
n_components=2, metric="cosine", n_neighbors=15, min_dist=0.1, random_state=42
|
| 125 |
+
)
|
| 126 |
+
coords = reducer.fit_transform(embeddings).astype(np.float32)
|
| 127 |
+
joblib.dump(reducer, os.path.join(config.DATA_DIR, f"umap_{key}.joblib"))
|
| 128 |
+
np.save(os.path.join(config.DATA_DIR, f"umap_coords_{key}.npy"), coords)
|
| 129 |
+
print(f"[{key}] Saved UMAP reducer + coords: {coords.shape}")
|
| 130 |
+
cfg_record[key] = {"model": model_name, "dim": int(embeddings.shape[1])}
|
| 131 |
|
| 132 |
+
# Config record (startup sanity check in app.py).
|
| 133 |
with open(os.path.join(config.DATA_DIR, "config.json"), "w") as f:
|
| 134 |
+
json.dump(cfg_record, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
print("\nDone. Artifacts written to", config.DATA_DIR)
|
| 137 |
|
config.py
CHANGED
|
@@ -5,12 +5,23 @@ are embedded with the *same* model. If they ever diverge, the query vector and t
|
|
| 5 |
vectors live in different spaces and every retrieval result is silently wrong.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
# --- Embedding
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# --- Corpus (offline build only) -----------------------------------------------------
|
| 16 |
# Europe PMC search query. See https://europepmc.org/Help for the query syntax.
|
|
|
|
| 5 |
vectors live in different spaces and every retrieval result is silently wrong.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
# --- Embedding models ----------------------------------------------------------------
|
| 9 |
+
# We build & ship artifacts for BOTH models so the Space can switch between them live.
|
| 10 |
+
# model_key -> (display label, HuggingFace model name). The key is used in artifact
|
| 11 |
+
# filenames (embeddings_<key>.npy, umap_<key>.joblib, umap_coords_<key>.npy), so keep it
|
| 12 |
+
# filesystem-safe. Add a model here + rebuild to offer it in the UI.
|
| 13 |
+
EMBEDDING_MODELS = {
|
| 14 |
+
"general": ("General — MiniLM-L6 (384-dim)", "sentence-transformers/all-MiniLM-L6-v2"),
|
| 15 |
+
"biomedical": ("Biomedical — S-PubMedBert (768-dim)", "pritamdeka/S-PubMedBert-MS-MARCO"),
|
| 16 |
+
}
|
| 17 |
+
DEFAULT_MODEL_KEY = "general"
|
| 18 |
+
|
| 19 |
+
# --- Reranker (cross-encoder, runtime only) ------------------------------------------
|
| 20 |
+
# A cross-encoder scores (query, document) PAIRS jointly — more accurate than the
|
| 21 |
+
# bi-encoder dense retrieval, but it can't be precomputed, so it only runs on a shortlist.
|
| 22 |
+
RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 23 |
+
# Biomedical alternative: "ncbi/MedCPT-Cross-Encoder"
|
| 24 |
+
RERANK_CANDIDATES = 30 # size of the hybrid shortlist fed to the reranker
|
| 25 |
|
| 26 |
# --- Corpus (offline build only) -----------------------------------------------------
|
| 27 |
# Europe PMC search query. See https://europepmc.org/Help for the query syntax.
|
data/bm25_tokens.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/config.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"general": {
|
| 3 |
+
"model": "sentence-transformers/all-MiniLM-L6-v2",
|
| 4 |
+
"dim": 384
|
| 5 |
+
},
|
| 6 |
+
"biomedical": {
|
| 7 |
+
"model": "pritamdeka/S-PubMedBert-MS-MARCO",
|
| 8 |
+
"dim": 768
|
| 9 |
+
}
|
| 10 |
}
|
data/embeddings_biomedical.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:192c1aa5aea5d13d32d86d78fca9e51b1a6e186d4df9df374445ba6053cfbb44
|
| 3 |
+
size 12266624
|
data/{embeddings.npy → embeddings_general.npy}
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 6133376
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f7ae07dfbae0a82d14b930f6c25f39ec667fe47ca95d5915ce9de4fcb9e61a74
|
| 3 |
size 6133376
|
data/metadata.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9a2236a72b5f1c6af19dc333c36f02567529fe38766ecf1c03b7bb9bf4017aeb
|
| 3 |
+
size 3826858
|
data/umap_biomedical.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b65f32aa01e697b7f1e1376a14598dd61256f0b5b75a7a9bbc0180bd6e6b5f5e
|
| 3 |
+
size 13078057
|
data/{umap_coords.npy → umap_coords_biomedical.npy}
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 32072
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:79f661e76f9248189db9209afb1a9b49043149414c89b7e45c27880711b599fe
|
| 3 |
size 32072
|
data/{umap.joblib → umap_coords_general.npy}
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0be6e2de236caa6973df9fc0c7d6f03acc005146e842c23e71b747a8b063b36c
|
| 3 |
+
size 32072
|
data/umap_general.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:92ed6eaef29f645eb22a6895bd2e99bb6b57cf2c4b5a7cd22336b649d6ad4340
|
| 3 |
+
size 6913641
|