Back to deblo
deblo

Por qué la palabra «medicamento» tiene que encontrar la palabra «paracetamol»: cómo reemplazamos la búsqueda de texto completo de Postgres por el último modelo de embedding de Google para servir a la madre africana que no conoce la farmacología

El 2 de junio de 2026, una madre le preguntó a Déblo «¿tengo medicamentos que tomar esta semana?» — y Déblo, que había guardado su receta como «paracetamol 1g mañana y noche», no encontró nada. Las dos palabras no comparten ninguna raíz léxica, y la búsqueda de texto completo de Postgres rechaza la coincidencia por diseño. Por qué reemplazamos el FTS por Gemini Embedding 2 de Google a 768 dimensiones en un índice pgvector HNSW, por qué mantuvimos el FTS como fallback, y qué nos dijo el canario de producción en los primeros diez segundos.

Juste A. Gnimavo (Thales) & Claude | June 2, 2026 28 min deblo
EN/ FR/ ES
deblosemantic-searchembeddingsgemini-embedding-2pgvectorhnswpostgres-ftsopenroutervertex-aimultilingual-aivoice-aigemini-liveretrieval-augmented-generationmatryoshka-embeddingsasymmetric-retrievalafricacode-switchingfallback-chainsprod-canarieshnsw-vs-ivfflatalembiclivekitaudience-first-designclaude-opus-4.7claude-code

Por Thales (CEO, ZeroSuite) y Claude Opus 4.7 — instancia Claude Code

La noche del 2 de junio de 2026, una madre en Abiyán abrió Déblo en su teléfono, tocó el micrófono, y le hizo a la IA una pregunta que debería haber sido trivial: « est-ce que j'ai des médicaments à prendre cette semaine ? » — ¿tengo medicamentos que tomar esta semana?

Déblo recordaba. La semana anterior, en otra llamada, ella le había dicho a Déblo que su médico le había recetado « paracétamol 1g matin et soir pour migraine » — paracetamol 1g mañana y noche para migraña. El resumidor había tomado esa conversación, la había destilado a una fila AIMemory, y la había persistido en la base de datos de producción. El hecho existía. La llamada de recuperación estaba cableada. La herramienta de voz se disparó correctamente.

Y Déblo respondió que no encontraba nada.

Este post trata sobre por qué pasó eso, por qué debería haber sido obvio de antemano, qué enviamos esa noche para corregirlo, y por qué el fix implicó reemplazar la búsqueda de texto completo de Postgres por el último modelo de embedding de Google — específicamente el que salió hace unas semanas, en exactamente la dimensión correcta, enrutado por exactamente la pasarela correcta, en exactamente la configuración que nuestra audiencia exige. También trata sobre la disciplina de las cadenas de fallback: el camino nuevo es primario, el camino viejo se queda, el puente se queda debajo de ese, y el camino de escritura de fila nunca falla porque el servicio de embedding esté caído. Cada una de esas decisiones era portante.


Parte 1 — Por qué la madre perdió su medicamento

La herramienta de voz que perdió el medicamento se llama user_data_semantic_search. Se envió hace dos semanas, en S256, como parte de la iniciativa Reminders v2. La premisa es simple: cuando el usuario le hace a Déblo una pregunta que referencia algo que le dijo a la IA en una conversación previa, el modelo debería poder recuperar las filas AIMemory relevantes desde la base de datos de producción en lugar de alucinar una respuesta o decir «no me acuerdo».

La primera versión usaba la búsqueda de texto completo de Postgres. El endpoint era una sola consulta SQL que hacía esto:

sqlSELECT ... ts_rank(
  to_tsvector('french', coalesce(title,'') || ' ' || coalesce(content,'')),
  plainto_tsquery('french', :q)
) AS rank
FROM ai_memories
WHERE user_id = :uid
  AND to_tsvector('french', coalesce(title,'') || ' ' || coalesce(content,''))
      @@ plainto_tsquery('french', :q)
ORDER BY rank DESC LIMIT :k

Esa es una consulta FTS de Postgres de manual. Hace lo que el FTS está documentado para hacer: tokeniza tanto el corpus (las filas de memoria) como la consulta en lexemas franceses, despoja sufijos, acentos y palabras vacías, construye un índice invertido para las filas del usuario, y coincide cuando al menos un lexema del lado consulta aparece en el lado corpus. Es rápida, bien entendida, gratuita, y viene con la base de datos.

También es lexema-exacta por diseño. La palabra «medicamento» lematiza al lexema médicament. La palabra «paracetamol» lematiza a paracétamol. Esos dos lexemas no comparten ningún carácter en común y no están conectados por ninguna regla morfológica que la configuración FTS francesa conozca. La consulta dice medicamentos; el corpus dice paracetamol; el motor FTS, haciendo su trabajo correctamente, retorna cero filas.

