Build a Gemini Product Recommender
Build a FastAPI app that recommends products using Gemini embeddings.
Introduction
30 Second Summary
Shoppers use everyday language that rarely matches a product catalog word for word. Useful items can stay hidden behind exact-match searches.
In this project, you will build a browser-based product recommender that uses text embeddings from the Gemini API to rank products by meaning. You will experience why strict keyword matching fails before replacing it with semantic retrieval.
What You'll Build
Your finished app turns a request like computer for office work without taking much desk space into three ranked product cards with similarity scores and catalog details.
By the end of this project, you'll have:
- A browser-based recommender that displays three ranked products with names, categories, brands, prices, descriptions, and similarity scores.
- A cached embedding workflow that builds vectors for 24 diverse products once and embeds only new search queries at runtime.
- A resilient recommendation API that keeps the Gemini API key on the Python backend and reports keyword fallback when semantic search is unavailable.
- Secret Mission: Generate a side-by-side report comparing keyword and semantic top-three results for three natural-language requests.
Are there any prerequisites?
You need internet access plus Python 3.10 or newer. You also need Visual Studio Code on Windows.
Step 1 covers the remaining Kaggle and Google AI Studio account setup.
Before We Start
Exact keyword search frustrates shoppers when their everyday words do not appear literally in product text. Before setup begins, commit to building a semantic product recommender that closes this vocabulary gap.
Set Up the Project and Data
Your recommender needs a public catalog from Kaggle before it can compare shopper requests with product descriptions. It also needs a protected credential for the Gemini API.
A reproducible Python environment keeps FastAPI plus Uvicorn consistent across machines. In this step, you will prepare every input that the first search version depends on.
In this step, get ready to:
- Create the external access needed for this project.
- Build the local workspace around the public catalog.
- Prepare the Python environment with protected configuration.
Create the external access
Google AI Studio provides the API key that authorizes embedding requests. Kaggle provides the MegaBasket catalog used throughout the project.
Handling a new API key can feel risky. You will keep it out of the project files before any code exists.
- Open the official Gemini API key guide in your browser.
- Sign in to Google AI Studio with your Google account.
- Follow the guide to create a key on the API Keys page.
- Keep the Google AI Studio browser tab open for the PowerShell setup later.
- Open the MegaBasket E-commerce Products Dataset page.
- Create a Kaggle account if you do not have one.
- Sign in to your Kaggle account.
- Download All products (MegaBasket).csv using the page's download control.
What Does the Free Tier Mean Here?
Gemini Embedding 2 text input is free of charge on the Gemini API Free Tier. Google states that Free Tier content may be used to improve its products.
This project sends public catalog text plus test queries. Those inputs keep the demonstration separate from private customer data.
- Keep private customer data out of this Free Tier project.
Create the workspace and protected files
A named workspace gives every file a predictable location. A .gitignore file tells Git to leave local data plus generated files out of future commits.
- Press the Windows key to open Windows search.
- Type Visual Studio Code.
- Press Enter.
- Select File from the top menu.
- Select Open Folder....
- Select New Folder.
- Name the folder semantic-product-recommender.
- Select Select Folder.
Seeing a Workspace Trust Dialog?
The folder is one you created for this project. You can enable the workspace features after confirming that the folder is empty.
- Select Yes, I trust the authors.
You should see SEMANTIC-PRODUCT-RECOMMENDER at the top of the Explorer sidebar. That heading confirms VS Code has opened the correct workspace.
- Select the Explorer view from the left Activity Bar.
- Select the New Folder control beside the workspace heading.
- Type data.
- Press Enter.
You should see a data folder beneath the workspace heading.
- Drag All products (MegaBasket).csv from your Windows Downloads folder into the data folder in the Explorer sidebar.
- Rename the copied file to products.csv using the Explorer context menu.
Expand the data folder. You should see products.csv inside it.
- Select the workspace heading in the Explorer sidebar.
- Select the New File... control.
- Create requirements.txt.
- Add the pinned project dependencies by pasting this content into requirements.txt:
fastapi==0.143.5
uvicorn==0.54.0
What Does This File Do?
- The fastapi==0.143.5 line pins the web framework to the project version.
- The uvicorn==0.54.0 line pins the server that runs the FastAPI application.
- Exact pins make the environment reproducible when someone installs the project later.
- Save requirements.txt by pressing Ctrl+S.
The Explorer should now list requirements.txt beside the data folder.
Cannot See requirements.txt?
- Check that the file is directly beneath the semantic-product-recommender workspace heading.
- Check that the filename does not end with an extra .txt extension.
Why is my requirements file missing from the VS Code Explorer?
- Select the workspace heading in the Explorer sidebar.
- Select the New File... control.
- Create .gitignore.
- Protect local project artifacts by pasting this content into .gitignore:
.venv/
__pycache__/
*.pyc
.env
data/products.csv
data/product_index.json
comparison_report.md
What Does This File Protect?
- The first three patterns exclude the virtual environment plus generated Python cache files.
- The .env pattern protects local environment files if one is ever created.
- The two paths beneath data exclude the raw catalog plus the future embedding index.
- The final pattern excludes the optional comparison report generated by the Secret Mission.
- Save .gitignore by pressing Ctrl+S.
The Explorer should now show .gitignore plus requirements.txt at the top level. The data folder should contain products.csv.
✔️ Awesome, I've got everything!
Your workspace files are saved. The catalog is also in its protected local path.
ⓧ I'd like to double check the full code
Compare your complete requirements.txt file with this reference:
fastapi==0.143.5
uvicorn==0.54.0
What Should Match?
Both dependency lines should match exactly. Each package name includes its pinned version.
Compare your complete .gitignore file with this reference:
.venv/
__pycache__/
*.pyc
.env
data/products.csv
data/product_index.json
comparison_report.md
What Should Match Here?
All seven patterns should appear in this order. These entries protect the environment plus every local data artifact created by the project.
Prepare the Python environment and session key
A virtual environment isolates this project's packages from other Python projects. The PowerShell session also gives the backend temporary access to the Gemini API key.
- Select View from the VS Code menu bar.
- Select Terminal.
- Choose PowerShell from the terminal's Launch Profile dropdown if another shell opens.
- Check the installed Python version by running this command:
python --version
What Does This Check Prove?
The command asks the active Python installation to print its version. FastAPI 0.143.5 plus Uvicorn 0.54.0 require Python 3.10 or newer.
✔️ I see version 3.10 or higher
Your Python installation supports the pinned dependencies. Continue with the virtual environment below.
ⓧ I see an older version
The installed version is below the project's minimum. Install a supported Python release before creating the environment.
- Visit the official Python downloads for Windows page.
- Install Python 3.10 or newer with the Windows installer.
- Close the current VS Code terminal.
- Create a new PowerShell terminal from the Terminal menu.
- Check the replacement installation by running:
python --version
What Should Change?
A new terminal reloads the available Python installation. The reported version should now be 3.10 or higher.
ⓧ Command not found
PowerShell cannot currently locate Python. Installing it through the official Windows package provides the command required by this project.
- Visit the official Python downloads for Windows page.
- Install Python 3.10 or newer with the Windows installer.
- Close the current VS Code terminal.
- Create a new PowerShell terminal from the Terminal menu.
- Confirm PowerShell can locate Python by running:
python --version
What Should Appear?
PowerShell should print a Python version of 3.10 or higher. That output confirms the command is available.
- Create the isolated environment inside the workspace by running this command:
python -m venv .venv
What Does This Command Do?
- The python -m venv portion uses Python's built-in virtual environment module.
- The .venv value names the environment folder created inside semantic-product-recommender.
- Future commands use the environment's full interpreter path. Manual activation is unnecessary.
You should see a new .venv folder in the Explorer sidebar. That folder contains the isolated Python installation for this project.
No .venv Folder?
- Confirm the terminal path ends with semantic-product-recommender.
- Confirm your earlier version check reported Python 3.10 or higher.
Why did Python fail to create my .venv folder?
Handling the key can feel delicate. The next command stores it only in the current PowerShell session.
- Replace paste-your-key-here with the key from your open Google AI Studio tab.
- Set the session environment variable by running this command:
$Env:GEMINI_API_KEY = "paste-your-key-here"
How Is the Key Protected?
PowerShell stores GEMINI_API_KEY in this terminal session. Closing the terminal clears the session value.
The key stays outside Python files plus browser JavaScript. It also stays outside future Git commits.
PowerShell should return to the prompt without displaying the key. Keep this terminal open for the remaining project commands.
The first dependency install can pause while package files download. That waiting is normal.
Before you run this check, do you expect the new virtual environment to contain the two pinned package versions?
- Install the pinned dependencies into .venv by running this command:
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
What Does This Command Verify?
- The full interpreter path guarantees that packages go into .venv.
- The -m pip portion runs the package installer attached to that interpreter.
- The requirements argument reads the exact package pins from requirements.txt.
You should see successful installation output naming FastAPI 0.143.5 plus Uvicorn 0.54.0. That result proves the project environment is reproducible.
Dependency Installation Failed?
- Confirm the Explorer shows requirements.txt directly beneath the workspace heading.
- Confirm the Explorer shows the .venv folder.
- Check that the terminal still points to semantic-product-recommender.
Help me diagnose my virtual environment dependency install.
That is the setup hurdle cleared. Your protected catalog plus reproducible backend environment are ready for the first working search path.
Build the Keyword Baseline
Your project environment now has the catalog data required for a real recommendation experiment. The next question is whether strict keyword matching can understand how a shopper describes a need.
A FastAPI backend will load a diverse sample of products. A browser page will send natural-language searches to an API route. This step gives exact word matching a fair test before you improve the retrieval method.
In this step, get ready to:
- Load a diverse set of 24 products from the MegaBasket catalog.
- Build the recommendation API with strict all-keyword matching.
- Run a natural-language search to expose vocabulary mismatch.
Create the catalog loader and keyword matcher
A useful baseline needs a balanced product sample. A round-robin selection takes one product from each category per pass. This prevents one large category from filling all 24 positions.
- In the VS Code Explorer sidebar from earlier, click the New File icon beside the `semantic-product-recommender` folder.
- Enter catalog.py as the file name.
You should see catalog.py in the Explorer sidebar. The new editor tab is ready for the catalog logic.
- Add the imports, searchable field names, and cleaning helper to catalog.py by pasting this code:
import csv
import re
from collections import defaultdict
from pathlib import Path
TEXT_FIELDS = (
"name",
"description",
"category",
"subcategory",
"brand",
"tags",
"specifications",
)
def clean(value):
return " ".join(str(value or "").split())
What does this foundation do?
- The imports provide CSV reading, token extraction, category grouping, and file-path handling.
- The TEXT_FIELDS tuple identifies the product evidence that shoppers can search.
- The clean() helper collapses inconsistent whitespace into readable text.
- Save catalog.py by pressing Ctrl+S.
- Confirm the editor tab no longer shows an unsaved-change dot.
Seeing an import warning?
Confirm VS Code is still using the Python environment inside .venv. The modules in this chunk come from Python itself.
Use this if the warning remains: Help me check why VS Code reports an import warning in catalog.py.
The loader now needs to turn each CSV row into one consistent product record. It also assigns a stable project ID based on the row number.
- Add the first part of load_products() directly below clean() by pasting this code:
def load_products(csv_path, max_products=24):
path = Path(csv_path)
if not path.exists():
raise FileNotFoundError(
f"Missing {path}. Download the MegaBasket CSV and save it at this path."
)
products = []
with path.open("r", encoding="utf-8-sig", newline="") as csv_file:
reader = csv.DictReader(csv_file)
for row_number, row in enumerate(reader, start=1):
name = clean(row.get("name"))
if not name:
continue
product = {
"id": f"product-{row_number}",
"name": name,
"description": clean(row.get("description")),
"category": clean(row.get("category")) or "Uncategorized",
"subcategory": clean(row.get("subcategory")),
"brand": clean(row.get("brand")),
"price": clean(row.get("price")),
"tags": clean(row.get("tags")),
"specifications": clean(row.get("specifications")),
}
How are rows normalized?
- The path check stops the loader with a specific message when the CSV is missing.
- The CSV reader turns every row into a dictionary keyed by the dataset column names.
- Rows without a product name are skipped because the interface needs a useful label for every result.
- Each remaining row becomes a product dictionary with the fields used by the recommender.
- Save catalog.py.
- Confirm the editor shows the product dictionary ending with the specifications field.
Seeing a syntax warning in the dictionary?
Check that every dictionary entry has a comma. Confirm the closing brace lines up beneath product = {.
Use this for a focused check: Help me find the syntax problem in my load_products product dictionary.
One product needs a single searchable representation. The loader joins each labeled field into search_text before balancing the sample by category.
- Continue load_products() directly below the product dictionary by pasting this code:
product["search_text"] = " | ".join(
f"{field}: {product[field]}"
for field in TEXT_FIELDS
if product.get(field)
)
products.append(product)
if not products:
raise ValueError("The CSV did not contain any products with a name.")
by_category = defaultdict(list)
for product in products:
by_category[product["category"]].append(product)
selected = []
categories = sorted(by_category)
target = min(max_products, len(products))
round_number = 0
while len(selected) < target:
added_product = False
for category in categories:
category_products = by_category[category]
if round_number < len(category_products):
selected.append(category_products[round_number])
added_product = True
if len(selected) == target:
break
if not added_product:
break
round_number += 1
return selected
How does the diverse sample work?
- The labeled search_text value preserves evidence from every searchable field.
- The category dictionary groups products before selection begins.
- Each loop pass takes the product at the current round number from every available category.
- The target caps the local demonstration catalog at 24 products.
- Save catalog.py.
- Confirm the completed load_products() function ends with return selected.
Does the selection loop look misaligned?
Keep each nested block indented by four additional spaces. The final return selected line belongs inside the function but outside the loop.
Use this if the indentation is unclear: Help me correct the indentation in the round-robin selection loop.
The baseline uses tokenization to reduce a request to lowercase words. A product qualifies only when its searchable text contains every query token.
- Finish catalog.py by adding the tokenizer and strict matcher below load_products():
def tokenize(text):
return re.findall(r"[a-z0-9]+", text.lower())
def strict_keyword_search(query, products, limit=3):
query_tokens = tokenize(query)
if not query_tokens:
return []
scored = []
for product in products:
haystack = product["search_text"].lower()
if all(token in haystack for token in query_tokens):
score = sum(haystack.count(token) for token in query_tokens)
scored.append((score, product))
scored.sort(key=lambda item: (-item[0], item[1]["name"]))
return [product for _, product in scored[:limit]]
Why is this baseline so strict?
- The regular expression extracts lowercase letters and numbers from the request.
- The all() check requires every request token to appear in a product's searchable text.
- Repeated tokens increase the score for products that satisfy the strict filter.
- The final list contains no more than three products.
- Save catalog.py.
- Confirm the final line returns a sliced list from scored.
Is catalog.py showing a syntax error?
Confirm the regular expression uses the exact quotes shown above. Check that strict_keyword_search() starts at the left edge with def.
Use this to inspect the whole module: Help me compare my catalog.py structure with the intended loader and strict matcher.
✔️ Awesome, I've got everything!
Your catalog module now creates balanced product records and performs strict all-keyword matching. Double check that catalog.py is saved.
- Confirm catalog.py appears in the VS Code Explorer sidebar.
- Confirm the file defines load_products() and strict_keyword_search().
ⓧ I'd like to double check the full code
Compare your saved catalog.py with this complete baseline module:
import csv
import re
from collections import defaultdict
from pathlib import Path
TEXT_FIELDS = (
"name",
"description",
"category",
"subcategory",
"brand",
"tags",
"specifications",
)
def clean(value):
return " ".join(str(value or "").split())
def load_products(csv_path, max_products=24):
path = Path(csv_path)
if not path.exists():
raise FileNotFoundError(
f"Missing {path}. Download the MegaBasket CSV and save it at this path."
)
products = []
with path.open("r", encoding="utf-8-sig", newline="") as csv_file:
reader = csv.DictReader(csv_file)
for row_number, row in enumerate(reader, start=1):
name = clean(row.get("name"))
if not name:
continue
product = {
"id": f"product-{row_number}",
"name": name,
"description": clean(row.get("description")),
"category": clean(row.get("category")) or "Uncategorized",
"subcategory": clean(row.get("subcategory")),
"brand": clean(row.get("brand")),
"price": clean(row.get("price")),
"tags": clean(row.get("tags")),
"specifications": clean(row.get("specifications")),
}
product["search_text"] = " | ".join(
f"{field}: {product[field]}"
for field in TEXT_FIELDS
if product.get(field)
)
products.append(product)
if not products:
raise ValueError("The CSV did not contain any products with a name.")
by_category = defaultdict(list)
for product in products:
by_category[product["category"]].append(product)
selected = []
categories = sorted(by_category)
target = min(max_products, len(products))
round_number = 0
while len(selected) < target:
added_product = False
for category in categories:
category_products = by_category[category]
if round_number < len(category_products):
selected.append(category_products[round_number])
added_product = True
if len(selected) == target:
break
if not added_product:
break
round_number += 1
return selected
def tokenize(text):
return re.findall(r"[a-z0-9]+", text.lower())
def strict_keyword_search(query, products, limit=3):
query_tokens = tokenize(query)
if not query_tokens:
return []
scored = []
for product in products:
haystack = product["search_text"].lower()
if all(token in haystack for token in query_tokens):
score = sum(haystack.count(token) for token in query_tokens)
scored.append((score, product))
scored.sort(key=lambda item: (-item[0], item[1]["name"]))
return [product for _, product in scored[:limit]]
What should match?
This reference contains the complete catalog loader used by the baseline. Your file should match its field names, function names, selection logic, and strict matcher.
Build the recommendation API
The catalog logic needs an HTTP interface before the browser can use it. The application will load either cached entries or raw catalog products. The generated index is absent during this baseline.
- In the VS Code Explorer sidebar, click the New File icon beside the semantic-product-recommender folder.
- Enter app.py as the file name.
You should see app.py beside catalog.py in the Explorer sidebar.
- Add the imports, project paths, and FastAPI application to app.py by pasting this code:
import json
import math
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from catalog import load_products, strict_keyword_search
BASE_DIR = Path(__file__).resolve().parent
CSV_PATH = BASE_DIR / "data" / "products.csv"
INDEX_PATH = BASE_DIR / "data" / "product_index.json"
HTML_PATH = BASE_DIR / "index.html"
app = FastAPI(title="Semantic Product Recommender")
What does the application foundation do?
- The imports provide JSON reading, vector mathematics, HTML responses, and access to the catalog helpers.
- The path constants locate the CSV, future embedding index, and browser page relative to app.py.
- The app object becomes the ASGI application that Uvicorn runs.
- Save app.py.
- Confirm the file shows four path constants beneath the imports.
Is FastAPI underlined in the editor?
Select the interpreter inside .venv if VS Code is using a different Python installation. FastAPI is already installed in that virtual environment.
Use this for environment help: Help me make VS Code use the existing .venv environment for app.py.
The entry loader checks for a cached vector file before reading the CSV. Its absence creates 24 entries with empty embedding lists. That state keeps the endpoint on the baseline path.
- Add load_entries() below the FastAPI application by pasting this code:
def load_entries():
if INDEX_PATH.exists():
index_data = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
return index_data["items"], True
products = load_products(CSV_PATH, max_products=24)
return [
{"product": product, "embedding": []}
for product in products
], False
How does the baseline state work?
- An existing index would provide products paired with cached vectors.
- The current path loads the same 24-product catalog selected by catalog.py.
- The returned Boolean records whether an embedding index was available.
- Save app.py.
- Confirm load_entries() ends by returning False with the raw catalog entries.
Is the entry list showing an indentation error?
Keep the dictionary and for product in products inside the square brackets. Keep False after the closing bracket.
Use this for a line-by-line check: Help me fix the list comprehension in load_entries.
You can now start the backend before its routes are complete. This check proves that FastAPI can import the catalog module and load the application object.
- Switch back to the VS Code PowerShell terminal from earlier.
- Start the development server by running this command:
.\.venv\Scripts\uvicorn.exe app:app --reload
What does this command do?
Uvicorn imports the app object from app.py. The reload flag watches Python files and restarts the local server after saved changes.
The command keeps this terminal busy while the server runs. Leave it running during the remaining edits.
You should see output indicating that the local server is running at http://127.0.0.1:8000. This proves the application imports successfully.
Did the server fail to start?
- Confirm the PowerShell terminal is inside the semantic-product-recommender folder.
- Check the first reported file and line for a syntax problem in catalog.py or app.py.
Use this with the visible terminal output: Help me diagnose why my FastAPI application failed to start.
API responses should omit the internal search_text field. The similarity helper also belongs in the shared backend now because the semantic branch will call it only when an index exists.
- Return to app.py and add public_product() below load_entries():
def public_product(product):
return {
key: value
for key, value in product.items()
if key != "search_text"
}
Why filter the product fields?
The browser needs product details but does not need the combined search document. Removing search_text keeps the API response focused on displayable data.
- Save app.py.
- Return to the running terminal and confirm Uvicorn reloads the application without stopping.
Did the reload report a syntax problem?
Check the braces around the returned dictionary comprehension. Confirm the if condition remains inside those braces.
Use this to inspect the helper: Help me fix public_product in app.py.
- Add cosine_similarity() directly below public_product() by pasting this code:
def cosine_similarity(first, second):
if len(first) != len(second):
raise ValueError("Embedding vectors must have the same length.")
dot_product = sum(a * b for a, b in zip(first, second))
first_norm = math.sqrt(sum(value * value for value in first))
second_norm = math.sqrt(sum(value * value for value in second))
if first_norm == 0 or second_norm == 0:
return 0.0
return dot_product / (first_norm * second_norm)
What does cosine similarity measure?
Cosine similarity compares the direction of two vectors. The calculation uses their dot product and magnitudes to produce a ranking signal.
The length and zero checks prevent invalid calculations. This helper remains idle while the embedding index is absent.
- Save app.py.
- Confirm the running terminal completes another automatic reload.
Is the math helper failing to reload?
Confirm math remains imported at the top of app.py. Check that both vector parameters use the same spelling throughout the function.
Use this for targeted help: Help me debug cosine_similarity in app.py.
The keyword recommendation wrapper adapts catalog matches to the response shape expected by the browser. Baseline results use a null score because exact matching does not calculate vector similarity.
- Add keyword_recommendations() below cosine_similarity():
def keyword_recommendations(query, entries):
products = [entry["product"] for entry in entries]
matches = strict_keyword_search(query, products, limit=3)
return [
{"product": public_product(product), "score": None}
for product in matches
]
How are keyword results shaped?
- The entries are reduced to their product dictionaries before strict matching.
- Each matched product passes through the public-field filter.
- The score value stays empty because this mode has no similarity measurement.
- Save app.py.
- Confirm the running terminal reloads without an import or syntax error.
Is strict_keyword_search undefined?
Confirm the import near the top of app.py includes strict_keyword_search after load_products.
Use this for import help: Help me connect keyword_recommendations to catalog.py.
The dormant semantic branch imports its query embedder only when called. During this baseline, the missing index prevents the request route from entering this function.
- Add semantic_recommendations() below keyword_recommendations():
def semantic_recommendations(query, entries):
from gemini_embeddings import embed_query
query_vector = embed_query(query)
scored = [
(
cosine_similarity(query_vector, entry["embedding"]),
entry["product"],
)
for entry in entries
]
scored.sort(key=lambda item: item[0], reverse=True)
return [
{
"product": public_product(product),
"score": round(score, 4),
}
for score, product in scored[:3]
]
Why can this branch remain dormant?
The import occurs inside the function. Python therefore does not need gemini_embeddings.py while the function remains unused.
The branch already defines the response shape that the API will use once cached vectors exist.
- Save app.py.
- Confirm the running server reloads successfully even though gemini_embeddings.py does not exist yet.
Did Python try to import gemini_embeddings immediately?
Keep the import indented inside semantic_recommendations(). A left-aligned import would run as soon as Uvicorn loads app.py.
Use this to check the placement: Help me keep the semantic import dormant until the function runs.
The home route reads the browser page from disk for each request. An HTML response tells the browser to render that file as a page.
- Add the home route below semantic_recommendations() by pasting this code:
@app.get("/", response_class=HTMLResponse)
def home():
return HTMLResponse(
content=HTML_PATH.read_text(encoding="utf-8"),
status_code=200,
)
How is the browser page served?
The route maps the root URL to home(). The function reads index.html and returns its contents with an HTML response.
- Save app.py.
- Confirm the running terminal reloads the route without reporting a decorator error.
Is HTMLResponse undefined?
Confirm HTMLResponse is imported from fastapi.responses near the top of the file.
Use this for route help: Help me fix the FastAPI home route in app.py.
The recommendation route validates the query before loading entries. It then checks the index state to choose a retrieval path.
- Start the recommendation route below home() by adding this first section:
@app.get("/api/recommendations")
def recommendations(q: str):
query = q.strip()
if len(query) < 3:
return {
"mode": "invalid",
"message": "Enter at least three characters.",
"results": [],
}
entries, has_index = load_entries()
What happens before retrieval?
- FastAPI reads q from the URL query string.
- Whitespace is removed before the length check.
- Short requests return a structured response without searching the catalog.
- Valid requests load the product entries and index state.
- Complete recommendations() directly below load_entries() by adding this section:
if has_index:
try:
results = semantic_recommendations(query, entries)
return {
"mode": "semantic",
"message": "Ranked by Gemini embeddings and cosine similarity.",
"results": results,
}
except Exception as error:
print(f"Semantic search failed: {error}")
results = keyword_recommendations(query, entries)
return {
"mode": "keyword-fallback",
"message": "Semantic search was unavailable, so strict keyword matching was used.",
"results": results,
}
results = keyword_recommendations(query, entries)
message = (
"Strict keyword matching found products containing every query word."
if results
else "Strict keyword matching could not connect this natural-language request to the catalog."
)
return {
"mode": "keyword",
"message": message,
"results": results,
}
How does the route choose a mode?
- An available index activates semantic ranking inside a protected block.
- A semantic error falls back to the strict matcher instead of crashing the request.
- The current no-index state calls keyword_recommendations() directly.
- The response explains whether strict matching found any qualifying products.
- Save app.py.
- Confirm the running terminal completes its reload without a traceback.
Did the recommendation route break the reload?
Confirm the second code section remains indented inside recommendations(). Check that the except line aligns with try.
Use this with the reported line number: Help me fix the recommendations route in app.py.
✔️ Awesome, I've got everything!
Your FastAPI backend is running. The absent data/product_index.json file keeps recommendation requests on strict keyword mode.
- Confirm app.py is saved.
- Confirm the Uvicorn process remains active in the PowerShell terminal.
ⓧ I'd like to double check the full code
Compare your saved app.py with this complete application:
import json
import math
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from catalog import load_products, strict_keyword_search
BASE_DIR = Path(__file__).resolve().parent
CSV_PATH = BASE_DIR / "data" / "products.csv"
INDEX_PATH = BASE_DIR / "data" / "product_index.json"
HTML_PATH = BASE_DIR / "index.html"
app = FastAPI(title="Semantic Product Recommender")
def load_entries():
if INDEX_PATH.exists():
index_data = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
return index_data["items"], True
products = load_products(CSV_PATH, max_products=24)
return [
{"product": product, "embedding": []}
for product in products
], False
def public_product(product):
return {
key: value
for key, value in product.items()
if key != "search_text"
}
def cosine_similarity(first, second):
if len(first) != len(second):
raise ValueError("Embedding vectors must have the same length.")
dot_product = sum(a * b for a, b in zip(first, second))
first_norm = math.sqrt(sum(value * value for value in first))
second_norm = math.sqrt(sum(value * value for value in second))
if first_norm == 0 or second_norm == 0:
return 0.0
return dot_product / (first_norm * second_norm)
def keyword_recommendations(query, entries):
products = [entry["product"] for entry in entries]
matches = strict_keyword_search(query, products, limit=3)
return [
{"product": public_product(product), "score": None}
for product in matches
]
def semantic_recommendations(query, entries):
from gemini_embeddings import embed_query
query_vector = embed_query(query)
scored = [
(
cosine_similarity(query_vector, entry["embedding"]),
entry["product"],
)
for entry in entries
]
scored.sort(key=lambda item: item[0], reverse=True)
return [
{
"product": public_product(product),
"score": round(score, 4),
}
for score, product in scored[:3]
]
@app.get("/", response_class=HTMLResponse)
def home():
return HTMLResponse(
content=HTML_PATH.read_text(encoding="utf-8"),
status_code=200,
)
@app.get("/api/recommendations")
def recommendations(q: str):
query = q.strip()
if len(query) < 3:
return {
"mode": "invalid",
"message": "Enter at least three characters.",
"results": [],
}
entries, has_index = load_entries()
if has_index:
try:
results = semantic_recommendations(query, entries)
return {
"mode": "semantic",
"message": "Ranked by Gemini embeddings and cosine similarity.",
"results": results,
}
except Exception as error:
print(f"Semantic search failed: {error}")
results = keyword_recommendations(query, entries)
return {
"mode": "keyword-fallback",
"message": "Semantic search was unavailable, so strict keyword matching was used.",
"results": results,
}
results = keyword_recommendations(query, entries)
message = (
"Strict keyword matching found products containing every query word."
if results
else "Strict keyword matching could not connect this natural-language request to the catalog."
)
return {
"mode": "keyword",
"message": message,
"results": results,
}
What should match?
This reference contains the complete backend for the baseline. The root route serves the page. The recommendation route returns strict keyword results while the index file is absent.
Create the browser search page and test the baseline
The backend is ready to receive search requests. A small HTML page will collect a request and display the API mode, message, and returned products.
- In the VS Code Explorer sidebar, click the New File icon beside the semantic-product-recommender folder.
- Enter index.html as the file name.
You should see index.html beside the two Python files in the Explorer sidebar.
- Add the page structure and search controls to index.html by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Semantic Product Recommender</title>
</head>
<body>
<main>
<h1>Find products by meaning, not exact words.</h1>
<form id="search-form">
<input
id="query"
name="q"
required
minlength="3"
autocomplete="off"
placeholder="Example: computer for office work without taking much desk space"
>
<button id="submit-button" type="submit">Recommend products</button>
</form>
<div class="status-row" aria-live="polite">
<span id="mode"></span>
<span id="status">Try a natural-language request.</span>
</div>
<section id="results" aria-label="Recommendations"></section>
</main>
What does the page structure provide?
- The form groups the search input with its submit button.
- The input requires at least three characters before submission.
- The status area has separate elements for the retrieval mode and explanation.
- The results section provides a destination for recommendation cards.
- Save index.html.
- Refresh http://127.0.0.1:8000 in your browser.
You should see a heading, a search field, a recommendation button, and the initial status text. The root route is now reading your HTML file successfully.
Does the browser show a file error?
Confirm the file is named exactly index.html inside the semantic-product-recommender folder. Save it before refreshing the page.
Use this with the browser and terminal output: Help me fix the FastAPI home page file error.
The JavaScript client needs references to each page element. It also needs a small escaping helper before catalog text can be placed inside result cards.
- Add the client element references and rendering helpers directly below </main> by pasting this code:
<script>
const form = document.querySelector("#search-form");
const queryInput = document.querySelector("#query");
const submitButton = document.querySelector("#submit-button");
const modeBadge = document.querySelector("#mode");
const statusText = document.querySelector("#status");
const results = document.querySelector("#results");
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
function productCard(item, mode) {
const product = item.product;
return `
<article class="card">
<div class="card-top">
<h2>${escapeHtml(product.name)}</h2>
</div>
<p class="description">${escapeHtml(product.description || "No description available.")}</p>
</article>
`;
}
How are results prepared for display?
- The six element references connect JavaScript to the form, status area, and result container.
- The escaping helper converts special markup characters before catalog values enter the page.
- The card helper renders each product name with its available description.
- Save index.html.
- Refresh the browser and confirm the existing form still renders.
Did the form disappear after this edit?
Confirm the script begins after the closing </main> tag. Check that the template returned by productCard() uses matching backticks.
Use this for markup help: Help me find the broken tag or template string in index.html.
The final client function sends the shopper's request to the recommendation route. It then places the returned mode and message into the status area.
- Finish the script and document directly below productCard() by pasting this code:
async function search(query) {
const response = await fetch(`/api/recommendations?q=${encodeURIComponent(query)}`);
const data = await response.json();
modeBadge.textContent = data.mode.replaceAll("-", " ");
modeBadge.style.display = "inline-block";
statusText.textContent = data.message;
results.innerHTML = data.results
.map((item) => productCard(item, data.mode))
.join("");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
search(queryInput.value.trim());
});
</script>
</body>
</html>
How does a browser search reach Python?
- The search function places the encoded request in the q query parameter.
- The API response updates the mode and explanatory status text.
- Each returned product passes through productCard() before entering the results section.
- The submit listener keeps the form on the same page while the request runs.
- Save index.html.
- Refresh http://127.0.0.1:8000 to load the completed client.
Does the button reload the whole page?
Confirm the listener targets form and calls event.preventDefault(). Check that the script closes before the body and document tags.
Use this for client-side help: Help me debug the index.html form submission and fetch request.
✔️ Awesome, I've got everything!
Your browser page can now submit a request to the local recommendation API. Double check that index.html is saved before testing the baseline.
- Confirm the browser shows the search form and initial status text.
- Confirm the PowerShell terminal still shows the running Uvicorn process.
ⓧ I'd like to double check the full code
Compare your saved starter index.html with this complete baseline page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Semantic Product Recommender</title>
</head>
<body>
<main>
<h1>Find products by meaning, not exact words.</h1>
<form id="search-form">
<input
id="query"
name="q"
required
minlength="3"
autocomplete="off"
placeholder="Example: computer for office work without taking much desk space"
>
<button id="submit-button" type="submit">Recommend products</button>
</form>
<div class="status-row" aria-live="polite">
<span id="mode"></span>
<span id="status">Try a natural-language request.</span>
</div>
<section id="results" aria-label="Recommendations"></section>
</main>
<script>
const form = document.querySelector("#search-form");
const queryInput = document.querySelector("#query");
const submitButton = document.querySelector("#submit-button");
const modeBadge = document.querySelector("#mode");
const statusText = document.querySelector("#status");
const results = document.querySelector("#results");
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
function productCard(item, mode) {
const product = item.product;
return `
<article class="card">
<div class="card-top">
<h2>${escapeHtml(product.name)}</h2>
</div>
<p class="description">${escapeHtml(product.description || "No description available.")}</p>
</article>
`;
}
async function search(query) {
const response = await fetch(`/api/recommendations?q=${encodeURIComponent(query)}`);
const data = await response.json();
modeBadge.textContent = data.mode.replaceAll("-", " ");
modeBadge.style.display = "inline-block";
statusText.textContent = data.message;
results.innerHTML = data.results
.map((item) => productCard(item, data.mode))
.join("");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
search(queryInput.value.trim());
});
</script>
</body>
</html>
What should match?
This starter page contains the form IDs and JavaScript references required by the baseline. Its fetch request targets the recommendation route defined in app.py.
The strict matcher now faces the request it was built to evaluate. Before you search, do you think every important word appears literally in one selected product record?
- Enter computer for office work without taking much desk space in the browser search field.
- Click Recommend products.
You should see the mode reported as keyword. The status explains that strict matching could not connect the natural-language request to the catalog.
That empty result is intentional. Requiring every query token creates vocabulary mismatch whenever shopper language differs from catalog wording.
Did the search request fail completely?
- Confirm Uvicorn is still running in the PowerShell terminal.
- Confirm the browser address starts with http://127.0.0.1:8000.
- Check the terminal for the Python file and line connected to the failed request.
Use this with the visible browser and terminal details: Help me debug why the keyword baseline request does not complete.
You have proved the limitation with a running application. Next, you will replace literal overlap with embedding-based ranking and repeat the same search.
Build Semantic Retrieval
Your FastAPI endpoint exposed the vocabulary mismatch in strict keyword search. A shopper can describe the right product without using the catalog's exact words.
This step uses text embeddings from the Gemini API to place product descriptions plus shopper requests in the same vector space. Your app then uses cosine similarity to rank the three closest products.
In this step, get ready to:
- Create the backend client for product document embeddings plus shopper query embeddings.
- Generate a cached index for the same 24 category-diverse products.
- Restart Uvicorn to verify that the designed query returns three semantic recommendations.
Create the Gemini embedding client
Product documents plus shopper queries play different roles in retrieval. The client gives each one its official asymmetric prompt format before sending it to Gemini.
- Create gemini_embeddings.py inside the semantic-product-recommender folder using the file controls in the VS Code Explorer.
- Add the imports plus Gemini configuration by pasting this first section into gemini_embeddings.py:
import json
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
MODEL = "gemini-embedding-2"
RESOURCE_MODEL = f"models/{MODEL}"
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
EMBED_URL = f"{API_ROOT}/{MODEL}:embedContent"
BATCH_EMBED_URL = f"{API_ROOT}/{MODEL}:batchEmbedContents"
def get_api_key():
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
raise RuntimeError(
"GEMINI_API_KEY is not set in this terminal session."
)
return api_key
What does this section do?
- The URL constants target the single-query endpoint plus the batch-document endpoint for gemini-embedding-2.
- RESOURCE_MODEL stores the model name in the request-body format expected by the API.
- get_api_key() reads GEMINI_API_KEY from the active terminal session. It stops with a clear message when the key is unavailable.
- Save gemini_embeddings.py.
- Confirm the first constant sets MODEL to gemini-embedding-2.
- Confirm the section ends with return api_key.
Seeing a problem marker in the first section?
Check the indentation inside get_api_key(). Confirm every opening parenthesis has a matching closing parenthesis.
Help me check the imports, constants, and get_api_key function in gemini_embeddings.py.
- Add the HTTP request helper directly below get_api_key() by pasting this section:
def post_json(url, payload):
request = Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"x-goog-api-key": get_api_key(),
},
method="POST",
)
try:
with urlopen(request, timeout=60) as response:
return json.load(response)
except HTTPError as error:
details = error.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"Gemini API returned HTTP {error.code}: {details}"
) from error
except URLError as error:
raise RuntimeError(f"Could not reach the Gemini API: {error.reason}") from error
How does the request helper work?
- Request converts the payload to JSON. It places the current API key in the x-goog-api-key header.
- urlopen() sends the backend request with a 60-second timeout.
- The exception handlers turn API failures plus network failures into readable runtime errors. The recommendation endpoint can catch those errors and use its keyword fallback.
- Save gemini_embeddings.py.
- Confirm the helper includes the x-goog-api-key header.
- Confirm the helper handles both HTTPError plus URLError.
Does the request helper look incomplete?
Make sure except HTTPError aligns with try. Check that except URLError uses the same indentation.
Help me fix the post_json function in gemini_embeddings.py.
- Add the retrieval prompt helpers plus batch embedding function directly below post_json() by pasting this section:
def prepare_document(product):
return f"title: {product['name']} | text: {product['search_text']}"
def prepare_query(query):
return f"task: search result | query: {query}"
def embed_documents(products):
payload = {
"requests": [
{
"model": RESOURCE_MODEL,
"content": {
"parts": [{"text": prepare_document(product)}]
},
}
for product in products
]
}
data = post_json(BATCH_EMBED_URL, payload)
embeddings = data.get("embeddings", [])
if len(embeddings) != len(products):
raise RuntimeError(
"Gemini returned a different number of embeddings than requested."
)
return [embedding["values"] for embedding in embeddings]
Why use two prompt formats?
- prepare_document() identifies each catalog entry as a titled document. The model receives the product name plus its combined searchable text.
- prepare_query() identifies shopper input as a search query. This tells the model to represent the request for retrieval.
- embed_documents() places all 24 product requests in one batch payload. It also confirms Gemini returned one vector per product.
- Save gemini_embeddings.py.
- Confirm prepare_document() starts its output with title:.
- Confirm prepare_query() starts its output with task: search result | query:.
Seeing quote or bracket problems?
Check the nested quotation marks around product['name'] plus product['search_text']. Confirm the payload closes its list before it closes the surrounding dictionary.
Help me fix the retrieval prompt helpers and batch embedding payload.
- Complete gemini_embeddings.py by adding the runtime query embedding function below embed_documents().
def embed_query(query):
payload = {
"model": RESOURCE_MODEL,
"content": {
"parts": [{"text": prepare_query(query)}]
},
}
data = post_json(EMBED_URL, payload)
return data["embedding"]["values"]
What happens to each shopper request?
- embed_query() formats one shopper request with prepare_query().
- post_json() sends that request to the single embedding endpoint.
- The function returns only the numeric vector stored under embedding plus values.
- Save gemini_embeddings.py.
- Confirm the completed file ends with return data["embedding"]["values"].
Is the query function nested inside another function?
Place def embed_query(query): at the far left of the editor. An extra indent can accidentally place it inside embed_documents().
Help me check the completed structure of gemini_embeddings.py.
✔️ Awesome, I've got everything!
Your embedding client now prepares retrieval prompts for both sides of the search. Make sure gemini_embeddings.py is saved before continuing.
ⓧ I'd like to double check the full code
Compare your complete gemini_embeddings.py file with this reference:
import json
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
MODEL = "gemini-embedding-2"
RESOURCE_MODEL = f"models/{MODEL}"
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
EMBED_URL = f"{API_ROOT}/{MODEL}:embedContent"
BATCH_EMBED_URL = f"{API_ROOT}/{MODEL}:batchEmbedContents"
def get_api_key():
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
raise RuntimeError(
"GEMINI_API_KEY is not set in this terminal session."
)
return api_key
def post_json(url, payload):
request = Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"x-goog-api-key": get_api_key(),
},
method="POST",
)
try:
with urlopen(request, timeout=60) as response:
return json.load(response)
except HTTPError as error:
details = error.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"Gemini API returned HTTP {error.code}: {details}"
) from error
except URLError as error:
raise RuntimeError(f"Could not reach the Gemini API: {error.reason}") from error
def prepare_document(product):
return f"title: {product['name']} | text: {product['search_text']}"
def prepare_query(query):
return f"task: search result | query: {query}"
def embed_documents(products):
payload = {
"requests": [
{
"model": RESOURCE_MODEL,
"content": {
"parts": [{"text": prepare_document(product)}]
},
}
for product in products
]
}
data = post_json(BATCH_EMBED_URL, payload)
embeddings = data.get("embeddings", [])
if len(embeddings) != len(products):
raise RuntimeError(
"Gemini returned a different number of embeddings than requested."
)
return [embedding["values"] for embedding in embeddings]
def embed_query(query):
payload = {
"model": RESOURCE_MODEL,
"content": {
"parts": [{"text": prepare_query(query)}]
},
}
data = post_json(EMBED_URL, payload)
return data["embedding"]["values"]
Build the cached product index
Product vectors only need to be generated when the catalog changes. A local JSON cache lets the app reuse those vectors while embedding only each new shopper query.
- Create build_index.py inside the semantic-product-recommender folder using the VS Code Explorer.
- Add the imports plus cache paths by pasting this first section into build_index.py:
import json
from pathlib import Path
from catalog import load_products
from gemini_embeddings import MODEL, embed_documents
BASE_DIR = Path(__file__).resolve().parent
CSV_PATH = BASE_DIR / "data" / "products.csv"
INDEX_PATH = BASE_DIR / "data" / "product_index.json"
What do these paths connect?
- load_products reuses the category-round-robin catalog loader from your baseline.
- embed_documents connects the selected products to the batch embedding request.
- CSV_PATH points to the downloaded catalog. INDEX_PATH points to the generated vector cache.
- Save build_index.py.
- Confirm the source path ends with data/products.csv.
- Confirm the cache path ends with data/product_index.json.
Are the imports underlined?
Check that build_index.py sits beside catalog.py plus gemini_embeddings.py. Matching folder locations allow Python to resolve both imports.
Help me fix the imports and paths in build_index.py.
- Complete build_index.py by adding the index-building function below INDEX_PATH.
def main():
products = load_products(CSV_PATH, max_products=24)
print(f"Embedding {len(products)} products with {MODEL}...")
vectors = embed_documents(products)
items = [
{"product": product, "embedding": vector}
for product, vector in zip(products, vectors)
]
INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
INDEX_PATH.write_text(
json.dumps(
{"model": MODEL, "count": len(items), "items": items},
ensure_ascii=False,
),
encoding="utf-8",
)
print(f"Saved {len(items)} product embeddings to {INDEX_PATH}")
if __name__ == "__main__":
main()
What does the index builder save?
- load_products() selects the same 24 products used by the keyword baseline.
- embed_documents() returns one vector for each product in a single batch response.
- zip() pairs each selected product with its corresponding vector.
- write_text() saves the model ID plus item count plus paired entries in data/product_index.json.
- Save build_index.py.
- Confirm the file ends with the call to main().
Is the main function showing indentation problems?
Keep every statement from products = through the final print() inside main(). Place the final conditional at the far left of the editor.
Help me check the structure of build_index.py.
✔️ Awesome, I've got everything!
Your index builder is ready to convert the selected catalog into a reusable cache. Make sure build_index.py is saved.
ⓧ I'd like to double check the full code
Compare your complete build_index.py file with this reference:
import json
from pathlib import Path
from catalog import load_products
from gemini_embeddings import MODEL, embed_documents
BASE_DIR = Path(__file__).resolve().parent
CSV_PATH = BASE_DIR / "data" / "products.csv"
INDEX_PATH = BASE_DIR / "data" / "product_index.json"
def main():
products = load_products(CSV_PATH, max_products=24)
print(f"Embedding {len(products)} products with {MODEL}...")
vectors = embed_documents(products)
items = [
{"product": product, "embedding": vector}
for product, vector in zip(products, vectors)
]
INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
INDEX_PATH.write_text(
json.dumps(
{"model": MODEL, "count": len(items), "items": items},
ensure_ascii=False,
),
encoding="utf-8",
)
print(f"Saved {len(items)} product embeddings to {INDEX_PATH}")
if __name__ == "__main__":
main()
The builder now has everything it needs for one batch request. Before you run it, how many product embeddings do you expect the completed cache to contain?
- Return to the PowerShell terminal that is running Uvicorn.
- Stop the running Uvicorn process while keeping the PowerShell terminal open.
- Generate the product embedding cache by running this command:
.\.venv\Scripts\python.exe build_index.py
What happens during this command?
The script loads the same category-diverse catalog subset. It sends all 24 retrieval-formatted product documents in one batch request.
The returned vectors are paired with their products in the original order. The finished cache gives the API a reusable semantic catalog.
You should see a line beginning with Embedding 24 products with gemini-embedding-2. The final line should contain Saved 24 product embeddings.
That is the indexing work complete. Your app can now reuse those 24 product vectors for every new search.
Did the index build fail?
- Check that you are using the same PowerShell session where GEMINI_API_KEY was set.
- Confirm data/products.csv still exists inside the project folder.
- Compare both new Python files with their full-code tabs if the terminal reports a syntax problem.
Help me diagnose why build_index.py did not save 24 product embeddings.
Verify semantic recommendations
The existing API checks whether data/product_index.json exists. Its presence activates the prepared semantic branch when the server starts again.
- Restart the local recommendation server by running this command:
.\.venv\Scripts\uvicorn.exe app:app --reload
Uvicorn keeps this terminal occupied while the app is running. Its reload mode watches the project for saved Python changes.
Did the server fail to restart?
- Confirm the terminal is still inside the semantic-product-recommender folder.
- Check the reported filename if the terminal shows a Python syntax problem.
- Confirm the virtual environment still contains the pinned Uvicorn installation.
Help me troubleshoot the Uvicorn restart for my semantic product recommender.
Before you repeat the designed search, do you expect the same vocabulary mismatch or three meaning-based results?
- Return to the browser tab from the keyword baseline.
- Refresh http://127.0.0.1:8000.
- Enter computer for office work without taking much desk space in the search field.
- Submit the search form.
You should now see three products with the mode labeled semantic. Each result should include a similarity score.
There it is. The same request that defeated strict keyword matching now returns a ranked set of meaning-based recommendations.
Why does the fallback remain useful?
Semantic search depends on the cached vectors plus a working runtime query request. The existing API catches a semantic failure and returns strict keyword results in keyword-fallback mode.
That fallback keeps the endpoint responsive during a missing-key problem or a temporary network failure. The app degrades to a simpler search method instead of crashing.
Your recommender now ranks products by meaning while preserving its keyword safety net. Next, you will turn these raw rankings into a polished experience that a reviewer can understand at a glance.
Finish the Recommendation Experience
Your FastAPI backend now turns everyday requests into three cosine-ranked recommendations. Each result already includes the product evidence needed to inspect the ranking.
Raw rankings are hard to evaluate without clear scores. A polished interface makes each recommendation understandable to a reviewer. This step also documents the architecture and proves the keyword fallback works.
In this step, get ready to:
- Replace the starter page with the final recommendation interface.
- Document the project for future demonstrations.
- Test semantic search and graceful keyword fallback.
Replace the starter interface
The final page makes the ranking process visible. It adds example searches, a mode badge, loading feedback, escaped catalog text, responsive cards, product details, and similarity percentages.
- In Visual Studio Code, select index.html from the Explorer sidebar.
- Press Ctrl+A to select the starter page.
- Open the full-code tab below to replace the selected content with the final interface.
✔️ Awesome, I've got everything!
Your file is ready if it already matches every section in the full-code tab. Continue to the save and refresh check below.
ⓧ I'd like to double check the full code
- Paste this complete interface over the selected content in index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Semantic Product Recommender</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, #14345d 0, transparent 35%),
linear-gradient(160deg, #07111f, #0d1726 55%, #111c2f);
}
main {
width: min(1040px, calc(100% - 32px));
margin: 0 auto;
padding: 64px 0 80px;
}
.eyebrow {
color: #79c7ff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 760px;
margin: 10px 0 14px;
font-size: clamp(2.35rem, 7vw, 5.2rem);
line-height: 0.98;
}
.intro {
max-width: 700px;
margin-bottom: 28px;
color: #aebed2;
font-size: 1.05rem;
line-height: 1.7;
}
form {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 10px;
border: 1px solid #29405e;
border-radius: 18px;
background: rgba(9, 22, 39, 0.82);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
}
input, button { font: inherit; }
input {
min-width: 0;
padding: 14px 16px;
border: 0;
outline: 0;
background: transparent;
color: #fff;
}
button {
padding: 13px 18px;
border: 0;
border-radius: 12px;
background: #69c0ff;
color: #06111e;
font-weight: 800;
cursor: pointer;
}
button:disabled { cursor: wait; opacity: 0.65; }
.examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0 28px;
}
.example {
padding: 8px 11px;
border: 1px solid #2b4567;
background: transparent;
color: #b9cce2;
font-size: 0.84rem;
font-weight: 600;
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 34px;
margin: 18px 0;
}
#mode {
display: none;
padding: 5px 9px;
border: 1px solid #2f5e81;
border-radius: 999px;
color: #8bd1ff;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
}
#status { color: #aebed2; }
#results {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.card {
min-height: 270px;
padding: 20px;
border: 1px solid #263d5a;
border-radius: 18px;
background: rgba(13, 27, 47, 0.88);
}
.card-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.card h2 {
margin: 0;
font-size: 1.18rem;
line-height: 1.35;
}
.score {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 999px;
background: #153d32;
color: #8df0c8;
font-size: 0.76rem;
font-weight: 800;
}
.meta {
margin: 12px 0;
color: #79c7ff;
font-size: 0.82rem;
font-weight: 700;
}
.description {
color: #b5c4d6;
line-height: 1.6;
}
.details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 18px;
color: #8ea3bb;
font-size: 0.82rem;
}
.details strong {
display: block;
color: #e8eef7;
font-size: 0.9rem;
}
@media (max-width: 760px) {
main { padding-top: 40px; }
form { grid-template-columns: 1fr; }
#results { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<div class="eyebrow">Gemini Embeddings Portfolio Demo</div>
<h1>Find products by meaning, not exact words.</h1>
<p class="intro">
Describe what you need in natural language. The backend embeds your request,
compares it with cached catalog vectors, and returns the three closest products.
</p>
<form id="search-form">
<input
id="query"
name="q"
required
minlength="3"
autocomplete="off"
placeholder="Example: computer for office work without taking much desk space"
>
<button id="submit-button" type="submit">Recommend products</button>
</form>
<div class="examples" aria-label="Example searches">
<button class="example" type="button">computer for office work without taking much desk space</button>
<button class="example" type="button">healthy breakfast for busy mornings</button>
<button class="example" type="button">gift for someone who loves taking photos</button>
</div>
<div class="status-row" aria-live="polite">
<span id="mode"></span>
<span id="status">Try a natural-language request.</span>
</div>
<section id="results" aria-label="Recommendations"></section>
</main>
<script>
const form = document.querySelector("#search-form");
const queryInput = document.querySelector("#query");
const submitButton = document.querySelector("#submit-button");
const modeBadge = document.querySelector("#mode");
const statusText = document.querySelector("#status");
const results = document.querySelector("#results");
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function productCard(item, mode) {
const product = item.product;
const score = mode === "semantic" && item.score !== null
? `<span class="score">${Math.round(item.score * 100)}% match</span>`
: "";
return `
<article class="card">
<div class="card-top">
<h2>${escapeHtml(product.name)}</h2>
${score}
</div>
<div class="meta">
${escapeHtml(product.category)} · ${escapeHtml(product.subcategory || "General")}
</div>
<p class="description">${escapeHtml(product.description || "No description available.")}</p>
<div class="details">
<div><span>Brand</span><strong>${escapeHtml(product.brand || "Not listed")}</strong></div>
<div><span>Price</span><strong>${escapeHtml(product.price || "Not listed")}</strong></div>
</div>
</article>
`;
}
async function search(query) {
submitButton.disabled = true;
modeBadge.style.display = "none";
statusText.textContent = "Ranking products...";
results.innerHTML = "";
try {
const response = await fetch(`/api/recommendations?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
modeBadge.textContent = data.mode.replaceAll("-", " ");
modeBadge.style.display = "inline-block";
statusText.textContent = data.message;
if (!data.results.length) {
results.innerHTML = `<article class="card"><h2>No matches yet</h2><p class="description">${escapeHtml(data.message)}</p></article>`;
return;
}
results.innerHTML = data.results
.map((item) => productCard(item, data.mode))
.join("");
} catch (error) {
statusText.textContent = error.message;
} finally {
submitButton.disabled = false;
}
}
form.addEventListener("submit", (event) => {
event.preventDefault();
search(queryInput.value.trim());
});
document.querySelectorAll(".example").forEach((button) => {
button.addEventListener("click", () => {
queryInput.value = button.textContent;
search(button.textContent);
});
});
</script>
</body>
</html>
What does this interface add?
- The responsive styles show three cards on wide screens. They switch to one column on narrow screens.
- The escapeHtml() function prevents catalog values from being interpreted as page markup.
- The productCard() function displays product evidence. Semantic results also receive a percentage score.
- The search() function displays loading feedback. It also handles API errors.
- Save index.html by pressing Ctrl+S.
- Return to the browser page from earlier.
- Refresh http://127.0.0.1:8000.
You should see a dark interface with three example-search buttons. The page should also show a search form and an empty recommendation area.
Still seeing the starter page?
- Confirm the active editor tab is index.html.
- Check that the file tab no longer shows an unsaved-change indicator.
- Refresh the browser after saving the file.
Ask for help if the old page remains visible: Why is FastAPI still serving my old index.html after I replaced and saved the file?
Document the portfolio project
A portfolio repository needs enough context for another developer to run it safely. Your README explains the Gemini API architecture, the Free Tier privacy trade-off, the fallback path, and the Kaggle data source.
The .gitignore from earlier already excludes the virtual environment, raw catalog, generated vectors, local secrets, and comparison report. That keeps the future GitHub repository focused on reproducible source files.
- In the Explorer sidebar, select the New File icon beside the SEMANTIC-PRODUCT-RECOMMENDER heading.
- Type README.md into the file-name field.
- Press Enter to create the file.
- Add the project summary, stack, and architecture by pasting this first section:
# Semantic Product Recommender
A local portfolio app that compares a shopper's natural-language request with cached product embeddings and returns the three closest products.
## Stack
- Python 3.10+
- FastAPI 0.143.5
- Uvicorn 0.54.0
- Gemini API model `gemini-embedding-2`
- MegaBasket E-commerce Products Dataset from Kaggle
## How it works
1. `catalog.py` loads 24 products in round-robin order across categories.
2. `build_index.py` sends retrieval-formatted product documents to the Gemini batch embeddings endpoint.
3. `app.py` embeds each new query, calculates cosine similarity, and returns the top three products.
4. If the index or Gemini request is unavailable, the app falls back to strict keyword matching.
What does this section document?
- The opening sentence describes the application from a reviewer's perspective.
- The stack records Python 3.10+, FastAPI 0.143.5, and Uvicorn 0.54.0.
- The architecture section follows a query from catalog loading through ranking.
- The final architecture item records the strict keyword fallback.
- Save README.md by pressing Ctrl+S.
- Confirm README.md now appears beside app.py in the Explorer sidebar.
README.md missing from the sidebar?
- Confirm the file name is exactly README.md.
- Check that the file sits directly inside semantic-product-recommender.
Ask for help with the file location: Help me find where VS Code saved my README.md file.
- Place the cursor after the strict keyword matching sentence.
- Add the Windows setup instructions by pasting this next section:
## Setup on Windows PowerShell
1. Download `All products (MegaBasket).csv` from:
https://www.kaggle.com/datasets/saurabhtbj143/ecommerce-products-dataset
2. Save it as `data\products.csv`.
3. Follow the Gemini API key guide:
https://ai.google.dev/gemini-api/docs/api-key
4. Run:
```powershell
python --version
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$Env:GEMINI_API_KEY = "paste-your-key-here"
.\.venv\Scripts\python.exe build_index.py
.\.venv\Scripts\uvicorn.exe app:app --reload
```
5. Open http://127.0.0.1:8000.
Why include the setup commands?
The setup section gives a Windows reviewer one reproducible path from the public dataset to the running app. The key remains a plain placeholder inside the documentation.
- Save README.md.
- Confirm the file now contains the Setup on Windows PowerShell heading.
Markdown commands look misplaced?
- Confirm the setup section starts below the final architecture item.
- Check that the PowerShell commands sit between the two triple-backtick lines.
Ask for help with the Markdown structure: Help me fix the code fence in my README.md setup section.
- Place the cursor after the local app URL.
- Finish the README with the cost, privacy, security, and attribution sections:
## Cost and privacy
Gemini Embedding 2 text input is free of charge on the Gemini API Free Tier. Google states that free-tier content may be used to improve its products. This demo sends public product text and the search queries you enter. Do not use private customer data in the Free Tier demo.
The API key is read from `GEMINI_API_KEY` on the backend. Never add the key to source control or browser JavaScript.
## Data attribution
This project uses the MegaBasket E-commerce Products Dataset by Saurabh Kumar on Kaggle. The dataset page identifies the catalog as 594 records across 15 columns and lists an MIT license.
What do these safeguards explain?
- The cost section records that Gemini Embedding 2 text input is free of charge on the Gemini API Free Tier.
- The privacy note explains how Google treats Free Tier content.
- The security note keeps GEMINI_API_KEY on the backend.
- The attribution section records the catalog source. It also records the dataset size and MIT license.
- Save README.md.
- Confirm the file ends with the Data attribution section.
README sections out of order?
- Confirm Cost and privacy appears after the setup section.
- Confirm Data attribution is the final heading.
Ask for help comparing the structure: Help me put my README.md sections in the correct order.
✔️ Awesome, I've got everything!
Your documentation now explains how to run the project safely. It also gives reviewers enough context to understand the retrieval architecture.
ⓧ I'd like to double check the full code
Compare your complete README.md with this reference:
# Semantic Product Recommender
A local portfolio app that compares a shopper's natural-language request with cached product embeddings and returns the three closest products.
## Stack
- Python 3.10+
- FastAPI 0.143.5
- Uvicorn 0.54.0
- Gemini API model `gemini-embedding-2`
- MegaBasket E-commerce Products Dataset from Kaggle
## How it works
1. `catalog.py` loads 24 products in round-robin order across categories.
2. `build_index.py` sends retrieval-formatted product documents to the Gemini batch embeddings endpoint.
3. `app.py` embeds each new query, calculates cosine similarity, and returns the top three products.
4. If the index or Gemini request is unavailable, the app falls back to strict keyword matching.
## Setup on Windows PowerShell
1. Download `All products (MegaBasket).csv` from:
https://www.kaggle.com/datasets/saurabhtbj143/ecommerce-products-dataset
2. Save it as `data\products.csv`.
3. Follow the Gemini API key guide:
https://ai.google.dev/gemini-api/docs/api-key
4. Run:
```powershell
python --version
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$Env:GEMINI_API_KEY = "paste-your-key-here"
.\.venv\Scripts\python.exe build_index.py
.\.venv\Scripts\uvicorn.exe app:app --reload
```
5. Open http://127.0.0.1:8000.
## Cost and privacy
Gemini Embedding 2 text input is free of charge on the Gemini API Free Tier. Google states that free-tier content may be used to improve its products. This demo sends public product text and the search queries you enter. Do not use private customer data in the Free Tier demo.
The API key is read from `GEMINI_API_KEY` on the backend. Never add the key to source control or browser JavaScript.
## Data attribution
This project uses the MegaBasket E-commerce Products Dataset by Saurabh Kumar on Kaggle. The dataset page identifies the catalog as 594 records across 15 columns and lists an MIT license.
What should match?
Check every heading in order. Confirm the PowerShell setup includes the six commands shown in the reference.
Test the finished experience
The finished app now has two surfaces to demonstrate. The browser explains each recommendation. The generated API documentation exposes the same endpoint directly.
- Return to http://127.0.0.1:8000 in your browser.
- Click the example for computer for office work without taking much desk space.
You should see three responsive product cards. Each card should show a percentage match, category, subcategory, description, brand, and price.
- Copy the name of one returned product into the search field.
- Click Recommend products to test a literal request.
- Click the example for healthy breakfast for busy mornings.
- Click the example for gift for someone who loves taking photos.
Each request should produce up to three cards with a semantic mode badge. The ranking changes as the requested need changes.
- Open a new browser tab.
- Visit http://127.0.0.1:8000/docs to inspect the generated API documentation.
- Confirm the page lists GET /api/recommendations.
Your browser interface and generated documentation now expose the same recommendation endpoint. That proves the backend can support both a user-facing page and direct API inspection.
Docs page unavailable?
- Confirm the browser address ends with /docs.
- Return to the active server terminal to check that Uvicorn is still running.
Ask for help with the documentation route: Why does my FastAPI app work at the home page but not at the docs page?
A graceful fallback keeps the app usable when semantic retrieval cannot reach its credential. You will temporarily remove the key from the active PowerShell session to test that safety path.
- Return to the terminal running Uvicorn.
- Press Ctrl+C to stop the server.
- Clear the current-session key by running:
$Env:GEMINI_API_KEY = $null
What does this command change?
The command removes GEMINI_API_KEY from the current PowerShell session. It does not write the key to a project file.
Before you restart the app, do you expect the browser to crash or stay usable through the fallback path?
- Restart the server without the key by running:
.\.venv\Scripts\uvicorn.exe app:app --reload
What happens during this run?
Uvicorn starts the same FastAPI application. The new server process cannot read a Gemini key from this terminal session.
- Return to the browser interface.
- Refresh http://127.0.0.1:8000.
- Search for computer for office work without taking much desk space.
The page stays responsive. You should see the mode change to keyword fallback instead of a browser crash.
Seeing a browser connection error?
- Check that the terminal still shows the running Uvicorn process.
- Confirm the browser address is http://127.0.0.1:8000.
- Check the terminal output for a Python syntax error before testing fallback behavior.
Ask for help with the fallback test: My recommender stops loading after I clear GEMINI_API_KEY. Help me separate a server problem from fallback behavior.
- Return to the terminal running Uvicorn.
- Press Ctrl+C to stop the fallback test.
- Restore the key in the current terminal session by running this command with your real key:
$Env:GEMINI_API_KEY = "paste-your-key-here"
Keep the key in PowerShell
Replace the plain placeholder with your key inside PowerShell. Keep the real value out of Python, HTML, README, and Git files.
Before the final restart, do you expect the designed query to return keyword fallback or semantic rankings?
- Restart the app with the restored key by running:
.\.venv\Scripts\uvicorn.exe app:app --reload
What does the restart prove?
The restarted backend inherits the restored key from PowerShell. Semantic query embedding becomes available again.
- Refresh the browser interface.
- Search for computer for office work without taking much desk space.
You should see three recommendation cards with percentage scores and a semantic mode badge. That confirms the final interface, backend retrieval, documentation, and fallback path all work together.
Still seeing keyword fallback?
- Confirm you stopped the keyless server before restoring the environment variable.
- Confirm you replaced the placeholder with your real key only inside PowerShell.
- Check that data/product_index.json still exists in the data folder.
Ask for help with semantic restoration: Why does my app still use keyword fallback after I restored GEMINI_API_KEY and restarted Uvicorn?
Secret mission
Compare Keyword and Semantic Search
Your recommender can rank products by meaning. Now create a repeatable report that shows how those rankings compare with strict keyword matching across three natural-language requests.
Clean Up Your Resources
Clean Up Your Resources
Your finished app has no ongoing local cost on your Windows machine. Gemini API text input costs $0 on the Free Tier.
Resources you used:
- A local Uvicorn process that serves your FastAPI app at http://127.0.0.1:8000 while it is running.
- The semantic-product-recommender folder containing your application code and .venv environment.
- The public catalog at data\products.csv and generated embedding cache at data\product_index.json.
- The generated comparison report at comparison_report.md.
- A Gemini API key managed through Google AI Studio and exposed to the current terminal through GEMINI_API_KEY.
Keep everything running
No action needed. Choose this if you are still actively demonstrating the recommender.
Uvicorn uses local memory while it runs. Google may use content sent through the Gemini API Free Tier to improve its products.
Pause - I'll come back to this later
Shut down the running process to free local memory while keeping your project files ready for another session.
- Switch back to the VS Code PowerShell terminal from earlier.
- Press Ctrl+C to stop Uvicorn if it is running.
- Clear the API key from the current PowerShell session by running:
$Env:GEMINI_API_KEY = $null
What does this command do?
The command removes GEMINI_API_KEY from the active PowerShell session. The key still exists in Google AI Studio for your next session.
Your recommender is now paused safely. The source files, public dataset, cached vectors, and comparison report remain on your machine.
Delete - I don't want to use this again
Permanent cleanup is the irreversible option. Your Google AI Studio account remains available after this project's key is revoked.
- Switch back to the VS Code PowerShell terminal from earlier.
- Press Ctrl+C to stop Uvicorn if it is running.
- Clear the API key from the current PowerShell session by running:
$Env:GEMINI_API_KEY = $null
What does this command do?
The command removes the credential from the current terminal before you delete the local project. Revoking the key in Google AI Studio removes the remaining access.
- Return to the Google AI Studio API Keys page from earlier.
- Revoke the Gemini API key used for this project.
- Confirm that the project key no longer appears as active on the API Keys page.
- Close Visual Studio Code.
- Use Windows File Explorer to locate the semantic-product-recommender folder.
- Delete the semantic-product-recommender folder.
- Confirm that the folder no longer appears in its previous location.
That completes the local cleanup. Your application code and every generated artifact inside the project folder are gone.
Nice Work!
Nice Work!
You did it! Your browser-based product recommender now turns everyday requests into ranked catalog matches using Gemini text embeddings.
You've learned how to:
- Build a FastAPI recommendation endpoint around a diverse 24-product catalog.
- Generate a cached embedding index for 24 products. Rank natural-language requests with cosine similarity to return three relevant products.
- Keep the Gemini API key in the backend environment. Preserve a working experience with a keyword fallback when semantic retrieval is unavailable.
- Complete the optional Secret Mission by generating comparison_report.md. The report compares keyword results with semantic results across three natural-language requests.
Ready to quiz yourself?