Search Photos with EmbeddingGemma

Build local photo search with EmbeddingGemma and compare vector sizes.

Introduction

30 Second Summary

Photo libraries grow faster than anyone can label them. Finding one exact memory often means scrolling through hundreds of images.

In this project, you will build a private photo search tool with Python. EmbeddingGemma 2 will match your descriptions to photos while every image stays on your laptop.

What You'll Build

Your results page places the closest photos for a description like "ocean waves at sunset" at the top with clear similarity scores.

By the end of this project, you'll have:

  • A private photo search that returns the five closest images for any description you enter.
  • A reusable local photo index that stores your image embeddings on your laptop.
  • A side-by-side quality comparison that shows how smaller vectors reduce storage while changing the ranked results.
  • Secret Mission: An optional challenge to push your skills further.

Are there any prerequisites?

This project requires Python on your laptop. A folder of about 50 personal photos gives your searches enough variety to produce useful rankings.

Before We Start

Private photo search depends on three local ingredients. You need a photo collection, enough storage for the model, plus a working Python environment.

This preparation gives EmbeddingGemma 2 a private workspace for producing embeddings. Your photos stay on your computer throughout the project.

In this step, get ready to:
  • Confirm Python 3 is available from a terminal.
  • Create a local project folder with a photos/ folder inside it.
  • Prepare approximately 50 personal photos with enough local storage for the model.
Confirm Python 3 is ready

Python runs every indexing task in this project. A version check confirms that your terminal can find the interpreter before you install any packages.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Terminal on macOS or Windows Terminal on Windows.
  • Press Enter to open the terminal.
  • Check whether Python 3 is available by running this command:
python3 --version

What should I see?

The version option prints the installed Python version. A result beginning with Python 3 confirms that the interpreter is ready.

✔️ I see Python 3

Python 3 is available from your terminal. That clears the first requirement for your local photo search.

ⓧ Python 3 isn't available

You need to install Python 3 before the project can run. Your computer may request permission during the installation.

  • Visit the official Python download page.
  • Download the Python 3 installer for your operating system.
  • Run the downloaded installer.
  • Complete the installation using its default options.
  • Close your current terminal window.
  • Use the same system search steps to reopen the terminal.
  • Confirm the installation by running this command:
python3 --version

What should I see?

You should now see a result beginning with Python 3. This proves the new installation is available from your terminal.

Python 3 still isn't available?

Close every open terminal window before trying again. An existing terminal may not detect a newly installed program.

If the command still fails, ask for help checking your Python installation.

Create the local folders

A dedicated project folder keeps the scripts, indexes, plus results together. The photos/ folder becomes the local collection that the model searches.

  • Set your project folder name to offline-photo-search.

macOS

  • Create the project folder on your Desktop with its photos/ folder by running these commands:
cd ~/Desktop
mkdir [[PROJECT_FOLDER="offline-photo-search"]]
cd [[PROJECT_FOLDER="offline-photo-search"]]
mkdir photos
ls

What do these commands do?

  • The first command moves the terminal to your Desktop.
  • The next two commands create your project folder and move the terminal inside it.
  • The final commands create photos/ and list the folder contents.

Folder already exists?

Choose a different value for offline-photo-search if another folder already uses that name.

If the terminal cannot create the folders, ask for help with the macOS folder commands.

Windows

  • Create the project folder on your Desktop with its photos/ folder by running these commands in PowerShell:
Set-Location -Path ([Environment]::GetFolderPath('Desktop'))
New-Item -Path '[[PROJECT_FOLDER="offline-photo-search"]]' -ItemType Directory
Set-Location -Path '[[PROJECT_FOLDER="offline-photo-search"]]'
New-Item -Path 'photos' -ItemType Directory
Get-ChildItem

What do these commands do?

  • The first command moves PowerShell to your Desktop.
  • The next two commands create your project folder and move PowerShell inside it.
  • The final commands create photos/ and list the folder contents.

Folder already exists?

Choose a different value for offline-photo-search if another folder already uses that name.

If PowerShell cannot create the folders, ask for help with the Windows folder commands.

Your workspace is ready. The terminal now points to the project folder that holds photos/.

Add your photos and check storage

Your photo set stays inside photos/. The project never uploads these files.

The main model weight file uses 1.49 GB of storage. You also need extra room for configuration files plus the indexes you create.

macOS

  • Click the Apple menu in the top-left corner.
  • Click System Settings.
  • Select General in the sidebar.
  • Select Storage.
  • Confirm the available storage is greater than the 1.49 GB model weight file.

Windows

  • Press Windows+I to open Settings.
  • Select System.
  • Select Storage.
  • Confirm the available storage is greater than the 1.49 GB model weight file.

Keep Some Extra Space

Package downloads need additional storage beyond the model weight file. Clear unused downloads before continuing if your drive is close to full.

macOS

  • Click the Finder icon in the Dock.
  • Navigate to the folder containing your personal photos.
  • Select approximately 50 image files.
  • Press Cmd+C to copy the selected files.
  • Select Desktop in the Finder sidebar.
  • Open offline-photo-search.
  • Open photos/.
  • Press Cmd+V to paste the copied photos.

Windows

  • Select File Explorer from the taskbar.
  • Navigate to the folder containing your personal photos.
  • Select approximately 50 image files.
  • Press Ctrl+C to copy the selected files.
  • Select Desktop in the File Explorer sidebar.
  • Open offline-photo-search.
  • Open photos/.
  • Press Ctrl+V to paste the copied photos.

Before you check the folder, what do you expect the terminal to list inside photos/?

macOS

  • Switch back to the terminal from earlier.
  • List the local photo collection by running this command:
ls photos

What should I see?

You should see the filenames of your copied photos. A populated list confirms that the files are inside the project workspace.

Is the list empty?

Confirm that Finder finished copying the files into photos/.

If the command cannot find the folder, ask for help locating your project directory.

Windows

  • Switch back to the PowerShell terminal from earlier.
  • List the local photo collection by running this command:
Get-ChildItem -Path photos

What should I see?

You should see the filenames of your copied photos. A populated list confirms that the files are inside the project workspace.

Is the list empty?

Confirm that File Explorer finished copying the files into photos/.

If PowerShell cannot find the folder, ask for help locating your project directory.

That is the setup complete. Python can run locally, your workspace is open, plus approximately 50 photos are ready to index.

Your private photo library is ready. Next, you will install the model tools and print your first 768-number embedding.

Install and Test EmbeddingGemma

Your photos are ready for local search. A sentence and an image still begin as different kinds of data.

In this step, you will use EmbeddingGemma 2 to turn a test sentence into an embedding. The first load stores the model files in your local Hugging Face cache.

In this step, get ready to:
  • Install the sentence-transformers package with its local dependencies.
  • Download EmbeddingGemma 2 into your local model cache.
  • Encode a test sentence as a 768-number vector.
Install the Python dependencies

The Sentence Transformers library provides the interface that loads the model. EmbeddingGemma 2 requires version 6.1.0 or later.

  • Check whether the package already exists in your Python environment by running:
pip show sentence-transformers

What does this command check?

The pip show command displays information about an installed Python package. Its output includes the installed version.

✔️ I see the required version

Your installed version is 6.1.0 or higher. The library is ready to load EmbeddingGemma 2.

ⓧ I see an older version

Your environment contains the package, but the installed release is below 6.1.0. The upgrade command replaces it with a compatible release.

  • Upgrade the package with its media dependencies by running:
pip install -U sentence-transformers[image,audio,video] transformers

What does this command install?

  • The -U option upgrades existing packages to compatible current releases.
  • The media extras add the local dependencies used for images, audio, and video.
  • Wait for the package installation to finish.

The terminal returns to its prompt when the upgraded environment is ready.

ⓧ The package is missing

The package needs to be added to your current Python environment. The first installation can take a while because it also downloads local machine learning dependencies.

  • Install the package with its media dependencies by running:
pip install -U sentence-transformers[image,audio,video] transformers

What does this command install?

  • The sentence-transformers package provides the model loading interface.
  • The media extras add the local dependencies used for images, audio, and video.
  • Wait for the package installation to finish.

The terminal returns to its prompt when the Python environment is ready.

Package installation failing?

  • Confirm that your terminal has an active internet connection.
  • Check that pip belongs to the Python environment you plan to use.
  • Run the installation command from your selected tab again after fixing the environment issue.

Help me fix the sentence-transformers installation.

Load EmbeddingGemma 2 locally

The model runs on your laptop after its files reach the local cache. Loading the full model gives Python access to text and image inputs through one shared vector space.

  • Start Python's interactive prompt by running this command:
python

What does this command do?

The command starts Python in interactive mode. Each Python statement runs as soon as you enter it.

