Búsqueda semántica
Busca en un pequeño corpus de centro de ayuda por significado. Tu query se
Showcase — Búsqueda semántica
Busca en un pequeño corpus de centro de ayuda por significado. Tu query se embebe y se rankea contra los documentos por cosine similarity, así que "how do I get my money back" saca a flote la política de reembolsos aunque no compartan ni una palabra clave. El corpus se embebe una vez y se cachea; cada query es una sola llamada de embedding más algo de aritmética.
Córrelo
bash bootstrap-secrets.sh # reads ../../../../.env, writes secrets/
docker compose up --build # default: PROVIDER=openai
Abre http://localhost:3000.
Para correrlo contra Gemini en su lugar:
PROVIDER=gemini docker compose up --build
Qué hay aquí
backend/ai_openai.py/backend/ai_gemini.py— embebe el corpus (cacheado), embebe el query, rankea por cosine similarity.backend/main.py— loader idéntico de FastAPI; lee PROVIDER y despacha.frontend/app/page.tsx— caja de query + resultados rankeados.
Cosine similarity es puro Python — todavía no hay base de datos vectorial. Eso es el próximo capítulo.
Detenlo
docker compose down
Ejecútalo en tu máquina
Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en 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
Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.
Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña 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)
Archivos del proyecto
.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