Saltar al contenido

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.

5 min de lectura
Pipeline de consultas de Elasticsearch que muestra las etapas de analizador, tokenizador y puntuación

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

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
// ❌ 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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX