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

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


