mrdbourke commited on
Commit
776605e
Β·
verified Β·
1 Parent(s): fb1bceb

uploading app_local.py for running locally (no @spaces.GPU)

Browse files
Files changed (2) hide show
  1. app.py +43 -43
  2. requirements.txt +1 -1
app.py CHANGED
@@ -143,7 +143,7 @@ def match_query_to_embeddings(
143
  ) -> tuple[torch.Tensor, torch.Tensor]:
144
  """
145
  Match a query (text or image) to target embeddings.
146
-
147
  Returns:
148
  Tuple of (sorted_scores, sorted_indices)
149
  """
@@ -152,7 +152,7 @@ def match_query_to_embeddings(
152
  query_embeddings = embed_model.encode_documents(images=[query])
153
  else:
154
  query_embeddings = embed_model.encode_queries([query])
155
-
156
  cos_sim = _l2_normalize(query_embeddings) @ _l2_normalize(target_embeddings_to_match).T
157
  cos_sim_flat = cos_sim.flatten()
158
  sorted_indices = torch.argsort(cos_sim_flat, descending=True)[:top_k]
@@ -175,7 +175,7 @@ def rerank_samples(
175
  ) -> tuple:
176
  """
177
  Rerank top samples using the vision-language reranker model.
178
-
179
  Returns:
180
  Tuple of (dataset_samples_to_rerank, rerank_sorted_indices)
181
  """
@@ -216,11 +216,11 @@ def generate_recipe_summary(
216
  model = qwen_model
217
  if processor is None:
218
  processor = qwen_processor
219
-
220
  recipes_combined = ""
221
  for i, recipe in enumerate(recipe_texts[:3], 1):
222
  recipes_combined += f"\n\n--- RECIPE {i} ---\n{recipe}"
223
-
224
  prompt = f"""You are a helpful culinary assistant. Below are {len(recipe_texts[:3])} recipes.
225
  Please provide a brief markdown summary with:
226
  - A short 1-2 sentence overview of each recipe
@@ -255,7 +255,7 @@ Keep the summary concise and well-formatted in markdown. Return in ```markdown``
255
  ## Summary:"""
256
 
257
  messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
258
-
259
  inputs = processor.apply_chat_template(
260
  messages,
261
  tokenize=True,
@@ -264,7 +264,7 @@ Keep the summary concise and well-formatted in markdown. Return in ```markdown``
264
  return_tensors="pt"
265
  )
266
  inputs = inputs.to(model.device)
267
-
268
  with torch.no_grad():
269
  generated_ids = model.generate(
270
  **inputs,
@@ -273,25 +273,25 @@ Keep the summary concise and well-formatted in markdown. Return in ```markdown``
273
  temperature=0.7,
274
  top_p=0.9
275
  )
276
-
277
  generated_ids_trimmed = [
278
  out_ids[len(in_ids):]
279
  for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
280
  ]
281
-
282
  output_text = processor.batch_decode(
283
  generated_ids_trimmed,
284
  skip_special_tokens=True,
285
  clean_up_tokenization_spaces=False
286
  )[0]
287
-
288
  return output_text.strip()
289
 
290
 
291
  def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
292
  """Convert recipe markdown to a simple HTML card."""
293
  lines = markdown_text.strip().split('\n')
294
-
295
  title = ""
296
  description = ""
297
  recipe_id = ""
@@ -301,21 +301,21 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
301
  steps = []
302
  tags = []
303
  reviews = []
304
-
305
  current_section = None
306
  in_ingredients = False
307
  in_steps = False
308
  in_reviews = False
309
  in_tags = False
310
  review_count = 0
311
-
312
  for line in lines:
313
  line = line.strip()
314
-
315
  if line.startswith('# ') and not title:
316
  title = line[2:].strip()
317
  continue
318
-
319
  if line.startswith('**ID:**'):
320
  recipe_id = line.replace('**ID:**', '').strip()
321
  continue
@@ -325,7 +325,7 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
325
  if line.startswith('**Number of Ratings:**'):
326
  num_ratings = line.replace('**Number of Ratings:**', '').strip()
327
  continue
328
-
329
  if line.startswith('## '):
330
  section_name = line[3:].strip().lower()
331
  in_ingredients = section_name == 'ingredients'
@@ -334,47 +334,47 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
334
  in_tags = section_name == 'tags'
335
  current_section = section_name
336
  continue
337
-
338
  if current_section == 'description' and line and not line.startswith('#'):
339
  description = line
340
  continue
341
-
342
  if in_ingredients and line.startswith('- '):
343
  ingredients.append(line[2:].strip())
344
  continue
345
-
346
  if in_steps and line and line[0].isdigit():
347
  step_text = line.split('. ', 1)[-1] if '. ' in line else line
348
  steps.append(step_text.strip())
349
  continue
350
-
351
  if in_tags and line.startswith('`'):
352
  tag_list = [t.strip().strip('`') for t in line.split(',')]
353
  tags.extend(tag_list)
354
  continue
355
-
356
  if in_reviews and line.startswith('> ') and review_count < max_reviews:
357
  reviews.append(line[2:].strip())
358
  review_count += 1
359
  continue
360
-
361
  html = f'''
362
  <div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 4px; background: #fff; font-family: system-ui, -apple-system, sans-serif; font-size: 12px; height: 400px; overflow-y: auto; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
363
  <div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 8px;">{title}</div>
364
-
365
  <div style="display: flex; gap: 12px; font-size: 11px; color: #666; margin-bottom: 10px; flex-wrap: wrap;">
366
  {f'<span>⏱️ {cook_time}</span>' if cook_time else ''}
367
  {f'<span>⭐ {num_ratings} ratings</span>' if num_ratings else ''}
368
  {f'<span style="color: #999;">ID: {recipe_id}</span>' if recipe_id else ''}
369
  </div>
370
-
371
  <div style="color: #555; margin-bottom: 12px; font-style: italic; line-height: 1.4;">{description[:150]}{"..." if len(description) > 150 else ""}</div>
372
-
373
  <div style="margin-bottom: 12px;">
374
  <div style="font-weight: bold; font-size: 11px; color: #333; margin-bottom: 4px;">πŸ“ Ingredients</div>
375
  <div style="color: #444; line-height: 1.5;">{", ".join(ingredients[:8])}{"..." if len(ingredients) > 8 else ""}</div>
376
  </div>
377
-
378
  <div style="margin-bottom: 12px;">
379
  <div style="font-weight: bold; font-size: 11px; color: #333; margin-bottom: 4px;">πŸ‘¨β€πŸ³ Steps ({len(steps)} total)</div>
380
  <ol style="margin: 0; padding-left: 20px; color: #444; line-height: 1.5;">
@@ -383,7 +383,7 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
383
  </ol>
384
  </div>
385
  '''
386
-
387
  if tags:
388
  display_tags = tags[:5]
389
  html += f'''
@@ -395,7 +395,7 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
395
  </div>
396
  </div>
397
  '''
398
-
399
  if reviews:
400
  html += f'''
401
  <div style="border-top: 1px solid #eee; padding-top: 10px; margin-top: 10px;">
@@ -403,7 +403,7 @@ def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
403
  <div style="color: #555; font-size: 11px; line-height: 1.4; background: #f9f9f9; padding: 8px; border-radius: 4px; font-style: italic;">"{reviews[0][:200]}{"..." if len(reviews[0]) > 200 else ""}"</div>
404
  </div>
405
  '''
406
-
407
  html += '</div>'
408
  return html
409
 
@@ -416,13 +416,13 @@ def create_recipe_cards_html(
416
  ) -> str:
417
  """Generate combined HTML cards from scored recipe samples."""
418
  recipe_cards_html = []
419
-
420
  for item in scores_and_samples[:num_results]:
421
  sample = item["sample"]
422
  markdown_text = sample.get(text_key, "") or sample.get("markdown", "")
423
  card_html = _markdown_to_simple_html(markdown_text, max_reviews=max_reviews)
424
  recipe_cards_html.append(f'<div style="flex: 1; min-width: 0;">{card_html}</div>')
425
-
426
  combined_html = f'''
427
  <div style="margin-top: 16px;">
428
  <h3 style="font-family: system-ui, -apple-system, sans-serif; font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px;">Retrieved Texts</h3>
@@ -431,7 +431,7 @@ def create_recipe_cards_html(
431
  </div>
432
  </div>
433
  '''
434
-
435
  return combined_html
436
 
437
 
@@ -448,13 +448,13 @@ def retrieve(
448
  ):
449
  """
450
  Main retrieval function for the Gradio interface.
451
-
452
  Args:
453
  query_text: Text query input
454
  query_image: Image query input (PIL Image)
455
  rerank_option: "True" or "False" to enable reranking
456
  generate_summary_option: "True" or "False" to enable summary generation
457
-
458
  Returns:
459
  Tuple of (image_gallery, recipe_cards_html, summary, timing_dict)
460
  """
@@ -468,7 +468,7 @@ def retrieve(
468
  input_query = query_image
469
  else:
470
  raise gr.Error("Please provide either a text query or an image query.")
471
-
472
  # === Retrieval ===
473
  start_time_query_to_embed_match = time.time()
474
  result_sorted_scores, result_sorted_indices = match_query_to_embeddings(
@@ -484,7 +484,7 @@ def retrieve(
484
  {"score": round(score.item(), 4), "sample": sample}
485
  for score, sample in zip(result_sorted_scores, top_dataset_results_to_show)
486
  ]
487
-
488
  output_image_gallery = [
489
  (item["sample"]["image"], f'Score: {item["score"]}')
490
  for item in scores_and_samples[:3]
@@ -543,7 +543,7 @@ def retrieve(
543
  recipe_texts = [item["sample"]["recipe_markdown"] for item in samples_and_rerank_changes[:3]]
544
  else:
545
  recipe_texts = [item["sample"]["recipe_markdown"] for item in scores_and_samples[:3]]
546
-
547
  summary = generate_recipe_summary(recipe_texts)
548
  summary = summary.replace("```markdown", "").replace("```", "")
549
  end_time_generation_output = time.time()
@@ -551,7 +551,7 @@ def retrieve(
551
  else:
552
  generation_time = "Generation turned off"
553
  summary = "Generation turned off, no summary created"
554
-
555
  timing_dict = {
556
  "query_embed_and_match_time": round(end_time_query_to_embed_match - start_time_query_to_embed_match, 4),
557
  "rerank_time": rerank_time,
@@ -567,7 +567,7 @@ def retrieve(
567
 
568
  with gr.Blocks(title="Multimodal RAG Demo") as demo:
569
  gr.Markdown("""# πŸ‘οΈπŸ“‘ Multimodal RAG Demo with Nemotron Embed VL and Rerank VL
570
-
571
  Input an image or text about food and get recipe images/text back.
572
 
573
  This is a scalable workflow that can lend itself to many use cases such as business document retrieval, technical manual look ups and more.
@@ -580,7 +580,7 @@ By default it returns the top 3 results from a database of 10,000+ recipes. We'v
580
  * **Rerank model used:** https://huggingface.co/nvidia/llama-nemotron-rerank-vl-1b-v2
581
  * **Generation model used:** https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct (note: you could use a larger model such as [Nemotron v3](https://huggingface.co/collections/nvidia/nvidia-nemotron-v3), however, this will require more compute resources)
582
  """)
583
-
584
  with gr.Row():
585
  with gr.Column(scale=1):
586
  gr.Markdown("### Query Input")
@@ -610,7 +610,7 @@ By default it returns the top 3 results from a database of 10,000+ recipes. We'v
610
  )
611
 
612
  search_btn = gr.Button("Search", variant="primary", size="lg")
613
-
614
  with gr.Column(scale=2):
615
  gr.Markdown("### Retrieved Results")
616
 
@@ -629,7 +629,7 @@ By default it returns the top 3 results from a database of 10,000+ recipes. We'v
629
  )
630
 
631
  timing_output = gr.JSON(label="Timings")
632
-
633
  gr.Markdown("### Example Queries")
634
 
635
  gr.Examples(
@@ -651,4 +651,4 @@ By default it returns the top 3 results from a database of 10,000+ recipes. We'v
651
  )
652
 
653
  if __name__ == "__main__":
654
- demo.launch()
 
143
  ) -> tuple[torch.Tensor, torch.Tensor]:
144
  """
145
  Match a query (text or image) to target embeddings.
146
+
147
  Returns:
148
  Tuple of (sorted_scores, sorted_indices)
149
  """
 
152
  query_embeddings = embed_model.encode_documents(images=[query])
153
  else:
154
  query_embeddings = embed_model.encode_queries([query])
155
+
156
  cos_sim = _l2_normalize(query_embeddings) @ _l2_normalize(target_embeddings_to_match).T
157
  cos_sim_flat = cos_sim.flatten()
158
  sorted_indices = torch.argsort(cos_sim_flat, descending=True)[:top_k]
 
175
  ) -> tuple:
176
  """
177
  Rerank top samples using the vision-language reranker model.
178
+
179
  Returns:
180
  Tuple of (dataset_samples_to_rerank, rerank_sorted_indices)
181
  """
 
216
  model = qwen_model
217
  if processor is None:
218
  processor = qwen_processor
219
+
220
  recipes_combined = ""
221
  for i, recipe in enumerate(recipe_texts[:3], 1):
222
  recipes_combined += f"\n\n--- RECIPE {i} ---\n{recipe}"
223
+
224
  prompt = f"""You are a helpful culinary assistant. Below are {len(recipe_texts[:3])} recipes.
225
  Please provide a brief markdown summary with:
226
  - A short 1-2 sentence overview of each recipe
 
255
  ## Summary:"""
256
 
257
  messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
258
+
259
  inputs = processor.apply_chat_template(
260
  messages,
261
  tokenize=True,
 
264
  return_tensors="pt"
265
  )
266
  inputs = inputs.to(model.device)
267
+
268
  with torch.no_grad():
269
  generated_ids = model.generate(
270
  **inputs,
 
273
  temperature=0.7,
274
  top_p=0.9
275
  )
276
+
277
  generated_ids_trimmed = [
278
  out_ids[len(in_ids):]
279
  for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
280
  ]
281
+
282
  output_text = processor.batch_decode(
283
  generated_ids_trimmed,
284
  skip_special_tokens=True,
285
  clean_up_tokenization_spaces=False
286
  )[0]
287
+
288
  return output_text.strip()
289
 
290
 
291
  def _markdown_to_simple_html(markdown_text: str, max_reviews: int = 1) -> str:
292
  """Convert recipe markdown to a simple HTML card."""
293
  lines = markdown_text.strip().split('\n')
294
+
295
  title = ""
296
  description = ""
297
  recipe_id = ""
 
301
  steps = []
302
  tags = []
303
  reviews = []
304
+
305
  current_section = None
306
  in_ingredients = False
307
  in_steps = False
308
  in_reviews = False
309
  in_tags = False
310
  review_count = 0
311
+
312
  for line in lines:
313
  line = line.strip()
314
+
315
  if line.startswith('# ') and not title:
316
  title = line[2:].strip()
317
  continue
318
+
319
  if line.startswith('**ID:**'):
320
  recipe_id = line.replace('**ID:**', '').strip()
321
  continue
 
325
  if line.startswith('**Number of Ratings:**'):
326
  num_ratings = line.replace('**Number of Ratings:**', '').strip()
327
  continue
328
+
329
  if line.startswith('## '):
330
  section_name = line[3:].strip().lower()
331
  in_ingredients = section_name == 'ingredients'
 
334
  in_tags = section_name == 'tags'
335
  current_section = section_name
336
  continue
337
+
338
  if current_section == 'description' and line and not line.startswith('#'):
339
  description = line
340
  continue
341
+
342
  if in_ingredients and line.startswith('- '):
343
  ingredients.append(line[2:].strip())
344
  continue
345
+
346
  if in_steps and line and line[0].isdigit():
347
  step_text = line.split('. ', 1)[-1] if '. ' in line else line
348
  steps.append(step_text.strip())
349
  continue
350
+
351
  if in_tags and line.startswith('`'):
352
  tag_list = [t.strip().strip('`') for t in line.split(',')]
353
  tags.extend(tag_list)
354
  continue
355
+
356
  if in_reviews and line.startswith('> ') and review_count < max_reviews:
357
  reviews.append(line[2:].strip())
358
  review_count += 1
359
  continue
360
+
361
  html = f'''
362
  <div style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 4px; background: #fff; font-family: system-ui, -apple-system, sans-serif; font-size: 12px; height: 400px; overflow-y: auto; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
363
  <div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 8px;">{title}</div>
364
+
365
  <div style="display: flex; gap: 12px; font-size: 11px; color: #666; margin-bottom: 10px; flex-wrap: wrap;">
366
  {f'<span>⏱️ {cook_time}</span>' if cook_time else ''}
367
  {f'<span>⭐ {num_ratings} ratings</span>' if num_ratings else ''}
368
  {f'<span style="color: #999;">ID: {recipe_id}</span>' if recipe_id else ''}
369
  </div>
370
+
371
  <div style="color: #555; margin-bottom: 12px; font-style: italic; line-height: 1.4;">{description[:150]}{"..." if len(description) > 150 else ""}</div>
372
+
373
  <div style="margin-bottom: 12px;">
374
  <div style="font-weight: bold; font-size: 11px; color: #333; margin-bottom: 4px;">πŸ“ Ingredients</div>
375
  <div style="color: #444; line-height: 1.5;">{", ".join(ingredients[:8])}{"..." if len(ingredients) > 8 else ""}</div>
376
  </div>
377
+
378
  <div style="margin-bottom: 12px;">
379
  <div style="font-weight: bold; font-size: 11px; color: #333; margin-bottom: 4px;">πŸ‘¨β€πŸ³ Steps ({len(steps)} total)</div>
380
  <ol style="margin: 0; padding-left: 20px; color: #444; line-height: 1.5;">
 
383
  </ol>
384
  </div>
385
  '''
386
+
387
  if tags:
388
  display_tags = tags[:5]
389
  html += f'''
 
395
  </div>
396
  </div>
397
  '''
398
+
399
  if reviews:
400
  html += f'''
401
  <div style="border-top: 1px solid #eee; padding-top: 10px; margin-top: 10px;">
 
403
  <div style="color: #555; font-size: 11px; line-height: 1.4; background: #f9f9f9; padding: 8px; border-radius: 4px; font-style: italic;">"{reviews[0][:200]}{"..." if len(reviews[0]) > 200 else ""}"</div>
404
  </div>
405
  '''
406
+
407
  html += '</div>'
408
  return html
409
 
 
416
  ) -> str:
417
  """Generate combined HTML cards from scored recipe samples."""
418
  recipe_cards_html = []
419
+
420
  for item in scores_and_samples[:num_results]:
421
  sample = item["sample"]
422
  markdown_text = sample.get(text_key, "") or sample.get("markdown", "")
423
  card_html = _markdown_to_simple_html(markdown_text, max_reviews=max_reviews)
424
  recipe_cards_html.append(f'<div style="flex: 1; min-width: 0;">{card_html}</div>')
425
+
426
  combined_html = f'''
427
  <div style="margin-top: 16px;">
428
  <h3 style="font-family: system-ui, -apple-system, sans-serif; font-size: 16px; font-weight: 600; color: #333; margin-bottom: 12px;">Retrieved Texts</h3>
 
431
  </div>
432
  </div>
433
  '''
434
+
435
  return combined_html
436
 
437
 
 
448
  ):
449
  """
450
  Main retrieval function for the Gradio interface.
451
+
452
  Args:
453
  query_text: Text query input
454
  query_image: Image query input (PIL Image)
455
  rerank_option: "True" or "False" to enable reranking
456
  generate_summary_option: "True" or "False" to enable summary generation
457
+
458
  Returns:
459
  Tuple of (image_gallery, recipe_cards_html, summary, timing_dict)
460
  """
 
468
  input_query = query_image
469
  else:
470
  raise gr.Error("Please provide either a text query or an image query.")
471
+
472
  # === Retrieval ===
473
  start_time_query_to_embed_match = time.time()
474
  result_sorted_scores, result_sorted_indices = match_query_to_embeddings(
 
484
  {"score": round(score.item(), 4), "sample": sample}
485
  for score, sample in zip(result_sorted_scores, top_dataset_results_to_show)
486
  ]
487
+
488
  output_image_gallery = [
489
  (item["sample"]["image"], f'Score: {item["score"]}')
490
  for item in scores_and_samples[:3]
 
543
  recipe_texts = [item["sample"]["recipe_markdown"] for item in samples_and_rerank_changes[:3]]
544
  else:
545
  recipe_texts = [item["sample"]["recipe_markdown"] for item in scores_and_samples[:3]]
546
+
547
  summary = generate_recipe_summary(recipe_texts)
548
  summary = summary.replace("```markdown", "").replace("```", "")
549
  end_time_generation_output = time.time()
 
551
  else:
552
  generation_time = "Generation turned off"
553
  summary = "Generation turned off, no summary created"
554
+
555
  timing_dict = {
556
  "query_embed_and_match_time": round(end_time_query_to_embed_match - start_time_query_to_embed_match, 4),
557
  "rerank_time": rerank_time,
 
567
 
568
  with gr.Blocks(title="Multimodal RAG Demo") as demo:
569
  gr.Markdown("""# πŸ‘οΈπŸ“‘ Multimodal RAG Demo with Nemotron Embed VL and Rerank VL
570
+
571
  Input an image or text about food and get recipe images/text back.
572
 
573
  This is a scalable workflow that can lend itself to many use cases such as business document retrieval, technical manual look ups and more.
 
580
  * **Rerank model used:** https://huggingface.co/nvidia/llama-nemotron-rerank-vl-1b-v2
581
  * **Generation model used:** https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct (note: you could use a larger model such as [Nemotron v3](https://huggingface.co/collections/nvidia/nvidia-nemotron-v3), however, this will require more compute resources)
582
  """)
583
+
584
  with gr.Row():
585
  with gr.Column(scale=1):
586
  gr.Markdown("### Query Input")
 
610
  )
611
 
612
  search_btn = gr.Button("Search", variant="primary", size="lg")
613
+
614
  with gr.Column(scale=2):
615
  gr.Markdown("### Retrieved Results")
616
 
 
629
  )
630
 
631
  timing_output = gr.JSON(label="Timings")
632
+
633
  gr.Markdown("### Example Queries")
634
 
635
  gr.Examples(
 
651
  )
652
 
653
  if __name__ == "__main__":
654
+ demo.launch()
requirements.txt CHANGED
@@ -7,4 +7,4 @@ safetensors==0.7.0
7
  Pillow==12.0.0
8
  accelerate==1.12.0
9
  qwen-vl-utils==0.0.14
10
- spaces
 
7
  Pillow==12.0.0
8
  accelerate==1.12.0
9
  qwen-vl-utils==0.0.14
10
+ spaces