Blog

Erstellen einer Such-App mit Blazor und Elasticsearch

Lernen Sie, wie Sie mit Blazor und Elasticsearch eine Suchanwendung erstellen und wie Sie den Elasticsearch .NET-Client für die hybride Suche verwenden.

In diesem Artikel erfahren Sie, wie Sie Ihre C#-Kenntnisse nutzen können, um eine Suchanwendung mit Blazor und Elasticsearch zu erstellen. Wir werden den Elasticsearch .NET- Client verwenden, um Volltext-, semantische und hybride Suchabfragen durchzuführen.

HINWEIS: Wenn Sie mit der älteren Version des Elasticsearch C#-Clients NEST vertraut sind, lesen Sie diesen Blogbeitrag über die Einstellung des NEST-Clients und neue Funktionen. NEST war die vorherige Generation des .NET-Clients, der durch den .NET-Client ersetzt wurde.Elastic.Clients.Elasticsearch package

Erstellen Sie eine Blazor-App: Esre mit Blazor-Diagramm

Was ist Blazor?

Blazor-Server

Bild: ASP.NETCoreBlazorhostingmodels

Blazor ist ein Open-Source-Webframework auf Basis von HTML, CSS und C#, das von Microsoft entwickelt wurde, um Entwicklern die Erstellung von Webanwendungen zu ermöglichen, die auf dem Client oder dem Server ausgeführt werden können. Blazor ermöglicht es Ihnen außerdem, wiederverwendbare Komponenten zu erstellen, um Anwendungen schneller zu entwickeln; es ermöglicht Entwicklern, die HTML-Ansicht und Aktionen in C# innerhalb derselben Datei zu erstellen, was dazu beiträgt, lesbaren und sauberen Code zu erhalten. Mit Blazor Hybrid können Sie außerdem native mobile Apps erstellen, die über .NET-Code auf die Funktionen der nativen Plattform zugreifen.

Einige der Merkmale, die Blazor zu einem großartigen Framework machen:

  • Serverseitige und clientseitige Rendering-Optionen

  • Wiederverwendbare UI-Komponenten

  • Echtzeit-Updates mit SignalR

  • Integrierte Zustandsverwaltung

  • Eingebautes Routingsystem

  • Strenge Typisierung und Kompilierzeitprüfungen

Warum Blazer?

Blazor bietet gegenüber anderen Frameworks und Bibliotheken mehrere Vorteile: Es ermöglicht Entwicklern, C# sowohl für Client- als auch für Servercode zu verwenden, bietet eine starke Typisierung und Kompilierzeitprüfung, was die Zuverlässigkeit erhöht. Es integriert sich nahtlos in das .NET-Ökosystem, ermöglicht die Wiederverwendung von .NET-Bibliotheken und -Tools und bietet robuste Unterstützung beim Debuggen.

Was ist ESRE?

Elasticsearch Relevance Engine ™ (ESRE) ist eine Sammlung von Tools zum Erstellen von Suchanwendungen mithilfe von maschinellem Lernen und künstlicher Intelligenz auf Basis der leistungsstarken Elasticsearch-Suchmaschine.

esre

Um mehr über ESRE zu erfahren, können Sie unseren aufschlussreichen Blogbeitrag hierlesen.

ELSER konfigurieren

Um die ESRE- Funktionen von Elastic optimal zu nutzen, werden wir ELSER als unseren Modellanbieter verwenden.

Hinweis: Für die Nutzung der ELSER-Modelle von Elasticsearch benötigen Sie eine Platinum- oder Enterprise-Lizenz sowie einen dedizierten Machine-Learning-Knoten (ML-Knoten) mit mindestens 4 GB Speicherplatz. Weitere Informationen finden Sie hier.

Beginnen Sie mit der Erstellung des Inferenzendpunkts:

PUT _inference/sparse_embedding/my-elser-model
{
  "service": "elser",
  "service_settings": {
    "num_allocations": 1,
    "num_threads": 1
  }
}

Wenn Sie ELSER zum ersten Mal verwenden, kann beim Laden des Modells im Hintergrund ein 502 Bad Gateway-Fehler auftreten. Den Status des Modells können Sie in Machine Learning > Trained Models in Kibana überprüfen. Sobald die Bereitstellung abgeschlossen ist, können Sie mit dem nächsten Schritt fortfahren.

Überprüfung trainierter Modelle

Indexierungsdaten

Sie können den Datensatz hier herunterladen und die Daten anschließend mit Kibana importieren. Gehen Sie dazu auf die Startseite und klicken Sie auf „Daten hochladen“. Laden Sie anschließend die Datei hoch und klicken Sie auf Import. Wechseln Sie abschließend zum Reiter Advanced und fügen Sie die folgenden Zuordnungen ein:

