Skip to content

🎯 Meta: el apéndice H.2 te dio full-text search dentro de PostgreSQL — suficiente para el 80% de los proyectos. Este apéndice cubre el 20% restante: cuando necesitas tolerancia a errores tipográficos de verdad, facetas (filtros combinables tipo Amazon), autocompletado instantáneo, o agregaciones sobre millones de documentos que empiezan a doler en Postgres. Verás Meilisearch (simple, la opción por defecto en 2026) y Elasticsearch (cuando el volumen o la complejidad lo piden de verdad).

Versiones: Meilisearch 1.41 · Elasticsearch 9.4.

⚠️ Antes de añadir un servicio más: repite la pregunta del apéndice H.2 y Q.1. tsvector de PostgreSQL con un buen índice GIN aguanta catálogos de cientos de miles de productos sin despeinarse. Un buscador dedicado es otro sistema que sincronizar, escalar y monitorizar (ap. F) — mételo cuando el full-text de Postgres se quede corto de verdad, no por moda.


T.1 · ¿Qué te da un buscador dedicado que Postgres no?

NecesitasPostgreSQL tsvector (ap. H.2)Buscador dedicado
Buscar palabras exactas o con raíz (stemming)
Tolerancia a errores tipográficos ("tecaldo" → "teclado")
Facetas combinables (categoría + precio + marca, con contadores en vivo)Posible, laborioso✅ nativo
Autocompletado instantáneo (< 50 ms, mientras el usuario teclea)Lento a partir de cierto volumen✅ diseñado para esto
Ranking de relevancia configurable (boost por campo, sinónimos)Básico✅ avanzado
Agregaciones/analítica sobre millones de documentosSe degrada✅ (sobre todo Elasticsearch)
Búsqueda multi-idioma con sinónimosManual✅ configurable
                    ¿Tu catálogo cabe en la cabeza y crece poco?

                    ┌───────────────┴───────────────┐
                    │ NO (facetas, typo-tolerance,   │  SÍ → tsvector + GIN (ap. H.2)
                    │  autocompletado en vivo)         │      no compliques esto
                    └───────────────┬───────────────┘

                    ¿Necesitas agregaciones/analítica    NO → Meilisearch
                    sobre logs o millones de eventos?          (simple, rápido de operar)

                                    SÍ → Elasticsearch

T.2 · Meilisearch: la opción por defecto en 2026

Diseñado para "funciona bien desde el minuto uno, sin ajustar 40 parámetros". Es lo que debes probar primero.

bash
docker run -d -p 7700:7700 -v meili_data:/meili_data \
  getmeili/meilisearch:v1.41 --master-key="clave-de-desarrollo"
typescript
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({ host: 'http://localhost:7700', apiKey: process.env.MEILI_KEY });

// Crear/actualizar el índice — la primera indexación configura tipos automáticamente
const productos = client.index('productos');

await productos.addDocuments([
  { id: 1, nombre: 'Teclado mecánico RGB', categoria: 'perifericos', precio: 89.9, marca: 'Logitech' },
  { id: 2, nombre: 'Teclado inalámbrico', categoria: 'perifericos', precio: 39.9, marca: 'Redragon' },
]);

// Configuración de búsqueda (una vez, no en cada query)
await productos.updateFilterableAttributes(['categoria', 'marca', 'precio']);
await productos.updateSortableAttributes(['precio']);
await productos.updateSearchableAttributes(['nombre', 'categoria', 'marca']);   // orden = prioridad
typescript
// Búsqueda con tolerancia a errores tipográficos — funciona out-of-the-box
const resultados = await productos.search('tecaldo mecanico', {   // ⚠️ error tipográfico a propósito
  filter: ['categoria = perifericos', 'precio < 100'],
  sort: ['precio:asc'],
  facets: ['marca', 'categoria'],
  limit: 20,
});

console.log(resultados.hits);              // [{ nombre: "Teclado mecánico RGB", ... }] — lo encuentra igual
console.log(resultados.facetDistribution);  // { marca: { Logitech: 1, Redragon: 1 } } — para pintar filtros

🧠 Facetas = los filtros combinables de cualquier tienda online real. facetDistribution te da, en la MISMA petición, cuántos resultados hay por cada valor de marca/categoría — exactamente lo que pinta el sidebar de "Marca (12) · Categoría (8)" sin peticiones extra.

Autocompletado con debounce (conecta con el cap. 15/17)

tsx
// React — el patrón de búsqueda en vivo del cap. 17.9, apuntando a Meilisearch en vez de tu API
function useBusquedaProductos(query: string) {
  return useQuery({
    queryKey: ['busqueda', query],
    queryFn: () => productos.search(query, { limit: 8 }),
    enabled: query.length >= 2,
    staleTime: 30_000,
  });
}
html
<!-- HTMX (cap. 16): el propio backend consulta Meilisearch y devuelve HTML -->
<input type="search" name="q"
       hx-get="/buscar" hx-trigger="input changed delay:200ms" hx-target="#resultados">

Búsqueda híbrida: texto + vectores (conecta con el apéndice V)

Todo lo anterior es búsqueda léxica: encuentra coincidencias de palabras (con tolerancia a errores). Pero "sillón cómodo para leer" no encontrará un producto llamado "butaca de lectura" — mismo significado, palabras distintas. Para eso hace falta búsqueda semántica por embeddings (apéndice V.2): Meilisearch soporta ambas a la vez desde sus versiones recientes:

typescript
// Búsqueda híbrida: combina coincidencia léxica (typo-tolerant) + similitud semántica
const resultados = await productos.search('mueble para sentarse a leer', {
  hybrid: {
    semanticRatio: 0.5,       // 0 = solo léxico, 1 = solo semántico, 0.5 = mitad y mitad
    embedder: 'default',      // configurado una vez para generar embeddings de cada documento
  },
  limit: 20,
});

🧠 Léxico y semántico resuelven problemas distintos, por eso se combinan. El léxico gana cuando el usuario ya conoce el término exacto (SKU, nombre de marca, "RGB"); el semántico gana cuando describe lo que busca con otras palabras. semanticRatio es el dial que decide cuánto pesa cada uno — empieza en 0.5 y ajústalo según el comportamiento real de tus usuarios.

🔗 Si tu caso de uso es "que el LLM responda con tus datos" (no solo mostrar resultados de búsqueda a un humano), esto es exactamente el mismo problema de fondo que resuelve el apéndice V (RAG) con pgvector — la diferencia es que aquí el motor de búsqueda ya trae los embeddings integrados, sin que gestiones tú la tabla de vectores.


T.3 · Elasticsearch: cuando el volumen o la analítica lo piden

Más potente y más complejo de operar. Brilla en dos casos: catálogos muy grandes con agregaciones pesadas, y análisis de logs (el "L" del stack ELK, que conecta con el cap. F).

bash
docker run -d -p 9200:9200 -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" elasticsearch:9.4.3    # sin auth: SOLO para desarrollo
typescript
import { Client } from '@elastic/elasticsearch';

const es = new Client({ node: 'http://localhost:9200' });

await es.index({
  index: 'productos',
  id: '1',
  document: { nombre: 'Teclado mecánico RGB', categoria: 'perifericos', precio: 89.9 },
});

const { hits } = await es.search({
  index: 'productos',
  query: {
    bool: {
      must: [{ match: { nombre: 'teclado mecanico' } }],
      filter: [{ range: { precio: { lt: 100 } } }],
    },
  },
  aggs: {                                    // agregaciones: la fuerza de Elasticsearch
    por_marca: { terms: { field: 'marca.keyword' } },
    precio_medio: { avg: { field: 'precio' } },
  },
});

💡 DSL de queries más expresivo, pero con más curva. Meilisearch resuelve el 90% de "buscador de e-commerce" con configuración mínima; Elasticsearch resuelve el 100% de "cualquier búsqueda o analítica imaginable" a cambio de más piezas que entender (shards, réplicas, mappings). Empieza por Meilisearch salvo que ya sepas que necesitas lo segundo.


T.4 · El problema real: mantener el índice sincronizado con tu BD

Tu buscador es una copia derivada de los datos que viven de verdad en PostgreSQL — el mismo problema de consistencia que el apéndice S (Kafka) resolvía con el outbox pattern:

❌ Sincronización ingenua: actualizas Postgres y el índice EN LA MISMA función,
   sin transacción — si falla la segunda escritura, quedan desincronizados para siempre.

✅ Opción simple (proyectos pequeños): reindexado completo periódico (cron, cap. B.5)
   — aceptas unos minutos de desfase, cero complejidad extra.

✅ Opción robusta (conecta con ap. S): outbox pattern — cada cambio en productos
   escribe también en la tabla outbox; un worker consume esa tabla y actualiza
   Meilisearch/Elasticsearch de forma reintentable.
