OpenSearch vs PostgreSQL — Búsqueda híbrida sin dolor

¿Te fue útil?

No te propusiste gestionar una “Plataforma de Búsqueda”. Solo querías que el cuadro de búsqueda de tu producto dejara de darte vergüenza:
los usuarios no encuentran cosas que saben que existen, la latencia se dispara durante actualizaciones por lotes y cada “ajuste rápido de relevancia” se convierte en un fin de semana.

La bifurcación habitual parece engañosamente simple: “¿Podemos mantener la búsqueda en PostgreSQL?” versus “¿Necesitamos OpenSearch?”
La respuesta real casi siempre es: “Usad ambos, pero solo si sois disciplinados sobre dónde reside la verdad y cómo operáis el pipeline.”

La decisión que realmente necesitas tomar

“OpenSearch vs PostgreSQL” es un encuadre equivocado. En realidad estás decidiendo:

  • ¿Dónde vive la fuente de la verdad? (Casi siempre en PostgreSQL.)
  • ¿Qué tipo de consultas deben ser rápidas? Palabra clave, frase, prefijo, difusa, facetas, similitud vectorial, ranking híbrido, geo, agregaciones.
  • ¿Qué fallo es aceptable? “La búsqueda está desactualizada durante 5 minutos” suele ser aceptable. “La compra falla” no lo es.
  • ¿Cuántas perillas operativas puede permitirse tu equipo? PostgreSQL puede hacer búsqueda, pero en el momento en que necesites ajustes serios de relevancia y recuperación híbrida a escala, OpenSearch justifica su existencia.

Aquí va una guía directa:

  • Usa solo PostgreSQL cuando: el conjunto de datos es modesto, los patrones de consulta son simples y la corrección de la búsqueda importa más que la relevancia avanzada. Quieres menos piezas en movimiento.
  • Usa solo OpenSearch cuando: tu producto tolera consistencia eventual y la búsqueda es tu producto. Seguirás manteniendo una base de datos en algún lado, pero no fingirás que es el motor de búsqueda.
  • Usa un híbrido (PostgreSQL + OpenSearch) cuando: necesitas ranking rico, agregaciones o búsqueda vectorial a escala, pero también necesitas verdad transaccional y auditoría sensata.

Una cita que vale la pena pegar en un post-it cuando te piden “solo un sistema”: “idea parafraseada” — Werner Vogels (CTO de Amazon) ha defendido durante mucho tiempo la idea de diseñar para disponibilidad aceptando consistencia eventual donde sea aceptable.
La búsqueda es uno de los pocos lugares donde ese intercambio suele ser racional.

Hechos históricos que importan más que las diapositivas de los proveedores

Esto no es trivia. Explican por qué cada herramienta se comporta como lo hace bajo presión.

  1. La búsqueda full-text de PostgreSQL está en el núcleo desde hace décadas (tsvector/tsquery), y está optimizada primero para cargas transaccionales, segundo para búsqueda.
  2. El modelo de índice invertido de Lucene (la base de Elasticsearch/OpenSearch) fue diseñado para recuperación y puntuación rápidas, no para actualizaciones ACID por fila.
  3. Los índices GIN en Postgres son brillantes para pertenencia a conjuntos (como presencia de tokens) pero pueden volverse pesados de mantener cuando tus campos de texto cambian continuamente.
  4. La historia del fork de Elasticsearch importa operativamente: OpenSearch existe por cambios de licencia; muchos equipos heredaron hábitos operativos de Elasticsearch y los aplicaron sin cambios — a veces correctamente, a veces desastrosamente.
  5. Los motores de búsqueda tratan las actualizaciones como operaciones de “reindex” internamente: una “actualización de documento” suele ser un delete + add. Por eso las actualizaciones pequeñas y frecuentes crean churn de segmentos y presión de merge.
  6. BM25 se convirtió en el caballo de batalla de relevancia para muchas pilas modernas; las funciones de ranking de PostgreSQL son útiles pero menos flexibles en flujos de trabajo reales de ajuste.
  7. La búsqueda vectorial se volvió mainstream rápido: pgvector y kNN en OpenSearch surgieron porque la “búsqueda por palabra clave” dejó de ser suficiente una vez que los embeddings se abarataron.
  8. Los shards distribuidos son un impuesto, no una característica: el escalado de OpenSearch mediante shards te da rendimiento, pero también modos de fallo (shards calientes, skew, tormentas de relocación) que una instancia única de Postgres no inventa.

En qué destaca cada sistema (y en qué falla)

Búsqueda en PostgreSQL: lo que clava

  • Corrección transaccional: los resultados de búsqueda reflejan los datos confirmados cuando consultas la misma base. No hay retardo de sincronización a menos que lo añadas.
  • Despliegue simple: un motor, una cadena de backups, un conjunto de permisos.
  • Filtrado con joins intensivos: cuando la “búsqueda” son mayormente filtros estructurados con algo de coincidencia de texto, Postgres suele ser más rápido y simple.
  • Auditoría y cumplimiento: recuperación a un punto en el tiempo, archivado de WAL y control de acceso estricto son herramientas del día a día.