The first model load is the slow part because Python downloads the model files. Later loads can reuse the files from your local cache.

  • Load the full EmbeddingGemma 2 model by pasting this code into the interactive prompt:
from sentence_transformers import SentenceTransformer

# Full model: all modalities (740M parameters)
MODEL_ID = "google/embeddinggemma-2"
model = SentenceTransformer(MODEL_ID)

What does this code do?

  • The SentenceTransformer import provides the model loading interface.
  • The MODEL_ID value identifies the EmbeddingGemma 2 model.
  • The model object holds the loaded encoders used for local embedding.
  • Wait for the interactive prompt to return after the download finishes.

The returned prompt confirms that EmbeddingGemma 2 loaded without an exception. Its files now live in your local Hugging Face cache.

Model download or load failing?

  • Confirm that the model download still has an active internet connection.
  • Close the interactive Python session if the package import fails.
  • Start a fresh session after confirming the installation completed in the same Python environment.

Help me diagnose why EmbeddingGemma 2 will not download or load locally.

Encode and inspect a test sentence

A text embedding represents meaning as a list of numbers. EmbeddingGemma 2 places each result in a 768-dimensional space so later photo vectors can be compared with it.

Before you run the test, what length do you expect the resulting vector to have?

  • Encode the test sentence and inspect the result by pasting:
# Embed using `prompt_name`
query_emb = model.encode("ocean waves at sunset", prompt_name="SearchQuery")

query_emb
query_emb.shape

What does this output represent?

  • The SearchQuery prompt prepares the sentence for retrieval tasks.
  • The query_emb value contains the numeric representation of the sentence.
  • The shape result reports the vector's single dimension.

You'll see rows of decimal numbers followed by a shape containing 768 as its only dimension. That confirms your sentence became a 768-number embedding.

That is your first local search signal working. EmbeddingGemma 2 can now translate a description into numbers without uploading your photos.

Not seeing a 768-value embedding?

  • Confirm that the model loading code completed before you encoded the sentence.
  • Check that SearchQuery uses the same capitalization shown in the test code.
  • Scroll through wrapped terminal lines if the vector fills more than one screen.

Help me understand why my EmbeddingGemma 2 test is not producing a 768-value embedding.

Your local model now turns text into a 768-number embedding. Next, you will use the same vector space with the photos already waiting in photos/.

Index Your Photo Folder

Your first test proved that EmbeddingGemma 2 can turn language into a 768-number vector. Photo search needs a matching vector for every image in photos/.

This step builds a local index from those photos. Every image embedding stays paired with its source path.

All processing stays on your laptop. These files give the next query step a searchable foundation.

In this step, get ready to:
  • Discover the supported photo files inside photos/.
  • Create one 768-dimensional image embedding for each discovered photo.
  • Save the vector index with its ordered photo path map.
List the photos to index

A stable index depends on a stable photo order. Sorting the paths keeps each future result connected to the correct image.

  • Create a new file named photo_search.py inside the open project folder using your editor's new-file control.
  • Paste the photo discovery code below into photo_search.py.
from pathlib import Path
import json
import numpy as np
from sentence_transformers import SentenceTransformer

# Keep the model and index locations consistent across each search step
MODEL_ID = "google/embeddinggemma-2"
PHOTO_DIR = Path("photos")
EMBEDDINGS_FILE = Path("photo_embeddings_768.npy")
PATHS_FILE = Path("photo_paths.json")
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}

# Sort paths so every embedding row has a repeatable source position
photo_paths = sorted(
    path
    for path in PHOTO_DIR.iterdir()
    if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
)

# Stop before indexing when the folder has no usable image files
if not photo_paths:
    raise FileNotFoundError("No supported photos found in photos/.")

print(f"Found {len(photo_paths)} supported photos.")

What does this code do?

  • The file constants give the script fixed locations for the photo folder and saved index files.
  • The supported extension set limits the scan to common image formats.
  • The sorted path list gives every photo a repeatable position.
  • The empty-folder check stops the script before it tries to create an index with no rows.
  • Save photo_search.py.
  • Check the folder scan by running this command in the terminal from the open project folder:
python photo_search.py

What should I see?

The command runs photo_search.py against the local photos/ folder. You should see a message reporting roughly 50 supported photos.

Seeing the count confirms that the script can reach your photo folder. It also confirms that the extension filter recognizes your files.

No photos found?

  • Confirm that photos/ sits directly inside the open project folder.
  • Move at least one supported image directly into photos/ if your images are inside nested folders.
  • Check that your image names end in .jpg, .jpeg, .png, or .webp.

