El Rediseño del Pipeline: IDs Estables, BM25 y Entity Resolution
Este artículo técnico detalla el rediseño de un pipeline de scraping y matching de productos para resolver problemas de IDs inestables y falsos positivos. Se implementa una estrategia de IDs estables para evitar duplicados, se fusionan señales de texto con BM25 y cosine similarity usando RRF, y se introduce un sistema de Entity Resolution local basado en scoring de componentes (marca, modelo, tamaño, etc.). El pipeline automatizado, desplegado en Cloud Run Job, mejoró la precisión y redujo drásticamente el tiempo de ejecución. El lector aprenderá sobre la implementación de estas técnicas y su
En el post anterior dejé el sistema con scrapers de proveedores, matching por embeddings y un dashboard funcional. Pero a la semana de usarlo, los problemas eran evidentes: cada scrape generaba IDs nuevos (el snapshot_date era parte de la clave), los embeddings se recalculaban completos cada vez, y el matching producía demasiados falsos positivos porque dos perfumes de la misma marca pero distinto tamaño se veían "similares" para el modelo de embeddings.
Necesitaba un rediseño desde los cimientos.
El problema de los IDs inestables
El modelo original tenía unique_together = (site, external_id, snapshot_date). Cada scrape creaba registros nuevos aunque el producto no hubiera cambiado. Eso significaba que los embeddings se regeneraban para miles de productos idénticos, las relaciones de matching se perdían entre snapshots, y la tabla crecía sin control.
La solución fue migrar a IDs estables: unique_together = (site, external_id). Un producto es un producto, sin importar cuándo lo scrapeé. Para el historial de precios creé SupplierProductSnapshot:
class SupplierProductSnapshot(models.Model):
product = models.ForeignKey(SupplierProduct, on_delete=models.CASCADE)
price = models.DecimalField(max_digits=12, decimal_places=2)
snapshot_date = models.DateField()
class Meta:
unique_together = ('product', 'snapshot_date')
La migración fue delicada — tenía ~1192 grupos de duplicados que había que deduplicar con DISTINCT ON y tablas temporales. Usé atomic=False para poder hacer el SQL en batches sin bloquear la BD.
BM25 + Cosine RRF: fusión de señales de texto
El matching por embeddings solo captura similitud semántica. "Carolina Herrera Good Girl 80ml" y "CH Good Girl EDP 50ml" tienen un cosine similarity de 0.85 — alto, pero ¿son el mismo producto? No. Son tamaños distintos.
Agregué BM25 (el algoritmo clásico de búsqueda de texto) como segunda señal. BM25 es sensible a tokens exactos: si "80ml" aparece en un producto y "50ml" en otro, BM25 los penaliza. Luego fusiono ambos rankings con RRF:
def _rrf_fuse(bm25_ranks, cosine_ranks, k=60):
scores = {}
for doc_id, rank in bm25_ranks.items():
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
for doc_id, rank in cosine_ranks.items():
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])
El mismo RRF que usé para fusionar tendencias de MercadoLibre con Google Trends, ahora aplicado al matching de productos. Es un patrón que se repite.
Entity Resolution: scoring de 5 componentes
El reranker LLM (Gemini Flash) funcionaba pero era lento y caro para miles de pares. Lo reemplacé por un sistema de scoring local con 5 componentes:
- Brand score — ¿Coincide la marca exacta?
- Model score — ¿Coincide el nombre del modelo (ej: "Good Girl")?
- Size score — ¿Coincide el tamaño (80ml vs 80ml)?
- Composite score — RRF fusionado de BM25 + cosine
- Similarity score — Cosine similarity del embedding
Cada componente produce un score entre 0 y 1. El match se clasifica como identical si brand + model + size coinciden exactamente, o similar si solo la marca y el embedding son altos. El LLM quedó como reranker opcional (--skip-rerank) para validar los similar.
15 scrapers
Paralelamente, expandí de 2 a 15 scrapers cubriendo wholesale y retail. Tres plataformas distintas:
- Shopify (5 sitios): API JSON pública en
/products.json, paginada. Extraigobody_htmlpara descripciones. - WooCommerce (3 sitios): REST API con consumer key.
description+short_description. - Jumpseller (3 sitios): HTML scraping con BeautifulSoup, sin API pública.
El dict SCRAPERS en scrape_suppliers.py es la fuente de verdad: cada entrada mapea un key a (ScraperClass, site_type). Agregar un sitio nuevo es una línea.
Cloud Run Job para automatización
Con 15 scrapers + matching + embeddings, el pipeline ya no se podía correr a mano. Creé un Cloud Run Job (suppliers-pipeline) con el mismo patrón que el pipeline anterior:
# Dockerfile multi-stage, entrypoint.sh que:
# 1. Scrape all sites
# 2. Match products (BM25 + cosine + entity resolution)
# 3. Map ML demand signals
# 4. Email con resumen
El Job corre semanal y se configura con setup.sh (build Docker + Cloud Scheduler). Los secrets los lee de Secret Manager, igual que el pipeline R.
Resultado
El pipeline pasó de ser un script manual que generaba duplicados a un sistema automatizado con IDs estables, historial de precios, matching multi-señal y entity resolution local que identifica correctamente "Good Girl 80ml" vs "Good Girl 50ml". Los falsos positivos bajaron drásticamente y el tiempo de ejecución se redujo de ~45 minutos (con LLM para todo) a ~8 minutos (entity resolution local + LLM solo para similar).
En el próximo post: cómo convertir estos matches técnicos en oportunidades de negocio con márgenes reales.