Búsqueda en PostgreSQL: donde duele

  • El ajuste de relevancia se vuelve torpe: puedes hacer ranking, pesos, diccionarios, sinónimos, pero el flujo es menos ergonómico que con herramientas dedicadas.
  • Facetas y agregaciones a escala: sí, SQL puede agregar; no, puede que no te guste la latencia con alta cardinalidad y muchas consultas concurrentes.
  • Recuperación híbrida (keyword + vector + rerank): es posible, pero tus planes de consulta se convierten en máquinas de Rube Goldberg.
  • Amplificación de escrituras: mantenimiento de GIN más actualizaciones frecuentes más realidades de vacuum pueden convertirse en tu techo de rendimiento.

OpenSearch: lo que clava

  • Recuperación full-text rápida: los índices invertidos están diseñados para esto.
  • Facetas/agrupaciones: agregaciones por términos, buckets de rango y consultas estilo analítica son ciudadanos de primera clase.
  • Herramientas de relevancia: analizadores, sinónimos, boosting, function scores y explicabilidad son para lo que sirve el producto.
  • Escala horizontal: puedes añadir nodos y redistribuir shards. No es gratis, pero es posible.
  • Patrones vector + híbridos: búsqueda kNN y recuperación híbrida son ahora patrones operativos comunes.

OpenSearch: donde duele

  • Complejidad operativa: tuning de JVM, dimensionamiento de shards, merge de segmentos, circuit breakers, presión de heap, coordinación de clúster.
  • Consistencia eventual: intervalos de refresh, pipelines de ingestión y reintentos hacen que el “ahora” sea un concepto difuso.
  • Reindexar es un estilo de vida: los mappings cambian, los analizadores cambian, los sinónimos cambian. Reindexarás. Planifícalo.

Broma #1: Un clúster de búsqueda es como un conejo mascota: silencioso, mono y de repente tienes 400 y nadie recuerda cómo cuidarlos.

Chequeo de la realidad del motor de almacenamiento

PostgreSQL almacena filas. OpenSearch almacena documentos. Suena a marketing, pero importa:

  • Actualizaciones parciales son baratas en Postgres y a menudo caras en OpenSearch (update = reindexar documento).
  • Campos de texto grandes pueden inflar TOAST en Postgres y aumentar IO; en OpenSearch incrementan el tamaño de segmento y los costes de merge.
  • Compresión y comportamiento de merge en OpenSearch es un proceso de fondo constante. En Postgres, vacuum y autovacuum son tus procesos de fondo. Ninguno es opcional.

Arquitecturas de búsqueda híbrida que no te castigarán luego

Patrón A: PostgreSQL es la verdad, OpenSearch es el índice optimizado para lectura

Este es el clásico. Escribes en Postgres. Transmites cambios a OpenSearch. La aplicación consulta OpenSearch por IDs y ranking, luego recupera filas canónicas de Postgres.
Es aburrido. Lo aburrido escala.

  • Pros: la corrección vive en un solo lugar; las caídas de búsqueda degradan con gracia (“resultados desactualizados” o “búsqueda limitada”).
  • Contras: debes operar un pipeline de sincronización y gestionar backfills.

Patrón B: Lectura dual con fallback por feature flag

Al migrar de FTS de Postgres a OpenSearch, ejecutas ambos en paralelo. Comparas resultados y latencia.
Controlas el despliegue por inquilino, por funcionalidad o por tipo de consulta.

  • Pros: migraciones más seguras; te da pruebas A/B de relevancia.
  • Contras: coste de consulta duplicado; requiere cacheo cuidadoso y límites de tasa.

Patrón C: Postgres para filtros + OpenSearch para recuperación, luego rerank

Cuando las restricciones estructuradas son complejas (permisos, derechos, ventanas temporales), Postgres puede calcular el conjunto de candidatos o las restricciones.
OpenSearch recupera candidatos; un reranker (posiblemente ML) reordena.

  • Pros: corrección fuerte en control de acceso; mantiene “quién puede ver qué” en SQL donde viven los auditores.
  • Contras: puede volverse caro si los conjuntos de candidatos son enormes; necesita semántica de paginación cuidadosa.

Estrategias de sincronización: elige una y hazla aburrida

La mayoría del dolor viene del “los mantendremos sincronizados” dicho a la ligera. Escoge un mecanismo real:

  • Outbox transaccional: escribe una fila en una tabla outbox en la misma transacción; un worker publica a OpenSearch. Alta corrección, reejecución simple.
  • Replicación lógica / CDC: stream del WAL y transforma en documentos. Potente, pero ahora operas un pipeline de datos.
  • Reconstrucción por lotes nocturna: aceptable para “búsqueda de catálogo” en algunos negocios; inaceptable para seguridad/alertas/búsqueda sensible al tiempo.