Help me fix the photo discovery step.

Build the 768-dimensional index

The model turns each photo into one 768-number row. The ordered path file records which image belongs to that row.

  • Place your cursor on a new line after print(f"Found {len(photo_paths)} supported photos.") in photo_search.py.
  • Add the embedding and save logic by pasting the code below:
# Load vision support while leaving the unused audio encoder disabled
model = SentenceTransformer(MODEL_ID, config_kwargs={"audio_config": None})

# Print progress so each locally processed photo remains visible
vectors = []
for position, photo_path in enumerate(photo_paths, start=1):
    print(f"Embedding {position}/{len(photo_paths)}: {photo_path}")
    vectors.append(model.encode({"image": str(photo_path)}))

# Keep one full 768-dimensional row for every photo path
embeddings = np.vstack(vectors)
if embeddings.shape[1] != 768:
    raise ValueError(f"Expected 768 dimensions, got {embeddings.shape[1]}.")

# Save the vectors and their matching path order as separate local files
np.save(EMBEDDINGS_FILE, embeddings)
PATHS_FILE.write_text(
    json.dumps([str(path) for path in photo_paths], indent=2),
    encoding="utf-8",
)

index_size_kb = EMBEDDINGS_FILE.stat().st_size / 1024
print()
print(f"Indexed {len(photo_paths)} photos.")
print(f"Embedding shape: {embeddings.shape}")
print(f"Saved {EMBEDDINGS_FILE} ({index_size_kb:.1f} KB)")
print(f"Saved {PATHS_FILE}")

How does the index stay connected?

  • The model loads from the local cache with its image encoder available.
  • The audio configuration stays disabled because this index only processes photos.
  • The loop prints progress before encoding each local image.
  • The np.vstack() call combines the individual vectors into one array.
  • The shape check protects the full 768-dimensional format used by this index.
  • The two save operations preserve the vectors and their matching path order.
  • Save photo_search.py.

Processing about 50 photos can take a while on a CPU. The progress lines show which local image the model is handling.

Before you run the indexer, what final array shape do you expect for your photo collection?

  • Create the local photo index by running this command:
python photo_search.py

What should I see?

You should see one progress line for every supported photo. The final shape has one row per indexed image and 768 columns.

The last lines show photo_embeddings_768.npy with its size in kilobytes. You should also see confirmation that photo_paths.json was saved.

Strong work. Your photo folder is now a searchable 768-dimensional local index with every row tied to its original file.

Indexing stopped before saving?

  • Check the last printed photo path to identify the image being processed when the script stopped.
  • Remove that file from photos/ if your image viewer also fails to open it.
  • Confirm that the terminal remains inside the project folder containing photo_search.py and photos/.

Help me diagnose the local indexing failure.

  • Compare your finished photo_search.py with the full reference if you want a line-by-line check.

✔️ Awesome, I've got everything!

Your script discovers supported photos, creates full image embeddings, preserves their ordered paths, and reports the saved index size.

ⓧ I'd like to double check the full code

from pathlib import Path
import json
import numpy as np
from sentence_transformers import SentenceTransformer

# Keep the model and index locations consistent across each search step
MODEL_ID = "google/embeddinggemma-2"
PHOTO_DIR = Path("photos")
EMBEDDINGS_FILE = Path("photo_embeddings_768.npy")
PATHS_FILE = Path("photo_paths.json")
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}

# Sort paths so every embedding row has a repeatable source position
photo_paths = sorted(
    path
    for path in PHOTO_DIR.iterdir()
    if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
)

# Stop before indexing when the folder has no usable image files
if not photo_paths:
    raise FileNotFoundError("No supported photos found in photos/.")

print(f"Found {len(photo_paths)} supported photos.")

# Load vision support while leaving the unused audio encoder disabled
model = SentenceTransformer(MODEL_ID, config_kwargs={"audio_config": None})

# Print progress so each locally processed photo remains visible
vectors = []
for position, photo_path in enumerate(photo_paths, start=1):
    print(f"Embedding {position}/{len(photo_paths)}: {photo_path}")
    vectors.append(model.encode({"image": str(photo_path)}))

# Keep one full 768-dimensional row for every photo path
embeddings = np.vstack(vectors)
if embeddings.shape[1] != 768:
    raise ValueError(f"Expected 768 dimensions, got {embeddings.shape[1]}.")

