Semantic search
Search a small help-center corpus by meaning. Your query is embedded and ranked
Showcase — Semantic search
Search a small help-center corpus by meaning. Your query is embedded and ranked against the documents by cosine similarity, so "how do I get my money back" surfaces the refund policy even though they share no keywords. The corpus is embedded once and cached; each query is a single embedding call plus some arithmetic.
Run
bash bootstrap-secrets.sh # reads ../../../../.env, writes secrets/
docker compose up --build # default: PROVIDER=openai
Open http://localhost:3000.
To run against Gemini instead:
PROVIDER=gemini docker compose up --build
What's where
backend/ai_openai.py/backend/ai_gemini.py— embed the corpus (cached), embed the query, rank by cosine similarity.backend/main.py— identical FastAPI loader; reads PROVIDER and dispatches.frontend/app/page.tsx— query box + ranked results.
Cosine similarity is pure Python — no vector database yet. That's next chapter.
Stop
docker compose down
Run locally
Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.
unzip semantic-search.zip
cd semantic-search
bash bootstrap-secrets.sh # one-time: pulls API keys into ./secrets
docker compose up --build # default provider: openai
# or: PROVIDER=gemini docker compose up --build
Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.
The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.
backend/ai_openai.py
"""Showcase 1 (OpenAI): semantic search over a small corpus.
Embed the corpus once (lazily, cached), embed the query per request, rank by
cosine similarity. No keyword index — "how do I get my money back" finds the
refund policy even though it shares no words with it, because the meanings sit
close together in embedding space.
"""
import math
from openai import OpenAI
_client = OpenAI()
_MODEL = "text-embedding-3-small"
# A tiny help-center corpus. Deliberately worded so keyword search would miss.
_CORPUS = [
"Reset your password from the login page using 'Forgot password'.",
"Refunds are issued to the original payment method within 5-7 business days.",
"Standard shipping takes 3-5 business days; express takes 1-2.",
"Cancel your subscription any time under Account > Billing before renewal.",
"We accept Visa, Mastercard, and American Express.",
"Two-factor authentication can be enabled under Account > Security.",
"Damaged items can be exchanged within 30 days with the order number.",
"Gift cards never expire and can be combined with one promo code.",
"Track your order from the shipping confirmation email's tracking link.",
"Our support team is available 9am-6pm ET, Monday through Friday.",
]
_corpus_vecs: list[list[float]] | None = None
def _embed(texts: list[str]) -> list[list[float]]:
return [item.embedding for item in _client.embeddings.create(model=_MODEL, input=texts).data]
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def _corpus() -> list[list[float]]:
global _corpus_vecs
if _corpus_vecs is None: # embed once, reuse across requests
_corpus_vecs = _embed(_CORPUS)
return _corpus_vecs
def run(query: str) -> str:
qv = _embed([query.strip()])[0]
ranked = sorted(
((_cosine(qv, dv), doc) for dv, doc in zip(_corpus(), _CORPUS)),
reverse=True,
)
lines = [f"{score:.3f} {doc}" for score, doc in ranked[:5]]
return "Top matches by cosine similarity:\n\n" + "\n".join(lines)
backend/ai_gemini.py
"""Showcase 1 (Gemini): semantic search over a small corpus.
Same corpus, same cosine ranking. Only the embedding call differs.
"""
import math
import os
from google import genai
_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
_MODEL = "gemini-embedding-001"
_CORPUS = [
"Reset your password from the login page using 'Forgot password'.",
"Refunds are issued to the original payment method within 5-7 business days.",
"Standard shipping takes 3-5 business days; express takes 1-2.",
"Cancel your subscription any time under Account > Billing before renewal.",
"We accept Visa, Mastercard, and American Express.",
"Two-factor authentication can be enabled under Account > Security.",
"Damaged items can be exchanged within 30 days with the order number.",
"Gift cards never expire and can be combined with one promo code.",
"Track your order from the shipping confirmation email's tracking link.",
"Our support team is available 9am-6pm ET, Monday through Friday.",
]
_corpus_vecs: list[list[float]] | None = None
def _embed(texts: list[str]) -> list[list[float]]:
return [e.values for e in _client.models.embed_content(model=_MODEL, contents=texts).embeddings]
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def _corpus() -> list[list[float]]:
global _corpus_vecs
if _corpus_vecs is None:
_corpus_vecs = _embed(_CORPUS)
return _corpus_vecs
def run(query: str) -> str:
qv = _embed([query.strip()])[0]
ranked = sorted(
((_cosine(qv, dv), doc) for dv, doc in zip(_corpus(), _CORPUS)),
reverse=True,
)
lines = [f"{score:.3f} {doc}" for score, doc in ranked[:5]]
return "Top matches by cosine similarity:\n\n" + "\n".join(lines)
Project files
.gitignoreREADME.es.mdREADME.mdbackend/Dockerfilebackend/ai_gemini.pybackend/ai_openai.pybackend/main.pybackend/requirements.txtbootstrap-secrets.shdocker-compose.ymlfrontend/Dockerfilefrontend/app/layout.tsxfrontend/app/page.tsxfrontend/next.config.tsfrontend/package.jsonfrontend/tsconfig.json