La única regla: diseña para la reproducción. Si no puedes rebobinar y reconstruir el índice de OpenSearch de forma determinista, no tienes un sistema—tienes una vibración.

Realidades operativas: SLOs, modos de fallo y sufrir de on-call

Define SLOs que coincidan con la arquitectura

Un sistema híbrido tiene al menos tres cubetas de latencia:

  • Lag de ingestión: tiempo desde el commit en Postgres hasta que es searchable en OpenSearch.
  • Latencia de consulta: P50/P95/P99 para consultas de búsqueda, más el tiempo para hidratar resultados desde Postgres.
  • Corrección de frescura: con qué frecuencia los usuarios ven resultados desactualizados o faltantes para datos recién escritos.

Modos de fallo de OpenSearch que debes esperar

  • Presión de heap conduce a tormentas de GC, que se ven como picos aleatorios de latencia hasta que el clúster pasa a amarillo/rojo.
  • Shards calientes por routing sesgado hacen que un nodo se funda mientras otros están ociosos.
  • Presión de merge durante altas tasas de actualización provoca picos de IO e inflación de latencia de consulta.
  • Explosiones de mapping (campos dinámicos) inflan el estado del clúster y rompen todo de manera muy democrática.

Modos de fallo de PostgreSQL que debes esperar

  • Deuda de autovacuum que lleva a tablas/índices hinchados, consultas lentas y eventualmente riesgos de wraparound de transaction ID.
  • Bloat de GIN especialmente para documentos que se actualizan con frecuencia; el índice crece más rápido que tu paciencia.
  • Planes de consulta malos por estadísticas obsoletas o problemas tipo parameter sniffing (planes genéricos vs personalizados).
  • Sorpresas de locking por cambios de esquema o actualizaciones concurrentes pesadas.

Broma #2: La forma más rápida de mejorar la latencia de búsqueda es renombrar el dashboard de “P95” a “P50” y salir a comer.

Qué evitar como un pager a las 3 a.m.

  • Dejar que OpenSearch se convierta en el sistema de registro para cualquier cosa que le importe al cumplimiento.
  • Mappings dinámicos en producción a menos que disfrutes descubrir conflictos “string vs number” en medio de un incidente.
  • Reindexar sin margen de capacidad. Reindexar no es un hobby de fondo; es una prueba de carga que te haces a ti mismo.
  • Filtrado de permisos solo en OpenSearch si tu modelo de autorización no es trivial. Mantén los derechos en Postgres y trata la búsqueda como un motor de sugerencias.

Guía de diagnóstico rápido

Tu búsqueda es lenta o incorrecta. No debatas arquitectura en Slack. Encuentra el cuello de botella en minutos.

Primero: ¿es lag de ingestión o dolor en tiempo de consulta?

  • Síntoma: los usuarios no encuentran elementos nuevos, pero los antiguos funcionan → probablemente lag de ingestión.
  • Síntoma: todo busca, pero lento/irregular → probablemente tiempo de consulta (OpenSearch/PG) o cuello de botella de hidratación.

Segundo: aisla qué salto está fallando

  1. App → OpenSearch: mide la latencia pura de la llamada de búsqueda y la tasa de errores.
  2. OpenSearch interno: CPU, heap, GC, thread pools, rechazos de cola, merges de segmentos.
  3. Hidratación → Postgres: SQL lento, saturación del pool de conexiones, misses de índice, locking, IO.
  4. Pipeline de sincronización: lag del consumidor, colas de mensajes muertos, reintentos, errores en bulk requests.

Tercero: elige la mitigación segura más rápida

  • Lag de ingestión: pausa actualizaciones no críticas; aumenta el tamaño de bulk con cuidado; añade consumidores al pipeline; aumenta temporalmente el refresh interval.
  • Sobrecarga de consultas en OpenSearch: reduce la complejidad de la consulta (menos agregaciones), añade caching, ajusta el conteo de shards solo con un plan, o enruta temporalmente tenants pesados a otro lado.
  • Sobrecarga de Postgres: limita el fanout de hidratación; busca IDs en lotes; añade índices faltantes; aumenta el pool de conexiones solo si la BD puede manejarlo.

Tareas prácticas: comandos, salidas y decisiones

Estas son tareas reales que puedes ejecutar durante la implementación o un incidente. Cada una incluye qué significa la salida y qué decisión tomar a continuación.

1) Comprueba la salud del clúster OpenSearch y qué significa “yellow”

cr0x@server:~$ curl -s localhost:9200/_cluster/health?pretty
{
  "cluster_name" : "search-prod",
  "status" : "yellow",
  "number_of_nodes" : 3,
  "active_primary_shards" : 48,
  "active_shards" : 48,
  "unassigned_shards" : 48
}

Significado: los primarios están asignados, las réplicas no. Las consultas pueden funcionar, pero no tienes redundancia y existe riesgo al reequilibrar.
Decisión: no inicies un reindex; primero arregla la asignación (disponibilidad de nodos, watermarks de disco, límites de shards) o reduce temporalmente el número de réplicas.