# Save the vectors and their matching path order as separate local files
np.save(EMBEDDINGS_FILE, embeddings)
PATHS_FILE.write_text(
    json.dumps([str(path) for path in photo_paths], indent=2),
    encoding="utf-8",
)

index_size_kb = EMBEDDINGS_FILE.stat().st_size / 1024
print()
print(f"Indexed {len(photo_paths)} photos.")
print(f"Embedding shape: {embeddings.shape}")
print(f"Saved {EMBEDDINGS_FILE} ({index_size_kb:.1f} KB)")
print(f"Saved {PATHS_FILE}")

Your local index now connects every photo to a searchable vector. Next, you will turn a text description into a ranked HTML results page.

Search Photos with Text

Your saved vector index already represents roughly 50 local photos as 768-dimensional embeddings. Each row still points to its original photo file.

A text search works when EmbeddingGemma 2 places the query in the same vector space as every photo. This step turns those local comparisons into an HTML results page with five ranked matches.

In this step, get ready to:
  • Encode ocean waves at sunset as a search query.
  • Rank the saved photos by similarity score.
  • Render the five strongest matches in a local results page.
Rank photos for a text query

Semantic similarity measures how closely two embeddings represent the same meaning. The model's search-query prompt keeps your text embedding aligned with the saved image embeddings.

How the ranking flow works

  • The script loads the saved photo vectors from photo_embeddings_768.npy.
  • The script restores each vector's source path from photo_paths.json.
  • The model.encode method converts ocean waves at sunset into a query vector.
  • The script passes SearchQuery through prompt_name so the model treats the text as a search query.
  • The model.similarity method compares the query vector with every saved photo vector.
  • The script sorts the similarity scores from highest to lowest.
  • The script retains the five highest-scoring photo records.
  • The terminal prints the five ranked records.
  • Return to photo_search.py in the editor from earlier.
  • Add the ranking flow after the existing index-size report.
  • Save photo_search.py.
  • Return to the terminal from earlier.
  • Run photo_search.py with the same Python command you used in the previous step.

You should see five ranked records in descending score order. Each record should show a path from photos/ with its similarity score.

That is your first working semantic photo search. Your sentence now retrieves images from a private index on your laptop.

Do the rankings look wrong?

  • Confirm that photo_paths.json preserves the same row order as photo_embeddings_768.npy.
  • Check that SearchQuery is passed through prompt_name when the query is encoded.
  • Check that the query embedding remains 768-dimensional before similarity is calculated.
  • Confirm that the scores are sorted from highest to lowest before five records are selected.

Help me fix my text-to-photo ranking.

Generate the HTML results page

A local results page gives you visual proof that the ranking matches your query. The browser reads the indexed photo paths directly from your laptop.

What the page needs

  • The page heading displays the query ocean waves at sunset.
  • The page contains exactly five result cards in ranking order.
  • Each result card displays its rank.
  • Each result card displays its similarity score.
  • Each result card displays the local photo as a thumbnail.
  • The script writes the completed page to text_search_results.html.
  • The terminal confirms that the results page was written.
  • Return to photo_search.py in the editor from earlier.
  • Extend the existing ranking flow so it writes the page described above.
  • Save photo_search.py.

Before you run the script again, picture which photos you expect to score highest for ocean waves at sunset.

  • Return to the terminal from earlier.
  • Run photo_search.py again with the same Python command.

You should see confirmation that text_search_results.html was written. The file should appear beside photo_search.py in your project folder.

  • Locate text_search_results.html in the project folder with Finder on macOS or File Explorer on Windows.
  • Double-click text_search_results.html to open it in your browser.

Your browser should show ocean waves at sunset at the top. You should see exactly five ranked photo matches with similarity scores.

You now have a visual search result you can inspect or show to someone. No photo leaves your laptop during the search.

Missing photos on the page?

  • Confirm that each image source uses the corresponding path from photo_paths.json.
  • Check that the referenced photo still exists inside photos/.
  • Rerun photo_search.py if the page still shows results from an earlier query.
  • Refresh the browser after the script rewrites text_search_results.html.

Help me fix missing thumbnails or incorrect scores on my local results page.

✔️ Awesome, I've got everything!

Great. Your cumulative photo_search.py can rank a text query against the saved 768-dimensional index. It also writes the five strongest matches to text_search_results.html.

ⓧ I'd like to double check the full flow

