Spaces:
Sleeping
Sleeping
File size: 12,693 Bytes
c42f4ef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | """Hugging Face Space (Gradio, ZeroGPU): multimodal RAG over PDFs, Word files and images.
CPU: parsing, OCR, BGE + CLIP embeddings, Qdrant (one in-memory index per browser session).
GPU (ZeroGPU, only while answering): Qwen3-VL reads the retrieved text and images and answers with citations.
"""
from __future__ import annotations
import spaces # noqa: I001 (import before torch on ZeroGPU)
import logging
import os
import shutil
import sys
import tempfile
from dataclasses import replace
from pathlib import Path
import gradio as gr
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "examples"))
from mrag.config import Config # noqa: E402
from mrag.generate import Generator, SYSTEM_PROMPT, build_prompt, load_image # noqa: E402
from mrag.models import Models # noqa: E402
from mrag.pipeline import MultimodalRAG # noqa: E402
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("space")
MAX_FILES = int(os.environ.get("MRAG_MAX_FILES", 10))
MAX_FILE_MB = int(os.environ.get("MRAG_MAX_FILE_MB", 25))
BASE_CFG = Config.load(
qdrant_url=":memory:",
llm_backend=os.environ.get("MRAG_LLM_BACKEND", "transformers"),
max_pdf_pages=int(os.environ.get("MRAG_MAX_PDF_PAGES", 40)),
device="cpu", # retrieval callbacks never have a ZeroGPU allocation
)
if BASE_CFG.llm_backend not in {"transformers", "extractive"}:
raise ValueError("This Space supports MRAG_LLM_BACKEND=transformers or extractive.")
# ---- models: loaded once per process -------------------------------------------------
MODELS = Models(BASE_CFG)
_ = MODELS.text, MODELS.clip, MODELS.ocr # CPU models, warm at startup
VLM = None
if BASE_CFG.llm_backend == "transformers":
from mrag.hf_vlm import HFVLM
VLM = HFVLM(BASE_CFG.vlm_id) # device "cuda" on ZeroGPU (attached per call)
log.info("VLM %s on %s", BASE_CFG.vlm_id, VLM.device)
DEMO_DIR = ROOT / "demo_docs"
if not DEMO_DIR.exists():
import make_demo_corpus
make_demo_corpus.build(DEMO_DIR)
# ---- per-session state ------------------------------------------------------------------
class Session:
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="mrag_session_")
self.rag = MultimodalRAG(replace(BASE_CFG, data_dir=self.dir), models=MODELS, vlm=VLM)
self.busy = False
def close(self):
self.rag.close()
shutil.rmtree(self.dir, ignore_errors=True)
def _cleanup(s):
if isinstance(s, Session):
s.close()
def _session(s):
return s if isinstance(s, Session) else Session()
def _idle(s):
if isinstance(s, Session) and s.busy:
raise gr.Error("An answer is still running. Wait for it to finish.")
def finish_answer(s):
"""Runs in the web process, including after a GPU error or quota rejection."""
if isinstance(s, Session):
s.busy = False
return s, "", [], "", *[gr.update(interactive=True) for _ in range(4)]
def _status(s: Session) -> str:
st = s.rag.stats()
names = sorted(Path(p).name for p in s.rag._manifest())
return (f"**Indexed:** {st['files']} file(s) · {st['mrag_text']} text chunks · {st['mrag_image']} images"
+ (f"\n\n{', '.join(names)}" if names else ""))
# ---- GPU part -------------------------------------------------------------------------
def _gpu_seconds(prompt, image_paths, max_new_tokens):
return int(min(120, 20 + 4 * len(image_paths) + max_new_tokens / 12))
@spaces.GPU(duration=lambda prompt, image_paths, max_new_tokens, history:
_gpu_seconds(prompt, image_paths, max_new_tokens))
def vlm_answer(prompt: str, image_paths: list[str], max_new_tokens: int, history: list[dict]):
"""Gradio-bound GPU callback: stream an answer directly into the chat history.
Hugging Face ZeroGPU must see @spaces.GPU on the ACTUAL registered event
handler (the second step in the Gradio event chain below), not on a helper
invoked by an undecorated event handler.
"""
if not prompt or not history or history[-1].get("role") != "assistant":
raise gr.Error("Submit a question before requesting an answer.")
images = [load_image(p, BASE_CFG.max_image_side) for p in image_paths]
messages = [dict(item) for item in (history or [])]
output = ""
for piece in VLM.stream(SYSTEM_PROMPT, prompt, images, int(max_new_tokens), BASE_CFG.llm_temperature):
output += piece
messages[-1] = {"role": "assistant", "content": output}
yield messages
if not output.strip():
messages[-1] = {"role": "assistant", "content": "The model returned an empty response."}
yield messages
# ---- handlers -------------------------------------------------------------------------
def index_files(files, s, progress=gr.Progress()):
_idle(s)
files = files or []
if len(files) > MAX_FILES:
raise gr.Error(f"At most {MAX_FILES} files per session.")
paths = []
for f in files:
p = Path(f if isinstance(f, str) else f.name)
if p.stat().st_size > MAX_FILE_MB * 2**20:
raise gr.Error(f"{p.name} is larger than {MAX_FILE_MB} MB.")
paths.append(p)
if not paths:
raise gr.Error("Choose some files first.")
existing = set(s.rag._manifest()) if isinstance(s, Session) else set()
if len(existing | {str(p.resolve()) for p in paths}) > MAX_FILES:
raise gr.Error(f"At most {MAX_FILES} indexed files per session. Clear the index first.")
s = _session(s)
progress(0, desc="Parsing, OCR and embedding")
stats = s.rag.ingest(paths)
if stats["errors"]:
gr.Warning("; ".join(stats["errors"])[:500])
return s, _status(s), None
def load_demo(s):
_idle(s)
from mrag.ingest import iter_files
paths = list(iter_files([DEMO_DIR]))
existing = set(s.rag._manifest()) if isinstance(s, Session) else set()
if len(existing | {str(p.resolve()) for p in paths}) > MAX_FILES:
raise gr.Error(f"Demo would exceed {MAX_FILES} indexed files. Clear the index first.")
s = _session(s)
stats = s.rag.ingest(paths)
if stats["errors"]:
gr.Warning("; ".join(stats["errors"])[:500])
return s, _status(s)
def reset(s):
_idle(s)
_cleanup(s)
s = Session()
return s, _status(s), [], [], "", "", [], ""
def prepare_answer(question, history, s, top_k_text, top_k_images, send_images):
"""CPU-only retrieval. Returns inputs for the separate ZeroGPU event."""
_idle(s)
if not question or not question.strip():
raise gr.Error("Enter a question first.")
if len(question) > 4000:
raise gr.Error("Keep the question within 4000 characters.")
if not isinstance(s, Session):
raise gr.Error("Index some files first (or click 'Load demo documents').")
if s.rag.stats()["files"] == 0:
raise gr.Error("Index some files first (or click 'Load demo documents').")
s.rag.cfg.top_k_text, s.rag.cfg.top_k_images = int(top_k_text), int(top_k_images)
texts, images = s.rag.retrieve(question)
selected_images = images if send_images else []
gallery = [(im.path, f"[Img {i}] {Path(im.source).name}" + (f" p.{im.page}" if im.page else ""))
for i, im in enumerate(images, 1)]
sources = "\n\n".join(
f"**[{i}] {Path(t.source).name}{f' p.{t.page}' if t.page else ''}** "
f"<small>({t.kind})</small>\n\n{t.text[:700]}" for i, t in enumerate(texts, 1)) or "_none_"
history = list(history or []) + [
{"role": "user", "content": question}, {"role": "assistant", "content": ""},
]
s.busy = True
return (s, history, gallery, sources, gr.update(value="", interactive=False), build_prompt(question, texts, selected_images),
[im.path for im in selected_images], question,
*[gr.update(interactive=False) for _ in range(3)])
def cpu_answer(prompt, image_paths, max_new_tokens, history, s, question):
"""Optional non-GPU local fallback. Not used for a standard ZeroGPU Space."""
messages = [dict(item) for item in history]
output = ""
texts, images = s.rag.retrieve(question)
if not image_paths:
images = []
cfg = replace(s.rag.cfg, max_new_tokens=int(max_new_tokens))
for piece in Generator(cfg, MODELS.text).answer(question, texts, images, stream=True):
output += piece
messages[-1] = {"role": "assistant", "content": output}
yield messages
if not output:
messages[-1] = {"role": "assistant", "content": "No evidence found."}
yield messages
# ---- UI -------------------------------------------------------------------------------
EXAMPLES = [
"How long is the warranty on the Nimbus R2?",
"Which region had the highest revenue in FY2025, and how much?",
"What is the maximum load of the electric forklift?",
"When is the server room door code changed?",
"Show me the photo of the office cat.",
]
with gr.Blocks(title="Multimodal RAG", delete_cache=(3600, 10800)) as demo:
gr.Markdown(
"# Multimodal RAG\n"
"Ask questions over **PDFs, Word files, images, charts and scanned pages**. "
"Retrieval: BGE-small + BM25 for text, OpenCLIP for images, RapidOCR for text inside images, Qdrant. "
f"Answering: **{Path(BASE_CFG.vlm_id).name}** on ZeroGPU, which reads the retrieved text *and* images. "
"Each browser session gets a separate temporary index. Avoid uploading confidential data.")
state = gr.State(None, delete_callback=_cleanup, time_to_live=3 * 3600)
pending_prompt = gr.State("")
pending_images = gr.State([])
pending_question = gr.State("")
with gr.Row():
with gr.Column(scale=1, min_width=300):
files = gr.File(label=f"Upload (≤{MAX_FILES} files, ≤{MAX_FILE_MB} MB each)", file_count="multiple",
file_types=[".pdf", ".docx", ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif",
".tif", ".tiff", ".txt", ".md"])
btn_index = gr.Button("Index files", variant="primary")
btn_demo = gr.Button("Load demo documents")
btn_reset = gr.Button("Clear index")
status = gr.Markdown("**Indexed:** nothing yet")
with gr.Accordion("Settings", open=False):
k_text = gr.Slider(1, 12, BASE_CFG.top_k_text, step=1, label="Text passages")
k_img = gr.Slider(0, 6, BASE_CFG.top_k_images, step=1, label="Images")
max_tokens = gr.Slider(64, 1024, BASE_CFG.max_new_tokens, step=32, label="Max new tokens")
send_imgs = gr.Checkbox(True, label="Send retrieved images to the VLM")
with gr.Column(scale=2):
chat = gr.Chatbot(label="Answer", height=420)
q = gr.Textbox(label="Question", placeholder="Ask about your documents", submit_btn=True)
gr.Examples(EXAMPLES, inputs=q)
gallery = gr.Gallery(label="Retrieved images", columns=3, height=260)
with gr.Accordion("Retrieved text", open=False):
src = gr.Markdown()
cpu_queue = dict(concurrency_id="retrieval", concurrency_limit=1)
btn_index.click(index_files, [files, state], [state, status, files], **cpu_queue)
btn_demo.click(load_demo, [state], [state, status], **cpu_queue)
btn_reset.click(reset, [state], [state, status, chat, gallery, src,
pending_prompt, pending_images, pending_question], **cpu_queue)
# A two-step Gradio event chain is necessary for ZeroGPU detection.
# Only successful retrieval can trigger generation; .then also runs on failure.
prep = q.submit(prepare_answer,
inputs=[q, chat, state, k_text, k_img, send_imgs],
outputs=[state, chat, gallery, src, q, pending_prompt, pending_images, pending_question,
btn_index, btn_demo, btn_reset], **cpu_queue)
if BASE_CFG.llm_backend == "transformers":
answer = prep.success(fn=vlm_answer,
inputs=[pending_prompt, pending_images, max_tokens, chat],
outputs=chat, concurrency_id="vlm", concurrency_limit=1)
else:
answer = prep.success(fn=cpu_answer,
inputs=[pending_prompt, pending_images, max_tokens, chat, state, pending_question],
outputs=chat, **cpu_queue)
answer.then(finish_answer, inputs=state,
outputs=[state, pending_prompt, pending_images, pending_question,
q, btn_index, btn_demo, btn_reset], **cpu_queue)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1, max_size=20).launch(max_file_size=f"{MAX_FILE_MB}mb")
|