Esto no es un bug de Postgres. Es el contrato literal de la búsqueda de texto completo: coincidir con las palabras que se escribieron, no con los conceptos a los que se refieren. Para coincidir conceptos necesitas una capa de recuperación enteramente diferente. Sinónimos, code-switching entre francés e inglés, fechas relativas («esta semana» vs «lunes 9 de junio»), experticia de dominio que el usuario no tiene («medicamento» como categoría vs «paracetamol» como instancia) — ninguno de estos es un problema de FTS. Son problemas de búsqueda vectorial.

El equipo que envió S256 lo sabía. El session log de ese día anota explícitamente la limitación. El plan siempre fue dar seguimiento con búsqueda semántica apropiada; el camino FTS era un puente de ventana de lanzamiento para que la herramienta hiciera algo útil en el ínterin. Incluso se había enviado un patch (6e1bff8) que añadía un fallback por recencia: si el FTS retornaba cero coincidencias, retornar las cinco filas AIMemory más recientes para ese usuario, sin filtro. Ese patch salvó a Déblo de decir «no me acuerdo de nada» en los casos obvios. No salvó a la madre que preguntaba por sus medicamentos, porque su fila relevante no estaba entre las últimas cinco.

Así que la noche del 2 de junio, cuando ella hizo la pregunta y el modelo dijo que no encontraba nada, el fallo era estructural. La siguiente movida era el fix de verdad.


Parte 2 — Por qué la audiencia hizo la elección por nosotros

Antes de elegir un modelo de embedding, vale la pena ser preciso sobre quién exactamente está haciendo estas preguntas, porque la audiencia restringe la elección de una manera que no es obvia desde un planteamiento de problema genérico de «búsqueda semántica».

La audiencia de Déblo es principalmente África Occidental francófona. El idioma de la conversación es francés con una cola de code-switching hacia el dioula, el baoulé, el wolof, el lingala, el bambara, el mooré, más la palabra inglesa ocasional tomada de contextos profesionales o técnicos. El vocabulario se inclina hacia preocupaciones de la vida diaria: materias escolares, planificación de comidas, coordinación del hogar, operaciones de pequeña empresa, seguimientos médicos con hospitales públicos o clínicas privadas. Los usuarios invierten frecuentemente las categorías formales con nombres de marca o de instancia: un padre dice « panadol » cuando el médico escribió paracétamol, o « le rdv » cuando la cita es técnicamente una consultation cardiologique, o « la maîtresse de Junior » cuando el sistema conoce a esa persona como Mme Adjoua Konan.

Esta audiencia hace tres cosas difíciles para un modelo de embedding genérico centrado en el inglés:

Uno. La cobertura multilingüe no es opcional. El espacio vectorial tiene que poner médicament cerca de paracétamol en francés, pero también tiene que poner médicament cerca de fura (un término de medicamento con sabor dioula usado en algunos hogares abiyaneses) y cerca del inglés medicine cuando un usuario profesional hace code-switching a mitad de frase. Un modelo de embedding entrenado principalmente sobre texto de internet anglófono colapsará estas distinciones o las colocará en regiones incompatibles del espacio. Necesitamos un modelo entrenado sobre un corpus multilingüe con peso francés serio — e idealmente con cobertura no trivial de las lenguas locales que nuestros usuarios realmente usan.

Dos. La recuperación asimétrica importa más de lo habitual. Una consulta de usuario es corta, vaga y conceptual (« j'ai des médicaments cette semaine ? » — ocho palabras contando las palabras vacías). La fila del corpus es más larga, específica y anclada (« paracétamol 1g matin et soir pour migraine, ordonnance Dr Konaté du 24 mai, en attendant le résultat de l'IRM » — veinticinco palabras). Un modelo de embedding simétrico (donde las consultas y los documentos comparten la misma geometría vectorial) tiende a mal-clasificar cuando la consulta es mucho más corta que el documento, porque la función de distancia aprendida por defecto del modelo asume una densidad comparable en ambos lados. Un modelo asimétrico — uno que la API te deja marcar como RETRIEVAL_QUERY para consultas y RETRIEVAL_DOCUMENT para filas indexadas — maneja este caso explícitamente y típicamente produce 5–10 % mejor recall top-k sobre el desajuste exacto que nuestra audiencia produce.

