airayven7 commited on
Commit
293ed8c
·
verified ·
1 Parent(s): 3f08eff

Sync from GitHub 4fcc227

Browse files
Files changed (8) hide show
  1. README.md +17 -6
  2. app.py +123 -50
  3. core/constants.py +32 -11
  4. core/store.py +2 -4
  5. models/colembed.py +17 -15
  6. models/minicpm.py +18 -14
  7. pipelines/ask.py +35 -19
  8. pipelines/ingest.py +16 -7
README.md CHANGED
@@ -24,13 +24,24 @@ pages streamed from disk via numpy memmap), and the top-K page images are
24
  read by MiniCPM-V 4.5 — also on ZeroGPU — to produce a grounded answer.
25
  Everything runs inside the Space; no external endpoints.
26
 
 
 
 
 
 
 
 
 
 
 
27
  ## Space setup
28
 
29
- - **Persistent storage** must be enabled (embeddings + PDFs live under
30
- `/data/library`). Budget roughly 5–12 MB per page of float16 token
31
- embeddings; a 300-page manual is ~23.5 GB.
32
- - Optional env vars: `COLEMBED_MODEL_ID` (defaults to the 4B model),
33
- `MINICPM_MODEL_ID` (defaults to `openbmb/MiniCPM-V-4_5`), `COLEMBED_ATTN`
34
- (defaults to `sdpa`; set `flash_attention_2` if flash-attn is installed).
 
35
 
36
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
24
  read by MiniCPM-V 4.5 — also on ZeroGPU — to produce a grounded answer.
25
  Everything runs inside the Space; no external endpoints.
26
 