Compare your cumulative script against this artifact checklist. Every item should be present before you continue.

  • The original local photo-indexing flow remains in photo_search.py.
  • The full 768-dimensional index remains saved in photo_embeddings_768.npy.
  • The ordered photo mapping remains saved in photo_paths.json.
  • The search query is set to ocean waves at sunset.
  • The query is encoded with model.encode.
  • The query encoding uses SearchQuery through prompt_name.
  • Similarity scores are calculated with model.similarity.
  • The five highest-scoring photo records are selected.
  • The output is written to text_search_results.html.
  • The page shows exactly five ranked photo matches.
  • Every match shows its rank.
  • Every match shows its similarity score.
  • Every match shows its local photo thumbnail.

Your private text-to-photo search now works from query to browser page. Next, you will shrink the vectors to 256 dimensions and 128 dimensions so you can compare storage savings against search quality.

Compare Smaller Embeddings

Your full-size photo search now turns a sentence into five ranked matches. That result gives you a baseline for judging every smaller index.

Each photo is represented by an embedding with 768 values. EmbeddingGemma 2 lets you keep the leading values to reduce storage. This step tests how that reduction changes your search results.

In this step, get ready to:
  • Create 256-dimensional and 128-dimensional indexes from the existing 768-dimensional index.
  • Match each text query vector to the corresponding index dimension before ranking.
  • Build an HTML comparison page showing the rankings plus file sizes.
Create the truncated indexes

Truncation starts from the original index that already represents every photo. Keeping that full index untouched gives you a consistent baseline for the comparison.

  • Return to photo_search.py from earlier.
  • Find the section that loads photo_embeddings_768.npy before the existing text similarity search.
  • Add one truncation loop that retains the first 256 or 128 columns from every row of the full array.
  • Save the smaller arrays as photo_embeddings_256.npy plus photo_embeddings_128.npy.
  • Save photo_search.py.
  • Rerun the script using the same method you used in the previous step.
  • Check the project folder for the two new vector files.

Good progress. You should now see photo_embeddings_256.npy plus photo_embeddings_128.npy beside the original photo_embeddings_768.npy file.

Why Keep the Leading Dimensions?

Embedding truncation retains the first values from every vector. The image rows stay aligned with the ordered paths in photo_paths.json.

Separate index files make the experiment reversible. Your original 768-dimensional baseline remains available for every comparison.

Missing a Smaller Index?

  • Confirm the truncation starts from the loaded photo_embeddings_768.npy array.
  • Check that the slice keeps every photo row.
  • Make sure the two new files are saved in the same folder as photo_search.py.

Why did my truncated embedding files fail to appear? The prompt focuses on the slicing logic plus save locations.

Compare the same text query

A similarity calculation compares corresponding positions from two vectors. The text query must therefore use the same number of dimensions as the photo index for each pass.

  • Return to the existing code that encodes ocean waves at sunset as the text query.
  • Refactor the existing text-search block into a dimension-aware loop covering matching index selection, query truncation, similarity ranking, top-five retention, plus index-size recording.
  • Keep the original query text identical across all three search passes.
  • Save photo_search.py.

Before you rerun the script, predict whether the same photo will rank first at every dimension. The next result tests that prediction.

  • Rerun the script using the same method as before.

The terminal should show separate result groups for 768, 256, plus 128 dimensions. Each group should contain five matches for the same query.

You should also see a recorded size for each vector file. The two truncated files should be smaller than photo_embeddings_768.npy.

Why Must the Dimensions Match?

Similarity compares one value from the query with the corresponding value from a photo vector. Both vectors need the same length for that comparison.

Each search pass uses matching slices from both sides. This keeps the comparison valid while the amount of stored data changes.

Seeing a Dimension Mismatch?

  • Confirm the query slice happens before the similarity calculation.
  • Check that each search pass loads the index file with the same dimension.
  • Verify that every pass still keeps exactly five ranked matches.

Why are my query vector and photo index incompatible? The prompt focuses on matching the vector lengths.

Generate the comparison page

Terminal output proves the searches ran. A shared page makes ranking changes easier to compare because every result set appears in one place.

  • Find the existing HTML generation section that writes text_search_results.html.
  • Extend that section into a comparison writer for embedding_comparison.html with one result group for each dimension.
  • Include the dimension label in each result group.
  • Include the corresponding vector filename plus its recorded size in each result group.
  • Include each photo thumbnail or path with its rank plus similarity score.
  • Save photo_search.py.

