Cómo construí una búsqueda híbrida de 7 niveles que no se rompe cuando falla el embedding

· 6 min read

# Cómo construí una búsqueda híbrida de 7 niveles que no se rompe cuando falla el embedding

Cuando empecé a construir la API de Conversor, di por sentado que la búsqueda sería fácil. Tengo 2.875 códigos —1.186 epígrafes de IAE y 1.060 de CNAE— con sus descripciones oficiales en castellano. Un ILIKE con un % a cada lado y a correr. Tardé un par de días en darme cuenta de que eso no servía para nada.

El problema no era encontrar "reparación de vehículos" cuando el usuario escribe "reparación de vehículos". El problema era cuando escribe "taller de coches". O "arreglo de furgonetas". O "mecánica rápida". La descripción oficial del epígrafe 452.0 del IAE dice "Mantenimiento y reparación de vehículos de motor", y un ILIKE no tiene ni idea de que "taller de coches" y "mantenimiento y reparación de vehículos de motor" hablan de lo mismo.

Así que me metí en el barro de la búsqueda semántica. Y lo que aprendí en el camino —sobre todo lo que salió mal— es lo que cuento aquí.

El primer intento: todo semántico, todo frágil

Mi razonamiento inicial era impecable sobre el papel: genero embeddings de las ~2.800 descripciones, los meto en un índice HNSW en PostgreSQL con pgvector, y cuando llega una consulta la convierto en embedding con Gemini y busco por similitud coseno. Problema resuelto.

Elegí Gemini-embedding-2 como modelo de embedding: 768 dimensiones, rápido, barato y con buen rendimiento en castellano. El índice HNSW por coseno montado en una columna description_embedding vector(768) de la tabla de códigos. Cada vez que un usuario envía una query, llamo a la API de embedding de Gemini, obtengo el vector de 768 dimensiones, y hago una búsqueda por similitud coseno contra el índice. Bonito.

Funcionaba de maravilla en local. "Taller de coches" devolvía "Mantenimiento y reparación de vehículos de motor" en la primera posición. "Tienda de ropa" encontraba "Comercio al por menor de prendas de vestir". Estaba contento.

Hasta que un día Gemini se cayó.

No fue una caída larga —unos minutos, quizás media hora—, pero bastó para que todas las llamadas a la API de búsqueda devolvieran un error 500. La RPC search_cnae_codes se quedaba esperando un vector que nunca llegaba, y en vez de devolver resultados parciales, reventaba. Una API de datos fiscales no puede depender de que un servicio externo de embedding esté siempre disponible. Si un banco regional español integra esto en su flujo de alta de cuentas y un martes a las diez de la mañana Gemini no responde, el alta se para. Eso no es aceptable.

El error de diseño: tratar el embedding como obligatorio

El fallo no era usar embeddings. El fallo era haber diseñado la RPC como si el embedding fuese un requisito. La función esperaba un query_embedding vector(768) y si no llegaba —porque la llamada a Gemini había fallado, porque el formato era incorrecto, porque lo que fuera—, no había plan B. La búsqueda simplemente no se ejecutaba.

Aquí aprendí algo que suena obvio pero que no interioricé hasta verlo en producción: un componente opcional que tratas como obligatorio deja de ser opcional. Si el camino feliz depende de él, el sistema es tan frágil como el eslabón más débil de la cadena. Y el eslabón más débil era una API externa sobre la que no tengo ningún control.

La solución: ranking léxico de 7 niveles con capa semántica opcional

La solución no fue elegir entre búsqueda léxica y semántica, sino combinarlas de forma que la semántica mejorase los resultados cuando está disponible pero no fuese necesaria para funcionar. Así nacieron las RPC search_iae_codes y search_cnae_codes con su arquitectura de 7 niveles.

El núcleo es un ranking lexicográfico de 7 niveles, no una suma plana de scores. Cada código que coincide con la consulta se clasifica en uno de estos niveles según dónde y cómo se produce la coincidencia:

  • Nivel 1: coincidencia exacta en el código numérico (el usuario teclea "452" y encuentra el epígrafe 452.0 directamente).
  • Nivel 2: coincidencia exacta en la descripción oficial.
  • Nivel 3: coincidencia de todas las palabras de la consulta en la descripción, en cualquier orden.
  • Nivel 4: coincidencia parcial con refuerzo semántico.
  • Niveles 5-7: coincidencias cada vez más laxas, con distintas estrategias de stemming y normalización para castellano.

El ranking es lexicográfico: primero se ordena por nivel (1 antes que 2, 2 antes que 3), y dentro del mismo nivel se aplican criterios de desempate. Esto significa que una coincidencia exacta en el código siempre gana a una coincidencia semántica, por muy alto que sea el score de similitud. Un usuario que sabe el código no debería recibir resultados "interpretados".

Y aquí está el detalle que más me costó afinar: el `query_embedding` es opcional. El parámetro se define como query_embedding vector(768) DEFAULT NULL. Si llega NULL —porque la llamada a Gemini falló, porque el cliente no quiere pagar el coste de generar el embedding, o porque sencillamente no lo necesita—, la RPC ejecuta los 7 niveles puramente con criterios léxicos. Sin romperse. Sin error 500. Sin excusas.

Cuando el embedding sí está disponible, la RPC lo usa exclusivamente en el nivel 4. Ahí es donde la componente semántica aporta al relevance_score normalizado: la fórmula es (1 - cosine_distance) * 0.45. Ese 0.45 no es arbitrario. Es el peso que le doy a la similitud semántica frente a la coincidencia léxica en ese nivel. Lo calibré probando con ~200 consultas reales hasta encontrar un valor que mejorase los resultados sin hacer que una coincidencia semántica débil desbancase a una léxica fuerte de un nivel superior.

La distancia coseno se calcula contra el índice HNSW montado sobre los embeddings de Gemini-embedding-2 —768 dimensiones, pregenerados y almacenados en la base, no calculados en cada consulta—. El embedding de la query del usuario sí se genera en tiempo real si el cliente lo envía, pero el índice contra el que se compara ya está ahí, listo.

Qué aprendí

La lección no es "usa búsqueda híbrida". Eso ya lo sabe cualquiera que haya leído dos posts sobre RAG. La lección es más concreta: diseña la degradación antes que la funcionalidad completa. La pregunta no es "¿qué pasa cuando todo funciona?", sino "¿qué pasa cuando falla la pieza sobre la que no tengo control?".

En mi caso, la pieza sin control era la API de embedding de Gemini. La respuesta fue hacer que el sistema funcionase perfectamente sin ella, y que su presencia fuese una mejora, no un requisito. Las RPC search_*_codes no preguntan si Gemini está disponible. Si el embedding llega, lo usan en el nivel 4 con peso 0.45. Si no llega, los otros seis niveles siguen funcionando exactamente igual.

Una API de datos fiscales no puede permitirse el lujo de ser frágil. Y la mejor forma de evitar la fragilidad no es añadir redundancia —es diseñar para que el fallo de una pieza externa no sea un fallo del sistema.

Si quieres ver cómo funciona en vivo, las RPC están documentadas en la API: https://www.conversoriaecnae.es/api/v1/docs

Brian Mena

Brian Mena

Software engineer building profitable digital products: SaaS, directories and AI agents. All from scratch, all in production.

LinkedIn