Course EN
← back to chapter

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.

Descargar semantic-search.zip

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

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

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json