Embeddings y búsqueda semántica sobre boletines oficiales
Si buscas "ayudas para digitalización de pymes" en un buscador de texto tradicional, necesitas que esas palabras exactas aparezcan en el documento. Pero un boletín oficial no escribe "ayudas para digitalización de pymes". Escribe "Resolución de 15 de marzo de 2026 por la que se convocan subvenciones destinadas a la transformación digital del tejido productivo de pequeñas y medianas empresas". La búsqueda por keywords falla porque el lenguaje administrativo y el lenguaje natural son mundos distintos.
Esto es exactamente el problema que resuelvo con búsqueda semántica en los buscadores de Boletín Claro. En este artículo explico cómo funciona, sin entrar en la matemática de los transformers pero con suficiente detalle técnico para que puedas implementar algo similar.
Qué son los embeddings (en 30 segundos)
Un embedding es una representación numérica de un texto en un espacio vectorial de alta dimensión. Dos textos con significado similar tendrán vectores cercanos, aunque no compartan ni una sola palabra. Un modelo de embeddings es una red neuronal entrenada para producir estas representaciones.
En la práctica, conviertes un texto en un array de 768 o 1536 floats. Luego comparas arrays con similaridad coseno. Dos textos con coseno cercano a 1.0 hablan de lo mismo. Cercano a 0.0, no tienen relación.
El pipeline de indexación
Cada día, el reader extrae entre 200 y 400 entradas de los boletines oficiales. Cada entrada necesita ser indexada para que sea buscable. El pipeline tiene tres fases:
1. Chunking
Las entradas de boletines varían mucho en longitud. Una disposición del BOE puede tener 200 palabras o 20.000. Los modelos de embeddings tienen un límite de tokens (normalmente 512 o 8192 dependiendo del modelo). Si el texto excede el límite, hay que trocearlo.
Mi estrategia de chunking es por párrafos con overlap. Cada chunk tiene un máximo de 512 tokens, se corta en límite de párrafo (nunca a mitad de frase) y tiene un solapamiento de 50 tokens con el chunk anterior. El solapamiento evita perder contexto en los bordes.
def chunk_text(text: str, max_tokens: int = 512, overlap: int = 50) -> list[str]:
paragraphs = text.split("\n\n")
chunks = []
current = []
current_len = 0
for para in paragraphs:
para_tokens = count_tokens(para)
if current_len + para_tokens > max_tokens and current:
chunks.append("\n\n".join(current))
# Keep last paragraph for overlap
overlap_paras = [current[-1]] if current else []
current = overlap_paras
current_len = count_tokens(current[0]) if current else 0
current.append(para)
current_len += para_tokens
if current:
chunks.append("\n\n".join(current))
return chunks
2. Generación de embeddings
Para generar los vectores uso la API de embeddings de Google (Vertex AI con el modelo text-embedding-004). La elección no fue casual: necesitaba un modelo que funcionara bien con español, que soportara textos largos y que tuviera un coste razonable para procesamiento batch.
El procesamiento es en batch de 100 textos por llamada a la API. Con 300 entradas diarias y un promedio de 3 chunks por entrada, son unos 900 textos a indexar, que se resuelven en 9 peticiones batch. El coste es de céntimos.
3. Almacenamiento
Los vectores se almacenan en Firestore junto con la metadata de la entrada (fuente, fecha, sección, título). Para la búsqueda, uso el soporte nativo de vectores de Firestore con un índice de tipo nearest-neighbor. Esto me evita necesitar una base de datos vectorial separada como Pinecone o Weaviate.
La búsqueda: query, retrieval y reranking
Cuando un usuario busca "subvenciones para energía solar en Andalucía" en el buscador de subvenciones, el proceso es:
- La query del usuario se convierte a embedding con el mismo modelo.
- Se buscan los K vectores más cercanos en Firestore (nearest neighbor search).
- Se aplican filtros de metadata: fuente, fecha, comunidad autónoma.
- Los resultados se reordenan con un modelo de reranking para mayor precisión.
El reranking es clave. La búsqueda por embeddings es buena para recall (encontrar candidatos relevantes) pero no siempre ordena bien por relevancia. Un cross-encoder reranker toma cada par (query, documento) y produce un score de relevancia más preciso que la similaridad coseno. Uso Cohere Rerank porque soporta español nativamente.
Por qué no vale solo con keywords
Un ejemplo real ilustra la diferencia. Estas son búsquedas reales en el buscador de licitaciones:
Consulta del usuario
"contratos de limpieza en colegios públicos"
Match semántico (no comparte keywords)
"Licitación del servicio de mantenimiento higiénico-sanitario en centros docentes de titularidad autonómica"
Con búsqueda por keywords, "limpieza" no aparece en el resultado (usan "mantenimiento higiénico-sanitario"). "Colegios" no aparece ("centros docentes"). "Públicos" no aparece ("titularidad autonómica"). Sin embargo, semánticamente son la misma búsqueda.
Consulta del usuario
"ayudas para montar una tienda online"
Match semántico
"Convocatoria de subvenciones para el fomento del comercio electrónico y la implantación de soluciones de venta digital en el sector minorista"
Otra vez: "montar" vs "implantación", "tienda online" vs "comercio electrónico y soluciones de venta digital", "ayudas" vs "subvenciones". El significado es el mismo, las palabras son completamente distintas.
Optimizaciones prácticas
Prefiltrado por metadata
No tiene sentido comparar vectores contra toda la base de datos si el usuario ya ha seleccionado "BDNS" como fuente o "Madrid" como comunidad autónoma. El prefiltrado por metadata reduce el espacio de búsqueda y mejora tanto la velocidad como la relevancia.
Cache de embeddings de queries frecuentes
Muchas queries son parecidas: "subvenciones autónomos", "ayudas pymes", "licitaciones limpieza". Mantengo un cache LRU de embeddings de queries para evitar llamadas repetidas a la API.
Rate limiting
Los buscadores públicos del buscador del BOE tienen un rate limit de 20 peticiones por minuto por IP. Es suficiente para uso humano pero previene abusos.
Resultados y métricas
No tengo un benchmark formal contra un dataset etiquetado (no existe un dataset de relevancia para boletines oficiales españoles). Lo que sí mido es la tasa de clics en resultados: un 34% de las búsquedas resultan en un clic en alguno de los 5 primeros resultados, lo cual es razonable para un dominio tan especializado.
El tiempo de respuesta medio es de 280ms, de los cuales unos 80ms son la generación del embedding de la query, 120ms la búsqueda vectorial en Firestore y 80ms el reranking. Suficientemente rápido para sentirse instantáneo en la interfaz.
Si estás pensando en implementar búsqueda semántica sobre un dominio específico, mi recomendación es empezar simple: un modelo de embeddings, un almacén vectorial y evaluar los resultados manualmente antes de añadir complejidad. El reranking solo merece la pena si tus primeros resultados ya son "casi buenos" pero mal ordenados.