2) Encuentra las razones de shards no asignados antes de adivinar

cr0x@server:~$ curl -s localhost:9200/_cat/shards?v
index             shard prirep state      docs store ip         node
products_v12      0     p      STARTED   9812  42mb  10.0.0.12  os-1
products_v12      0     r      UNASSIGNED

Significado: la réplica no puede colocarse.
Decisión: inspecciona allocation explain; si es watermark de disco, libera espacio o añade nodos; si es límite de shards, reduce shards o aumenta límites deliberadamente.

3) Explica la asignación de shards con las palabras del clúster

cr0x@server:~$ curl -s -H 'Content-Type: application/json' localhost:9200/_cluster/allocation/explain?pretty -d '{"index":"products_v12","shard":0,"primary":false}'
{
  "index" : "products_v12",
  "shard" : 0,
  "primary" : false,
  "current_state" : "unassigned",
  "can_allocate" : "no",
  "allocate_explanation" : "cannot allocate because allocation is not permitted to any of the nodes",
  "node_allocation_decisions" : [
    {
      "node_name" : "os-2",
      "node_decision" : "no",
      "deciders" : [
        {
          "decider" : "disk_threshold",
          "decision" : "NO",
          "explanation" : "the node is above the high watermark"
        }
      ]
    }
  ]
}

Significado: los thresholds de disco están bloqueando la asignación de réplicas.
Decisión: libera disco, añade almacenamiento o mueve shards; no “desactives los watermarks” a menos que disfrutes índices de solo lectura de repente.

4) Comprueba la presión de recursos del nodo OpenSearch desde fuera (Linux)

cr0x@server:~$ ps -o pid,cmd,%cpu,%mem --sort=-%mem | head
  PID CMD                         %CPU %MEM
 2178 /usr/share/opensearch/jdk   312.0 48.6
 1042 /usr/bin/node app-server     64.2  3.1

Significado: la JVM de OpenSearch consume mucha memoria y CPU.
Decisión: comprueba heap vs RSS; si RSS es enorme, puedes estar swappeando o la caché de páginas está bajo presión. Siguiente paso: verifica GC y uso de heap.

5) Inspecciona el uso del heap de la JVM vía node stats

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/jvm?pretty | head -n 25
{
  "nodes" : {
    "n1" : {
      "jvm" : {
        "mem" : {
          "heap_used_in_bytes" : 15234567890,
          "heap_max_in_bytes" : 17179869184
        },
        "gc" : {
          "collectors" : {
            "old" : {
              "collection_count" : 92,
              "collection_time_in_millis" : 184322
            }
          }
        }
      }
    }
  }
}

Significado: el heap está ~88% usado con tiempo significativo en GC old.
Decisión: reduce carga de consultas/aggregaciones, revisa uso de fielddata y considera tuning de heap solo después de confirmar que no es un problema de mapping/aggregación.

6) Detecta rechazos en thread pool (un asesino silencioso)

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/thread_pool?pretty | grep -A3 rejected | head
          "search" : {
            "threads" : 13,
            "queue" : 1000,
            "rejected" : 421

Significado: las peticiones de búsqueda están siendo rechazadas porque la cola está llena.
Decisión: frena a los clientes (circuit breaker), reduce fanout, baja agregaciones costosas o añade capacidad. No aumentes la cola y lo llames arreglo.

7) Revisa presión de segmentos/merge (por qué las actualizaciones raspan)

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/indices/segments,merge?pretty | head -n 35
{
  "nodes" : {
    "n1" : {
      "indices" : {
        "segments" : {
          "count" : 2345,
          "memory_in_bytes" : 987654321
        },
        "merge" : {
          "current" : 12,
          "current_docs" : 1900000,
          "total_throttled_time_in_millis" : 842112
        }
      }
    }
  }
}

Significado: muchos segmentos y merges, con tiempo de throttling acumulado.
Decisión: ajusta refresh interval, estrategia de bulk ingest y patrones de actualización. Considera mover campos que cambian frecuentemente fuera del documento o usar desnormalización con más cuidado.

8) Revisa ajustes del índice que afectan frescura y coste de ingestión

cr0x@server:~$ curl -s localhost:9200/products_v12/_settings?pretty | head -n 40
{
  "products_v12" : {
    "settings" : {
      "index" : {
        "refresh_interval" : "1s",
        "number_of_shards" : "12",
        "number_of_replicas" : "1"
      }
    }
  }
}

Significado: refresh_interval de 1s es genial para frescura, costoso para ingestión.
Decisión: si la ingestión se queda atrás, eleva temporalmente refresh_interval (p. ej., 10s–30s) y comunica expectativas de frescura; revierte cuando el backlog se limpie.

9) Verifica a primera vista el bloat y salud de vacuum en PostgreSQL

cr0x@server:~$ psql -d app -c "SELECT relname,n_live_tup,n_dead_tup,round(100.0*n_dead_tup/nullif(n_live_tup+n_dead_tup,0),2) AS dead_pct FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
  relname   | n_live_tup | n_dead_tup | dead_pct