{
   "properties":{
      "authors":{
         "type":"keyword"
      },
      "categories":{
         "type":"keyword"
      },
      "longDescription":{
         "type":"semantic_text",
         "inference_id":"my-elser-model",
         "model_settings":{
            "task_type":"sparse_embedding"
         }
      },
      "pageCount":{
         "type":"integer"
      },
      "publishedDate":{
         "type":"date"
      },
      "shortDescription":{
         "type":"text"
      },
      "status":{
         "type":"keyword"
      },
      "thumbnailUrl":{
         "type":"keyword"
      },
      "title":{
         "type":"text"
      }
   }
}
Importdaten

Wir werden einen Index erstellen, der semantische und Volltextabfragen ausführen kann. Der Feldtyp semantic_text kümmert sich um die Datensegmentierung und -einbettung. Beachten Sie, dass wir als indizieren longDescription  semantic_text. Sie können copy_to verwenden, wenn Sie ein Feld sowohl als als semantic_text auch als text` indizieren möchten .

Entwicklung der App mit Blazor & Elasticsearch

API-Schlüssel

Als Erstes müssen wir einen API-Schlüssel erstellen, um unsere Anfragen an Elasticsearch zu authentifizieren. Der API-Schlüssel sollte schreibgeschützt sein und nur Abfragen des Index books-blazor erlauben.

POST /_security/api_key
{
  "name": "books-blazor-key",
  "role_descriptors": {
    "books-blazor-reader": {
      "indices": [
        {
          "names": ["books-blazor"],
          "privileges": ["read"]
        }
      ]
    }
  }
}

Sie werden etwa Folgendes sehen:

{
  "id": "XXXXXXXXXXXXXXXXXXXXXXXX",
  "name": "books-blazor-key",
  "api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "encoded": "XXXXXXXXXXXXXXXXXXXXXXXX=="
}

Speichern Sie den Wert des Antwortfelds encoded , da er später benötigt wird. Wenn Sie Elastic Cloud nutzen, benötigen Sie außerdem Ihre Cloud-ID. (Sie finden sie hier).

Das Blazor-Projekt wird erstellt

Beginnen Sie mit der Installation von Blazor und der Erstellung eines Beispielprojekts gemäß den offiziellen Anweisungen.

Blazor-Tutorial – Erstellen des Blazor-Projekts

Sobald das Projekt erstellt ist, sollte die Ordnerstruktur und die Dateistruktur wie folgt aussehen:

BlazorApp/
|-- BlazorApp.csproj
|-- BlazorApp.sln
|-- Program.cs
|-- appsettings.Development.json
|-- appsettings.json
|-- Properties/
|   `-- launchSettings.json
|-- Components/
|   |-- App.razor
|   |-- Routes.razor
|   |-- _Imports.razor
|   |-- Layout/
|   |   |-- MainLayout.razor
|   |   |-- MainLayout.razor.css
|   |   |-- NavMenu.razor
|   |   `-- NavMenu.razor.css
|   `-- Pages/
|       |-- Counter.razor
|       |-- Error.razor
|       |-- Home.razor
|       `-- Weather.razor
|-- wwwroot/
|-- bin/
`-- obj/

Die Template-Anwendung enthält Bootstrap v5.1.0. zum Styling.

Schließen Sie die Projekteinrichtung ab, indem Sie den Elasticsearch .NET -Client installieren:

dotnet add package Elastic.Clients.Elasticsearch

Sobald Sie diesen Schritt abgeschlossen haben, sollte Ihre Seite wie folgt aussehen:

Blazor Hallo Welt

Ordnerstruktur

Nun werden wir unsere Ordner wie folgt organisieren:

BlazorApp/
|-- Components/
|   |-- Pages/
|   |   |-- Search.razor
|   |   `-- Search.razor.css
|   `-- Elasticsearch/
|       |-- SearchBar.razor
|       |-- Results.razor
|       `-- Facet.razor
|-- Models/
|   |-- Book.cs
|   `-- Response.cs
`-- Services/
    `-- ElasticsearchService.cs

Dateitypen erklärt:

  • Components/Pages/Search.razor: Hauptseite mit Suchleiste, Suchergebnissen und Filtern.

  • Components/Pages/Search.razor.css: Seitenstile.

  • Components/Elasticsearch/SearchBar.razor: Suchleistenkomponente.

  • Components/Elasticsearch/Results.razor: Ergebniskomponente.

  • Components/Elasticsearch/Facet.razor: Filterkomponente.

  • Components/Svg/GlassIcon.razor: Suchsymbol.

  • Components/_Imports.razor: Dadurch werden alle Komponenten importiert.

  • Models/Book.cs: Hier wird das Schema der Buchfelder gespeichert.

  • Models/Response.cs: Hier wird das Antwortschema gespeichert, einschließlich der Suchergebnisse, Facetten und der Gesamtzahl der Treffer.

  • Services/ElasticsearchService.cs: Elasticsearch-Dienst. Es übernimmt die Verbindung und die Abfragen an Elasticsearch.

Erstkonfiguration

Fangen wir mit Aufräumarbeiten an.