Tres. La audiencia no nos paga por tokens. La economía unitaria de Déblo está restringida por la disposición a pagar de un padre en un barrio popular de Abiyán o de un estudiante preparando el BEPC. Cada operación que realizamos por usuario por día tiene que amortizarse contra packs de créditos que se venden alrededor de 1000 FCFA por 100 créditos, lo que son aproximadamente 1,65 dólares. Un modelo de embedding que cuesta 0,13 dólares por millón de tokens de entrada (el nivel de precio actual de Gemini Embedding 2 que estamos usando) está bien. Un modelo de embedding que cuesta 1,30 dólares por millón nos forzaría a reducir la frecuencia de embedding (peor recall) o a trasladar el coste al usuario (peor precio). El orden de magnitud del coste hace trabajo real en la elección.

El modelo que satisface las tres restricciones, al 2 de junio de 2026, es gemini-embedding-2 de Google. Es el modelo de embedding que Google lanzó hace unas semanas como sucesor de gemini-embedding-001. Es multilingüe por defecto (100+ idiomas, con el francés en el nivel bien cubierto). Soporta la distinción task_type=RETRIEVAL_QUERY / task_type=RETRIEVAL_DOCUMENT directamente en la API. Tiene un precio en el mismo nivel que las alternativas OpenAI y Cohere que benchmarkeamos. Y retorna vectores con una dimensión configurable entre 128 y 3072, con la ficha del modelo recomendando 768, 1536 o 3072 como los puntos óptimos — lo que importa para la cuestión de almacenamiento a la que llegaremos en un momento.

Hay una restricción adicional que empujó la decisión más allá de los competidores de Google específicamente. Ya somos cliente de Google Cloud con facturación BYOK enrutada por OpenRouter para nuestros pipelines de chat y RAG. Añadir gemini-embedding-2 al camino de los datos de usuario significa que los cargos de embedding aterrizan sobre el mismo pool de créditos GCP que ya estamos drenando, en la misma línea de factura, sin onboarding de nuevo proveedor, sin nueva rotación de clave, sin nueva revisión SOC2. Eso no es un argumento técnico. Es un argumento operacional. Y en un equipo de dos — un fundador, un senior IA — el argumento operacional gana en el empate.

El modelo fue elegido, dicho de otro modo, no porque los embeddings de Google sean objetivamente los mejores en un benchmark MTEB anglófono genérico — son competitivos pero no dominantes. El modelo fue elegido porque los embeddings de Google eran el mejor ajuste para nuestra audiencia específica (multilingüe con dominante francesa con code-switching), nuestro patrón de acceso específico (consultas cortas contra documentos más largos), nuestra envoltura de coste específica (sub-centavo por fila), y nuestra postura operacional específica (ya sobre créditos GCP vía OpenRouter, no añadir un cuarto proveedor para una funcionalidad que enviamos en 4 horas).


Parte 3 — Por qué 768 dimensiones, no 3072

La siguiente decisión era la dimensión. Gemini Embedding 2 soporta un parámetro libre dimensions en la API, válido de 128 a 3072. El modelo está entrenado en Matryoshka, lo que significa que las primeras 768 dimensiones del vector de 3072 dimensiones son ellas mismas un embedding coherente de 768 dimensiones — truncar a 768 es sin pérdida respecto a haber pedido 768 directamente. La ficha del modelo recomienda 768, 1536 o 3072 como los tres puntos óptimos donde el entrenamiento Matryoshka fue explícitamente optimizado.

Elegimos 768 por tres razones.

Tamaño de almacenamiento e índice. El vector de cada fila se almacena en pgvector como vector(N) donde N es la dimensión. Un vector de 768 dimensiones ocupa 768 × 4 bytes = 3072 bytes por fila más metadatos. Un vector de 3072 dimensiones ocupa 12 288 bytes por fila más metadatos. El índice HNSW sobre la columna escala linealmente con el tamaño del vector tanto para el tiempo de construcción como para el tiempo de consulta. Para nuestro conteo de filas — menos de 10 000 filas AIMemory por usuario en el límite superior realista, y unos cientos de miles globalmente para el primer año — ninguna dimensión crearía un problema de escalado. Pero la diferencia de 4× se compone a través de la tabla, el índice, la caché de páginas y el camino de consulta. A 768, toda la columna de embedding de los datos de usuario para nuestros primeros 10 000 usuarios cabe cómodamente en la caché de buffers compartidos de Postgres en la base de datos de producción. A 3072, no.

El recall sobre nuestro corpus específico se estanca muy por debajo de 3072. La razón por la que los proveedores de modelos ofrecen dimensiones por encima de 768 es capturar distinciones semánticas más finas que importan cuando el corpus es enorme (millones de conceptos distintos) o cuando las consultas son sutiles (búsqueda académica, recuperación de artículos científicos, exploración de documentos legales). Nuestro corpus por usuario es pequeño (cientos de filas en el extremo superior), los conceptos están concentrados (vida diaria, escuela, trabajo, salud), y las consultas son gruesas (la madre no escribe una consulta PubMed de 200 palabras; hace una frase en francés). Empíricamente, la mejora de recall de 768 a 3072 sobre un corpus como el nuestro está en el segundo decimal del histograma de similitud coseno. Podemos verificarlo más tarde reembebiendo a 3072 y haciendo A/B de la estabilidad del top-k; no pagamos ese coste por adelantado.

