Búsqueda precisa en ElasticSearch
El otro día implementé mi buscador de blogs e inicialmente no le había dedicado demasiado a la estrategia de búsqueda. Era una implementación a secas que tiraba el contenido en ElasticSearch y luego hacía la búsqueda más simple del mundo.
Eso en el mundo real, no funciona.
Así que me puse manos a la obra pensando y analizando junto a don Claude qué podía hacer para mejorar y que diera resultados decentes. Al haber indexado más de 30.000 artículos tenía suficiente material como para que el buscador anduviera como se debe, aquí los cambios que hice...
La cosa es simple, si no le indicás nada ElasticSearch hace una búsqueda "fuzzy" de un término. Le pedís por Fabio y te da Pablo, Fabián, Fabricio (y todas las formas con las que me han llamado mal siempre 😅).
Por default es bastante malo el resultado, pero no porque Elastic no tenga forma de hacerlo mejor, es así como viene. Lo que hay que hacer es indicarle en detalle qué algoritmo y estrategia de búsqueda querés.
Al inicio no tenía casi data porque recién había empezado, pero al ingresar unos 100 blogs y 30.000 artículos hay material de sobra. Ahí empecé a notar los errores.
Por suerte tengo un analytics de términos de búsqueda que me indica, entre otras cosas, qué palabras se buscaron y cuántos resultados dio. En algunas había sospechosamente demasiados aciertos y eso no sonaba bien.
Repetí esas búsquedas y lo confirmé: por cada palabra traía decenas de artículos indexados que no tenían nada que ver, la "coincidencia" era apenas una palabra "parecida", si pasaba a hacer una búsqueda con comillas, en cambio, el resultado era muy preciso.
Otro problema que había notado era que me traía, con el mismo nivel de prioridad, cualquier cosa de cualquier año. Sin un orden ni una lógica.
Si estás buscando artículos en Google o Bing lo que querés es que el resultado esté más cerca de la actualidad porque en el medio pasaron cosas, para sucesos antiguos no es un problema, pero para cosas recientes es frustrante. Ejemplo: preguntás a un buscador por el último procesador de Intel, si no ordenás por fecha da lo mismo uno de 2001 que de 2026.
Así que lo primero fue ajustar el peso de los artículos por fecha, eso funcionó bien, pero lo otro apremiaba más.
La solución: estrategia
ElasticSearch te permite definirle estrategias de búsqueda con mucha precisión, esto me encantó, acostumbrado a las limitaciones de MySQL es un golazo.
Armé una que me permitiera darle un puntaje en base a criterios bien claros, aquí lo ideal es apoyarse en una AI para armar el request porque tenés que manejar varios niveles y no es precisamente fácil sin leer documentación obtusa, por suerte un LLM ya la leyó por mí 😁
La estrategia comienza así:
Si viene con comillas u operadores lo que pretende es el término exacto, eso es más fácil para preparar la búsqueda:
$hasQuotes = preg_match('/"[^"]+"/', $query);
// Detect if query uses advanced operators (AND, OR, NOT, -)
$hasOperators = preg_match('/\b(AND|OR|NOT)\b|^-/', $query);
if ($hasQuotes || $hasOperators) {
// Use query_string for advanced syntax with quotes and operators
$must[] = [
'query_string' => [
'query' => $query,
'fields' => [
'title^3',
'summary^2',
'content',
'site_title'
],
'default_operator' => 'AND',
'analyze_wildcard' => true,
'allow_leading_wildcard' => false,
'enable_position_increments' => true
]
];
}
Ahora bien, la cosa se complica cuando tenés que armar una "estrategia" para el scoring.
La divido en cuatro niveles, primero priorizar cuando la frase que encuentro es exacta a lo que puso en el diálogo de búsqueda.
En segundo nivel si los términos coiniciden de forma exacta, tercero si la coincidencia es parcial y cuarto lo que se "parezca", palabras parecidas pero no exactas.
Así se ve la estrategia:
$must[] = [
'bool' => [
'should' => [
// 1. Exact phrase match (highest priority)
[
'multi_match' => [
'query' => $query,
'fields' => [
'title^5', // Title exact phrase gets highest boost
'summary^2.5',
'content^1.5'
],
'type' => 'phrase',
'boost' => 10 // Very high boost for exact phrases
]
],
// 2. Exact term match (high priority)
[
'multi_match' => [
'query' => $query,
'fields' => [
'title^4',
'summary^2.5',
'content',
'site_title^2'
],
'type' => 'best_fields',
'operator' => 'and', // All terms must match
'boost' => 5 // High boost for exact terms
]
],
// 3. Partial term match (medium priority)
[
'multi_match' => [
'query' => $query,
'fields' => [
'title^3',
'summary^2',
'content',
'site_title'
],
'type' => 'best_fields',
'operator' => 'or', // At least one term must match
'boost' => 2 // Medium boost
]
],
// 4. Fuzzy match (lowest priority, for typos and similar words)
[
'multi_match' => [
'query' => $query,
'fields' => [
'title^2',
'summary',
'content'
],
'type' => 'best_fields',
'operator' => 'or',
'fuzziness' => 'AUTO',
'boost' => 1 // Lowest boost for fuzzy matches
]
]
],
'minimum_should_match' => 1 // At least one strategy must match
]
];
Aquí se puede jugar con los puntajes que se le de a cada acierto y en qué parte del texto, obviamente si algo está mencionado en el título es mucho más importante que en el contenido que puede ser una mención suelta.
| Nivel | Tipo | Boost | Descripción |
|---|---|---|---|
| 1 | Frase Exacta | 10x | Encuentra la frase completa exactamente como fue escrita. Ejemplo: buscar "memes" encuentra "10 memes divertidos" |
| 2 | Términos Exactos (AND) | 5x | Todos los términos deben estar presentes exactamente. Sin variaciones ni typos |
| 3 | Coincidencia Parcial (OR) | 2x | Al menos uno de los términos debe coincidir exactamente |
| 4 | Fuzzy Match | 1x | Permite palabras similares y typos. Prioridad más baja |
Ahora bien, aquí no termina todo, si lo uso así anda bien, pero me va a tirar resultados de 2012 a la misma altura que uno de 2025, necesito que además priorice el scoring por fecha, arriba los que tienen buen puntaje Y son más nuevos:
$query = [
'query' => [
'function_score' => [
'query' => [
'bool' => $boolQuery
],
'functions' => [
[
'gauss' => [
'published_date' => [
'origin' => 'now',
'scale' => '60d', // Wider scale: 60 days
'offset' => '7d', // 7-day window for maximum boost
'decay' => 0.7 // Gentler decay
]
],
'weight' => 2.0 // Adds up to ~2 points for very recent posts
]
],
'score_mode' => 'sum', // Add the boost instead of multiplying
'boost_mode' => 'sum', // Add to query score instead of multiplying
'max_boost' => 3.0 // Cap the maximum boost from recency
]
]
];
Aquí también se puede modificar bastante los multiplicadores y cómo "decae" el puntaje con la edad del posteo y el peso que tienen los más nuevos. Con estos valores logré una especie de equilibrio y ya no me muestra tantas cosas antiguas cuando estoy buscando algo muy general, te acerca más las "noticias" por decirlo así.
Me di cuenta de que necesitaba esto luego de ver que alguien había buscado "memes" y le había dado como 10.000 resultados. era imposible que esa palabra tuviera tantas menciones, cuando busqué lo último que me aparecían eran memes y veía muchos "meses" y cualquier cosa parecida en cinco letras.
Con el nuevo esquema se da lo siguiente:
Búsqueda: "memes"
| Artículo | Match Type | Score Base | Boost Fecha | Score Final | Posición |
|---|---|---|---|---|---|
| "Los 10 memes más divertidos" (hoy) | Frase exacta | 50.0 | +2.0 | 52.0 | 🥇 1º |
| "Mejores memes del año" (hace 6 meses) | Frase exacta | 50.0 | +0.6 | 50.6 | 🥈 2º |
| "Top 5 memes virales" (hace 1 año) | Frase exacta | 50.0 | +0.1 | 50.1 | 🥉 3º |
| "Calendario de meses 2026" (hoy) | Fuzzy | 8.0 | +2.0 | 10.0 | ⬇️ 10º+ |
Así no es que desaparecen las "fuzzy" sino que les quita peso, si escribís como el culo una palabra es probable que la encuentre como cualquier buscador, sólo que si hay un match exacto, te dará eso primero.
Con esto los resultados ahora son más precisos ¿Hay margen para mejorarlo? Sin dudas, hay mucho tuneo por delante, pero con esto el buscador ya funciona como un buscador y es lo que importa!
Otros posts que podrían llegar a gustarte...