Löschen Sie die Dateien:

  • Komponenten/Seiten/Counter.razor

  • Komponenten/Seiten/Wetter.razor

  • Komponenten/Seiten/Startseite.razor

  • Komponenten/Layout/NavMenu.razor

  • Components/Layout/NavMenu.razor.css

Überprüfen Sie die Datei /Components/_Imports.razor . Sie sollten folgende Importe haben:

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorApp
@using BlazorApp.Components

Integration von Elastic in das Projekt

Importieren wir nun die Elasticsearch-Komponenten:

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorApp
@using BlazorApp.Components
@using BlazorApp.Components.Elasticsearch @* <--- Add this line *@

Wir werden die Standard-Seitenleiste entfernen, um mehr Platz für unsere Anwendung zu schaffen, indem wir sie aus der Datei /Components/Layout/MainLayout.razor löschen:

@inherits LayoutComponentBase

<div class="page">
    <main>
        <article class="content">
            @Body
        </article>
    </main>
</div>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>
Navigationsleiste aus dem Layout entfernt

Geben wir nun die Elasticsearch-Zugangsdaten für die Benutzergeheimnisse ein:

dotnet user-secrets init
dotnet user-secrets set ElasticsearchCloudId "your Cloud ID"
dotnet user-secrets set ElasticsearchApiKey "your API Key"

Bei diesem Ansatz speichert .NET 8 sensible Daten an einem separaten Ort außerhalb des Projektordners und ermöglicht den Zugriff über die IConfiguration -Schnittstelle. Diese Variablen stehen jedem .NET-Projekt zur Verfügung, das dieselben Benutzergeheimnisse verwendet.

Als Nächstes modifizieren wir die Datei Program.cs , um die Geheimnisse zu lesen und den Elasticsearch-Client einzubinden:

Zuerst müssen die benötigten Bibliotheken importiert werden:

using BlazorApp.Services;
using Elastic.Clients.Elasticsearch;
using Elastic.Transport;
  • BlazorApp.Services: enthält den Elasticsearch-Dienst.

  • Elastic.Clients.Elasticsearch: importiert die Elasticsearch-Client-Bibliothek für .NET 8.

  • Elastic.Transport: importiert die Elasticsearch-Transportbibliothek, die es uns ermöglicht, die ApiKey-Klasse zur Authentifizierung unserer Anfragen zu verwenden.

Zweitens fügen Sie den folgenden Code vor der Zeile var app = builder.Build() ein:

// Initialize the Elasticsearch client.
builder.Services.AddScoped(sp =>
{
    // Getting access to the configuration service to read the Elasticsearch credentials.
    var configuration = sp.GetRequiredService<IConfiguration>();
    var cloudId = configuration["ElasticsearchCloudId"];
    var apiKey = configuration["ElasticsearchApiKey"];

    if (string.IsNullOrEmpty(cloudId) || string.IsNullOrEmpty(apiKey))
    {
        throw new InvalidOperationException(
            "Elasticsearch credentials are missing in configuration."
        );
    }

    var settings = new ElasticsearchClientSettings(cloudId, new ApiKey(apiKey)).EnableDebugMode();
    return new ElasticsearchClient(settings);
});

Dieser Code liest die Elasticsearch-Zugangsdaten aus den Benutzergeheimnissen und erstellt eine Elasticsearch-Clientinstanz.

Nach der Initialisierung des Elasticsearch-Clients fügen Sie die folgende Zeile hinzu, um den Elasticsearch-Dienst zu registrieren:

builder.Services.AddScoped<ElasticsearchService>();

Der nächste Schritt besteht darin, die Suchlogik in der Datei /Services/ElasticsearchService.cs zu implementieren:

Zuerst müssen die benötigten Bibliotheken und Modelle importiert werden:

using BlazorApp.Models;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.QueryDsl;

Zweitens fügen Sie die Klasse ElasticsearchService, den Konstruktor und die Variablen hinzu:

namespace BlazorApp.Services
{
    public class ElasticsearchService
    {
        private readonly ElasticsearchClient _client;

        // The logger is used to log information, warnings and errors about the Elasticsearch service and requests.
        private readonly ILogger<ElasticsearchService> _logger;

        public ElasticsearchService(
            ElasticsearchClient client,
            ILogger<ElasticsearchService> logger
        )
        {
            _client = client ?? throw new ArgumentNullException(nameof(client));
            _logger = logger;
        }
    }
}

Suche konfigurieren

Nun erstellen wir unsere Suchlogik:

private static Action<RetrieverDescriptor<BookDoc>> BuildHybridQuery(
    string searchTerm,
    Dictionary<string, List<string>> selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    return retrievers =>
        retrievers.Rrf(rrf =>
            rrf.RankWindowSize(50)
                .RankConstant(20)
                .Retrievers(
                    retrievers =>
                        retrievers.Standard(std =>
                            std.Query(q =>
                                q.Bool(b =>
                                    b.Must(m =>
                                            m.MultiMatch(mm =>
                                                mm.Query(searchTerm)
                                                    .Fields(
                                                        new[]
                                                        {
                                                            "title",
                                                            "shortDescription",
                                                        }
                                                    )
                                            )
                                        )
                                        .Filter(filters.ToArray())
                                )
                            )
                        ),
                    retrievers =>
                        retrievers.Standard(std =>
                            std.Query(q =>
                                q.Bool(b =>
                                    b.Must(m =>
                                            m.Semantic(sem =>
                                                sem.Field("longDescription")
                                                    .Query(searchTerm)
                                            )
                                        )
                                        .Filter(filters.ToArray())
                                )
                            )
                        )
                )
        );
}

public static List<Action<QueryDescriptor<BookDoc>>> BuildFilters(
    Dictionary<string, List<string>> selectedFacets
)
{
    var filters = new List<Action<QueryDescriptor<BookDoc>>>();

    if (selectedFacets != null)
    {
        foreach (var facet in selectedFacets)
        {
            foreach (var value in facet.Value)
            {
                var field = facet.Key.ToLower();
                if (!string.IsNullOrEmpty(field))
                {
                    filters.Add(m => m.Term(t => t.Field(new Field(field)).Value(value)));
                }
            }
        }
    }

    return filters;
}
  • BuildFilters wird die Filter für die Suchanfrage anhand der vom Benutzer ausgewählten Facetten erstellen.

  • BuildHybridQuery wird eine hybride Suchanfrage erstellen, die Volltext- und semantische Suche kombiniert.

Als Nächstes fügen Sie die Suchmethode hinzu:

public async Task<ElasticResponse> SearchBooksAsync(
    string searchTerm,
    Dictionary<string, List<string>> selectedFacets
)
{
    try
    {
        _logger.LogInformation($"Performing search for: {searchTerm}");

        // Retrieve the hybrid query with filters applied.
        var retrieverQuery = BuildHybridQuery(searchTerm, selectedFacets);

        var response = await _client.SearchAsync<BookDoc>(s =>
            s.Index("elastic-blazor-books")
                .Retriever(retrieverQuery)
                .Aggregations(aggs =>
                    aggs.Add("Authors", agg => agg.Terms(t => t.Field(p => p.Authors)))
                        .Add(
                            "Categories",
                            agg => agg.Terms(t => t.Field(p => p.Categories))
                        )
                        .Add("Status", agg => agg.Terms(t => t.Field(p => p.Status)))
                )
        );

        if (response.IsValidResponse)
        {
            _logger.LogInformation($"Found {response.Documents.Count} documents");

            var hits = response.Total;
            var facets =
                response.Aggregations != null
                    ? FormatFacets(response.Aggregations)
                    : new Dictionary<string, Dictionary<string, long>>();

            var elasticResponse = new ElasticResponse
            {
                TotalHits = hits,
                Documents = response.Documents.ToList(),
                Facets = facets,
            };

            return elasticResponse;
        }
        else
        {
            _logger.LogWarning($"Invalid response: {response.DebugInformation}");
            return new ElasticResponse();
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error performing search");
        return new ElasticResponse();
    }
}

public static Dictionary<string, Dictionary<string, long>> FormatFacets(
    Elastic.Clients.Elasticsearch.Aggregations.AggregateDictionary aggregations
)
{
    var facets = new Dictionary<string, Dictionary<string, long>>();

    foreach (var aggregation in aggregations)
    {
        if (
            aggregation.Value
            is Elastic.Clients.Elasticsearch.Aggregations.StringTermsAggregate termsAggregate
        )
        {
            var facetName = aggregation.Key;
            var facetDictionary = ConvertFacetDictionary(
                termsAggregate.Buckets.ToDictionary(b => b.Key, b => b.DocCount)
            );
            facets[facetName] = facetDictionary;
        }
    }

    return facets;
}

private static Dictionary<string, long> ConvertFacetDictionary(
    Dictionary<Elastic.Clients.Elasticsearch.FieldValue, long> original
)
{
    var result = new Dictionary<string, long>();
    foreach (var kvp in original)
    {
        result[kvp.Key.ToString()] = kvp.Value;
    }
    return result;
}
  • SearchBooksAsync: führt die Suche mit Hilfe der Hybridabfrage durch und gibt die Ergebnisse einschließlich der Aggregationen zum Aufbau der Facetten zurück.

  • FormatFacets: formatiert die Aggregationsantwort in ein Wörterbuch.

  • ConvertFacetDictionary: wird das Facettenwörterbuch in ein besser lesbares Format umwandeln.

Im nächsten Schritt werden die Modelle erstellt, die die Daten repräsentieren, die in der hits der Elasticsearch-Abfrage zurückgegeben werden und als Ergebnisse auf unserer Suchseite angezeigt werden.

Wir beginnen damit, die Datei /Models/Book.cs zu erstellen und Folgendes hinzuzufügen:

namespace BlazorApp.Models
{
    public class BookDoc
    {
        public string? Title { get; set; }
        public int? PageCount { get; set; }
        public string? PublishedDate { get; set; }
        public string? ThumbnailUrl { get; set; }
        public string? ShortDescription { get; set; }
        public LongDescription? LongDescription { get; set; }
        public string? Status { get; set; }
        public List<string>? Authors { get; set; }
        public List<string>? Categories { get; set; }
    }

    public class LongDescription
    {
        public string? Text { get; set; }
    }
}

Anschließend wird die Elastic-Antwort in der Datei /Models/Response.cs eingerichtet und Folgendes hinzugefügt:

namespace BlazorApp.Models
{
    public class ElasticResponse
    {
        public ElasticResponse()
        {
            Documents = new List<BookDoc>();
            Facets = new Dictionary<string, Dictionary<string, long>>();
        }

        public long TotalHits { get; set; }
        public List<BookDoc> Documents { get; set; }
        public Dictionary<string, Dictionary<string, long>> Facets { get; set; }
    }
}

Konfigurieren einer grundlegenden Benutzeroberfläche

Als Nächstes fügen Sie die Komponente SearchBar hinzu. Fügen Sie in der Datei /Components/Elasticsearch/SearchBar.razor Folgendes hinzu:

@using System.Threading.Tasks

<form @onsubmit="SubmitSearch">
  <div class="input-group mb-3">
    <input type="text" @bind-value="searchTerm" class="form-control" placeholder="Enter search term..." />
    <button type="submit" class="btn btn-primary input-btn">
      <span class="input-group-svg">
        Search
      </span>
    </button>
  </div>
</form>

@code {
  [Parameter]
  public EventCallback<string> OnSearch { get; set; }

  private string searchTerm = "";

  private async Task SubmitSearch()
  {
    await OnSearch.InvokeAsync(searchTerm);
  }
}

Diese Komponente enthält eine Suchleiste und eine Schaltfläche zur Durchführung der Suche.

Blazor bietet große Flexibilität, da es die dynamische Generierung von HTML mithilfe von C#-Code innerhalb derselben Datei ermöglicht.

Anschließend erstellen wir in der Datei /Components/Elasticsearch/Results.razor die Ergebniskomponente, die die Suchergebnisse anzeigt:

@using BlazorApp.Models

@if (SearchResults != null && SearchResults.Any())
{
  <div class="row">
  @foreach (var result in SearchResults)
    {
      <div class="col-12 mb-3">
        <div class="card">
          <div class="row g-0">
            <div class="col-md-3 image-container">
              @if (!string.IsNullOrEmpty(result?.ThumbnailUrl))
              {
                <img src="@result?.ThumbnailUrl" class="img-fluid rounded-start" alt="Thumbnail">
              }
              else
              {
                <div class="placeholder">
                  @result?.Title
                </div>
              }
            </div>

            <div class="col-md-9"> <!-- Adjusted to use the remaining 75% -->
              <div class="card-body">
                <h4 class="card-title">
                  @result?.Title
                </h4>

                <div class="details-container">
                  <div class="">

                    @if (result?.Authors?.Any() == true)
                    {
                      <p class="card-text p-first">
                        Authors: <small class="text-muted">@string.Join(", ", result.Authors)</small>
                      </p>
                    }

                    @if (result?.Categories?.Any() == true)
                    {
                      <p class="card-text p-second">
                        Categories: <small class="text-muted">@string.Join(", ", result.Categories)</small>
                      </p>
                    }
                  </div>
                  <div class="numPages-status">
                    @if (result?.PageCount != null)
                    {
                      <p class="card-text p-first">
                        Pages: <small class="text-muted">@result.PageCount</small>
                      </p>
                    }

                    @if (result?.Status != null)
                    {
                      <p class="card-text p-second">
                        Status: <small class="text-muted">@result.Status</small>
                      </p>
                    }
                  </div>
                </div>

                <div class="long-text-container">
                  <p class="card-text"><small class="text-muted">@result?.LongDescription?.Text</small></p>
                </div>
                @if (!string.IsNullOrEmpty(result?.PublishedDate))
                {
                  <div class="date-container">
                    <p class="card-text">
                      Published Date: <small class="text-muted small-date">@FormatDate(result.PublishedDate)</small>
                    </p>
                  </div>
                }
              </div>
            </div>
          </div>
        </div>
      </div>
    }
  </div>
}
else if (SearchResults != null)
{
  <p>No results found.</p>
}

@code {
  [Parameter]
  public List<BookDoc> SearchResults { get; set; } = new List<BookDoc>();

  private string FormatDate(string? date)
  {
    if (DateTime.TryParse(date, out DateTime parsedDate))
    {
      return parsedDate.ToString("MMMM dd, yyyy");
    }
    return "";
  }
}

Abschließend müssen wir noch Facetten erstellen, um die Suchergebnisse zu filtern.

Hinweis: Facetten sind Filter, mit denen Nutzer Suchergebnisse anhand bestimmter Attribute oder Kategorien wie Produkttyp, Preisspanne oder Marke eingrenzen können. Diese Filter werden üblicherweise als anklickbare Optionen, oft in Form von Kontrollkästchen, dargestellt, um Nutzern die Suche zu erleichtern und relevante Ergebnisse schneller zu finden. In Elasticsearch werden Facetten mithilfe von Aggregationenerstellt.

Wir richten Facetten ein, indem wir den folgenden Code in die Datei /Components/Elasticsearch/Facet.razor einfügen:

@if (Facets != null)
{
  <div class="facets-container">
  @foreach (var facet in Facets)
    {
      <h3>@facet.Key</h3>
      @foreach (var option in facet.Value)
      {
        <div>
          <input type="checkbox" checked="@IsFacetSelected(facet.Key, option.Key)"
            @onclick="() => ToggleFacet(facet.Key, option.Key)" />
          @option.Key (@option.Value)
        </div>
      }
    }
  </div>
}


@code {
  [Parameter]
  public Dictionary<string, Dictionary<string, long>>? Facets { get; set; }

  [Parameter]
  public EventCallback<Dictionary<string, List<string>>> OnFacetChanged { get; set; }

  private Dictionary<string, List<string>> selectedFacets = new();

  private void ToggleFacet(string facetName, string facetValue)
  {
    if (!selectedFacets.TryGetValue(facetName, out var facetValues))
    {
      facetValues = selectedFacets[facetName] = new List<string>();
    }

    if (!facetValues.Remove(facetValue))
    {
      facetValues.Add(facetValue);
    }

    OnFacetChanged.InvokeAsync(selectedFacets);
  }

  private bool IsFacetSelected(string facetName, string facetValue)
  {
    return selectedFacets.ContainsKey(facetName) && selectedFacets[facetName].Contains(facetValue);
  }
}

Diese Komponente liest Daten aus einer terms -Aggregation der Felder author, categories und status und erzeugt anschließend eine Liste von Filtern, die an Elasticsearch zurückgesendet werden.

Nun fügen wir alles zusammen.

In der Datei /Components/Pages/Search.razor :

@page "/"
@rendermode InteractiveServer
@using BlazorApp.Models
@using BlazorApp.Services
@inject ElasticsearchService ElasticsearchService
@inject ILogger<Search> Logger

<PageTitle>Search</PageTitle>

<div class="top-row px-4 ">

    <div class="searchbar-container">
        <h4>Semantic Search with Elasticsearch and Blazor</h4>

        <SearchBar OnSearch="PerformSearch" />
    </div>

    <a href="https://www.elastic.co/search-labs/esre-with-blazor" target="_blank">About</a>
</div>

<div class="px-4">

    <div class="search-details-container">
        <p role="status">Current search term: @currentSearchTerm</p>
        <p role="status">Total results: @totalResults</p>
    </div>

    <div class="results-facet-container">
        <div class="facets-container">
            <Facet Facets="facets" OnFacetChanged="OnFacetChanged" />
        </div>
        <div class="results-container">
            <Results SearchResults="searchResults" />
        </div>
    </div>
</div>

@code {
    private string currentSearchTerm = "";
    private long totalResults = 0;
    private List<BookDoc> searchResults = new List<BookDoc>();
    private Dictionary<string, Dictionary<string, long>> facets = new Dictionary<string, Dictionary<string, long>>();
    private Dictionary<string, List<string>> selectedFacets = new Dictionary<string, List<string>>();

    protected override async Task OnInitializedAsync()
    {
        await PerformSearch();
    }

    private async Task PerformSearch(string searchTerm = "")
    {
        try
        {
            currentSearchTerm = searchTerm;

            var response = await ElasticsearchService.SearchBooksAsync(currentSearchTerm, selectedFacets);
            if (response != null)
            {
                searchResults = response.Documents;
                facets = response.Facets;
                totalResults = response.TotalHits;
            }
            else
            {
                Logger.LogWarning("Search response is null.");
            }

            StateHasChanged();
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Error performing search.");
        }
    }

    private async Task OnFacetChanged(Dictionary<string, List<string>> newSelectedFacets)
    {
        selectedFacets = newSelectedFacets;
        await PerformSearch(currentSearchTerm);
    }
}

Unsere Seite funktioniert!

Blazor-Seitenbeispiel

Wie Sie sehen können, ist die Seite funktional, aber es fehlen ihr die Stile. Fügen wir etwas CSS hinzu, damit es übersichtlicher und responsiver aussieht.

Fangen wir an, die Layoutstile zu ersetzen. In der Datei Components/Layout/MainLayout.razor.css :

.page {
  position: relative;
  display: flex;
  flex-direction: column;
}

main {
  flex: 1;
}

#blazor-error-ui {
  background: lightyellow;
  bottom: 0;
  box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
  display: none;
  left: 0;
  padding: 0.6rem 1.25rem 0.7rem 1.25rem;
  position: fixed;
  width: 100%;
  z-index: 1000;
}