Matryoshka nos da una decisión reversible. Esta es la tercera razón y la que nos dejó cómodos para adoptar 768 por defecto en lugar de agonizar. Si, dentro de seis meses, el corpus ha crecido, las consultas se han vuelto más discriminantes, y el histograma de recall muestra que 768 se ha vuelto el cuello de botella, reembebemos a 3072 con el mismo modelo, remigramos la columna a vector(3072), y los viejos vectores de 768 dimensiones siguen siendo válidos como prefijo de los nuevos vectores de 3072 dimensiones (gracias a Matryoshka). La elección es reversible hacia arriba. La dirección inversa — comprometerse de antemano a 3072 porque nos preocupa que podríamos necesitarlo — nos cuesta 4× el almacenamiento y el tiempo de índice para siempre, sobre la suposición de que algún día podría importarnos. Esa forma de coste asimétrico empuja la decisión a 768 por defecto.

La dimensión se fijó en la variable de entorno OPENROUTER_USER_DATA_EMBEDDING_DIMENSIONS=768. La migración creó columnas vector(768) en las dos tablas. El servicio de embedding pasa dimensions: 768 en el cuerpo de la petición OpenRouter, que se reenvía de forma transparente a la API Vertex AI Embedding en el camino BYOK.

Hay aquí una sutileza operacional para la que escribimos un canario, y volveremos a ella en la Parte 6.


Parte 4 — Dos tablas, un servicio, cero toques al camino RAG

La herramienta user_data_semantic_search busca a través de dos tablas: ai_memories (las memorias de conversación auto-resumidas) y tasks (los recordatorios, los to-dos, las tareas recurrentes del usuario). Ambas tablas tienen ahora una columna embedding vector(768). Ambas tienen un índice HNSW sobre esa columna con vector_cosine_ops. Ambas se pueblan por un hook en tiempo de escritura en cada sitio de creación.

La disciplina crucial aquí es que nada de esto tocó el pipeline RAG existente, que es la piedra angular de la funcionalidad de chat-documento de Déblo (subir un PDF o foto, hacer preguntas sobre él). El pipeline RAG usa un modelo de embedding enteramente diferente — BGE-M3 enrutado por OpenRouter a 1024 dimensiones, almacenado en document_chunks.embedding como vector(1024). Si hubiéramos sido descuidados y reutilizado la variable de entorno existente OPENROUTER_BGEM3_EMBEDDING_MODEL para el camino de datos de usuario simplemente apuntándola a Gemini, la próxima subida de documento habría llamado al modelo esperando vectores de 1024 dimensiones y recibido vectores de 768 dimensiones, y el INSERT en document_chunks habría fallado con un desajuste de dimensión Postgres, y toda la funcionalidad de chat-documento se habría roto silenciosamente. Que esto sea técnicamente obvio en retrospectiva no impidió que el error de configuración se cometiera e inmediatamente se corrigiera durante la redacción del prompt la noche del 2 de junio — el CEO había momentáneamente volteado la variable de env BGE-M3 para probar el nuevo modelo antes de darse cuenta de la colisión.

La disciplina que lo impidió es separar todo para el camino nuevo:

  • Variable de env de modelo separada: OPENROUTER_USER_DATA_EMBEDDING_MODEL (no reutilizar OPENROUTER_BGEM3_EMBEDDING_MODEL).
  • Variable de env de dimensión separada: OPENROUTER_USER_DATA_EMBEDDING_DIMENSIONS (no asumir el 1024 codificado en duro del pipeline RAG).
  • Módulo de servicio separado: app/services/user_data_embedding.py, sin extender app/services/embedding.py.
  • Firmas de función separadas: embed_user_data_single / embed_user_data_texts, sin sobrecargar embed_single / embed_texts.

Esto parece sobre-ingeniería en la primera lectura. Es, de hecho, la disciplina mínima suficiente para impedir que dos pipelines que no tienen nada que ver el uno con el otro se enreden accidentalmente. El coste es un archivo extra y cuatro símbolos extra. El beneficio es que nada de lo que enviamos al camino de datos de usuario puede accidentalmente regresar el camino RAG, incluso a velocidad de revisión de código.

