🎯 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.
tsvectorde 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?
| Necesitas | PostgreSQL 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 documentos | Se degrada | ✅ (sobre todo Elasticsearch) |
| Búsqueda multi-idioma con sinónimos | Manual | ✅ 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Í → ElasticsearchT.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.
docker run -d -p 7700:7700 -v meili_data:/meili_data \
getmeili/meilisearch:v1.41 --master-key="clave-de-desarrollo"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// 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.
facetDistributionte 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)
// 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,
});
}<!-- 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:
// 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.
semanticRatioes 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).
docker run -d -p 9200:9200 -e "discovery.type=single-node" \
-e "xpack.security.enabled=false" elasticsearch:9.4.3 # sin auth: SOLO para desarrolloimport { 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.// 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// 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
- 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. - Meilisearch por defecto; Elasticsearch cuando necesites agregaciones pesadas o ya tengas un caso de logs/analítica (ELK) que lo justifique doblemente.
- PostgreSQL es siempre la fuente de verdad. El índice de búsqueda se reconstruye desde cero cuando haga falta, nunca al revés.
- 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.
- Configura
searchableAttributes/mappings con intención — el orden en Meilisearch define prioridad de relevancia, no es solo "qué campos son buscables". - 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