Before you generate the final page, predict which smaller dimension will preserve your original ranking most closely. The page gives you the evidence.

  • Rerun the script using the same method as before.
  • Open embedding_comparison.html from the project folder using the same browser method you used earlier.

Your browser should show three side-by-side top-five result groups for ocean waves at sunset. Each group should identify its dimension plus vector file size.

Compare the top photo across the three groups. Any changed rank or score makes the search-quality tradeoff visible.

Comparison Page Missing a Result Group?

  • Confirm the script rewrites embedding_comparison.html during every run.
  • Reload the local page after rerunning the script.
  • Check the generated page for separate sections covering all three dimensions.

Why is my embedding comparison page incomplete or stale? The prompt focuses on generated page data plus local refresh behavior.

✔️ Awesome, I've got everything!

Strong work. Your script now turns one full photo index into a visible storage-versus-ranking comparison across three embedding sizes.

ⓧ I'd like to double check the full code

  • Confirm photo_embeddings_768.npy remains unchanged as the full baseline.
  • Confirm photo_embeddings_256.npy retains the first 256 dimensions from every original row.
  • Confirm photo_embeddings_128.npy retains the first 128 dimensions from every original row.
  • Confirm each text query vector is truncated to match its selected photo index.
  • Confirm every dimension produces five ranked matches.
  • Confirm the script records the sizes of all three vector files.
  • Confirm embedding_comparison.html displays all three result groups for the same query.

Your text search now exposes the storage and ranking tradeoff at a glance. Next up, you will use one of your own photos as the query.

Find Similar Photos by Image

Your side-by-side comparison proved that smaller vectors save storage. The full 768-dimensional index gives this final search its strongest baseline.

Now a photo becomes the query. EmbeddingGemma 2 places that image in the same embedding space as the indexed photos.

The source photo would rank first because it already exists in the index. This step filters that self-match before writing five useful neighbors to a local HTML page.

In this step, get ready to:
  • Choose one indexed photo as the visual query.
  • Compare its full 768-dimensional embedding with the saved photo index.
  • Generate a local page with five ranked matches plus similarity scores.
Choose an image query

A photo with a clear subject makes the final ranking easier to judge. Distinct colors also give the model useful visual details to compare.

  • Locate the exact relative path for one photo in photo_paths.json from earlier.
  • Record the selected path here: path to your selected photo.

Why remove the source photo?

The query photo already has a matching row in the saved index. That identical row would take the first position.

Removing the source path before ranking leaves five different photos for you to inspect.

Add the image query flow

The saved index contains one 768-dimensional vector for every processed photo. The image query needs the same dimension before similarity can be calculated.

How the image query works

  • The selected path identifies the source photo inside photos/.
  • The existing image-loading pattern prepares that photo for the model.
  • The model produces one full 768-dimensional query embedding.
  • The existing similarity calculation scores the query against every row in photo_embeddings_768.npy.
  • A source-path filter removes the query photo before the five highest scores are selected.
  • The existing page-writing pattern saves the ranked matches in image_search_results.html.
  • Switch back to photo_search.py from the previous step.
  • Add a new image-query section after the existing comparison-page generation using the six requirements above plus path to your selected photo as its source path.
  • Save photo_search.py.

Before you run the updated script, do you think the selected source photo could still appear in the top five?

  • Rerun photo_search.py with the terminal command you used in the earlier steps.

You should see image_search_results.html in the same project folder as your earlier results pages.

Image results page missing?

  • Check that the query path matches an entry in photo_paths.json exactly.
  • Confirm that the query embedding contains 768 dimensions.
  • Place the source-path filter before the top-five selection.
  • Confirm that the page writer targets image_search_results.html.

Help me debug my local image-to-image search.

Verify the visual ranking

The finished page turns the similarity scores into something you can judge with your eyes. Shared subjects or colors can explain why particular photos rank near the top.

Before you open the page, which visual details do you expect to appear in the highest-ranked matches?

  • Open image_search_results.html using the browser method you used for the earlier pages.
  • Count the five ranked photo matches.
  • Inspect the similarity score displayed with each match.
  • Confirm that path to your selected photo is absent from the ranked matches.

You should see five different photos ranked by visual similarity. Their exact order reflects the contents of your local collection.

You made it. Your laptop can now answer one photo with ranked visual neighbors without uploading the source image.

Secret mission

Audit Retrieval Quality Across Dimensions