#blazor-error-ui .dismiss {
  cursor: pointer;
  position: absolute;
  right: 0.75rem;
  top: 0.5rem;
}

Fügen Sie die Stile für die Suchseite in der Datei Components/Pages/Search.razor.css hinzu:

.input-group .input-group-svg {
  background: transparent;
  border: transparent;
  pointer-events: none;
}

.results-facet-container {
  display: flex;
  margin-top: 1rem;
  overflow-x: auto;
}

.search-details-container {
  display: flex;
  justify-content: space-between;
  margin-top: 1rem;
}

.searchbar-container {
  padding-top: 2rem;
  display: flex;
  flex-direction: column; 
  flex-grow: 1;
  height: 100%;
  max-width: 100%; 
}

.searchbar-container h4 {
  margin: 0;
}

.top-row {
  margin-top: -1.1rem;
  position: relative; 
  background-color: hsl(216, 29%, 67%);
  border-bottom: 1px solid #d6d5d5;
  display: flex;
  align-items: center;
  height: 100%;
  padding: 0 1rem;
}

.top-row a {
  margin-left: auto;
  margin-top: -4rem; 
  color: #000000;
  text-decoration: none;
}

.top-row a:hover {
  text-decoration: underline;
}

@media (max-width: 640.98px) {
  .top-row {
    justify-content: space-between;
  }

  .top-row ::deep a,
  .top-row ::deep .btn-link {
    margin-left: 0;
  }
}