27
+ ## Two tabs
28
+
29
+ - **Library** — large manuals indexed offline (`scripts/index_modal.py` in the
30
+ GitHub repo runs it on a Modal GPU; `index_local.py` if you have a CUDA box)
31
+ and pushed to the
32
+ [library dataset](https://huggingface.co/datasets/build-small-hackathon/repair-guy-library);
33
+ the Space syncs it to `/data/preindexed` at startup.
34
+ - **Upload your own** — indexes on the Space's ZeroGPU, capped at
35
+ `MAX_UPLOAD_PAGES` (default 50) to protect quota.
36
+
37
  ## Space setup
38
 
39
+ - **Persistent storage** must be enabled (the library sync and uploads live
40
+ under `/data`). Budget roughly 5–12 MB per page of float16 token
41
+ embeddings; a 1000-page manual is ~612 GB — size the tier to the library.
42
+ - Optional env vars: `LIBRARY_DATASET_ID`, `MAX_UPLOAD_PAGES`,
43
+ `COLEMBED_MODEL_ID` (defaults to the 4B model), `MINICPM_MODEL_ID`
44
+ (defaults to `openbmb/MiniCPM-V-4_5`), `COLEMBED_ATTN` (defaults to `sdpa`;
45
+ set `flash_attention_2` if flash-attn is installed).
46
 
47
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -1,96 +1,169 @@
1
- """Gradio + ZeroGPU Space: upload a repair manual, ask questions.
2
 
3
  Visual RAG with no parsing/chunking: every PDF page is embedded as an image
4
- with Nemotron ColEmbed v2 (multi-vector, late interaction) when the manual is
5
- uploaded. A question is answered by embedding the query, running MaxSim over
6
- the page embeddings streamed from disk, and handing the top pages to MiniCPM-V.
 
 
 
 
 
 
7
 
8
  Module layout:
9
- models/colembed.py ColEmbed — embedding model + GPU embed/search
10
- models/minicpm.py MiniCPM — remote VLM that answers over page images
11
  core/store.py Store — on-disk per-page token embeddings
12
  core/pdf.py render_pages — PDF -> RGB page images (CPU)
13
  pipelines/ingest.py IngestPipeline — PDF -> embeddings -> store
14
  pipelines/ask.py AskPipeline — question -> retrieve -> answer
15
- app.py this file: builds the objects + Gradio UI
16
  """
17
 
18
  import os
19
 
20
  import gradio as gr
 
21
 
22
- from core.constants import DEFAULT_TOP_K
 
 
 
 
 
 
23
  from core.store import Store, slugify
24
  from models.colembed import ColEmbed
25
- from models.minicpm import MiniCPM
26
  from pipelines.ask import AskPipeline
27
  from pipelines.ingest import IngestPipeline
28
 
29
- # Construct once at startup (the model loads onto cuda here, in the main process).
30
- store = Store()
 
31
  embedder = ColEmbed()
32
- ingest_pipeline = IngestPipeline(embedder, store)
33
- ask_pipeline = AskPipeline(embedder, store, MiniCPM())
34
 
35
 
36
- def index_manual(pdf_file, progress=gr.Progress()):
37
- """Runs on upload: embed the manual's pages (or reuse a previous index)."""
38
- if not pdf_file:
39
- return None, ""
40
- name = os.path.splitext(os.path.basename(pdf_file))[0].replace("_", " ")
41
- doc_id = slugify(name)
42
- if store.exists(doc_id):
43
- pages = len(store.meta(doc_id)["pages"])
44
- return doc_id, f"**{name}** is already indexed ({pages} pages) — ask away."
45
  try:
46
- doc = ingest_pipeline.run(
47
- pdf_file, name, lambda frac, desc: progress(frac, desc=desc)
48
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  except ValueError as e:
50
  raise gr.Error(str(e)) from e
51
- return doc_id, f"Indexed **{doc['name']}** ({doc['pages']} pages) — ask away."
52
 
53
 
54
- def answer_question(question, doc_id):
55
  if not doc_id:
56
  raise gr.Error("Upload a repair manual first.")
57
  try:
58
- return ask_pipeline.run(question, [doc_id], DEFAULT_TOP_K)
59
  except ValueError as e:
60
  raise gr.Error(str(e)) from e
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  with gr.Blocks(title="Repair Guy") as demo:
64
  gr.Markdown(
65
  "# 🔧 Repair Guy\n"
66
- "Upload a repair manual (PDF) and ask it questions. Pages are embedded "
67
- "with [Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2) "
68
- "on upload; answers come from MiniCPM-V reading the most relevant pages."
 
69
  )
70
- doc_state = gr.State(None)
71
- with gr.Row():
72
- with gr.Column(scale=1):
73
- pdf_in = gr.File(
74
- label="Repair manual (PDF)", file_types=[".pdf"], type="filepath"
75
- )
76
- status_out = gr.Markdown()
77
- question_in = gr.Textbox(
78
- label="Question",
79
- lines=2,
80
- placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
81
- )
82
- ask_btn = gr.Button("Ask", variant="primary")
83
- with gr.Column(scale=2):
84
- answer_out = gr.Markdown(label="Answer")
85
- pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
 
 
 
 
 
 
 
 
87
  pdf_in.upload(index_manual, inputs=[pdf_in], outputs=[doc_state, status_out])
88
- ask_btn.click(
89
- answer_question, inputs=[question_in, doc_state], outputs=[answer_out, pages_out]
 
90
  )
91
- question_in.submit(
92
- answer_question, inputs=[question_in, doc_state], outputs=[answer_out, pages_out]
 
93
  )
 
94
 
95
 
96
  if __name__ == "__main__":
 
1
+ """Gradio + ZeroGPU Space: ask questions over repair manuals.
2
 
3
  Visual RAG with no parsing/chunking: every PDF page is embedded as an image
4
+ with Nemotron ColEmbed v2 (multi-vector, late interaction). A question is
5
+ answered by embedding the query, running MaxSim over page embeddings streamed
6
+ from disk, and handing the top pages to MiniCPM-V — all in one ZeroGPU call.
7
+
8
+ Two tabs:
9
+ Library — large manuals pre-indexed offline (scripts/index_local.py),
10
+ synced at startup from the library dataset on the Hub.
11
+ Upload — index your own small PDF on ZeroGPU (page-capped to protect
12
+ the Space's GPU quota), then ask it questions.
13
 
14
  Module layout:
15
+ models/colembed.py ColEmbed — embedding model + GPU embed / MaxSim
16
+ models/minicpm.py MiniCPM — local VLM that answers over page images
17
  core/store.py Store — on-disk per-page token embeddings
18
  core/pdf.py render_pages — PDF -> RGB page images (CPU)
19
  pipelines/ingest.py IngestPipeline — PDF -> embeddings -> store
20
  pipelines/ask.py AskPipeline — question -> retrieve -> answer
 
21
  """
22
 
23
  import os
24
 
25
  import gradio as gr
26
+ from huggingface_hub import snapshot_download
27
 
28
+ from core.constants import (
29
+ DEFAULT_TOP_K,
30
+ LIBRARY_DATASET_ID,
31
+ MAX_UPLOAD_PAGES,
32
+ PREINDEXED_DIR,
33
+ UPLOADS_DIR,
34
+ )
35
  from core.store import Store, slugify
36
  from models.colembed import ColEmbed
 
37
  from pipelines.ask import AskPipeline
38
  from pipelines.ingest import IngestPipeline
39
 
40
+ # Construct once at startup (the models load onto cuda here, in the main process).
41
+ library = Store(PREINDEXED_DIR)
42
+ uploads = Store(UPLOADS_DIR)
43
  embedder = ColEmbed()
44
+ ingest_pipeline = IngestPipeline(embedder, uploads)
45
+ ask_pipeline = AskPipeline()
46
 
47
 
48
+ def sync_library() -> None:
49
+ """Pull pre-indexed manuals from the library dataset into /data.
50
+ A missing or empty dataset just means an empty library tab."""
 
 
 
 
 
 
51
  try:
52
+ snapshot_download(
53
+ LIBRARY_DATASET_ID, repo_type="dataset", local_dir=library.root
54
  )
55
+ except Exception as e:
56
+ print(f"Library dataset not synced ({LIBRARY_DATASET_ID}): {e}")
57
+
58
+
59
+ sync_library()
60
+
61
+
62
+ def _choices(store: Store) -> list[tuple[str, str]]:
63
+ return [(d["name"], d["doc_id"]) for d in store.list_docs()]
64
+
65
+
66
+ def ask_library(question, doc_id):
67
+ if not doc_id:
68
+ raise gr.Error("Pick a manual first.")
69
+ try:
70
+ return ask_pipeline.run(library, question, [doc_id], DEFAULT_TOP_K)
71
  except ValueError as e:
72
  raise gr.Error(str(e)) from e
 
73
 
74
 
75
+ def ask_upload(question, doc_id):
76
  if not doc_id:
77
  raise gr.Error("Upload a repair manual first.")
78
  try:
79
+ return ask_pipeline.run(uploads, question, [doc_id], DEFAULT_TOP_K)
80
  except ValueError as e:
81
  raise gr.Error(str(e)) from e
82
 
83
 
84
+ def index_manual(pdf_file, progress=gr.Progress()):
85
+ """Runs on upload: embed the manual's pages (or reuse a previous index).
86
+ Generator — each yield streams (doc_state, status markdown) to the UI."""
87
+ if not pdf_file:
88
+ yield None, ""
89
+ return
90
+ name = os.path.splitext(os.path.basename(pdf_file))[0].replace("_", " ")
91
+ doc_id = slugify(name)
92
+ if uploads.exists(doc_id):
93
+ pages = len(uploads.meta(doc_id)["pages"])
94
+ yield doc_id, f"**{name}** is already indexed ({pages} pages) — ask away."
95
+ return
96
+ yield None, f"⏳ Indexing **{name}** — preparing…"
97
+ try:
98
+ for event in ingest_pipeline.run(pdf_file, name, max_pages=MAX_UPLOAD_PAGES):
99
+ if event[0] == "progress":
100
+ _, done, total = event
101
+ progress(done / total, desc=f"Embedding pages {done}/{total}")
102
+ yield None, f"⏳ Indexing **{name}** — {done}/{total} pages embedded…"
103
+ else:
104
+ doc = event[1]
105
+ except ValueError as e:
106
+ raise gr.Error(str(e)) from e
107
+ yield doc_id, f"✅ Indexed **{doc['name']}** ({doc['pages']} pages) — ask away."
108
+
109
+
110
  with gr.Blocks(title="Repair Guy") as demo:
111
  gr.Markdown(
112
  "# 🔧 Repair Guy\n"
113
+ "Ask questions over repair manuals. Pages are retrieved visually with "
114
+ "[Nemotron ColEmbed v2](https://huggingface.co/nvidia/nemotron-colembed-vl-4b-v2) "
115
+ "(late interaction, no parsing) and answered by MiniCPM-V reading the "
116
+ "most relevant pages."
117
  )
118
+ with gr.Tab("📚 Library"):
119
+ with gr.Row():
120
+ with gr.Column(scale=1):
121
+ manual_in = gr.Dropdown(label="Manual", choices=[])
122
+ lib_question_in = gr.Textbox(
123
+ label="Question",
124
+ lines=2,
125
+ placeholder="e.g. What is the tightening torque for the universal joint flange bolts?",
126
+ )
127
+ lib_ask_btn = gr.Button("Ask", variant="primary")
128
+ with gr.Column(scale=2):
129
+ lib_answer_out = gr.Markdown(label="Answer")
130
+ lib_pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
131
+ with gr.Tab("📄 Upload your own"):
132
+ gr.Markdown(
133
+ f"Upload a PDF of up to **{MAX_UPLOAD_PAGES} pages** (indexing runs "
134
+ "on this Space's GPU quota — big manuals live in the Library tab)."
135
+ )
136
+ doc_state = gr.State(None)
137
+ with gr.Row():
138
+ with gr.Column(scale=1):
139
+ pdf_in = gr.File(
140
+ label="Repair manual (PDF)", file_types=[".pdf"], type="filepath"
141
+ )
142
+ status_out = gr.Markdown()
143
+ up_question_in = gr.Textbox(label="Question", lines=2)
144
+ up_ask_btn = gr.Button("Ask", variant="primary")
145
+ with gr.Column(scale=2):
146
+ up_answer_out = gr.Markdown(label="Answer")
147
+ up_pages_out = gr.Gallery(label="Pages used", columns=3, height=420)
148
 
149
+ lib_ask_btn.click(
150
+ ask_library, inputs=[lib_question_in, manual_in],
151
+ outputs=[lib_answer_out, lib_pages_out],
152
+ )
153
+ lib_question_in.submit(
154
+ ask_library, inputs=[lib_question_in, manual_in],
155
+ outputs=[lib_answer_out, lib_pages_out],
156
+ )
157
  pdf_in.upload(index_manual, inputs=[pdf_in], outputs=[doc_state, status_out])
158
+ up_ask_btn.click(
159
+ ask_upload, inputs=[up_question_in, doc_state],
160
+ outputs=[up_answer_out, up_pages_out],
161
  )
162
+ up_question_in.submit(
163
+ ask_upload, inputs=[up_question_in, doc_state],
164
+ outputs=[up_answer_out, up_pages_out],
165
  )
166
+ demo.load(lambda: gr.update(choices=_choices(library)), outputs=[manual_in])
167
 
168
 
169
  if __name__ == "__main__":
core/constants.py CHANGED
@@ -1,9 +1,14 @@
1
  import os
2
 
3
  # Embedding model (late-interaction / ColBERT-style page embeddings).
 
 
4
  COLEMBED_MODEL_ID = os.environ.get(
5
  "COLEMBED_MODEL_ID", "nvidia/nemotron-colembed-vl-4b-v2"
6
  )
 
 
 
7
  # sdpa works for this model and needs no extra wheels on ZeroGPU; set
8
  # COLEMBED_ATTN=flash_attention_2 if flash-attn is installed.
9
  COLEMBED_ATTN = os.environ.get("COLEMBED_ATTN", "sdpa")
@@ -13,27 +18,43 @@ RENDER_DPI = 150
13
 
14
  # Indexing: pages embedded per ZeroGPU call, and model batch size within a call.
15
  # Chunking keeps each GPU call well under its duration limit; progress is
16
- # reported between chunks.
17
- EMBED_PAGES_PER_CALL = 16
18
- EMBED_BATCH_SIZE = 4
19
- EMBED_GPU_DURATION = 120
 
 
20
 
21
  # Retrieval: MaxSim is computed on GPU over fixed-size batches of pages
22
  # streamed from the on-disk store.
23
  SCORE_PAGES_PER_BATCH = 32
24
- SEARCH_GPU_DURATION = 60
25
  DEFAULT_TOP_K = 3
26
  MAX_TOP_K = 5
27
 
28
  # Answering model (runs locally on ZeroGPU).
29
  MINICPM_MODEL_ID = os.environ.get("MINICPM_MODEL_ID", "openbmb/MiniCPM-V-4_5")
30
- ANSWER_GPU_DURATION = 120
 
 
31
  ANSWER_MAX_NEW_TOKENS = 2048
32
 
33
- # Embedding store. HF Spaces persistent storage mounts at /data; fall back to a
34
- # local directory for development.
35
- STORE_DIR = os.environ.get("STORE_DIR") or (
36
- "/data/library"
 
 
 
 
 
 
 
37
  if os.path.isdir("/data")
38
- else os.path.join(os.path.dirname(os.path.dirname(__file__)), "library")
 
 
 
 
 
39
  )
 
 
1
  import os
2
 
3
  # Embedding model (late-interaction / ColBERT-style page embeddings).
4
+ # Revisions are pinned because both models load trust_remote_code; bump
5
+ # deliberately after reviewing upstream changes.
6
  COLEMBED_MODEL_ID = os.environ.get(
7
  "COLEMBED_MODEL_ID", "nvidia/nemotron-colembed-vl-4b-v2"
8
  )
9
+ COLEMBED_REVISION = os.environ.get(
10
+ "COLEMBED_REVISION", "0ed152d91f8ad4c5d48296b51c220f686641a398"
11
+ )
12
  # sdpa works for this model and needs no extra wheels on ZeroGPU; set
13
  # COLEMBED_ATTN=flash_attention_2 if flash-attn is installed.
14
  COLEMBED_ATTN = os.environ.get("COLEMBED_ATTN", "sdpa")
 
18
 
19
  # Indexing: pages embedded per ZeroGPU call, and model batch size within a call.
20
  # Chunking keeps each GPU call well under its duration limit; progress is
21
+ # reported between chunks. Bigger chunks mean fewer ZeroGPU queue waits per
22
+ # manual (~0.5s/page observed, so 64 pages ≈ 35-40s of a 240s budget — a
23
+ # 1000-page manual is ~16 GPU calls).
24
+ EMBED_PAGES_PER_CALL = 64
25
+ EMBED_BATCH_SIZE = 8
26
+ EMBED_GPU_DURATION = 240
27
 
28
  # Retrieval: MaxSim is computed on GPU over fixed-size batches of pages
29
  # streamed from the on-disk store.
30
  SCORE_PAGES_PER_BATCH = 32
 
31
  DEFAULT_TOP_K = 3
32
  MAX_TOP_K = 5
33
 
34
  # Answering model (runs locally on ZeroGPU).
35
  MINICPM_MODEL_ID = os.environ.get("MINICPM_MODEL_ID", "openbmb/MiniCPM-V-4_5")
36
+ MINICPM_REVISION = os.environ.get(
37
+ "MINICPM_REVISION", "fd3209b2e0580e346fc33d2c6f85b6e9332eecda"
38
+ )
39
  ANSWER_MAX_NEW_TOKENS = 2048
40
 
41
+ # One ZeroGPU call covers the whole question: query embedding + MaxSim +
42
+ # page rendering + answer generation.
43
+ ASK_GPU_DURATION = 120
44
+
45
+ # Manual stores. HF Spaces persistent storage mounts at /data; fall back to a
46
+ # local directory for development. Pre-indexed manuals (large PDFs embedded
47
+ # offline, see scripts/index_local.py) are synced from the library dataset at
48
+ # startup; in-app uploads are indexed on ZeroGPU and page-capped to protect
49
+ # the Space's quota.
50
+ _DATA_ROOT = os.environ.get("DATA_ROOT") or (
51
+ "/data"
52
  if os.path.isdir("/data")
53
+ else os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
54
+ )
55
+ PREINDEXED_DIR = os.path.join(_DATA_ROOT, "preindexed")
56
+ UPLOADS_DIR = os.path.join(_DATA_ROOT, "uploads")
57
+ LIBRARY_DATASET_ID = os.environ.get(
58
+ "LIBRARY_DATASET_ID", "build-small-hackathon/repair-guy-library"
59
  )
60
+ MAX_UPLOAD_PAGES = int(os.environ.get("MAX_UPLOAD_PAGES", "50"))
core/store.py CHANGED
@@ -1,6 +1,6 @@
1
  """On-disk store of per-page ColEmbed token embeddings.
2
 
3
- Layout: STORE_DIR/<doc_id>/
4
  doc.pdf original PDF, kept to re-render retrieved pages at answer time
5
  embeddings.bin raw float16 [total_tokens, dim], pages stored back to back
6
  index.json manual name, dim, dpi, model id, per-page (offset, count)
@@ -20,8 +20,6 @@ import shutil
20
 
21
  import numpy as np
22
 
23
- from core.constants import STORE_DIR
24
-
25
  EMB_DTYPE = np.float16
26
 
27
 
@@ -69,7 +67,7 @@ class DocWriter:
69
 
70
 
71
  class Store:
72
- def __init__(self, root: str = STORE_DIR):
73
  self.root = os.path.abspath(root)
74
  os.makedirs(self.root, exist_ok=True)
75
 
 
1
  """On-disk store of per-page ColEmbed token embeddings.
2
 
3
+ Layout: <store root>/<doc_id>/
4
  doc.pdf original PDF, kept to re-render retrieved pages at answer time
5
  embeddings.bin raw float16 [total_tokens, dim], pages stored back to back
6
  index.json manual name, dim, dpi, model id, per-page (offset, count)
 
20
 
21
  import numpy as np
22
 
 
 
23
  EMB_DTYPE = np.float16
24
 
25
 
 
67
 
68
 
69
  class Store:
70
+ def __init__(self, root: str):
71
  self.root = os.path.abspath(root)
72
  os.makedirs(self.root, exist_ok=True)
73
 
models/colembed.py CHANGED
@@ -1,14 +1,13 @@
1
  """Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
2
 
3
- Two ZeroGPU entry points:
4
- _embed_pages_on_gpu page images -> per-page token embeddings (index time)
5
- _search_on_gpu question -> top-K (doc, page, score) via MaxSim over
6
- batches of page embeddings streamed from the store
7
-
8
  The model is a module-level global: ZeroGPU packs module-level CUDA tensors at
9
  startup and shares them with the GPU worker, whereas function arguments are
10
  pickled — and the trust_remote_code model class is not picklable.
11
 
 
 
 
 
12
  forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
13
  with real tokens L2-normalized, so padding rows are exactly zero. We strip them
14
  before storing and rely on the same property when scoring zero-padded batches.
@@ -22,20 +21,21 @@ import numpy as np
22
  import spaces
23
  import torch
24
  from PIL import Image
25
- from transformers import AutoModel
26
 
27
  from core.constants import (
28
  COLEMBED_ATTN,
29
  COLEMBED_MODEL_ID,
 
30
  EMBED_BATCH_SIZE,
31
  EMBED_GPU_DURATION,
32
  SCORE_PAGES_PER_BATCH,
33
- SEARCH_GPU_DURATION,
34
  )
35
 
36
  _MODEL = (
37
  AutoModel.from_pretrained(
38
  COLEMBED_MODEL_ID,
 
39
  trust_remote_code=True,
40
  dtype=torch.bfloat16,
41
  attn_implementation=COLEMBED_ATTN,
@@ -43,6 +43,11 @@ _MODEL = (
43
  .to("cuda")
44
  .eval()
45
  )
 
 
 
 
 
46
 
47
  # The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
48
  # the ZeroGPU worker is a daemonic process and may not spawn children
@@ -72,8 +77,11 @@ def _embed_pages_on_gpu(images: list[Image.Image]) -> list[np.ndarray]:
72
  return out
73
 
74
 
75
- @spaces.GPU(duration=SEARCH_GPU_DURATION)
76
- def _search_on_gpu(question: str, store, doc_ids, top_k: int):
 
 
 
77
  results = []
78
  with torch.no_grad():
79
  q = _MODEL.forward_queries([question], batch_size=1)[0].to(torch.float16)
@@ -95,9 +103,3 @@ class ColEmbed:
95
  def embed_pages(self, images: list[Image.Image]) -> list[np.ndarray]:
96
  """Embed page images -> list of [n_tokens, dim] float16 arrays."""
97
  return _embed_pages_on_gpu(images)
98
-
99
- def search(
100
- self, question: str, store, doc_ids: list[str] | None, top_k: int
101
- ) -> list[tuple[str, int, float]]:
102
- """Return the top_k (doc_id, page_num, score) across the given docs."""
103
- return _search_on_gpu(question, store, doc_ids, top_k)
 
1
  """Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
2
 
 
 
 
 
 
3
  The model is a module-level global: ZeroGPU packs module-level CUDA tensors at
4
  startup and shares them with the GPU worker, whereas function arguments are
5
  pickled — and the trust_remote_code model class is not picklable.
6
 
7
+ _embed_pages_on_gpu is a ZeroGPU entry point (used at index time);
8
+ maxsim_search is a plain function so the ask pipeline can run it inside its
9
+ own single GPU call together with answer generation.
10
+
11
  forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
12
  with real tokens L2-normalized, so padding rows are exactly zero. We strip them
13
  before storing and rely on the same property when scoring zero-padded batches.
 
21
  import spaces
22
  import torch
23
  from PIL import Image
24
+ from transformers import AutoModel, AutoProcessor
25
 
26
  from core.constants import (
27
  COLEMBED_ATTN,
28
  COLEMBED_MODEL_ID,
29
+ COLEMBED_REVISION,
30
  EMBED_BATCH_SIZE,
31
  EMBED_GPU_DURATION,
32
  SCORE_PAGES_PER_BATCH,
 
33
  )
34
 
35
  _MODEL = (
36
  AutoModel.from_pretrained(
37
  COLEMBED_MODEL_ID,
38
+ revision=COLEMBED_REVISION,
39
  trust_remote_code=True,
40
  dtype=torch.bfloat16,
41
  attn_implementation=COLEMBED_ATTN,
 
43
  .to("cuda")
44
  .eval()
45
  )
46
+ # Pre-build the processor the remote code would otherwise lazily create per
47
+ # GPU worker (it caches on this exact attribute, see _get_processor).
48
+ _MODEL._processor = AutoProcessor.from_pretrained(
49
+ COLEMBED_MODEL_ID, revision=COLEMBED_REVISION, trust_remote_code=True
50
+ )
51
 
52
  # The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
53
  # the ZeroGPU worker is a daemonic process and may not spawn children
 
77
  return out
78
 
79
 
80
+ def maxsim_search(
81
+ question: str, store, doc_ids: list[str] | None, top_k: int
82
+ ) -> list[tuple[str, int, float]]:
83
+ """Top-K (doc_id, page_num, score) across docs. Must run on GPU (called
84
+ from within a @spaces.GPU context)."""
85
  results = []
86
  with torch.no_grad():
87
  q = _MODEL.forward_queries([question], batch_size=1)[0].to(torch.float16)
 
103
  def embed_pages(self, images: list[Image.Image]) -> list[np.ndarray]:
104
  """Embed page images -> list of [n_tokens, dim] float16 arrays."""
105
  return _embed_pages_on_gpu(images)
 
 
 
 
 
 
models/minicpm.py CHANGED
@@ -1,19 +1,20 @@
1
- """MiniCPM-V on ZeroGPU: answers a question grounded in the retrieved
2
- repair-manual pages.
3
 
4
  The model and tokenizer are module-level globals: ZeroGPU packs module-level
5
  CUDA tensors at startup and shares them with the GPU worker, whereas function
6
  arguments are pickled — and trust_remote_code model classes are not picklable.
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- import spaces
12
  import torch
13
  from PIL import Image
14
- from transformers import AutoModel, AutoTokenizer
15
 
16
- from core.constants import ANSWER_GPU_DURATION, ANSWER_MAX_NEW_TOKENS, MINICPM_MODEL_ID
17
 
18
  PROMPT = (
19
  "You are a repair-manual assistant. The images are the manual pages most "
@@ -38,6 +39,7 @@ PROMPT = (
38
  _MODEL = (
39
  AutoModel.from_pretrained(
40
  MINICPM_MODEL_ID,
 
41
  trust_remote_code=True,
42
  dtype=torch.bfloat16,
43
  attn_implementation="sdpa",
@@ -45,11 +47,19 @@ _MODEL = (
45
  .to("cuda")
46
  .eval()
47
  )
48
- _TOKENIZER = AutoTokenizer.from_pretrained(MINICPM_MODEL_ID, trust_remote_code=True)
 
 
 
 
 
 
 
49
 
50
 
51
- @spaces.GPU(duration=ANSWER_GPU_DURATION)
52
- def _answer_on_gpu(question: str, pages: list[tuple[str, Image.Image]]) -> str:
 
53
  content = []
54
  for label, img in pages: # chat() accepts interleaved strings and PIL images
55
  content.append(f"[{label}]")
@@ -63,9 +73,3 @@ def _answer_on_gpu(question: str, pages: list[tuple[str, Image.Image]]) -> str:
63
  max_new_tokens=ANSWER_MAX_NEW_TOKENS,
64
  )
65
  return str(answer).strip()
66
-
67
-
68
- class MiniCPM:
69
- def answer(self, question: str, pages: list[tuple[str, Image.Image]]) -> str:
70
- """pages: [(label, page image)] in retrieval order."""
71
- return _answer_on_gpu(question, pages)
 
1
+ """MiniCPM-V: answers a question grounded in the retrieved repair-manual pages.
 
2
 
3
  The model and tokenizer are module-level globals: ZeroGPU packs module-level
4
  CUDA tensors at startup and shares them with the GPU worker, whereas function
5
  arguments are pickled — and trust_remote_code model classes are not picklable.
6
+
7
+ generate_answer is a plain function: the ask pipeline calls it inside its own
8
+ single @spaces.GPU call, right after retrieval.
9
  """
10
 
11
  from __future__ import annotations
12
 
 
13
  import torch
14
  from PIL import Image
15
+ from transformers import AutoModel, AutoProcessor, AutoTokenizer
16
 
17
+ from core.constants import ANSWER_MAX_NEW_TOKENS, MINICPM_MODEL_ID, MINICPM_REVISION
18
 
19
  PROMPT = (
20
  "You are a repair-manual assistant. The images are the manual pages most "
 
39
  _MODEL = (
40
  AutoModel.from_pretrained(
41
  MINICPM_MODEL_ID,
42
+ revision=MINICPM_REVISION,
43
  trust_remote_code=True,
44
  dtype=torch.bfloat16,
45
  attn_implementation="sdpa",
 
47
  .to("cuda")
48
  .eval()
49
  )
50
+ _TOKENIZER = AutoTokenizer.from_pretrained(
51
+ MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
52
+ )
53
+ # Pre-build the processor chat() would otherwise lazily create per GPU worker
54
+ # (it caches on this exact attribute, see modeling_minicpmv.chat).
55
+ _MODEL.processor = AutoProcessor.from_pretrained(
56
+ MINICPM_MODEL_ID, revision=MINICPM_REVISION, trust_remote_code=True
57
+ )
58
 
59
 
60
+ def generate_answer(question: str, pages: list[tuple[str, Image.Image]]) -> str:
61
+ """pages: [(label, page image)] in retrieval order. Must run on GPU
62
+ (called from within a @spaces.GPU context)."""
63
  content = []
64
  for label, img in pages: # chat() accepts interleaved strings and PIL images
65
  content.append(f"[{label}]")
 
73
  max_new_tokens=ANSWER_MAX_NEW_TOKENS,
74
  )
75
  return str(answer).strip()
 
 
 
 
 
 
pipelines/ask.py CHANGED
@@ -1,35 +1,51 @@
1
  """Ask pipeline: question -> MaxSim retrieval over the store -> top-K page
2
- images -> MiniCPM answer grounded in those pages."""
 
 
 
 
 
3
 
4
  from __future__ import annotations
5
 
 
 
 
6
  from core.pdf import render_page
7
  from core.store import Store
8
- from models.colembed import ColEmbed
9
- from models.minicpm import MiniCPM
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  class AskPipeline:
13
- def __init__(self, embedder: ColEmbed, store: Store, llm: MiniCPM):
14
- self.embedder = embedder
15
- self.store = store
16
- self.llm = llm
17
 
18
- def run(self, question: str, doc_ids: list[str] | None, top_k: int):
19
  """Return (answer markdown, gallery items [(image, caption)])."""
20
  question = (question or "").strip()
21
  if not question:
22
  raise ValueError("Please enter a question.")
23
- docs = self.store.list_docs()
24
  if not docs:
25
- raise ValueError("No manuals indexed yet — add one in the Library tab.")
26
-
27
- hits = self.embedder.search(question, self.store, doc_ids or None, int(top_k))
28
  names = {d["doc_id"]: d["name"] for d in docs}
29
- pages = [
30
- (f"{names[doc_id]} — p.{page}", render_page(self.store.pdf_path(doc_id), page), score)
31
- for doc_id, page, score in hits
32
- ]
33
- answer = self.llm.answer(question, [(label, img) for label, img, _ in pages])
34
- gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
35
- return answer, gallery
 
1
  """Ask pipeline: question -> MaxSim retrieval over the store -> top-K page
2
+ images -> MiniCPM answer grounded in those pages.
3
+
4
+ The whole question runs in ONE @spaces.GPU call (query embedding + MaxSim +
5
+ page rendering + answer generation), so each question pays the ZeroGPU
6
+ allocation wait once. Indexing has its own separate GPU entry point.
7
+ """
8
 
9
  from __future__ import annotations
10
 
11
+ import spaces
12
+
13
+ from core.constants import ASK_GPU_DURATION
14
  from core.pdf import render_page
15
  from core.store import Store
16
+ from models.colembed import maxsim_search
17
+ from models.minicpm import generate_answer
18
+
19
+
20
+ @spaces.GPU(duration=ASK_GPU_DURATION)
21
+ def _ask_on_gpu(
22
+ question: str,
23
+ store: Store,
24
+ doc_ids: list[str] | None,
25
+ top_k: int,
26
+ names: dict[str, str],
27
+ ):
28
+ hits = maxsim_search(question, store, doc_ids, top_k)
29
+ pages = [
30
+ (f"{names[doc_id]} — p.{page}", render_page(store.pdf_path(doc_id), page), score)
31
+ for doc_id, page, score in hits
32
+ ]
33
+ answer = generate_answer(question, [(label, img) for label, img, _ in pages])
34
+ gallery = [(img, f"{label} (score {score:.1f})") for label, img, score in pages]
35
+ return answer, gallery
36
 
37
 
38
  class AskPipeline:
39
+ """Stateless: the store is passed per call, so the same pipeline serves
40
+ both the pre-indexed library and user uploads."""
 
 
41
 
42
+ def run(self, store: Store, question: str, doc_ids: list[str] | None, top_k: int):
43
  """Return (answer markdown, gallery items [(image, caption)])."""
44
  question = (question or "").strip()
45
  if not question:
46
  raise ValueError("Please enter a question.")
47
+ docs = store.list_docs()
48
  if not docs:
49
+ raise ValueError("No manuals indexed yet — upload one first.")
 
 
50
  names = {d["doc_id"]: d["name"] for d in docs}
51
+ return _ask_on_gpu(question, store, doc_ids or None, int(top_k), names)
 
 
 
 
 
 
pipelines/ingest.py CHANGED
@@ -1,12 +1,13 @@
1
  """Ingest pipeline: PDF -> page images -> ColEmbed embeddings -> on-disk store.
2
 
3
  Pages are embedded in chunks of EMBED_PAGES_PER_CALL so each ZeroGPU call stays
4
- short; progress is reported between chunks.
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import os
 
10
 
11
  from core.constants import EMBED_PAGES_PER_CALL, RENDER_DPI
12
  from core.pdf import page_count, render_pages
@@ -19,9 +20,12 @@ class IngestPipeline:
19
  self.embedder = embedder
20
  self.store = store
21
 
22
- def run(self, pdf_path: str | None, doc_name: str = "", progress=None) -> dict:
23
- """Index one PDF; returns the stored doc's summary. Re-indexing a manual
24
- with the same name overwrites it."""
 
 
 
25
  if not pdf_path:
26
  raise ValueError("Please upload a PDF first.")
27
  if not pdf_path.lower().endswith(".pdf"):
@@ -32,6 +36,12 @@ class IngestPipeline:
32
  )
33
  doc_id = slugify(name)
34
  total = page_count(pdf_path)
 
 
 
 
 
 
35
 
36
  writer = self.store.create(doc_id, name, pdf_path, RENDER_DPI, self.embedder.MODEL_ID)
37
  try:
@@ -40,10 +50,9 @@ class IngestPipeline:
40
  images = render_pages(pdf_path, nums)
41
  for num, emb in zip(nums, self.embedder.embed_pages(images)):
42
  writer.add_page(num, emb)
43
- if progress:
44
- progress(nums[-1] / total, f"Embedded {nums[-1]}/{total} pages")
45
  writer.finalize()
46
  except BaseException:
47
  writer.abort()
48
  raise
49
- return {"doc_id": doc_id, "name": name, "pages": total}
 
1
  """Ingest pipeline: PDF -> page images -> ColEmbed embeddings -> on-disk store.
2
 
3
  Pages are embedded in chunks of EMBED_PAGES_PER_CALL so each ZeroGPU call stays
4
+ short; progress events are yielded between chunks so the UI can stream them.
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import os
10
+ from collections.abc import Iterator
11
 
12
  from core.constants import EMBED_PAGES_PER_CALL, RENDER_DPI
13
  from core.pdf import page_count, render_pages
 
20
  self.embedder = embedder
21
  self.store = store
22
 
23
+ def run(
24
+ self, pdf_path: str | None, doc_name: str = "", max_pages: int | None = None
25
+ ) -> Iterator[tuple]:
26
+ """Index one PDF. Generator yielding ("progress", pages_done, total)
27
+ after each embedded chunk, then ("done", doc summary dict) last.
28
+ Re-indexing a manual with the same name overwrites it."""
29
  if not pdf_path:
30
  raise ValueError("Please upload a PDF first.")
31
  if not pdf_path.lower().endswith(".pdf"):
 
36
  )
37
  doc_id = slugify(name)
38
  total = page_count(pdf_path)
39
+ if max_pages and total > max_pages:
40
+ raise ValueError(
41
+ f"This PDF has {total} pages — uploads are capped at {max_pages} "
42
+ "pages to conserve the Space's GPU quota. Large manuals belong "
43
+ "in the pre-indexed library."
44
+ )
45
 
46
  writer = self.store.create(doc_id, name, pdf_path, RENDER_DPI, self.embedder.MODEL_ID)
47
  try:
 
50
  images = render_pages(pdf_path, nums)
51
  for num, emb in zip(nums, self.embedder.embed_pages(images)):
52
  writer.add_page(num, emb)
53
+ yield ("progress", nums[-1], total)
 
54
  writer.finalize()
55
  except BaseException:
56
  writer.abort()
57
  raise
58
+ yield ("done", {"doc_id": doc_id, "name": name, "pages": total})