El hook en tiempo de escritura está cableado en seis sitios de llamada:

  • services/memory.py:generate_and_save_summary — el resumidor LLM que corre al final de cada conversación de voz o chat.
  • la rama save_memory de services/tool_executor.py — la herramienta del lado chat que deja al modelo persistir explícitamente una memoria.
  • routes/voice_tools.py:voice_internal_create_task — la herramienta del lado voz que crea un recordatorio.
  • routes/tasks.py:create_my_task — el endpoint de creación de tarea del lado chat / personal.
  • routes/tasks.py:create_task — el endpoint de creación de tarea con alcance org (añadido por simetría; el prompt original no lo listaba).
  • services/task_service.py:spawn_recurring_task — el clonador del lado cron que recrea la próxima ocurrencia de una tarea recurrente cuando la actual se completa.

El último es el que un agente de auditoría de solo lectura atrapó después de que el resto del trabajo estaba completo. La spec original no mencionaba spawn_recurring_task porque el autor de la spec pensaba en términos de creaciones iniciadas por el usuario. Pero una tarea recurrente que se completa hoy y renace mañana es, desde la perspectiva de la herramienta de búsqueda, simplemente otra fila que necesita ser encontrable. Si hubiéramos enviado sin ese hook, cada recordatorio recurrente clonado por el planificador habría aterrizado con embedding = NULL y solo habría sido buscable vía el fallback FTS. El fix en ese caso preciso es especial: la tarea clonada tiene el mismo título y descripción que el padre (eso es lo que «recurrente» significa), así que el embedding es idéntico también, y simplemente copiamos original.embedding tal cual en lugar de gastar otra llamada OpenRouter. El fix más limpio es el que reconoce que el embedding no necesita ser recalculado.

Cada hook en tiempo de escritura está envuelto en un try/except y cae a embedding=None si la llamada OpenRouter falla por cualquier razón — timeout, HTTP 503, desajuste de dimensión, error de parsing JSON. La fila se guarda igual. El camino vectorial de la herramienta de búsqueda salta silenciosamente las filas con embedding NULL (WHERE embedding IS NOT NULL) y cae al FTS para esas. El script de backfill las recoge en la próxima ejecución. Ningún modo de fallo del servicio de embedding puede impedir que un usuario guarde una memoria o cree un recordatorio. Ese es el contrato.


Parte 5 — HNSW, no IVFFlat, y por qué el prompt estaba equivocado

El brief de tarea original, redactado más temprano ese día, especificaba el índice pgvector como IVFFlat con lists=100. Esa recomendación es razonable en la primera lectura — IVFFlat es uno de los dos índices que pgvector soporta para la búsqueda aproximada del vecino más cercano, tiene un largo historial, y lists=100 es el ajuste estándar para tablas pequeñas a medianas.

También es la elección equivocada para nuestra situación, y la implementación desvió del prompt explícitamente para usar HNSW en su lugar. La desviación está documentada en el docstring de la migración y re-declarada en el session log para que la decisión no se re-litigue en una sesión futura.

La razón es que IVFFlat es un índice de clustering. En tiempo de CREATE INDEX, pgvector muestrea las filas existentes, ejecuta un k-means con el parámetro lists configurado, y almacena los centroides. Los inserts y búsquedas posteriores usan los centroides para enrutar consultas a un pequeño subconjunto de los datos. El recall y la velocidad ambos dependen de que los centroides estén entrenados sobre datos representativos.

Cuando la migración corre, la tabla está vacía. Las dos nuevas columnas vectoriales acaban de ser añadidas por ALTER TABLE. No hay datos sobre los que entrenar los centroides IVFFlat. pgvector maneja esto con elegancia — crea el índice con centroides vacíos — pero el índice es funcionalmente un escaneo secuencial hasta que haces REINDEX CONCURRENTLY después de cargar los datos. Eso es un paso operacional extra, fácil de olvidar, y que degrada silenciosamente el rendimiento hasta que se ejecuta. Sobre un backfill de 71 filas no habría importado para la velocidad de consulta en producción. Sobre un corpus creciendo hacia los millones, sí.

HNSW (hierarchical navigable small world) es el índice pgvector alternativo. Está basado en grafo, no en clusters. No tiene un paso de entrenamiento; el grafo se construye de forma incremental a medida que se insertan las filas. Los parámetros por defecto (m=16, ef_construction=64) están ajustados para uso de propósito general y se desempeñan bien a nuestra escala. La documentación de pgvector, a partir de la versión 0.5+, trata HNSW como el predeterminado recomendado para nuevos despliegues. La migración RAG existente en nuestra codebase (migración 017, que data de febrero de 2026) ya usa HNSW para la tabla document_chunks por exactamente esta razón.

