Guía técnica

Arquitectura de Búsqueda E-Commerce: MeiliSearch, OpenSearch e Historias Reales de Migración

Cómo diseñar la búsqueda de productos para comercio. MeiliSearch vs OpenSearch vs Elasticsearch, diseño de índices, búsqueda facetada, estrategias multilingües, búsqueda híbrida y sincronización en tiempo real desde PIM y sistemas de comercio.

3 de febrero de 202618 min de lecturaEquipo de Ingeniería Oronts

Por Qué la Búsqueda de Productos No Es Búsqueda de Texto

La búsqueda de productos parece simple. Un usuario escribe "zapatillas azules running talla 42" y espera resultados relevantes. Pero la implementación es fundamentalmente diferente a la búsqueda de documentos o la búsqueda web. Los productos tienen atributos estructurados (talla, color, precio, marca), categorías jerárquicas, disponibilidad que cambia en tiempo real, nombres y descripciones localizados, y facetas por las que los usuarios esperan poder filtrar.

Un motor de búsqueda de documentos encuentra documentos que coinciden con una consulta. Un motor de búsqueda de productos debe encontrar productos, clasificarlos por relevancia comercial (no solo relevancia de texto), presentar facetas filtrables, manejar errores tipográficos y sinónimos, soportar múltiples idiomas y actualizarse en tiempo real cuando cambia el inventario.

Hemos construido sistemas de búsqueda de productos tanto con MeiliSearch como con OpenSearch, migrando desde Elasticsearch 7.4 en un caso y construyendo desde cero en otro. Este artículo cubre las decisiones de arquitectura, no los detalles de configuración. Para patrones de búsqueda vectorial específicamente, consulta nuestra guía de arquitectura de búsqueda vectorial. Para el contexto más amplio de comercio, consulta nuestra guía de plataformas ecommerce.

MeiliSearch vs OpenSearch vs Elasticsearch

CriterioMeiliSearchOpenSearchElasticsearch
LenguajeRustJavaJava
Tolerancia a erroresIncorporada, excelentePlugin/personalizadoPlugin/personalizado
Búsqueda facetadaIncorporada, rápidaIncorporada (agregaciones)Incorporada (agregaciones)
Búsqueda vectorialExperimentalIncorporada (k-NN)Incorporada (dense_vector)
MultilingüeBueno (tokenizadores por idioma)Excelente (analizadores por campo)Excelente (analizadores por campo)
Indexación en tiempo realCasi instantánea (< 50ms)Casi tiempo real (1s de refresco)Casi tiempo real (1s de refresco)
ComplejidadBaja (binario único, API REST)Alta (clúster, shards, réplicas)Alta (clúster, shards, réplicas)
Uso de memoriaBajo (Rust, eficiente)Alto (JVM heap)Alto (JVM heap)
Costo operativoBajo (funciona en instancias pequeñas)Medio a altoMedio a alto
OrdenamientoReglas de ranking incorporadasOrdenamiento flexibleOrdenamiento flexible
LicenciaMITApache 2.0SSPL (no es open source)
Ideal paraCatálogos pequeños a medianos (< 500K productos)Catálogos grandes, consultas complejas, búsqueda híbridaIgual que OpenSearch (si ya tienes inversión previa)

Cuándo Elegir MeiliSearch

  • Catálogo con menos de 500K productos
  • La tolerancia a errores tipográficos es crítica (búsqueda orientada al consumidor)
  • El equipo tiene experiencia limitada en infraestructura de búsqueda
  • La rapidez de configuración importa más que las funciones avanzadas de consulta
  • El presupuesto es ajustado (funciona en una sola instancia pequeña)

Cuándo Elegir OpenSearch

  • Catálogo con más de 100K productos con facetas complejas
  • Necesitas búsqueda híbrida (texto + vector / k-NN)
  • Múltiples grupos de consumidores procesan el mismo índice
  • Ya estás en AWS (OpenSearch Serverless es gestionado)
  • Necesitas agregaciones avanzadas y analítica sobre datos de búsqueda