------------+------------+------------+---------
products    |   12400123 |    3120099 |   20.11
events      |    9021123 |    2019921 |   18.29

Significado: los tuples muertos son altos; el vacuum puede estar retrasado.
Decisión: revisa settings de autovacuum, transacciones largas e IO. Considera VACUUM manual (no FULL) en una ventana tranquila si estás en apuros.

10) Confirma si Postgres carece del índice que crees que tiene

cr0x@server:~$ psql -d app -c "\d+ products"
                                                  Table "public.products"
   Column   |           Type           | Collation | Nullable | Default | Storage  | Stats target | Description
------------+--------------------------+-----------+----------+---------+----------+--------------+-------------
 id         | bigint                   |           | not null |         | plain    |              |
 title      | text                     |           | not null |         | extended |              |
 body       | text                     |           |          |         | extended |              |
 search_tsv | tsvector                 |           |          |         | extended |              |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

Significado: no hay índice GIN en search_tsv.
Decisión: añade el índice correcto antes de culpar al hardware. Verifica también cómo se mantiene search_tsv (trigger vs columna generada vs batch).

11) Explica correctamente una consulta de búsqueda lenta en Postgres

cr0x@server:~$ psql -d app -c "EXPLAIN (ANALYZE,BUFFERS) SELECT id FROM products WHERE search_tsv @@ plainto_tsquery('english','wireless headphones') ORDER BY ts_rank(search_tsv, plainto_tsquery('english','wireless headphones')) DESC LIMIT 20;"
                                                         QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..152.33 rows=20 width=8) (actual time=812.221..812.260 rows=20 loops=1)
   Buffers: shared hit=120 read=9812
   ->  Index Scan using products_search_tsv_gin on products  (cost=0.43..9821.33 rows=1290 width=8) (actual time=812.219..812.248 rows=20 loops=1)
         Index Cond: (search_tsv @@ plainto_tsquery('english'::regconfig, 'wireless headphones'::text))
 Planning Time: 2.012 ms
 Execution Time: 812.312 ms

Significado: lecturas de disco pesadas (read=9812) dominan; el índice existe pero los datos no están en caché.
Decisión: considera calentar la caché para consultas hot, añadir RAM, reducir el conjunto de trabajo o mover esta carga a OpenSearch si la concurrencia va a crecer.

12) Revisa consultas lentas de Postgres en tiempo real

cr0x@server:~$ psql -d app -c "SELECT pid,now()-query_start AS age,wait_event_type,wait_event,substr(query,1,80) AS q FROM pg_stat_activity WHERE state='active' ORDER BY age DESC LIMIT 8;"
 pid  |   age   | wait_event_type |  wait_event   |                                        q
------+---------+-----------------+---------------+--------------------------------------------------------------------------------
 8123 | 00:01:12| IO              | DataFileRead  | SELECT id FROM products WHERE search_tsv @@ plainto_tsquery('english','wirel...
 8344 | 00:00:44| Lock            | tuple         | UPDATE products SET body = $1 WHERE id = $2

Significado: hay esperas de IO y locks a nivel de fila.
Decisión: separa hotspots de lectura/escritura; considera mover escrituras pesadas fuera de tablas que soportan la búsqueda, o desacoplar la indexación de las rutas OLTP.

13) Valida el lag de replicación (si la hidratación lee una réplica)

cr0x@server:~$ psql -d app -c "SELECT application_name,state,write_lag,flush_lag,replay_lag FROM pg_stat_replication;"
 application_name |   state   | write_lag | flush_lag | replay_lag
------------------+-----------+-----------+-----------+-----------
 app-read-replica  | streaming | 00:00:00  | 00:00:01  | 00:00:08

Significado: el replay lag de la réplica es de 8 segundos.
Decisión: si los usuarios se quejan de “elementos faltantes después de crear”, revisa si la hidratación lee réplicas. Enruta lecturas críticas de frescura al primario o acepta la obsolescencia explícitamente.

14) Revisa el backlog de tu pipeline de sincronización (tabla outbox)

cr0x@server:~$ psql -d app -c "SELECT count(*) AS pending, min(created_at) AS oldest FROM search_outbox WHERE processed_at IS NULL;"
 pending |         oldest
---------+------------------------
  184221 | 2026-02-04 08:11:03+00

Significado: tienes un gran backlog y es antiguo.
Decisión: pausa productores no esenciales, escala consumidores y verifica errores bulk en OpenSearch. Si el backlog crece durante carga normal, dimensionaste el pipeline con optimismo, no con la realidad.

15) Verifica errores en ingestión bulk de OpenSearch (no confíes en “200 OK”)

cr0x@server:~$ curl -s -H 'Content-Type: application/json' localhost:9200/_bulk -d $'{"index":{"_index":"products_v12","_id":"42"}}\n{"title":"x","price":"not-a-number"}\n' | head
{"took":7,"errors":true,"items":[{"index":{"_index":"products_v12","_id":"42","status":400,"error":{"type":"mapper_parsing_exception","reason":"failed to parse field [price] of type [float]"}}}]}