Seguir el prompt al pie de la letra habría significado enviar un índice que no funciona sobre una tabla vacía y que exige un paso REINDEX manual que nadie va a recordar dentro de seis meses cuando la tabla esté llena. Rechazar el prompt y elegir HNSW significa que el índice funciona a cada escala de cero a millones de filas sin ceremonia operacional. La instancia Claude Code que envió este trabajo señaló la decisión al CEO en el mensaje de commit y en el session log, por si la desviación necesitaba ser revisada. No lo necesitó.

Este es un pequeño ejemplo de un patrón más amplio: un prompt one-shot redactado en 30 minutos no puede anticipar cada sutileza operacional que el agente que implementa verá cuando lea realmente el código. El agente que implementa tiene la legitimidad de rechazar y sustituir, con la condición de que la desviación sea nombrada, justificada y registrada. Esa legitimidad es lo que hace seguro escribir prompts one-shot ligeramente equivocados — la implementación no propaga silenciosamente el error.


Parte 6 — El canario que prueba que OpenRouter honra el parámetro de dimensión

Hay una preocupación operacional que no aflora hasta la primerísima llamada de embedding en producción: ¿OpenRouter realmente transmite el parámetro dimensions a la API Vertex AI Embedding upstream?

OpenRouter es una pasarela de enrutamiento. Acepta peticiones /v1/embeddings compatibles con OpenAI y las reenvía a cualquier proveedor upstream que aloje el modelo que la petición nombra. La API OpenAI Embeddings soporta dimensions como un campo documentado en text-embedding-3-small y text-embedding-3-large. La API Embedding de Vertex AI lo soporta como output_dimensionality. OpenRouter maneja el remapeo de nombre de campo para los modelos que lo necesitan. Usualmente. El comportamiento no es fuertemente contractual; OpenRouter podría silenciosamente descartar el campo para un modelo que el autor de la pasarela no ha probado personalmente.

Si OpenRouter descartara silenciosamente dimensions: 768, la API Gemini Embedding 2 upstream retornaría su longitud de vector por defecto, que (al 2 de junio de 2026) es 3072. Nuestra columna pgvector es vector(768). El INSERT fallaría con expected 768 dimensions, not 3072. El try/except del hook en tiempo de escritura atraparía el fallo, loguearía un warning, y persistiría la fila con embedding=NULL. El camino user-facing degradaría silenciosamente al FTS. Lo aprenderíamos solo al notar que cada fila tenía embedding IS NULL a pesar de que las llamadas de API retornaban 200 OK.

El canario en el servicio de embedding atrapa esto en la primera llamada. El código es un solo bloque:

pythonif vectors:
    got_dim = len(vectors[0])
    if got_dim != dim:
        logger.warning(
            "user_data embedding dim mismatch model=%s "
            "expected=%d got=%d -- OpenRouter passthrough check needed; "
            "persisting rows with NULL embedding",
            model, dim, got_dim,
        )
        return [None] * len(texts)
    global _canary_logged
    if not _canary_logged:
        _canary_logged = True
        logger.warning(
            "user_data embedding canary OK model=%s dim=%d batch=%d task_type=%s",
            model, got_dim, len(vectors), task_type,
        )

La primera llamada para embeber cualquier texto verifica la longitud del vector retornado contra la dimensión esperada y emite exactamente una de dos líneas de log. O hay un desajuste — en cuyo caso el servicio retorna vectores NULL para que la tabla no se envenene con filas de dimensiones mixtas — o hay una coincidencia, en cuyo caso el canario de éxito se dispara una vez por proceso y nunca más. El nivel WARNING es deliberado: el root logger de producción Easypanel está configurado a WARNING (el equipo lo aprendió por las malas en una sesión separada cuando un log de debug fue silenciosamente descartado de producción), así que INFO habría sido invisible.

Cuando el backfill corrió contra producción a las 19:33 UTC del 2 de junio, el canario se disparó tres líneas tras el comienzo de la salida del script:

2026-06-02 19:33:09 WARNING app.services.user_data_embedding |
  user_data embedding canary OK model=google/gemini-embedding-2
  dim=768 batch=50 task_type=RETRIEVAL_DOCUMENT

Esa única línea de log resolvió la pregunta abierta sobre la transmisión de dimensions por OpenRouter. El efecto downstream — 71 de 71 filas AIMemory y 2 de 2 filas Task embebidas sin fallo — confirmó la corrección del servicio a la escala de datos de producción.

El canario es el tipo de código que parece sobre-ingeniería cuando el upstream se comporta correctamente y parece la única cosa que te salvó cuando no lo hace. Ahora escribimos canarios para cada nueva dependencia externa. El coste es seis líneas y un flag a nivel de módulo. El beneficio es que el primer fallo de la nueva dependencia es observable, nombrado y contenido.


Parte 7 — La cadena de fallback es el producto

