import hashlib import os import gradio as gr import numpy as np import pandas as pd import torch from sentence_transformers import SentenceTransformer MODEL_ID = "google/embeddinggemma-2" ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") # cpu-basic: float32. bfloat16 is for GPUs; float16 overflows this model. torch.set_num_threads(min(2, os.cpu_count() or 2)) model = SentenceTransformer( MODEL_ID, device="cpu", model_kwargs={"dtype": torch.float32}, config_kwargs={"audio_config": None}, ) model.max_seq_length = 2048 DIMS = ["768", "512", "256", "128"] SEARCH_TASKS = { "Web search": "SearchQuery", "Question answering": "QuestionAnswering", "Fact checking": "FactChecking", "Code search": "CodeRetrieval", } SAMPLES = [ ("car.jpg", "Green car"), ("cat.jpg", "Tabby cat"), ("dog.jpg", "Spotted dog"), ("bee.jpg", "Honey bee"), ("beach.jpg", "Sunny beach"), ("mountain.jpg", "Snowy mountain"), ("bike.jpg", "Bicycle gathering"), ] DEFAULT_DOCS = """Northern lights | The aurora appears when charged particles from the sun collide with gases in Earth's atmosphere. Ocean currents | Large-scale currents move heat around the planet and shape regional climates. Eiffel Tower | The iron tower was built in Paris for the 1889 World's Fair. Sleep | During sleep the brain strengthens memories formed during the day. Photosynthesis | Plants turn sunlight, water, and carbon dioxide into glucose and oxygen. Auroras | Las auroras se producen cuando el viento solar choca con la atmósfera terrestre. Bread | Il pane si prepara con farina, acqua, lievito e sale. Markets | The stock market closed higher on Friday after strong earnings reports.""" DEFAULT_SENTENCES = """The cat is sleeping on the sofa. Le chat dort sur le canapé. Die Katze schläft auf dem Sofa. A dog is barking in the garden. Quarterly revenue grew by 12 percent.""" def _unit(embeddings: torch.Tensor) -> np.ndarray: if embeddings.ndim == 1: embeddings = embeddings.unsqueeze(0) return torch.nn.functional.normalize(embeddings.float(), dim=-1).cpu().numpy() _IMAGE_CACHE: dict[tuple[str, int], np.ndarray] = {} def _file_key(image, dim: int) -> tuple[str, int] | None: if not isinstance(image, str) or not os.path.isfile(image): return None digest = hashlib.sha256() with open(image, "rb") as handle: digest.update(handle.read()) return digest.hexdigest(), int(dim) def _embed(inputs, prompt_name: str | None, dim: int) -> np.ndarray: embeddings = model.encode( inputs, prompt_name=prompt_name, batch_size=8, truncate_dim=int(dim), normalize_embeddings=True, convert_to_tensor=True, show_progress_bar=False, ) return _unit(embeddings) def _embed_images(images, dim: int) -> np.ndarray: """Embed images, reusing vectors for files we have already seen.""" rows: list[np.ndarray | None] = [None] * len(images) missing_images = [] missing_at = [] for index, image in enumerate(images): key = _file_key(image, dim) cached = _IMAGE_CACHE.get(key) if key is not None else None if cached is not None: rows[index] = cached else: missing_images.append(image) missing_at.append(index) if missing_images: fresh = _embed([{"image": image} for image in missing_images], None, dim) for row, index in enumerate(missing_at): rows[index] = fresh[row] key = _file_key(images[index], dim) if key is not None: _IMAGE_CACHE[key] = fresh[row] return np.stack(rows) def _parse_docs(raw: str) -> list[tuple[str, str]]: documents = [] for line in (raw or "").splitlines(): line = line.strip() if not line: continue if " | " in line: title, content = line.split(" | ", 1) title, content = title.strip() or "none", content.strip() else: title, content = "none", line if content: documents.append((title, content)) return documents def _iter_images(gallery): if not gallery: return for item in gallery: caption = None image = item if isinstance(item, (list, tuple)) and item: image = item[0] caption = item[1] if len(item) > 1 else None elif isinstance(item, dict): image = item.get("image", item.get("path", item.get("name"))) caption = item.get("caption") if isinstance(image, dict): caption = caption or image.get("orig_name") image = image.get("path") or image.get("url") if image is None: continue if isinstance(image, str): label = caption or os.path.splitext(os.path.basename(image))[0] else: label = caption or "image" yield image, str(label) def _sample_gallery(): return [(os.path.join(ASSETS, name), caption) for name, caption in SAMPLES] print("Caching sample image embeddings...", flush=True) _embed_images([os.path.join(ASSETS, name) for name, _caption in SAMPLES], 768) print(f"Cached {len(_IMAGE_CACHE)} sample image embeddings", flush=True) def search(query: str, docs_text: str, task: str, dim: int): """Rank documents against a text query with EmbeddingGemma 2. The query receives the selected task prefix. Each document is formatted as `title: … | text: …`. Vectors are Matryoshka-truncated and L2-normalized, then scored with cosine similarity. """ query = (query or "").strip() documents = _parse_docs(docs_text) if not query: raise gr.Error("Enter a query.") if not documents: raise gr.Error("Add at least one document, one per line.") if len(documents) > 32: raise gr.Error("Use at most 32 documents.") prompt_name = SEARCH_TASKS[task] query_vec = _embed([query], prompt_name, dim)[0] doc_inputs = [f"title: {title} | text: {content}" for title, content in documents] doc_vecs = _embed(doc_inputs, None, dim) scores = doc_vecs @ query_vec order = np.argsort(-scores) rows = [] for rank, index in enumerate(order, start=1): title, content = documents[int(index)] rows.append([rank, title, content, round(float(scores[index]), 4)]) note = ( f"{len(documents)} documents · {int(dim)}-d · " f"query prefix `{model.prompts[prompt_name].strip()}`" ) return rows, note def search_images(query: str, gallery, dim: int): """Rank images against a text query in EmbeddingGemma 2's shared vector space. The query uses the web-search prefix. Images are embedded with no text prefix. Both sides are truncated to the same dimension and L2-normalized before cosine scoring. """ query = (query or "").strip() images = list(_iter_images(gallery)) if not query: raise gr.Error("Enter a query.") if not images: raise gr.Error("Add at least one image.") if len(images) > 12: raise gr.Error("Use at most 12 images.") query_vec = _embed([query], "SearchQuery", dim)[0] image_vecs = _embed_images([image for image, _label in images], dim) scores = image_vecs @ query_vec order = np.argsort(-scores) ranked_gallery = [] rows = [] for rank, index in enumerate(order, start=1): image, label = images[int(index)] score = float(scores[index]) ranked_gallery.append((image, f"{score:.3f} · {label}")) rows.append([rank, label, round(score, 4)]) note = f"{len(images)} images · {int(dim)}-d · query prefix `{model.prompts['SearchQuery'].strip()}`" return ranked_gallery, rows, note def compare(text: str, dim: int): """Score pairwise cosine similarity for one sentence per line. Every sentence uses the sentence-similarity task prefix. Vectors are Matryoshka-truncated and L2-normalized. """ sentences = [line.strip() for line in (text or "").splitlines() if line.strip()] if len(sentences) < 2: raise gr.Error("Enter at least two sentences, one per line.") if len(sentences) > 16: raise gr.Error("Use at most 16 sentences.") vectors = _embed(sentences, "SentenceSimilarity", dim) similarity = np.round((vectors @ vectors.T).astype(np.float64), 3) headers = [f"#{i + 1}" for i in range(len(sentences))] frame = pd.DataFrame(similarity, columns=headers) frame.insert(0, "Sentence", [f"#{i + 1} {sentence}" for i, sentence in enumerate(sentences)]) note = f"{len(sentences)} sentences · {int(dim)}-d · `{model.prompts['SentenceSimilarity'].strip()}`" return frame, note CSS = """ .gradio-container { max-width: 1100px !important; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ with gr.Blocks(title="EmbeddingGemma 2") as demo: gr.Markdown( """# EmbeddingGemma 2 Search text and images in one vector space with [google/embeddinggemma-2](https://huggingface.co/google/embeddinggemma-2). Task prefixes steer text. Images share the same 768-d space, and vectors can be shortened to 512, 256, or 128 dimensions. """ ) with gr.Tabs(): with gr.Tab("Documents"): with gr.Row(): with gr.Column(scale=5): query = gr.Textbox( label="Query", value="What causes the northern lights?", lines=2, ) documents = gr.Textbox( label="Documents", info="One per line. Optional title: `Title | text`.", value=DEFAULT_DOCS, lines=12, ) with gr.Accordion("Settings", open=False): task = gr.Dropdown( choices=list(SEARCH_TASKS), value="Web search", label="Task", ) dim = gr.Radio(choices=DIMS, value="768", label="Dimensions") search_btn = gr.Button("Search", variant="primary") with gr.Column(scale=6): doc_note = gr.Markdown() doc_results = gr.Dataframe( headers=["Rank", "Title", "Document", "Cosine"], label="Closest documents", interactive=False, wrap=True, ) doc_examples = [ ["What causes the northern lights?", DEFAULT_DOCS, "Web search", "768"], ["When was the Eiffel Tower built?", DEFAULT_DOCS, "Question answering", "768"], ["Solar wind creates auroras.", DEFAULT_DOCS, "Fact checking", "256"], ] gr.Examples( examples=doc_examples, inputs=[query, documents, task, dim], outputs=[doc_results, doc_note], fn=search, cache_examples=True, cache_mode="lazy", label="Examples", ) search_btn.click(search, [query, documents, task, dim], [doc_results, doc_note], api_name="search") query.submit(search, [query, documents, task, dim], [doc_results, doc_note]) with gr.Tab("Images"): with gr.Row(): with gr.Column(scale=5): image_query = gr.Textbox( label="Query", value="a green car parked by a yellow wall", lines=2, ) gallery = gr.Gallery( value=_sample_gallery(), label="Image collection", type="filepath", columns=4, height=360, interactive=True, ) image_dim = gr.Radio(choices=DIMS, value="768", label="Dimensions") image_btn = gr.Button("Rank images", variant="primary") with gr.Column(scale=6): image_note = gr.Markdown() ranked = gr.Gallery(label="Ranked images", columns=4, height=360, interactive=False) image_table = gr.Dataframe( headers=["Rank", "Image", "Cosine"], label="Scores", interactive=False, ) gr.Examples( examples=[ ["a green car parked by a yellow wall"], ["a tabby cat"], ["a honey bee"], ["a sandy beach with umbrellas"], ["people with bicycles"], ], inputs=[image_query], label="Try a query", ) image_btn.click( search_images, [image_query, gallery, image_dim], [ranked, image_table, image_note], api_name="search_images", ) image_query.submit( search_images, [image_query, gallery, image_dim], [ranked, image_table, image_note], ) with gr.Tab("Similarity"): with gr.Row(): with gr.Column(): sentences = gr.Textbox( label="Sentences", info="One per line. The same prefix is applied to every line.", value=DEFAULT_SENTENCES, lines=10, ) sim_dim = gr.Radio(choices=DIMS, value="768", label="Dimensions") sim_btn = gr.Button("Compare", variant="primary") with gr.Column(): sim_note = gr.Markdown() sim_table = gr.Dataframe(label="Cosine similarity", interactive=False, wrap=True) gr.Examples( examples=[ [DEFAULT_SENTENCES, "768"], [ "I love this phone, the battery lasts forever.\nThe screen cracked after one day.\nCe téléphone est génial.\nPantalla rota, muy decepcionado.", "512", ], ], inputs=[sentences, sim_dim], outputs=[sim_table, sim_note], fn=compare, cache_examples=True, cache_mode="lazy", label="Examples", ) sim_btn.click(compare, [sentences, sim_dim], [sim_table, sim_note], api_name="compare") gr.Markdown( "Loads the text and vision encoders from " f"[{MODEL_ID}](https://huggingface.co/{MODEL_ID}) in float32. " "Images are embedded without a task prefix. Audio and video are supported by the model and omitted here." ) if __name__ == "__main__": demo.launch( mcp_server=True, theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"), css=CSS, )