Significado: la API bulk aceptó la petición pero falló un item por conflictos de mapping.
Decisión: envía items fallidos a una ruta dead-letter; arregla el mapping y reindexa. Si ignoras esto, tu índice quedará silenciosamente incompleto.

16) Confirma mappings de campos en OpenSearch para evitar “sorpresas de mapping dinámico”

cr0x@server:~$ curl -s localhost:9200/products_v12/_mapping?pretty | head -n 40
{
  "products_v12" : {
    "mappings" : {
      "properties" : {
        "title" : { "type" : "text" },
        "price" : { "type" : "float" }
      }
    }
  }
}

Significado: price es float; la ingestión debe respetarlo.
Decisión: fija mappings y valida datos en el productor. “Deja que OpenSearch lo descubra” funciona hasta que no, y nunca falla educadamente.

Mini-historias del mundo corporativo (anonimizadas)

Mini-historia 1: El incidente causado por una suposición equivocada

Una compañía B2B SaaS mediana movió la “búsqueda global” de PostgreSQL FTS a OpenSearch para soportar facetas y consultas de prefijo más rápidas.
Hicieron la migración como la gente suele hacerlo: nuevo servicio, nuevo índice, un pipeline CDC y un dashboard triunfante mostrando que la latencia de consulta bajó.

Entonces llegaron tickets de soporte: “Creé un registro y la búsqueda no lo encuentra”. El manager de ingeniería asumió que era error de usuario o cache.
El equipo dobló la tasa de refresh de OpenSearch a 500ms y lo dio por resuelto. Los tickets continuaron.

La suposición equivocada fue sutil: pensaban que el pipeline CDC era “casi en tiempo real” porque procesaba mensajes rápidamente cuando la cola estaba vacía.
Durante horas pico, el consumidor del outbox se atrasó minutos. Los intervalos de refresh no arreglan documentos que no han sido indexados.

La solución no fue sofisticada. Añadieron monitorización de lag, escalaron consumidores y honestaron la UI: “Los nuevos elementos pueden tardar hasta N minutos en aparecer.”
Para un puñado de flujos donde la frescura importaba, la app consultaba directamente PostgreSQL durante una breve ventana “recién creado”.

La lección: el refresh interval no es una garantía de consistencia. Es un control de visibilidad después de la ingestión. Tu pipeline es ahora la frontera de consistencia.

Mini-historia 2: La optimización que salió mal

Otra organización tenía un marketplace multi-tenant. La búsqueda “iba bien” hasta que añadieron búsqueda semántica con embeddings.
Alguien propuso una optimización: poner todo en un solo índice de OpenSearch con mapping dinámico agresivo, y routar por tenant ID para localidad.
Funcionó de maravilla en staging. Staging es un lugar mágico donde los datos nunca discuten.

En producción, el comportamiento por tenant divergió. Un tenant envió documentos con un objeto “metadata” que contenía cientos de keys que variaban por registro.
El mapping dinámico creó campos como si le pagaran por campo. El estado del clúster creció, el uso de heap subió y los reinicios por rolling failaron porque los nodos tardaban demasiado en unirse.

Intentaron “optimizar” más aumentando el heap. Eso compró tiempo, luego aumentó las pausas de GC.
Mientras tanto, depurar relevancia fue imposible porque las necesidades de analizadores de diferentes tenants colisionaban dentro de un mismo mapping.

La solución eventual: pasar a un esquema controlado. Aplanaron metadata en un único campo keyword cuando fue necesario, y whitelistaron explícitamente las keys indexables.
Algunos tenants de alto volumen obtuvieron índices dedicados con políticas de ciclo de vida separadas. El sistema quedó menos elegante pero mucho más fiable.

La lección: mapping dinámico + variabilidad multi-tenant es un bug de escalado, no una característica. Pagas la flexibilidad con estabilidad del clúster.

Mini-historia 3: La práctica aburrida que salvó el día

Una compañía relacionada con pagos tenía PostgreSQL como verdad y OpenSearch para búsqueda. Tenían un hábito que parecía exagerado: cada versión de índice era inmutable.
products_v10, products_v11, products_v12. La aplicación consultaba un alias llamado products_current.

Un desarrollador necesitó añadir sinónimos y cambiar analizadores para mejor relevancia. Eso requiere reindexar.
En lugar de “actualizar el índice en sitio”, construyeron products_v13 en paralelo, validaron conteos de documentos, ejecutaron comparaciones de consultas y luego cambiaron el alias de forma atómica.

Durante el despliegue descubrieron un bug de ingestión: el campo price a veces llegaba como “N/A”. El nuevo índice rechazó esos documentos. El índice antiguo seguía sirviendo tráfico.
Arreglaron el productor, reejecutaron entradas fallidas del outbox, reconstruyeron v13 y volvieron a cambiar el alias. Los usuarios casi no notaron nada además de una relevancia que mejoró gradualmente.