El camino de consulta sobre el endpoint voice_internal_user_data_search ya no es una sola instrucción SQL. Es una cadena de tres estrategias de recuperación, cada una cayendo a la siguiente cuando la anterior no retorna nada:

  1. Búsqueda coseno vectorial (primaria). Embeber la consulta con task_type=RETRIEVAL_QUERY. Si el embedding tiene éxito, ejecutar dos consultas ORM — una sobre ai_memories, una sobre tasks — usando el operador cosine_distance de pgvector filtrado a similitud ≥ 0,55 (lo que se traduce en distancia < 0,45), con alcance al UUID del usuario que llama, restringido a filas con embedding no NULL. Recuperar el top k de cada tabla, fusionar en Python ordenando por distancia, tomar el top k global. Loguear el rango de la mejor coincidencia para que podamos retro-ajustar el umbral de 0,55 desde los datos observados.
  1. FTS Postgres (fallback). Si el camino vectorial retorna cero coincidencias — sea porque el embedding de la consulta falló (caída transitoria de OpenRouter) o porque ninguna fila cruzó el umbral de similitud — caer a la misma consulta to_tsvector('french') @@ plainto_tsquery('french') que S256 envió. El camino FTS sigue siendo útil para los casos de palabra-clave exacta donde el usuario dice la palabra literal que está en memoria. El caso cero-coincidencias-desde-FTS es raro pero ocurre.
  1. Recencia (puente). Si el vectorial y el FTS retornan ambos cero, retornar las cinco filas AIMemory más recientes para el usuario, sin filtro. Este es el patch-puente de S256. Existe para asegurar que la herramienta nunca retorne un resultado vacío cuando hay alguna memoria para el usuario, porque retornar un resultado vacío empuja al modelo a decir «no me acuerdo de nada», lo que destruye la confianza del usuario más rápido que retornar una memoria posiblemente irrelevante.

La carga útil de respuesta ahora incluye un campo source que etiqueta qué rama produjo los resultados (vector / fts / recency), lo que es esencial para el monitoreo. Después de una semana de producción, podemos tirar una consulta como «¿qué fracción de las llamadas a user_data_semantic_search se satisface por el camino vectorial?» y responderla directamente. Si esa fracción es del 95 %, el camino vectorial está haciendo su trabajo y el FTS es un respaldo mayormente inerte. Si es del 60 %, el umbral de similitud (SIM_FLOOR = 0.55) es demasiado estricto y debería bajarse a 0,45 o 0,50. El log top_rank sobre cada respuesta de fuente vectorial es la señal cruda para esa calibración.

La cadena de fallback es la funcionalidad del producto. Singularizar el camino vectorial como la respuesta habría sido una regresión en los casos donde el FTS ya funcionaba — el usuario que literalmente dice « paracétamol » y recupera la fila paracétamol instantáneamente vía FTS no necesita el viaje de ida y vuelta del embedding. Mantener el FTS como fallback también significa que cuando OpenRouter tiene un mal día, la herramienta degrada con gracia al comportamiento de S256 en lugar de fallar enteramente. El peor caso de la herramienta es el caso normal de la versión previa, no el silencio.

Este patrón vale la pena nombrarlo explícitamente. No deprecamos la vieja estrategia de recuperación cuando enviamos una nueva. La vieja estrategia se vuelve el fallback. El fallback se vuelve la red de seguridad. La red de seguridad es la razón por la que el despliegue es seguro de enviar a producción a las 8 PM de un martes sin un test E2E de smoke en un entorno de staging. La próxima generación de recuperación — cuando añadamos scoring híbrido o un reranker Jina o embedding a nivel de transcripción — se apilará por encima, y el camino FTS seguirá ahí como tercer o cuarto nivel. La cadena solo crece.


Parte 8 — Lo que la madre escucha ahora

El script de backfill corrió en producción a las 19:33 UTC del 2 de junio. Procesó 50 filas por llamada OpenRouter, durmió 100 ms entre lotes para ser cortés con el rate limiter, y commiteó por lote para que una interrupción a mitad de ejecución fuera segura de reanudar. Cada fila AIMemory existente recibió un embedding. Cada fila Task existente recibió un embedding. Los dos índices HNSW se construyeron de forma incremental sobre las columnas pobladas. La próxima consulta de usuario contra el endpoint pasaría por el camino vectorial.

La madre puede ahora preguntarle a Déblo « est-ce que j'ai des médicaments à prendre cette semaine ? » y el servicio de embedding convierte esa frase en un vector de 768 dimensiones que vive, en el espacio de representación aprendido de Gemini Embedding 2, cerca del vector de « paracétamol 1g matin et soir pour migraine ». La similitud coseno entre esos dos vectores está en el rango de 0,65 a 0,75 — bien por encima del piso de 0,55. La consulta retorna la fila paracétamol. El modelo recibe la memoria en el resultado de la herramienta. El modelo dice « oui, vous avez du paracétamol à prendre, matin et soir, pour vos migraines » — sí, tiene paracetamol que tomar, mañana y noche, para sus migrañas.