Cuándo Elegir Elasticsearch

  • Ya estás ejecutando Elasticsearch y no hay razón para migrar
  • Necesitas funciones específicas de Elastic (inferencia ML, seguridad)
  • Se requiere un contrato de soporte empresarial

Para la mayoría de proyectos de comercio nuevos, recomendamos MeiliSearch por simplicidad u OpenSearch por potencia. La licencia SSPL de Elasticsearch lo hace menos atractivo para nuevos despliegues.

Diseño de Índices

El error más común: indexar tu esquema de base de datos directamente. Las tablas de productos están normalizadas. Los índices de búsqueda deben estar desnormalizados.

// Base de datos: normalizada (relacional)
// tabla products: id, name, category_id, brand_id
// tabla categories: id, name, parent_id
// tabla variants: id, product_id, sku, price, size, color
// tabla translations: id, product_id, locale, name, description

// Índice de búsqueda: desnormalizado (documento plano)
interface ProductSearchDocument {
    id: string;
    name: string;                    // locale actual
    description: string;             // locale actual
    slug: string;
    sku: string[];                   // todos los SKUs de variantes
    brand: string;                   // desnormalizado de la tabla de marcas
    categories: string[];            // jerarquía completa: ["Zapatos", "Running", "Trail"]
    categoryIds: string[];           // para filtrado de facetas
    price: number;                   // precio más bajo de variantes (para ordenamiento)
    priceRange: { min: number; max: number };
    sizes: string[];                 // todas las tallas disponibles
    colors: string[];                // todos los colores disponibles
    inStock: boolean;                // alguna variante en stock
    imageUrl: string;                // imagen principal
    rating: number;                  // calificación promedio de reseñas
    reviewCount: number;
    tags: string[];                  // etiquetas buscables
    createdAt: number;               // para ordenamiento "más recientes primero"
    popularity: number;              // conteo de ventas o vistas
}

Reglas para la desnormalización:

  • Aplana todas las relaciones en el documento (nombre de marca, no ID de marca)
  • Incluye la jerarquía completa de categorías como un array (permite drill-down en facetas)
  • Incluye todos los atributos de variantes (tallas, colores) como arrays en el producto
  • Usa el precio más bajo para ordenar, rango de precios para mostrar
  • Incluye campos calculados (rating, reviewCount, popularity) para el ranking
  • Un documento por producto por locale (no un documento con todos los locales)

Un Índice por Locale

Para comercio multilingüe, crea un índice por locale:

products_en
products_de
products_fr
products_ar

Cada índice usa analizadores, tokenizadores y stop words específicos del idioma. Una búsqueda en alemán de "Laufschuhe" usa stemming alemán. Una búsqueda en árabe usa análisis morfológico árabe. Mezclar locales en un solo índice te obliga a hacer compromisos en el análisis que degradan la calidad para todos los idiomas.

// MeiliSearch: un índice por locale
await meili.createIndex('products_de', { primaryKey: 'id' });
await meili.index('products_de').updateSettings({
    searchableAttributes: ['name', 'description', 'brand', 'tags', 'categories'],
    filterableAttributes: ['categories', 'brand', 'sizes', 'colors', 'price', 'inStock'],
    sortableAttributes: ['price', 'createdAt', 'popularity', 'rating'],
});

Arquitectura de Búsqueda Facetada

Las facetas son los filtros en el lado izquierdo de toda página de búsqueda de comercio. Parecen simples pero requieren un diseño cuidadoso.

Tipos de Facetas

TipoEjemploImplementación
Faceta de términoMarca: Nike (42), Adidas (38)Agregación de términos en campo brand
Faceta de rangoPrecio: 0-50 (15), 50-100 (28), 100+ (12)Agregación de rango en campo price
Faceta jerárquicaCategoría: Zapatos > Running > TrailAgregación de términos multinivel en jerarquía de categorías
Faceta booleanaEn Stock: Sí (89), No (11)Agregación de términos en campo inStock
Faceta de colorMuestras de color con conteosAgregación de términos en campo array colors
Faceta de tallaTalla: 40 (5), 41 (8), 42 (12)Agregación de términos en campo array sizes