@media (min-width: 641px) {
  .top-row.auth ::deep a:first-child {
    flex: 1;
    text-align: right;
    width: 0;
  }

  .top-row,
  article {
    padding-left: 2rem !important;
    padding-right: 1.5rem !important;
  }
}

Unsere Seite sieht jetzt besser aus:

Blazor-Seite nach dem Hinzufügen von Stilen für die Suchseite

Geben wir dem Ganzen noch den letzten Schliff:

Erstellen Sie die folgenden Dateien:

  • Komponenten/Elasticsearch/Facet.razor.css

  • Components/Elasticsearch/Results.razor.css

Und fügen Sie die Stile für Facet.razor.css hinzu:

.facets-container {
  font-size: 15px;
  margin-right: 4rem;
  overflow-x: auto;
  white-space: nowrap;
  max-width: 300px;
}

.results-facet-container {
  display: flex;
  margin-top: 1rem;
  overflow-x: auto;
}

.results-facet-container > * {
  flex-shrink: 0;
}

Für Results.razor.css:

.image-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  padding: 1rem;
  box-sizing: border-box;
}

.image-container img {
  max-width: 100%;
  height: auto;
  border-radius: 0.5rem;
}

.placeholder {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  width: 100%;
  background-color: #f0f0f0;
  border: 1px solid #ccc;
  font-size: 0.9rem;
  color: #888;
  text-align: center;
  padding: 1rem;
  border-radius: 0.5rem;
}

.card-body {
  padding: 1rem;
}

.details-container {
  display: flex;
  justify-content: space-between;
  padding: 1.5rem 0;
}

.date-container {
  margin-top: 1rem;
  display: flex;
  justify-content: flex-end;
}

.date-container .small-date {
  font-weight: bold;
}

Endergebnis:

Endergebnis der Erstellung der Blazor-App-Seite

Um die Anwendung auszuführen, können Sie folgenden Befehl verwenden:

dotnet watch

Du hast es geschafft! Sie können nun mithilfe der Suchleiste in Ihrem Elasticsearch-Index nach Büchern suchen und die Ergebnisse nach Autor, Kategorie und Status filtern.

Durchführung der Volltext- und semantischen Suche

Standardmäßig führt unsere App eine hybride Suche durch, die sowohl Volltext- als auch semantische Suche nutzt. Sie können die Suchlogik ändern, indem Sie zwei separate Methoden erstellen, eine für die Volltextsuche und eine weitere für die semantische Suche, und dann eine Methode auswählen, um die Abfrage basierend auf der Eingabe des Benutzers zu erstellen.

Fügen Sie der Klasse ElasticsearchService in der Datei /Services/ElasticsearchService.cs die folgenden Methoden hinzu:

private static Action<QueryDescriptor<BookDoc>> BuildSemanticQuery(
    string searchTerm,
    Dictionary<string, List<string>> selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    return query =>
        query.Bool(b =>
            b.Must(m => m.Semantic(sem => sem.Field("longDescription").Query(searchTerm)))
                .Filter(filters.ToArray())
        );
}