typescript
// Reindexado completo — sencillo, suficiente para catálogos que cambian poco
async function reindexarProductos() {
  const productos = await db.producto.findMany();
  await meiliClient.index('productos').addDocuments(productos, { primaryKey: 'id' });
}
// cron.schedule('0 */6 * * *', reindexarProductos)  — cada 6h, como el ap. B.5
typescript
// Sincronización por evento (conecta con outbox del ap. S) — casi en tiempo real
consumidor.run({
  eachMessage: async ({ message }) => {
    const evento = JSON.parse(message.value!.toString());
    if (evento.tipo === 'producto.actualizado') {
      await meiliClient.index('productos').addDocuments([evento.producto]);
    }
  },
});

🧠 Nunca conviertas tu buscador en la fuente de verdad. PostgreSQL sigue siendo dueño del dato; el índice de búsqueda es una proyección optimizada para leer rápido, que puedes borrar y reconstruir por completo sin perder nada. Si tu buscador cae, tu tienda sigue funcionando (peor, sin búsqueda avanzada) — no se cae el checkout.


T.5 · Buenas prácticas

  1. Prueba tsvector (ap. H.2) primero. Un buscador dedicado es infraestructura extra a operar y sincronizar — solo vale la pena cuando el problema real lo justifica.
  2. Meilisearch por defecto; Elasticsearch cuando necesites agregaciones pesadas o ya tengas un caso de logs/analítica (ELK) que lo justifique doblemente.
  3. PostgreSQL es siempre la fuente de verdad. El índice de búsqueda se reconstruye desde cero cuando haga falta, nunca al revés.
  4. Sincronización asíncrona, nunca acoplada a la transacción que escribe en tu BD principal — usa cron para catálogos pequeños, eventos/outbox (ap. S) para volumen alto.
  5. Configura searchableAttributes/mappings con intención — el orden en Meilisearch define prioridad de relevancia, no es solo "qué campos son buscables".
  6. Monitoriza el desfase de sincronización (ap. F) igual que monitorizas el lag de un consumer group.

✅ Ejercicio del apéndice

1. Levanta Meilisearch con Docker e indexa la carta de la Cantina (cap. 22): nombre,
   categoría, precio, ingredientes.
2. Búsqueda con typo-tolerance desde el frontend (React o HTMX) con debounce de 200ms
   y facetas por categoría con contadores en vivo.
3. Reindexado por cron cada hora (patrón simple) — mide cuánto tarda con tu catálogo.
4. Bonus: sincronización por evento reusando el outbox pattern del apéndice S —
   demuestra que un cambio de precio se refleja en la búsqueda en segundos, no en horas.
5. Compara: implementa la misma búsqueda con tsvector + GIN (ap. H.2) y anota,
   por escrito, en qué momento notarías la diferencia con tu volumen real de datos.
6. Bonus: activa búsqueda híbrida con un embedder configurado y compara los
   resultados de una consulta con sinónimos (ej. "bebida fría") con
   semanticRatio en 0, 0.5 y 1 — anota cómo cambia el orden de los resultados.

🧠 Autoevaluación

Tolerancia a errores tipográficos nativa, facetas combinables con contadores en vivo, y autocompletado sub-50ms a gran volumen. Postgres hace full-text razonablemente bien, pero esas tres cosas requieren mucho trabajo manual para igualarlas.

Porque es una copia derivada, optimizada para lectura rápida. Si se pierde o corrompe, se reconstruye reindexando desde PostgreSQL sin pérdida de datos reales. Si fuera la fuente de verdad, perderlo significaría perder información de negocio.

Cuando necesitas agregaciones pesadas sobre volúmenes muy grandes, o cuando el caso de uso es análisis de logs/observabilidad (ELK, conecta con el ap. F) más que búsqueda de catálogo. Para "buscador de e-commerce típico", Meilisearch resuelve más con menos operación.

Semántica. La búsqueda léxica (aunque tolere errores tipográficos) compara palabras; no relaciona "mueble para sentarse a leer" con "butaca de lectura" porque no comparten términos. La búsqueda semántica compara el significado vía embeddings (ap. V.2), por eso la búsqueda híbrida combina ambas con semanticRatio.


Volver al: README.md · Relacionado: H-patrones-datos.md, S-kafka-rabbitmq.md, 17-react.md, V-introduccion-rag.md