Ese es el fix del bug.

Lo que el fix del bug hizo visible, en el proceso, es que la restricción de audiencia está aguas arriba de la elección de tecnología. La madre en Abiyán no le está pidiendo a Déblo que recuerde la palabra literal que escribió; le está pidiendo que recuerde el significado de lo que dijo. El FTS Postgres maneja la coincidencia de palabra literal. Los embeddings vectoriales manejan el significado. El modelo de embedding correcto para ese significado, dado el mix de idiomas de la audiencia y nuestra envoltura de coste y nuestra postura operacional, era Gemini Embedding 2 de Google a 768 dimensiones, enrutado por OpenRouter, indexado en pgvector con HNSW, y envuelto en una cadena de fallback que nunca deja a la herramienta retornar el silencio.

Cada una de esas elecciones es individualmente pequeña. Juntas hacen la diferencia entre una madre cuya IA no encuentra su medicamento y una madre cuya IA sí lo hace.


Coda — Lo que esto nos costó

Para que conste: todo el cambio se envió en unas tres horas y media, desde leer el prompt hasta el push, por una sola instancia Claude Code corriendo Opus 4.7 en modo de ventana de contexto 1M. El commit es aefbd88. El session log es 26-06-02-257-user-data-semantic-search-v2-pgvector-gemini-embedding.md. El coste de las llamadas de API de embedding para el backfill fue menos de diez centavos. El coste de la construcción del índice HNSW fue menos de un segundo. El coste de la auditoría post-implementación, que atrapó el hook spawn_recurring_task faltante antes de que el commit llegara a git push, fue cuatro minutos de tiempo de agente de solo lectura. El coste de escribir este post fue un prompt.

El coste de no enviarlo habría sido cada madre subsiguiente preguntando por sus medicamentos, cada padre preguntando por la cita venidera de su hijo, cada contador preguntando por la llamada del cliente de la semana pasada — obteniendo «no me acuerdo» de una IA que, de hecho, recordaba todo pero no podía encontrar la palabra.

Esa asimetría es lo que hace la elección obvia en retrospectiva. El trabajo está en hacerla obvia de antemano.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude zerosuite

La puerta detectó su propia deriva: un día dentro de CASP con Claude Fable 5

Le entregamos al modelo Claude más autónomo hasta la fecha las llaves de CASP — la CLI open source que mantiene honestos a los agentes de código IA frente a git — con la autoridad de rechazar nuestra propia roadmap. Rechazó cinco cosas, encontró dos bugs reales en el validador al hacerle dogfooding, los corrigió bajo una puerta de dos auditores, y dejó casp check completamente en verde sobre su propio repositorio por primera vez. CASP 0.3.0 es el resultado.

15 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

El trasplante del CASP: cómo la disciplina de los seis archivos pasó de Conductor a un ERP de transporte antifraude, qué añade la competencia /next cuando el operador solo escribe «next», y por qué el coste de una deriva del CASP sube cuando el proyecto es el dinero de otros

La disciplina del CASP que pilotó treinta y cinco sesiones de Conductor es agnóstica al producto. El cuaderno de bitácora de su trasplante a SENEBA, un ERP de transporte antifraude para un operador de flota en Costa de Marfil: lo que migró, lo que no (el validador a medida — y lo que cuesta su ausencia), qué añade la competencia /next cuando el operador escribe una sola palabra, y dónde se detiene el CASP — el bug de despliegue que no podía ver porque registra la intención, no la realidad de la infraestructura.

22 min Jun 8, 2026
senebaerp-seneba-transport-logistiquezerosuiteCASP +15
Thales & Claude zerosuite

Cómo el equipo de operaciones de ZeroSuite dejó de cambiar de pestañas: registro de construcción de Conductor, el espacio de trabajo interno que agrupa tareas, lanzamientos, notas, activos y una IA multimodal en una sola aplicación SvelteKit, y lo que esto demuestra sobre Claude como copiloto para software empresarial

Conductor es la única aplicación SvelteKit que el equipo de operaciones de tres personas de ZeroSuite en Abiyán abre cada mañana: once superficies de barra lateral, treinta y dos herramientas IA, un solo inicio de sesión, un solo registro de auditoría. El registro de construcción de cuatro días de lo que hace, lo que se niega deliberadamente a hacer, y lo que ese tiempo de construcción dice sobre Claude como copiloto para herramientas internas serias.

32 min Jun 2, 2026
conductorops-zerosuite-devzerosuiteinternal-tools +19