private static Action<QueryDescriptor<BookDoc>> BuildMultiMatchQuery(
    string searchTerm,
    Dictionary<string, List<string>> selectedFacets
)
{
    var filters = BuildFilters(selectedFacets);

    if (string.IsNullOrEmpty(searchTerm))
    {
        return query => query.Bool(b => b.Filter(filters.ToArray()));
    }

    return query =>
        query.Bool(b =>
            b.Should(m =>
                    m.MultiMatch(mm =>
                        mm.Query(searchTerm).Fields(new[] { "title", "shortDescription" })
                    )
                )
                .Filter(filters.ToArray())
        );
}

Beide Methoden funktionieren ähnlich wie die BuildHybridQuery -Methode, führen aber nur eine Volltext- oder semantische Suche durch.

Sie können die SearchBooksAsync -Methode so anpassen, dass sie die ausgewählte Suchmethode verwendet:

public async Task<ElasticResponse> SearchBooksAsync(
    string searchTerm,
    Dictionary<string, List<string>> selectedFacets
)
{
    try
    {
        _logger.LogInformation($"Performing search for: {searchTerm}");
        
        // Modify the query builder to use the selected search method.
        var multiMatchQuery = BuildMultiMatchQuery(searchTerm, selectedFacets); // For full text search
        var semanticQuery = BuildSemanticQuery(searchTerm, selectedFacets); // For semantic search

        // In this case we will not use retrievers, but you can add them if you want to use them.
        var response = await _client.SearchAsync<BookDoc>(s =>
            s.Index("elastic-blazor-books")
                .Query(multiMatchQuery) // Change this line to use different search methods, for example: .Query(semanticQuery) for semantic search
                .Aggregations(aggs =>
                    aggs.Add("Authors", agg => agg.Terms(t => t.Field(p => p.Authors)))
                        .Add(
                            "Categories",
                            agg => agg.Terms(t => t.Field(p => p.Categories))
                        )
                        .Add("Status", agg => agg.Terms(t => t.Field(p => p.Status)))
                )
        );

        if (response.IsValidResponse)
        {
            _logger.LogInformation($"Found {response.Documents.Count} documents");

            var hits = response.Total;
            var facets =
                response.Aggregations != null
                    ? FormatFacets(response.Aggregations)
                    : new Dictionary<string, Dictionary<string, long>>();

            var elasticResponse = new ElasticResponse
            {
                TotalHits = hits,
                Documents = response.Documents.ToList(),
                Facets = facets,
            };

            return elasticResponse;
        }
        else
        {
            _logger.LogWarning($"Invalid response: {response.DebugInformation}");
            return new ElasticResponse();
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error performing search");
        return new ElasticResponse();
    }
}

Die vollständige Bewerbung finden Sie hier.

Fazit

Blazor ist ein effektives Framework, mit dem Sie Webanwendungen in C# erstellen können. Elasticsearch ist eine leistungsstarke Suchmaschine, mit der Sie Suchanwendungen erstellen können. Durch die Kombination beider können Sie auf einfache Weise robuste Suchanwendungen erstellen und die Leistungsfähigkeit von ESRE nutzen, um in kurzer Zeit ein semantisches Sucherlebnis zu schaffen.

Häufige Fragen

Was ist Blazor?

Blazor ist ein Open-Source-Webframework auf Basis von HTML, CSS und C#, das von Microsoft entwickelt wurde, um Entwicklern die Erstellung von Webanwendungen zu ermöglichen, die auf dem Client oder dem Server ausgeführt werden können. Es ermöglicht außerdem die Erstellung wiederverwendbarer Komponenten zum Aufbau von Anwendungen und mehr.

Welche Vorteile bietet die Verwendung von Blazor?

Blazor bietet gegenüber anderen Frameworks und Bibliotheken mehrere Vorteile: Es ermöglicht Entwicklern die Verwendung von C# für Client- und Servercode, es integriert sich nahtlos in das .NET-Ökosystem und bietet robuste Unterstützung beim Debuggen.

Zugehörige Inhalte

LINQ to Elasticsearch ES|QL: C# schreiben, Elasticsearch abfragen

Florian Bernd

Erweiterung der Chatbot-Funktionen mit NLP und Vektorsuche in Elasticsearch

Priscilla Parodi

Elasticsearch vs. OpenSearch: Vergleich der Leistung bei der Vektorsuche

Ugo Sangiorgi

KI-Plagiat: Plagiatserkennung mit Elasticsearch

Priscilla Parodi

Fortgeschrittene RAG-Techniken Teil 2: Abfragen und Testen

Han Xiang Choong

Sind Sie bereit, hochmoderne Sucherlebnisse zu schaffen?

Eine ausreichend fortgeschrittene Suche kann nicht durch die Bemühungen einer einzelnen Person erreicht werden. Elasticsearch wird von Datenwissenschaftlern, ML-Ops-Experten, Ingenieuren und vielen anderen unterstützt, die genauso leidenschaftlich an der Suche interessiert sind wie Sie. Lasst uns in Kontakt treten und zusammenarbeiten, um das magische Sucherlebnis zu schaffen, das Ihnen die gewünschten Ergebnisse liefert.

Probieren Sie es selbst aus