Course EN
← back to chapter

Buscador de duplicados

Pega una lista — tickets de soporte, respuestas de encuestas, nombres de

Showcase — Buscador de duplicados

Pega una lista — tickets de soporte, respuestas de encuestas, nombres de producto — y encuentra los pares que significan lo mismo aunque estén redactados distinto. Cada línea se embebe una vez, todos los pares se comparan por cosine similarity y los más cercanos aparecen hasta arriba. El dedup por coincidencia exacta o por fuzzy-string no puede ver más allá de las palabras; los embeddings comparan significado.

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 cada línea, compara todos los pares por cosine similarity, rankea los más cercanos.
  • backend/main.py — loader idéntico de FastAPI; lee PROVIDER y despacha.
  • frontend/app/page.tsx — una caja de texto (un item por línea) + pares rankeados.

La comparación de todos los pares es O(n²) — bien para una caja de texto, no para un millón de filas. Ese es el problema que resuelve una base de datos vectorial, 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 dedup-finder.zip

unzip dedup-finder.zip
cd dedup-finder
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 3 (OpenAI): near-duplicate detection with embeddings.

Paste a list of lines — support tickets, survey answers, product names — and
find the pairs that MEAN the same thing even when they're worded differently.
Embed every line once, compare all pairs by cosine similarity, and surface the
closest ones. Exact-match dedup can't do this; embeddings can.
"""
import math

from openai import OpenAI

_client = OpenAI()

_MODEL = "text-embedding-3-small"


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 run(text: str) -> str:
    items = [line.strip() for line in text.splitlines() if line.strip()]
    if len(items) < 2:
        return "Enter at least two lines (one item per line)."
    vecs = _embed(items)
    pairs = []
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            pairs.append((_cosine(vecs[i], vecs[j]), items[i], items[j]))
    pairs.sort(reverse=True)
    rows = [f"{score:.3f}  [{a}]  ≈  [{b}]" for score, a, b in pairs[:8]]
    return "Most similar pairs (likely duplicates at the top):\n\n" + "\n".join(rows)

backend/ai_gemini.py

"""Showcase 3 (Gemini): near-duplicate detection with embeddings.

Same all-pairs cosine comparison, Gemini's embedding model.
"""
import math
import os

from google import genai

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-embedding-001"


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 run(text: str) -> str:
    items = [line.strip() for line in text.splitlines() if line.strip()]
    if len(items) < 2:
        return "Enter at least two lines (one item per line)."
    vecs = _embed(items)
    pairs = []
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            pairs.append((_cosine(vecs[i], vecs[j]), items[i], items[j]))
    pairs.sort(reverse=True)
    rows = [f"{score:.3f}  [{a}]  ≈  [{b}]" for score, a, b in pairs[:8]]
    return "Most similar pairs (likely duplicates at the top):\n\n" + "\n".join(rows)

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