Interacción de Facetas

Cuando un usuario selecciona una faceta, las demás facetas deben actualizarse para reflejar los resultados filtrados. Esto se llama "refinamiento de facetas" y es la parte más compleja de la UI de búsqueda.

// MeiliSearch: conteo de facetas con filtros activos
const results = await meili.index('products_de').search('laufschuhe', {
    filter: ['brand = "Nike"', 'inStock = true'],
    facets: ['categories', 'brand', 'sizes', 'colors', 'price'],
});

// results.facetDistribution:
// {
//   categories: { "Running": 42, "Trail": 18, "Road": 24 },
//   brand: { "Nike": 42 },  // solo Nike (porque está filtrado)
//   sizes: { "40": 5, "41": 8, "42": 12, "43": 10, "44": 7 },
//   colors: { "Black": 20, "White": 15, "Blue": 7 },
// }

La decisión clave de UX: cuando un filtro de marca está activo, la faceta de marca, debe mostrar solo la marca seleccionada (con su conteo) o todas las marcas (con conteos que reflejen la consulta actual menos el filtro de marca)? El segundo enfoque ("facetado disjuntivo") permite a los usuarios comparar conteos entre marcas. MeiliSearch soporta esto de forma nativa. OpenSearch requiere consultas de agregación separadas por cada faceta disjuntiva.

Sincronización en Tiempo Real desde Sistemas de Origen

Los índices de búsqueda deben mantenerse sincronizados con la fuente de verdad (PIM, base de datos de comercio, ERP). La arquitectura de sincronización depende del sistema de origen.

Sincronización Basada en Eventos (Recomendada)

El sistema de origen emite eventos ante cambios en los datos. Un worker consume los eventos y actualiza el índice de búsqueda.

// Vendure: sincronizar ante eventos de productos
@Injectable()
export class SearchIndexSubscriber {
    constructor(
        private eventBus: EventBus,
        private searchService: SearchIndexService,
    ) {
        this.eventBus.ofType(ProductEvent).subscribe(async event => {
            if (event.type === 'updated' || event.type === 'created') {
                await this.searchService.indexProduct(event.ctx, event.product.id);
            }
            if (event.type === 'deleted') {
                await this.searchService.removeProduct(event.product.id);
            }
        });

        this.eventBus.ofType(ProductVariantEvent).subscribe(async event => {
            // El cambio en la variante afecta al documento de búsqueda del producto padre
            await this.searchService.indexProduct(event.ctx, event.productVariant.productId);
        });
    }
}

Reindexación Completa Programada

Incluso con sincronización basada en eventos, ejecuta una reindexación completa programada como red de seguridad. Los eventos pueden perderse (caída del broker, fallo del worker). Una reindexación completa nocturna captura todo lo que la sincronización basada en eventos haya omitido.

// Job de reindexación completa nocturna
async function fullReindex(locale: string) {
    const batchSize = 500;
    let offset = 0;
    let products = [];

    do {
        products = await productService.findAll({ take: batchSize, skip: offset });
        const documents = products.map(p => buildSearchDocument(p, locale));
        await meili.index(`products_${locale}`).addDocuments(documents);
        offset += batchSize;
    } while (products.length === batchSize);
}

Manejo de Eliminaciones

Las eliminaciones de productos son complicadas. Si eliminas un producto de la base de datos, la sincronización basada en eventos lo elimina del índice. Pero si el evento se pierde, el producto eliminado permanece en los resultados de búsqueda.

Dos soluciones:

  1. Registrar marcas de tiempo de eliminación y filtrar por "no eliminado" en las consultas
  2. La reindexación completa reemplaza el índice entero de forma atómica (intercambio de alias)
