Zum Inhalt springen

Volltextsuche mit Elasticsearch und Node.js

Schritt-für-Schritt zur Volltextsuche mit Elasticsearch: Indexdesign, Analyzer, Fuzzy-Matching, Facettensuche und Performance-Optimierung.

4 Min. Lesezeit
Elasticsearch-Abfragepipeline mit den Phasen Analyzer, Tokenizer und Scoring

Warum eine eigene Suche bauen

Die LIKE-Abfragen herkömmlicher Datenbanken sind nicht mehr brauchbar, sobald der Datensatz über ein paar tausend Datensätze hinauswächst oder die Nutzer mehr erwarten als exakte Teilstring-Treffer. Tippfehlertoleranz, Relevanzbewertung, Synonymerweiterung und Facettenfilterung erfordern eine dedizierte Suchmaschine.

Elasticsearch ist die Standardwahl für Volltextsuche in der Anwendungsentwicklung. Dieses Tutorial führt durch den Aufbau einer produktionsreifen Suchschicht, vom Indexdesign bis zur Abfrageoptimierung.

Den Elasticsearch-Client einrichten

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;
  }
}

Indexdesign mit benutzerdefinierten Analyzern

Das Index-Mapping bestimmt, wie Text tokenisiert, normalisiert und gespeichert wird. Ein gut durchdachtes Mapping ist der Unterschied zwischen Suchergebnissen, die sich magisch anfühlen, und Ergebnissen, die Nutzer frustrieren.

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" },
        },
      },
    },
  });
}

Der Index verwendet getrennte Analyzer für Indexierung und Suche. Der Indexierungs-Analyzer wendet Edge-N-Gramme für Präfix-Treffer an („lapt" trifft „laptop"), während der Such-Analyzer die N-Gramme auslässt, um übermäßige Treffer zu vermeiden.

Bulk-Indexierung für bessere Performance

Dokumente einzeln zu indexieren ist ineffizient. Bulk-Operationen verbessern den Indexierungsdurchsatz drastisch.

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" });
}

Die Suchabfrage erstellen

Eine produktive Suchabfrage kombiniert Volltext-Treffer, Fuzzy-Toleranz, Feld-Boosting und Filterung in einer einzigen Anfrage.

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" } },
      },
    },
  });
}

Die Syntax name^3 boostet Namenstreffer auf das Dreifache der Punktzahl von Beschreibungstreffern. fuzziness: "AUTO" erlaubt eine Zeichenänderung bei kürzeren Begriffen und zwei bei längeren Begriffen und fängt damit die meisten Tippfehler ganz natürlich ab.

Suchergebnisse verarbeiten

Wandle die rohen Elasticsearch-Ergebnisse in eine saubere API-Antwort mit Paginierungsmetadaten, hervorgehobenen Ausschnitten und Facettenzählern um.

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),
    },
  };
}

Wichtige Erkenntnisse

Effektive Volltextsuche zu bauen erfordert ein Verständnis der Indexierungspipeline: Analyzer tokenisieren und normalisieren Text, Mappings definieren Feldtypen und -verhalten, und Abfragen kombinieren Matching mit Filterung und Boosting, um relevante Ergebnisse zu liefern.

Verwende getrennte Indexierungs- und Such-Analyzer – Edge-N-Gramme zur Indexierungszeit ermöglichen Präfix-Treffer ohne übermäßige Treffer zur Suchzeit. Indexiere per Bulk in Batches für bessere Performance. Baue boolesche Abfragen, die das Scoring (must) von der Filterung (filter) trennen, um den Abfrage-Cache von Elasticsearch zu nutzen.

Aggregationen treiben die Facettennavigation an und geben Nutzern die Möglichkeit, nach Kategorie, Marke, Preisbereich und weiteren Dimensionen zu filtern. Sie laufen parallel zur Hauptabfrage in einer einzigen Anfrage und machen das Sucherlebnis so flüssig, ohne zusätzliche Roundtrips.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX