Búsqueda de texto completo con Elasticsearch y Node.js
Tutorial paso a paso para implementar búsqueda de texto completo con Elasticsearch: diseño de índices, analizadores, fuzzy, facetas y rendimiento.

Por qué construir una búsqueda personalizada
Las consultas LIKE de las bases de datos tradicionales dejan de ser viables en cuanto tu conjunto de datos supera unos pocos miles de registros o tus usuarios esperan algo más que coincidencias exactas de subcadenas. La tolerancia a errores tipográficos, la puntuación por relevancia, la expansión de sinónimos y el filtrado por facetas requieren un motor de búsqueda dedicado.
Elasticsearch es la opción estándar para la búsqueda de texto completo en el desarrollo de aplicaciones. Este tutorial recorre la construcción de una capa de búsqueda con calidad de producción, desde el diseño del índice hasta la optimización de consultas.
Configuración del cliente de Elasticsearch
import { Client } from "@elastic/elasticsearch";
const client = new Client({
node: process.env.ELASTICSEARCH_URL || "http://localhost:9200",
auth: {
username: process.env.ES_USERNAME || "elastic",
password: process.env.ES_PASSWORD || "",
},
maxRetries: 3,
requestTimeout: 30000,
});
// Verify connection
async function checkConnection(): Promise<boolean> {
try {
const health = await client.cluster.health();
console.log(`Cluster: ${health.cluster_name}, Status: ${health.status}`);
return health.status !== "red";
} catch (error) {
console.error("Elasticsearch connection failed:", error);
return false;
}
}Diseño del índice con analizadores personalizados
El mapping del índice determina cómo se tokeniza, normaliza y almacena el texto. Un mapping bien diseñado es la diferencia entre unos resultados de búsqueda que parecen mágicos y unos resultados que frustran a los usuarios.
async function createProductIndex(): Promise<void> {
await client.indices.create({
index: "products",
body: {
settings: {
number_of_shards: 1,
number_of_replicas: 1,
analysis: {
analyzer: {
product_analyzer: {
type: "custom",
tokenizer: "standard",
filter: [
"lowercase",
"product_synonyms",
"product_stemmer",
"edge_ngram_filter",
],
},
search_analyzer: {
type: "custom",
tokenizer: "standard",
filter: ["lowercase", "product_synonyms", "product_stemmer"],
},
},
filter: {
product_synonyms: {
type: "synonym",
synonyms: [
"laptop,notebook,macbook",
"phone,mobile,smartphone,cellphone",
"headphones,earbuds,earphones",
],
},
product_stemmer: {
type: "stemmer",
language: "english",
},
edge_ngram_filter: {
type: "edge_ngram",
min_gram: 2,
max_gram: 15,
},
},
},
},
mappings: {
properties: {
name: {
type: "text",
analyzer: "product_analyzer",
search_analyzer: "search_analyzer",
fields: {
exact: { type: "keyword" },
suggest: {
type: "completion",
analyzer: "simple",
},
},
},
description: {
type: "text",
analyzer: "product_analyzer",
search_analyzer: "search_analyzer",
},
category: { type: "keyword" },
brand: { type: "keyword" },
price: { type: "float" },
rating: { type: "float" },
inStock: { type: "boolean" },
tags: { type: "keyword" },
createdAt: { type: "date" },
},
},
},
});
}El índice usa analizadores separados para la indexación y la búsqueda. El analizador de indexación aplica edge n-grams para la coincidencia por prefijo ("lapt" coincide con "laptop"), mientras que el analizador de búsqueda omite los n-grams para evitar coincidencias excesivas.
Indexación masiva para mejorar el rendimiento
Indexar documentos de uno en uno es ineficiente. Las operaciones bulk mejoran drásticamente el rendimiento de la indexación.
interface Product {
id: string;
name: string;
description: string;
category: string;
brand: string;
price: number;
rating: number;
inStock: boolean;
tags: string[];
}
async function bulkIndex(products: Product[]): Promise<void> {
const batchSize = 500;
for (let i = 0; i < products.length; i += batchSize) {
const batch = products.slice(i, i + batchSize);
const operations = batch.flatMap((product) => [
{ index: { _index: "products", _id: product.id } },
product,
]);
const result = await client.bulk({ body: operations, refresh: false });
if (result.errors) {
const failedItems = result.items.filter(
(item) => item.index?.error
);
console.error(
`Batch ${i / batchSize}: ${failedItems.length} failures`
);
for (const item of failedItems) {
console.error(item.index?.error);
}
} else {
console.log(
`Batch ${i / batchSize}: indexed ${batch.length} documents`
);
}
}
// Refresh index after bulk indexing is complete
await client.indices.refresh({ index: "products" });
}Construcción de la consulta de búsqueda
Una consulta de búsqueda de producción combina coincidencia de texto completo, tolerancia difusa, boosting de campos y filtrado en una sola petición.
// ❌ Naive search — no relevance tuning, no fuzzy matching
async function naiveSearch(query: string) {
return client.search({
index: "products",
body: {
query: {
match: { name: query },
},
},
});
}
// ✅ Production search with fuzzy matching, boosting, and filters
interface SearchParams {
query: string;
category?: string;
priceMin?: number;
priceMax?: number;
inStockOnly?: boolean;
page?: number;
pageSize?: number;
sortBy?: "relevance" | "price_asc" | "price_desc" | "rating";
}
async function searchProducts(params: SearchParams) {
const {
query,
category,
priceMin,
priceMax,
inStockOnly = false,
page = 1,
pageSize = 20,
sortBy = "relevance",
} = params;
const filters: object[] = [];
if (category) {
filters.push({ term: { category } });
}
if (inStockOnly) {
filters.push({ term: { inStock: true } });
}
if (priceMin !== undefined || priceMax !== undefined) {
const range: Record<string, number> = {};
if (priceMin !== undefined) range.gte = priceMin;
if (priceMax !== undefined) range.lte = priceMax;
filters.push({ range: { price: range } });
}
const sort =
sortBy === "price_asc"
? [{ price: "asc" }]
: sortBy === "price_desc"
? [{ price: "desc" }]
: sortBy === "rating"
? [{ rating: "desc" }]
: [{ _score: "desc" }];
return client.search({
index: "products",
body: {
from: (page - 1) * pageSize,
size: pageSize,
query: {
bool: {
must: [
{
multi_match: {
query,
fields: ["name^3", "description", "brand^2", "tags"],
type: "best_fields",
fuzziness: "AUTO",
prefix_length: 2,
},
},
],
filter: filters,
},
},
sort,
highlight: {
fields: {
name: { number_of_fragments: 0 },
description: { fragment_size: 150, number_of_fragments: 2 },
},
pre_tags: ["<mark>"],
post_tags: ["</mark>"],
},
aggs: {
categories: { terms: { field: "category", size: 20 } },
brands: { terms: { field: "brand", size: 20 } },
price_ranges: {
range: {
field: "price",
ranges: [
{ to: 50 },
{ from: 50, to: 100 },
{ from: 100, to: 500 },
{ from: 500 },
],
},
},
avg_rating: { avg: { field: "rating" } },
},
},
});
}La sintaxis name^3 multiplica por tres la puntuación de las coincidencias en el nombre respecto a las de la descripción. fuzziness: "AUTO" permite una edición de carácter en términos cortos y dos ediciones en términos más largos, lo que gestiona la mayoría de los errores tipográficos de forma natural.
Procesamiento de los resultados de búsqueda
Transforma los resultados sin procesar de Elasticsearch en una respuesta de API limpia con metadatos de paginación, fragmentos resaltados y recuentos de facetas.
interface SearchResult {
products: Array<{
id: string;
name: string;
description: string;
price: number;
rating: number;
highlights: {
name?: string;
description?: string[];
};
score: number;
}>;
facets: {
categories: Array<{ key: string; count: number }>;
brands: Array<{ key: string; count: number }>;
priceRanges: Array<{ label: string; count: number }>;
};
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
function transformResults(
esResponse: any,
page: number,
pageSize: number
): SearchResult {
const hits = esResponse.hits;
return {
products: hits.hits.map((hit: any) => ({
id: hit._id,
...hit._source,
highlights: {
name: hit.highlight?.name?.[0],
description: hit.highlight?.description,
},
score: hit._score,
})),
facets: {
categories: esResponse.aggregations.categories.buckets.map(
(b: any) => ({ key: b.key, count: b.doc_count })
),
brands: esResponse.aggregations.brands.buckets.map(
(b: any) => ({ key: b.key, count: b.doc_count })
),
priceRanges: esResponse.aggregations.price_ranges.buckets.map(
(b: any) => ({ label: b.key, count: b.doc_count })
),
},
pagination: {
page,
pageSize,
total: hits.total.value,
totalPages: Math.ceil(hits.total.value / pageSize),
},
};
}Conclusiones clave
Construir una búsqueda de texto completo eficaz requiere entender el pipeline de indexación: los analizadores tokenizan y normalizan el texto, los mappings definen los tipos y comportamientos de los campos, y las consultas combinan coincidencia con filtrado y boosting para producir resultados relevantes.
Usa analizadores de indexación y de búsqueda separados: los edge n-grams en el momento de la indexación permiten la coincidencia por prefijo sin coincidencias excesivas en el momento de la búsqueda. Indexa por lotes con bulk para mejorar el rendimiento. Construye consultas booleanas que separen la puntuación (must) del filtrado (filter) para aprovechar la caché de consultas de Elasticsearch.
Las agregaciones alimentan la navegación por facetas, dando a los usuarios la capacidad de filtrar por categoría, marca, rango de precio y otras dimensiones. Se ejecutan junto a la consulta principal en una sola petición, lo que hace que la experiencia de búsqueda sea ágil sin peticiones adicionales.


