afg1 commited on
Commit
a2c6853
·
verified ·
1 Parent(s): 549bbcc

Add cross-encoder rerank button + dual embedding models (general/biomedical) with live switch

Browse files
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 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 **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 the PCA, and tokenises for BM25. The artifacts in `./data/` are
32
- committed. The app **never** embeds the corpus or calls an external API at startup —
33
- it only embeds your *live query*.
 
34
  - **ZeroGPU:** GPU is allocated on demand only inside the `@spaces.GPU`-decorated query
35
- embedding functions. The model is loaded on CPU at import; nothing touches CUDA at
36
- startup. ⚠️ **The first GPU call after idle has a cold start of a few seconds — pre-warm
37
- with a dummy query before presenting.**
38
- - **Swappable model:** `EMBEDDING_MODEL` in `config.py` is the single source of truth for
39
- both the offline build and the live query. Swap the one line (a biomedical
40
- PubMedBERT sentence model is included as a comment) and re-run `build_index.py`.
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
- 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 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
- EMBEDDINGS = np.load(os.path.join(D, "embeddings.npy")) # (N, dim), L2-normalised
34
- COORDS = np.load(os.path.join(D, "umap_coords.npy")) # (N, 2) UMAP projection
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
- METHOD_COLORS = {"BM25": "#ff7f0e", "Dense": "#1f77b4", "Hybrid": "#2ca02c"}
 
 
 
 
 
 
 
 
53
 
54
- # ====================================================================================
55
- # Embedding model — loaded on CPU. GPU is used ONLY inside @spaces.GPU below.
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
- def _encode(text: str) -> np.ndarray:
67
- """Embed one string -> (dim,) float32, L2-normalised. Uses CUDA when available."""
68
- device = "cuda" if torch.cuda.is_available() else "cpu" # safe: only called under @spaces.GPU
69
- MODEL.to(device)
70
- vec = MODEL.encode(
71
- [text], normalize_embeddings=True, convert_to_numpy=True, device=device
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: all
95
- three methods then search only within these documents.
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 (helpful for seeing *why* something ranked highly).
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 of the corpus, plus query point + connector lines to hits.
154
 
155
- `cand` (optional) = the indices that passed the metadata filter; when given, only
156
- those points are drawn as the grey background, so the plot reflects the filter.
157
  """
 
158
  fig = go.Figure()
159
- bg = np.arange(len(COORDS)) if cand is None else cand
160
- # Grey background — "the space the database searches through" (after filtering).
161
  fig.add_trace(
162
  go.Scatter(
163
- x=COORDS[bg, 0], y=COORDS[bg, 1], mode="markers",
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], COORDS[doc, 0], None]
181
- ys += [query_coord[1], COORDS[doc, 1], None]
182
  fig.add_trace(
183
  go.Scatter(x=xs, y=ys, mode="lines",
184
- line=dict(color=color, width=WIDTHS[method]),
185
  opacity=0.5, name=f"{method} links", hoverinfo="skip")
186
  )
187
  fig.add_trace(
188
  go.Scatter(
189
- x=[COORDS[d, 0] for d, _ in results],
190
- y=[COORDS[d, 1] for d, _ in results],
191
  mode="markers",
192
- marker=dict(size=SIZES[method], color=color, symbol="circle-open",
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="PC 1", yaxis_title="PC 2",
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
- def run_search(query, k, year_lo, year_hi, journal):
 
 
 
 
 
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) # <-- the only GPU work in search
233
- dense_scores = EMBEDDINGS @ qvec # exact cosine (vectors are normalised)
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
- query_coord = REDUCER.transform(qvec.reshape(1, -1))[0]
241
- fig = make_plot(
242
- query_coord, {"BM25": bm25_top, "Dense": dense_top, "Hybrid": hybrid_top}, cand=cand
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
- fig,
250
- info,
251
  )
252
 
253
 
254
- def run_embed_own(text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  text = (text or "").strip()
256
  if not text:
257
- return make_plot()
258
- vec = embed_text(text) # <-- GPU work
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: an exact miRNA identifier. The literal token "miR-208a" is matched precisely;
268
- # dense retrieval drifts to other, merely-similar cardiac miRNA papers.
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: a bare acronym (acute myocardial infarction). "AMI" is a literal token BM25
275
- # nails, but as a 3-letter string it carries little semantic signal, so dense flounders.
276
- ACRONYM_QUERY = "AMI"
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), and **Hybrid** "
307
- "(Reciprocal Rank Fusion) over a corpus of biomedical abstracts. "
308
- f"Corpus: {len(META):,} Europe PMC abstracts on microRNA & disease."
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 3 methods).")
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 same model and dropped onto 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
- inputs = [query, k, year_lo, year_hi, journal]
350
- outputs = [bm25_out, dense_out, hybrid_out, plot, filter_info]
351
- search_btn.click(run_search, inputs=inputs, outputs=outputs)
352
- query.submit(run_search, inputs=inputs, outputs=outputs)
 
 
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
- # 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. 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: {df.shape}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- # 6. Config record (startup sanity check in app.py) -----------------------------
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 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.
 
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
- "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
3
- "dim": 384
 
 
 
 
 
 
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:2f81a7b345de3c3173cd6b8219789968f061980d7d5d6dffe8445e82d84298c6
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:d002fef7b9475d2794670200a5ec53092d7816d769583330c6ec7dedfabc24a1
3
- size 3825335
 
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:e94741ae6cd213b43a26442c133d5ab9988024cd2e548bb2729e272dfa05e7af
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:b6a25ecb9954e1500337230a69921f65caff1c4b2c615fce4e5deff58b86e4a8
3
- size 6913369
 
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