Storage savings matter only when the search results remain useful. Audit the existing 768-dimensional results against both smaller indexes. Choose the smallest index that meets a clear quality threshold.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your offline photo search ready for more experiments, pause your workspace, or remove its generated files. Everything ran locally on your laptop with no cloud charges or ongoing service costs.

Resources you used:

  • Local Python search script: photo_search.py.
  • Full index files: photo_embeddings_768.npy and photo_paths.json.
  • Truncated index files: photo_embeddings_256.npy and photo_embeddings_128.npy.
  • Generated HTML result pages: text_search_results.html, embedding_comparison.html, and image_search_results.html.
  • Downloaded EmbeddingGemma 2 model files in your local Hugging Face cache.

Keep everything running

Keeping everything preserves your finished search tool for more queries or dimension experiments. No process needs to stay open because the script and indexes are stored locally.

  • Keep photo_search.py in the project folder.
  • Keep the three embedding arrays and photo_paths.json in the project folder.
  • Keep the three generated result pages for future comparisons.
  • Keep the downloaded model files in the local Hugging Face cache for faster future runs.
  • Preserve your personal source images in photos/.

Your offline search remains ready to run again without another model download or indexing pass.

There are no ongoing charges for keeping these local files.

Pause - I'll come back to this later

Pausing this local project closes the tools you are using while preserving every search artifact. This project has no cloud resource or background server that requires a pause control.

  • Wait for any current photo_search.py run to finish.
  • Close the browser tabs showing the three local result pages.
  • Close the terminal session for this project.
  • Leave the project files in place.
  • Leave the local Hugging Face cache in place.

You're set to return later with every index and result page available.

Your personal photos remain inside photos/.

Delete - I don't want to use this again

Local deletion is permanent. The steps below preserve the personal photos in photos/ while removing every generated resource listed above.

macOS

The macOS path removes only the generated search artifacts and downloaded model files.

  • In Finder, return to the project folder from earlier.
  • Select photo_search.py.
  • Add photo_embeddings_768.npy, photo_embeddings_256.npy, photo_embeddings_128.npy, and photo_paths.json to the selection.
  • Add text_search_results.html, embedding_comparison.html, and image_search_results.html to the selection.
  • Move the selected files to your Mac's trash.

The project folder should still contain photos/. None of the generated files listed above should remain.

  • Use Finder to locate the local Hugging Face cache that contains the downloaded EmbeddingGemma 2 files.
  • Select only the directory containing the downloaded EmbeddingGemma 2 model files.
  • Move the selected model directory to your Mac's trash.
  • Empty the trash to release the disk space immediately.

The Hugging Face cache should no longer contain the EmbeddingGemma 2 model files. Any other cached models remain untouched.

Windows

The Windows path removes only the generated search artifacts and downloaded model files.

  • In File Explorer, return to the project folder from earlier.
  • Select photo_search.py.
  • Add photo_embeddings_768.npy, photo_embeddings_256.npy, photo_embeddings_128.npy, and photo_paths.json to the selection.
  • Add text_search_results.html, embedding_comparison.html, and image_search_results.html to the selection.
  • Move the selected files to the recycle bin.

The project folder should still contain photos/. None of the generated files listed above should remain.

  • Use File Explorer to locate the local Hugging Face cache that contains the downloaded EmbeddingGemma 2 files.
  • Select only the directory containing the downloaded EmbeddingGemma 2 model files.
  • Move the selected model directory to the recycle bin.
  • Empty the recycle bin to release the disk space immediately.

The Hugging Face cache should no longer contain the EmbeddingGemma 2 model files. Any other cached models remain untouched.

Can't remove a local file?

If your system reports that a file is still in use, close any running Python process for photo_search.py. Retry the deletion after the process has stopped.

Help me identify what is still using my local photo-search files.

Nice Work!

Nice Work!

Nice work! You built an offline photo search workflow that ranks your own images without uploading them.

You've learned how to:

  • Use Python to create a local photo index with 768-dimensional EmbeddingGemma 2 embeddings. Keep every vector connected to its source photo path.
  • Run text-to-image search that turns ocean waves at sunset into five ranked photo matches with similarity scores. Use a photo as the query for image-to-image search.
  • Measure the storage-quality tradeoff across indexes with 768 dimensions, 256 dimensions, or 128 dimensions. Inspect the rankings side by side in a local HTML page.
  • Secret Mission: Complete the optional challenge to extend your offline photo-search workflow.

Ready to quiz yourself?