// Reindexación atómica con intercambio de alias (OpenSearch/Elasticsearch)
async function atomicReindex(locale: string) {
    const newIndex = `products_${locale}_${Date.now()}`;
    await opensearch.indices.create({ index: newIndex, body: indexSettings });

    // Indexar todos los productos en el nuevo índice
    await bulkIndex(newIndex, locale);

    // Intercambiar alias de forma atómica
    await opensearch.indices.updateAliases({
        body: {
            actions: [
                { remove: { index: `products_${locale}_*`, alias: `products_${locale}` } },
                { add: { index: newIndex, alias: `products_${locale}` } },
            ],
        },
    });

    // Eliminar índices antiguos
    await cleanupOldIndices(`products_${locale}_*`, keepLast: 2);
}

Para ver cómo manejamos pipelines de sincronización de datos a escala, nuestro Vendure Data Hub Plugin implementa todos estos patrones con 7 sinks de búsqueda diferentes.

Ajuste de Relevancia

La relevancia de búsqueda por defecto es incorrecta para comercio. La relevancia de texto (qué tan bien la consulta coincide con el documento) es una señal. La relevancia comercial (qué tan probable es que el usuario compre) es igual de importante.

Señales de Ranking

SeñalPesoOrigen
Coincidencia de texto (título)AltoMotor de búsqueda
Coincidencia de texto (descripción)MedioMotor de búsqueda
En stockCrítico (boost o filtro)Sistema de inventario
Popularidad (conteo de ventas)MedioDatos de pedidos
Calificación de reseñasBajo-MedioReseñas
Novedad (productos nuevos)BajoFecha de creación del producto
Margen (interno)OpcionalReglas de negocio
// MeiliSearch: reglas de ranking personalizadas
await meili.index('products_de').updateSettings({
    rankingRules: [
        'words',           // 1. Calidad de coincidencia de texto
        'typo',            // 2. Tolerancia a errores tipográficos
        'proximity',       // 3. Proximidad de palabras
        'attribute',       // 4. Qué campo coincidió (título > descripción)
        'sort',            // 5. Ordenamiento solicitado por el usuario
        'exactness',       // 6. Coincidencia exacta vs parcial
        'popularity:desc', // 7. Productos populares se posicionan más arriba
        'rating:desc',     // 8. Productos mejor calificados se posicionan más arriba
    ],
});

Impulsar Productos en Stock

Los productos agotados deberían aparecer más abajo en los resultados, no desaparecer por completo. Los usuarios podrían querer ver productos próximos o suscribirse a notificaciones de reposición.

// OpenSearch: impulsar productos en stock
const query = {
    bool: {
        must: [{ match: { searchText: userQuery } }],
        should: [
            { term: { inStock: { value: true, boost: 5.0 } } }, // Impulso fuerte para productos en stock
        ],
        filter: [
            { term: { tenant_id: tenantId } },
        ],
    },
};

Búsqueda Híbrida para Comercio

Combinar búsqueda de texto con búsqueda vectorial mejora los resultados para consultas en lenguaje natural, preservando la capacidad de coincidencia exacta para SKUs y códigos de producto.

// OpenSearch: búsqueda híbrida (texto + vector)
const results = await opensearch.search({
    index: 'products_en',
    body: {
        query: {
            bool: {
                should: [
                    // Búsqueda de texto (maneja SKUs, nombres exactos de productos)
                    { multi_match: { query: userQuery, fields: ['name^3', 'description', 'sku^5', 'tags'], type: 'best_fields' } },
                    // Búsqueda vectorial (maneja lenguaje natural, similitud semántica)
                    { knn: { embedding: { vector: queryEmbedding, k: 20 } } },
                ],
            },
        },
        // Facetas
        aggs: {
            brands: { terms: { field: 'brand.keyword', size: 20 } },
            categories: { terms: { field: 'categories.keyword', size: 30 } },
            price_ranges: { range: { field: 'price', ranges: [{ to: 50 }, { from: 50, to: 100 }, { from: 100 }] } },
        },
    },
});

Las consultas de SKU ("ABC-12345") van por la ruta de búsqueda de texto con alta precisión. Las consultas en lenguaje natural ("zapatos cómodos para caminatas largas") van por la ruta de búsqueda vectorial con comprensión semántica. Ambas contribuyen al ranking final.

Para más detalles sobre los mecanismos internos de la búsqueda vectorial, consulta nuestra guía de arquitectura de búsqueda vectorial.

Errores Comunes

  1. Indexar datos normalizados. Tus documentos de búsqueda deben estar desnormalizados. Aplana todas las relaciones en el documento. No hagas referencia a IDs que requieran una segunda consulta.

  2. Un solo índice para todos los locales. Crea un índice por locale. Los índices con locales mezclados no pueden usar analizadores específicos del idioma, y la calidad de búsqueda se degrada para todos los idiomas.

  3. Sin diseño de facetas. Las facetas no son algo secundario. Planifica qué atributos son filtrables, cómo funcionan las categorías jerárquicas y cómo se actualizan los conteos de facetas cuando se aplican filtros.

  4. Sincronización solo mediante reindexación programada. La sincronización basada en eventos proporciona actualizaciones casi en tiempo real. La reindexación programada es una red de seguridad, no el mecanismo principal.

  5. Sin ajuste de relevancia. La relevancia de texto por defecto es incorrecta para comercio. Impulsa los productos en stock, incorpora popularidad y calificaciones, y pondera las coincidencias en el título por encima de las coincidencias en la descripción.

  6. Ignorar productos agotados. No los elimines del índice. Degrádalos en el ranking. Los usuarios podrían querer alertas de reposición o navegar productos próximos.

  7. Sin reindexación atómica. Si tu proceso de reindexación falla a la mitad, tienes un índice parcialmente actualizado. Usa el intercambio de alias para un cambio atómico.

  8. Tratar la búsqueda como una funcionalidad, no como infraestructura. La búsqueda es un servicio central. Necesita su propio clúster, su propio monitoreo, su propia estrategia de escalado. No la ejecutes en el mismo servidor que tu base de datos.

Conclusiones Clave

  • La búsqueda de productos no es búsqueda de texto. Atributos estructurados, facetas, relevancia comercial, inventario en tiempo real y soporte multilingüe la hacen fundamentalmente diferente.

  • Desnormaliza para la búsqueda, normaliza para el almacenamiento. El documento de búsqueda es una representación plana y autocontenida de todo lo necesario para renderizar un resultado de búsqueda. Sin joins, sin lookups.

  • Un índice por locale. Los analizadores, tokenizadores y stop words específicos del idioma producen resultados drásticamente mejores que un solo índice con idiomas mezclados.

  • Sincronización basada en eventos con reindexación programada como red de seguridad. Actualizaciones en tiempo real para operaciones normales. Reindexación completa nocturna para capturar todo lo que los eventos hayan omitido.

  • El ajuste de relevancia es una decisión de negocio. Calidad de coincidencia de texto, estado de stock, popularidad, calificaciones y margen son todas señales de ranking. La relevancia por defecto es incorrecta para comercio.

  • MeiliSearch para simplicidad, OpenSearch para potencia. MeiliSearch es perfecto para catálogos con menos de 500K productos y excelente tolerancia a errores tipográficos. OpenSearch maneja agregaciones complejas, búsqueda híbrida y despliegues a gran escala.

Construimos infraestructura de búsqueda como parte de nuestra práctica de ingeniería de datos y ecommerce. Si estás construyendo o migrando la búsqueda de productos, habla con nuestro equipo o solicita un presupuesto. Nuestro Vendure Data Hub Plugin incluye sinks de búsqueda para MeiliSearch, OpenSearch, Elasticsearch, Algolia y Typesense.

Temas cubiertos

búsqueda ecommerceMeiliSearch ecommercearquitectura de búsqueda de productosbúsqueda facetadabúsqueda híbrida comerciomigración ElasticsearchOpenSearch comercioindexación de búsqueda

¿Listo para construir sistemas de IA listos para producción?

Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.

Iniciar una conversación