uploading app_local.py for running locally (no @spaces.GPU)
Browse files- app.py +43 -43
- 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
|