La lección: versiones de índice inmutables + cortes de alias no son glamorosos, pero convierten el “reindex” de una crisis en una rutina.

Errores comunes: síntomas → causa raíz → solución

1) “La búsqueda es lenta solo cuando añadimos agregaciones”

  • Síntomas: P95 se dispara en consultas facetadas; CPU sube; rechazos del thread pool de búsqueda en OpenSearch.
  • Causa raíz: agregaciones de términos de alta cardinalidad en campos no analizados, demasiados shards o heap insuficiente para estructuras de agregación.
  • Solución: reduce la cardinalidad de agregaciones, usa campos keyword, añade contexto de filtro, precomputea facetas o divide índices por tenant/categoría. Valida con stats de thread pool y heap.

2) “Los registros nuevos no aparecen, pero OpenSearch está verde”

  • Síntomas: datos frescos faltantes; salud del clúster bien; sin errores de consulta.
  • Causa raíz: lag de ingestión, errores bulk, acumulación en dead-letter o refresh_interval demasiado alto para las expectativas.
  • Solución: monitoriza lag de outbox/CDC, inspecciona respuestas bulk para errores, implementa replay. Define y comunica SLOs de frescura.

3) “Postgres FTS era rápido, luego se volvió lento con los meses”

  • Síntomas: latencia creciente; lecturas de IO altas; tablas e índices GIN crecen desproporcionadamente.
  • Causa raíz: bloat por updates/deletes + deuda de vacuum; crecimiento de la lista pendiente de GIN; transacciones largas bloqueando vacuum.
  • Solución: ajusta autovacuum, rompe transacciones largas, considera REINDEX o reconstrucción de GIN en mantenimiento y mantén eficiente el mantenimiento de search_tsv.

4) “Reinicios de nodos OpenSearch causan cascada de timeouts”

  • Síntomas: reinicio en rolling causa amarillo/rojo en el clúster; requests timeout; relocaciones de shards saturan red/disco.
  • Causa raíz: recuento de shards demasiado alto, falta de espacio en disco, throttles de relocación mal configurados o réplicas indisponibles.
  • Solución: reduce recuento de shards (shards más grandes, menos unidades), asegura espacio en disco, programa reinicios rolling y mantiene réplicas saludables.

5) “Afinamos relevancia y ahora los resultados son incorrectos”

  • Síntomas: coincidencias exactas enterradas; términos de marca ignorados; cambios de ranking impredecibles.
  • Causa raíz: cambios de analizador sin reindex, sinónimos aplicados erróneamente en tiempo de consulta, desajuste de mapping multi-field.
  • Solución: versiona analizadores e índices, prueba con explain, mantiene consultas gold, y despliega con flips de alias y corridas comparativas.

6) “Nuestra consulta híbrida duplica o pierde resultados al paginar”

  • Síntomas: usuarios ven ítems repetidos en la página 2; ítems faltantes; orden inconsistente.
  • Causa raíz: claves de orden inestables entre OpenSearch y la hidratación en Postgres, uso de paginación basada en offset bajo escrituras concurrentes.
  • Solución: usa search_after o paginación por cursor en OpenSearch; hidrata por IDs estables; mantiene un orden secundario determinista (p. ej., _score + id).

Listas de verificación / plan paso a paso

Paso a paso: elegir Postgres-only vs OpenSearch vs híbrido

  1. Anota los tipos de consulta: palabra clave, frase, prefijo, difusa, facetas, geo, vector, ranking híbrido.
  2. Anota requisitos de frescura: “segundos”, “minutos”, “cada hora”. Sé explícito.
  3. Define comportamiento ante fallos: ¿qué pasa si la búsqueda está caída o desactualizada?
  4. Estima churn de datos: ¿se actualizan los documentos frecuentemente o son mayormente append-only?
  5. Prototipa la consulta más difícil en ambos sistemas y mide P95 bajo concurrencia, no solo tiempos de usuario único.
  6. Elige la arquitectura más simple que cumpla los SLOs. No la más molona.

Paso a paso: construir un pipeline híbrido seguro

  1. Mantén Postgres como verdad: no dejes que OpenSearch sea la única copia de campos críticos.
  2. Implementa una tabla outbox con cursor durable y soporte de replay.
  3. Haz idempotente la indexación: ID de documento derivado de la clave primaria; las actualizaciones sobrescriben.
  4. Usa indexación bulk con backoff y mecanismo dead-letter.
  5. Versiona índices y usa aliases para el cutover.
  6. Monitoriza el lag: edad del backlog del outbox, tasa de error bulk, rechazos de thread pool de OpenSearch, lag de replicación de Postgres si aplica.
  7. Prueba carga de ingest + consulta juntos: el clúster que indexa bien puede consultar mal cuando empiezan los merges.

