""" Multimodal RAG Demo with Nemotron Embed VL and Rerank VL (ZeroGPU-friendly) Key ZeroGPU rule: - DO NOT load GPU models at import time. - Lazy-load models INSIDE the @spaces.GPU function (or inside helpers called from it). Models: - Embed: nvidia/llama-nemotron-embed-vl-1b-v2 - Rerank: nvidia/llama-nemotron-rerank-vl-1b-v2 - Gen (preferred): nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8 (text-only summary, trust_remote_code) - If it fails, fallback to a smaller text-only model. Attention: - Default: SDPA (most stable on Spaces) - Optional: FlashAttention-2 if USE_FA2=1 and flash-attn is installed & compatible. """ import os import time import spaces import torch import gradio as gr from PIL import Image from datasets import load_dataset from safetensors.torch import load_file from transformers import ( AutoModel, AutoModelForSequenceClassification, AutoProcessor, AutoTokenizer, AutoModelForCausalLM, ) # ----------------------------------------------------------------------------- # Config # ----------------------------------------------------------------------------- DEVICE_CPU = torch.device("cpu") EMBED_MODEL_PATH = "nvidia/llama-nemotron-embed-vl-1b-v2" EMBED_COMMIT_HASH = "5b5ca69c35bf6ec1484d2d5ff238626e67a745e2" RERANK_MODEL_PATH = "nvidia/llama-nemotron-rerank-vl-1b-v2" RERANK_COMMIT_HASH = "47e5a355d1a050c3e5f69d53f14964b1d34bcd9d" GENERATION_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8" FALLBACK_GEN_MODEL_ID = os.getenv("FALLBACK_GEN_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct") ATTN_IMPL = "flash_attention_2" if os.getenv("USE_FA2", "0") == "1" else "sdpa" modality_to_tokens = {"image": 2048, "image_text": 10240, "text": 8192} PATH_TO_EMBEDDING_FILE = os.getenv("EMBEDDINGS_FILE", "image_text_embeddings_10k.safetensors") # ----------------------------------------------------------------------------- # Load dataset + embeddings (CPU only) # ----------------------------------------------------------------------------- print("[INFO] Loading dataset (CPU)...") dataset = load_dataset("mrdbourke/recipe-synthetic-images-10k") train_split = dataset["train"] print(f"[INFO] Dataset loaded with {len(train_split)} samples") # Pick the main markdown field robustly PREFERRED_TEXT_COL = "recipe_markdown" FALLBACK_TEXT_COLS = ["markdown", "text", "recipe_text", "content"] if PREFERRED_TEXT_COL in train_split.column_names: TEXT_COL = PREFERRED_TEXT_COL else: found = None for c in FALLBACK_TEXT_COLS: if c in train_split.column_names: found = c break if found is None: raise RuntimeError( f"Could not find a recipe text column. Available columns: {train_split.column_names}" ) TEXT_COL = found print(f"[WARN] '{PREFERRED_TEXT_COL}' not found. Using '{TEXT_COL}' instead.") if "image" not in train_split.column_names: raise RuntimeError(f"Dataset does not contain 'image' column. Columns: {train_split.column_names}") print(f"[INFO] Using TEXT_COL='{TEXT_COL}'") print("[INFO] Loading embeddings (CPU)...") emb = load_file(PATH_TO_EMBEDDING_FILE) if "image_text_embeddings" not in emb: raise RuntimeError(f"'{PATH_TO_EMBEDDING_FILE}' missing key 'image_text_embeddings'. Keys: {list(emb.keys())}") image_text_embeddings = emb["image_text_embeddings"].to(DEVICE_CPU) print(f"[INFO] Embeddings loaded: {tuple(image_text_embeddings.shape)} | device={image_text_embeddings.device}") # ----------------------------------------------------------------------------- # Lazy GPU globals (must only be initialized inside @spaces.GPU) # ----------------------------------------------------------------------------- _embed_model = None _embed_processor = None _rerank_model = None _rerank_processor = None _gen_model = None _gen_tokenizer = None _embeddings_gpu = None def _cuda() -> torch.device: return torch.device("cuda") def _load_embed_and_rerank_on_gpu(): global _embed_model, _embed_processor, _rerank_model, _rerank_processor device = _cuda() if _embed_model is None or _embed_processor is None: print("[INFO] Lazy-loading EMBED model on GPU...") _embed_model = AutoModel.from_pretrained( EMBED_MODEL_PATH, revision=EMBED_COMMIT_HASH, trust_remote_code=True, torch_dtype=torch.bfloat16, attn_implementation=ATTN_IMPL, ).to(device).eval() _embed_processor = AutoProcessor.from_pretrained( EMBED_MODEL_PATH, revision=EMBED_COMMIT_HASH, trust_remote_code=True, max_input_tiles=6, use_thumbnail=True, p_max_length=modality_to_tokens["image_text"], ) if _rerank_model is None or _rerank_processor is None: print("[INFO] Lazy-loading RERANK model on GPU...") _rerank_model = AutoModelForSequenceClassification.from_pretrained( RERANK_MODEL_PATH, revision=RERANK_COMMIT_HASH, trust_remote_code=True, torch_dtype=torch.bfloat16, attn_implementation=ATTN_IMPL, ).to(device).eval() _rerank_processor = AutoProcessor.from_pretrained( RERANK_MODEL_PATH, revision=RERANK_COMMIT_HASH, trust_remote_code=True, max_input_tiles=6, use_thumbnail=True, rerank_max_length=modality_to_tokens["image_text"], ) return _embed_model, _embed_processor, _rerank_model, _rerank_processor def _load_generation_model_on_gpu(): """ Try Nemotron 30B FP8 first. If it fails (e.g., missing mamba-ssm), fall back to a smaller text model. """ global _gen_model, _gen_tokenizer if _gen_model is not None and _gen_tokenizer is not None: return _gen_model, _gen_tokenizer device = _cuda() # 1) Try Nemotron FP8 try: print("[INFO] Lazy-loading GENERATION model (Nemotron 30B FP8) on GPU...") _gen_tokenizer = AutoTokenizer.from_pretrained( GENERATION_MODEL_ID, trust_remote_code=True, use_fast=True, ) _gen_model = AutoModelForCausalLM.from_pretrained( GENERATION_MODEL_ID, trust_remote_code=True, torch_dtype="auto", device_map="auto", attn_implementation=ATTN_IMPL, ).eval() print("[INFO] Nemotron generation model loaded OK") return _gen_model, _gen_tokenizer except Exception as e: print(f"[WARN] Nemotron FP8 load failed: {repr(e)}") print(f"[WARN] Falling back to: {FALLBACK_GEN_MODEL_ID}") _gen_tokenizer = AutoTokenizer.from_pretrained( FALLBACK_GEN_MODEL_ID, trust_remote_code=True, use_fast=True, ) _gen_model = AutoModelForCausalLM.from_pretrained( FALLBACK_GEN_MODEL_ID, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="auto", attn_implementation=ATTN_IMPL, ).eval() return _gen_model, _gen_tokenizer # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- def _l2_normalize(x: torch.Tensor, eps: float = 1e-12) -> torch.Tensor: return x / (x.norm(p=2, dim=-1, keepdim=True) + eps) def match_query_to_embeddings( query: str | Image.Image, target_embeddings_to_match: torch.Tensor, top_k: int = 50 ) -> tuple[torch.Tensor, torch.Tensor]: with torch.inference_mode(): if isinstance(query, Image.Image): q = _embed_model.encode_documents(images=[query]) else: q = _embed_model.encode_queries([query]) sim = _l2_normalize(q) @ _l2_normalize(target_embeddings_to_match).T sim = sim.flatten() idx = torch.argsort(sim, descending=True)[:top_k] scores = sim[idx] return scores, idx def rerank_samples( query_text: str, sorted_indices: torch.Tensor, num_samples_to_rerank: int = 20, ) -> tuple: device = _cuda() top_idx = sorted_indices[:num_samples_to_rerank] subset = dataset["train"].select(top_idx.tolist()) texts = subset[TEXT_COL] images = subset["image"] pairs = [{"question": query_text, "doc_text": t, "doc_image": im} for t, im in zip(texts, images)] batch = _rerank_processor.process_queries_documents_crossencoder(pairs) batch = {k: (v.to(device) if isinstance(v, torch.Tensor) else v) for k, v in batch.items()} with torch.inference_mode(): out = _rerank_model(**batch, return_dict=True) logits = out.logits.squeeze(-1) rerank_sorted = torch.argsort(logits, descending=True) return subset, rerank_sorted def generate_recipe_summary(recipe_texts: list[str], max_new_tokens: int = 384) -> str: model, tok = _load_generation_model_on_gpu() combined = "" for i, r in enumerate(recipe_texts[:3], 1): combined += f"\n\n--- RECIPE {i} ---\n{r}" prompt = ( "You are a helpful culinary assistant.\n" "Summarize the following recipes in Markdown.\n\n" "Return:\n" "- 1–2 sentence overview of each\n" "- key ingredients\n" "- difficulty (Easy/Medium/Hard)\n" "- which is best for a quick weeknight dinner\n\n" f"{combined}\n\n" "## Summary:\n" ) inputs = tok(prompt, return_tensors="pt").to(model.device) with torch.inference_mode(): out = model.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=True, temperature=0.7, top_p=0.9, pad_token_id=tok.eos_token_id, ) gen = tok.decode(out[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True) return gen.strip() def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str: # Keep your original “card” parsing lightweight. lines = (markdown_text or "").strip().split("\n") title = "" description = "" cook_time = "" num_ratings = "" ingredients = [] steps = [] reviews = [] current_section = None in_ingredients = False in_steps = False in_reviews = False review_count = 0 for raw in lines: line = raw.strip() if line.startswith("# ") and not title: title = line[2:].strip() continue if line.startswith("**Time:**"): cook_time = line.replace("**Time:**", "").strip() continue if line.startswith("**Number of Ratings:**"): num_ratings = line.replace("**Number of Ratings:**", "").strip() continue if line.startswith("## "): section = line[3:].strip().lower() current_section = section in_ingredients = (section == "ingredients") in_steps = section.startswith("steps") in_reviews = (section == "reviews") continue if current_section == "description" and line and not line.startswith("#"): description = line continue if in_ingredients and line.startswith("- "): ingredients.append(line[2:].strip()) continue if in_steps and line and line[0].isdigit(): step_text = line.split(". ", 1)[-1] if ". " in line else line steps.append(step_text.strip()) continue if in_reviews and line.startswith("> ") and review_count < max_reviews: reviews.append(line[2:].strip()) review_count += 1 continue html = f"""