Paso a paso: reindexar sin drama

  1. Crea una nueva versión de índice con mappings y settings explícitos.
  2. Backfill desde Postgres en lotes controlados.
  3. Dual-write cambios nuevos (o reejecuta outbox) tanto en índices viejo como nuevo.
  4. Valida conteos de documentos, consulta muestras y logs de errores bulk.
  5. Cambia el alias atómicamente al nuevo índice.
  6. Mantén el índice antiguo para rollback hasta tener confianza; luego bórralo.

Preguntas frecuentes

1) ¿Puede PostgreSQL hacer una búsqueda “suficientemente buena” para un catálogo de producto?

Sí, si tus necesidades son mayormente búsqueda tipo exacta + filtros, y tu escala/churn son razonables.
Cuando necesitas facetas intensas, coincidencia difusa, sinónimos a escala o ranking híbrido vector+keyword, OpenSearch se vuelve la elección sensata.

2) Si añado pgvector, ¿sigo necesitando OpenSearch?

A veces no. pgvector es genial cuando quieres embeddings cerca de tus datos transaccionales y puedes tolerar el coste de consulta.
Pero OpenSearch suele ganar cuando necesitas relevancia combinada, agregaciones y rendimiento de recuperación bajo alta concurrencia.

3) ¿Por qué no almacenar todo en OpenSearch y omitir Postgres?

Porque reinventarás garantías transaccionales con cinta y pegamento. OpenSearch no es una base OLTP.
Mantén la verdad en Postgres (u otra BD) a menos que el dominio realmente tolere consistencia eventual para todo lo que te importe.

4) ¿Cuál es la mayor trampa operativa con OpenSearch?

El sprawl de shards y mappings descontrolados. Demasiados shards crean overhead de coordinación; campos dinámicos inflan el estado del clúster y la memoria.
Ambos fallan despacio y luego de golpe.

5) ¿Cuál es la mayor trampa operativa con búsqueda solo en Postgres?

Gestión de vacuum y bloat, especialmente con índices GIN sobre texto que se actualiza con frecuencia.
Postgres puede hacerlo, pero debes tratar el vacuum como una preocupación de producción de primera clase, no como un hada de fondo.

6) ¿Cómo hago que la búsqueda híbrida sea lo bastante consistente para los usuarios?

Define SLOs de frescura, mide el lag del pipeline y proporciona fallback con gracia.
Para flujos que requieren visibilidad inmediata, consulta Postgres directamente para registros recién creados o muestra estado “pendiente de indexación”.

7) ¿Cómo debería paginar en un sistema híbrido?

Prefiere la paginación estilo cursor de OpenSearch (search_after) con orden estable, y luego hidrata esos IDs en Postgres con una sola consulta por lotes.
Evita la paginación por offset en conjuntos que cambian a menos que te gusten los duplicados.

8) ¿Necesito un índice OpenSearch separado por tenant?

No siempre. Índices por tenant pueden explotar el número de shards. Un índice compartido con routing puede funcionar, pero solo con control estricto del esquema.
Tenants de alto volumen o alta variabilidad a menudo justifican índices dedicados.

9) ¿Cómo sé si debo subir el refresh_interval de OpenSearch?

Si la ingestión va retrasada, los merges están limitando y los usuarios pueden tolerar cierta obsolescencia, súbelo temporalmente.
Si la promesa del producto es “búsqueda instantánea”, subirlo solo mueve la queja de ops a soporte.

10) ¿Deben hidratarse los resultados de búsqueda desde Postgres cada vez?

A menudo sí para corrección y control de acceso, pero no siempre por rendimiento. Muchos sistemas almacenan campos desnormalizados suficientes en OpenSearch para mostrar.
La regla: si un campo debe ser correcto y actual, hidrata o verifica contra Postgres.

Próximos pasos que puedes hacer esta semana

  • Decide la fuente de la verdad: escríbelo. Si no es Postgres, sé explícito sobre lo que estás arriesgando.
  • Añade métricas de lag para tu pipeline de indexación (edad del backlog del outbox, throughput de consumidores, tasa de error bulk).
  • Ejecuta la guía de diagnóstico rápido una vez en un día tranquilo, no durante un incidente. Captura líneas base.
  • Versiona tu índice e implementa cortes por alias si no lo has hecho. Esto es la diferencia entre “cambio” e “incidente”.
  • Elige tres consultas golden y síguelas en CI o con un job programado con latencia y comprobaciones del primer resultado.
  • Reduce sorpresas: desactiva mapping dinámico donde sea posible, mapea campos explícitamente y valida tipos de datos en el productor.

Si estás atascado decidiendo: empieza con solo Postgres si puedes cumplir objetivos de relevancia y latencia hoy. Pasa a híbrido cuando puedas nombrar los patrones de consulta específicos que lo demandan.
No adoptes OpenSearch solo porque alguien quiere sinónimos. Adóptalo porque tu sistema necesita un motor de búsqueda, no porque quieras otro clúster que cuidar.

← Anterior
Endurecer Windows para servidores de laboratorio doméstico: cambios mínimos, ganancia máxima
Siguiente →
¿Arranque dual roto? Restaura el cargador de arranque de Windows de forma segura

Deja un comentario