ブログ

BlazorとElasticsearchを使用した検索アプリの構築

Blazor と Elasticsearch を使用して検索アプリケーションを構築する方法と、ハイブリッド検索に Elasticsearch .NET クライアントを使用する方法を学習します。

この記事では、C# スキルを活用して Blazor と Elasticsearch を使用した検索アプリケーションを構築する方法を学習します。Elasticsearch .NETクライアントを使用して、フルテキストセマンティックハイブリッド検索クエリを実行します。

注: Elasticsearch C# クライアントNESTの古いバージョンに精通している場合は、NEST クライアントの廃止と新機能に関するこちらのブログ投稿をお読みください。NESTは.NETクライアントの前世代であり、Elastic.Clients.Elasticsearch package

Blazor アプリの構築: Blazor ダイアグラムを使用した esre

Blazor とは何ですか?

Blazorサーバー

画像: ASP.NETCoreBlazorホスティングモデル

Blazor は、開発者がクライアントまたはサーバー上で実行される Web アプリケーションを構築できるようにするために Microsoft によって作成された、オープン ソースの HTML、CSS、および C# ベースの Web フレームワークです。Blazor を使用すると、再利用可能なコンポーネントを作成してアプリケーションをより速く構築することもできます。開発者は、同じファイル内に C# で HTML ビューとアクションを構築できるため、読みやすくクリーンなコードを維持できます。さらに、Blazor Hybrid を使用すると、.NET コードを介してネイティブ プラットフォーム機能にアクセスするネイティブ モバイル アプリを構築できます。

Blazor を優れたフレームワークにする機能の一部を以下に示します。

  • サーバー側とクライアント側のレンダリングオプション

  • 再利用可能なUIコンポーネント

  • SignalRによるリアルタイム更新

  • 組み込みの状態管理

  • 組み込みルーティングシステム

  • 強力な型付けとコンパイル時のチェック

Blazor を選ぶ理由

Blazor は他のフレームワークやライブラリに比べていくつかの利点があります。開発者はクライアント コードとサーバー コードの両方に C# を使用できるため、強力な型指定とコンパイル時のチェックが可能になり、信頼性が向上します。.NET エコシステムとシームレスに統合され、.NET ライブラリとツールの再利用を可能にし、強力なデバッグ サポートを提供します。

ESRE とは何ですか?

Elasticsearch Relevance Engine ™ (ESRE)は、強力な Elasticsearch 検索エンジンをベースに機械学習と人工知能を使用して検索アプリケーションを構築するためのツールセットです。

エスレ

ESREについて詳しく知りたい方は、こちらのブログ記事をご覧ください。

ELSER の設定

Elastic のESRE機能を活用するために、モデルプロバイダーとしてELSER を使用します。

ElasticsearchのELSERモデルを使用するには、PlatinumまたはEnterpriseライセンスと、最低4GBの専用機械学習(ML)ノードが必要です。詳細はこちらをご覧ください。

まず推論エンドポイントを作成します。

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

ELSER を初めて使用する場合は、モデルがバックグラウンドで読み込まれるときに 502 Bad Gateway エラーが発生する可能性があります。Kibana のMachine Learning > Trained Modelsでモデルのステータスを確認できます。デプロイが完了したら、次のステップに進むことができます。

訓練済みモデルのチェック

データのインデックス作成

ここからデータセットをダウンロードし、Kibana を使用してデータをインポートできます。これを行うには、ホームページにアクセスして「データのアップロード」をクリックします。次に、ファイルをアップロードしてImportをクリックします。最後に、 Advancedタブに移動して、次のマッピングを貼り付けます。

{
   "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"
      }
   }
}
データのインポート

セマンティック クエリとフルテキスト クエリを実行できるインデックスを作成します。semantic_textフィールド タイプは、データのチャンク化と埋め込みを処理します。として インデックス付けしていることに注意してください 。フィールドを と text` の 両方としてインデックス付けしたい場合は、 copy_to を使用できますlongDescriptionsemantic_textsemantic_text

BlazorとElasticsearchを使ったアプリ構築

APIキー

最初に行う必要があるのは、Elasticsearch へのリクエストを認証するための API キーを作成することです。API キーは読み取り専用であり、 books-blazorインデックスのクエリのみに許可される必要があります。

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

次のような画面が表示されます。

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

後で必要になるので、 encoded応答フィールドの値を保存します。Elastic Cloudで実行している場合は、Cloud ID も必要になります。(こちらで確認できます)。

Blazorプロジェクトの作成

まず Blazor をインストールし、公式の手順に従ってサンプル プロジェクトを作成します。

Blazor チュートリアル - Blazor プロジェクトの作成

プロジェクトを作成すると、フォルダー構造とファイルは次のようになります。

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/

テンプレートアプリケーションにはBootstrap v5.1.0が含まれていますスタイリング用。

Elasticsearch .NETクライアントをインストールしてプロジェクトのセットアップを完了します。

dotnet add package Elastic.Clients.Elasticsearch

この手順を完了すると、ページは次のようになります。

Blazor の hello world

フォルダ構造

ここで、フォルダーを次のように整理します。

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

ファイルの説明:

  • Components/Pages/Search.razor: 検索バー、結果、フィルターを含むメイン ページ。

  • コンポーネント/ページ/Search.razor.css:ページ スタイル。

  • Components/Elasticsearch/SearchBar.razor: 検索バー コンポーネント。

  • Components/Elasticsearch/Results.razor: 結果コンポーネント。

  • Components/Elasticsearch/Facet.razor: フィルター コンポーネント。

  • Components/Svg/GlassIcon.razor: 検索アイコン。

  • Components/_Imports.razor: これにより、すべてのコンポーネントがインポートされます。

  • Models/Book.cs: ブックフィールドのスキーマを保存します。

  • Models/Response.cs: 検索結果、ファセット、ヒット数の合計を含む応答スキーマを保存します。

  • Services/ElasticsearchService.cs: Elasticsearch サービス。Elasticsearch への接続とクエリを処理します。

初期設定

まずはクリーンアップから始めましょう。

ファイルを削除します:

  • コンポーネント/ページ/Counter.razor

  • コンポーネント/ページ/Weather.razor

  • コンポーネント/ページ/ホーム.razor

  • コンポーネント/レイアウト/NavMenu.razor

  • コンポーネント/レイアウト/NavMenu.razor.css

/Components/_Imports.razorファイルを確認してください。次のインポートが必要です。

@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

プロジェクトにElasticを統合する

次に、Elasticsearch コンポーネントをインポートします。

@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 *@

アプリケーションのスペースを増やすために、 /Components/Layout/MainLayout.razorファイルからデフォルトのサイドバーを削除します。

@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>
ナビゲーションバーをレイアウトから削除しました

次に、ユーザー シークレットの Elasticsearch 資格情報を入力します。

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

このアプローチを使用すると、.Net 8 は機密データをプロジェクト フォルダー外の別の場所に保存し、 IConfigurationインターフェースを使用してアクセスできるようになります。これらの変数は、同じユーザー シークレットを使用するすべての .Net プロジェクトで使用できます。

次に、 Program.csファイルを変更してシークレットを読み取り、Elasticsearch クライアントをマウントします。

まず、必要なライブラリをインポートします。

using BlazorApp.Services;
using Elastic.Clients.Elasticsearch;
using Elastic.Transport;
  • BlazorApp.Services: Elasticsearch サービスが含まれます。

  • Elastic.Clients.Elasticsearch: Elasticsearch クライアント .Net 8 ライブラリをインポートします。

  • Elastic.Transport: Elasticsearch トランスポート ライブラリをインポートします。これにより、ApiKey クラスを使用してリクエストを認証できるようになります。

次に、 var app = builder.Build()行の前に次のコードを挿入します。

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

このコードは、ユーザー シークレットから Elasticsearch 資格情報を読み取り、Elasticsearch クライアント インスタンスを作成します。

ElasticSearch クライアントの初期化後、次の行を追加して Elasticsearch サービスを登録します。

builder.Services.AddScoped<ElasticsearchService>();

次のステップでは、 /Services/ElasticsearchService.csファイルに検索ロジックを構築します。

まず、必要なライブラリとモデルをインポートします。

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

次に、クラスElasticsearchService 、コンストラクター、および変数を追加します。

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

検索の設定

それでは、検索ロジックを構築してみましょう。

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 ユーザーが選択したファセットを使用して、検索クエリのフィルターを構築します。

  • BuildHybridQuery 全文検索とセマンティック検索を組み合わせたハイブリッド検索クエリを構築します。

次に、検索メソッドを追加します。

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: ハイブリッド クエリを使用して検索を実行し、ファセットを構築するための集計が含まれた結果を返します。

  • FormatFacets: 集計応答を辞書にフォーマットします。

  • ConvertFacetDictionary: ファセット辞書をより読みやすい形式に変換します。

次のステップでは、検索ページの結果として出力される Elasticsearch クエリのhitsで返されるデータを表すモデルを作成します。

まず、ファイル/Models/Book.csを作成し、次の内容を追加します。

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

次に、 /Models/Response.csファイルに Elastic レスポンスを設定し、次のコードを追加します。

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

基本的なUIの設定

次に、SearchBar コンポーネントを追加します。ファイル/Components/Elasticsearch/SearchBar.razorに次の内容を追加します。

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

このコンポーネントには、検索バーと検索を実行するためのボタンが含まれています。

Blazor は、同じファイル内で C# コードを使用して HTML を動的に生成できるようにすることで、優れた柔軟性を提供します。

その後、ファイル/Components/Elasticsearch/Results.razorで、検索結果を表示する結果コンポーネントを構築します。

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

最後に、検索結果をフィルタリングするためのファセットを作成する必要があります。

注: ファセットとは、製品タイプ、価格帯、ブランドなど、特定の属性やカテゴリに基づいて検索結果を絞り込むことができるフィルターです。これらのフィルターは通常、クリック可能なオプション(多くの場合チェックボックス)として表示され、ユーザーが検索を絞り込み、関連性の高い結果を見つけやすくします。Elasticsearchでは、ファセットは集計を使用して作成されます

ファイル/Components/Elasticsearch/Facet.razorに次のコードを配置してファセットを設定します。

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

このコンポーネントは、 authorcategories 、およびstatusフィールドのterms集計を読み取り、Elasticsearch に送り返すフィルターのリストを生成します。

さて、すべてをまとめてみましょう。

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

私たちのページは機能しています!

Blazorページの例

ご覧のとおり、ページは機能しますが、スタイルが欠けています。見た目をもっと整理してレスポンシブにするために、CSS を追加してみましょう。

レイアウト スタイルの置き換えを始めましょう。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;
}

Components/Pages/Search.razor.cssファイルで検索ページのスタイルを追加します。

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

ページの見た目が良くなり始めました:

検索ページにスタイルを追加した後の Blazor ページ

最後に仕上げましょう:

次のファイルを作成します。

  • コンポーネント/Elasticsearch/Facet.razor.css

  • コンポーネント/Elasticsearch/Results.razor.css

Facet.razor.cssのスタイルを追加します:

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

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

最終結果:

Blazor アプリページの構築の最終結果

アプリケーションを実行するには、次のコマンドを使用できます。

dotnet watch

やったね!検索バーを使用して Elasticsearch インデックス内の書籍を検索し、著者、カテゴリ、ステータス別に結果をフィルタリングできるようになりました。

全文検索とセマンティック検索の実行

デフォルトでは、アプリは 全文検索セマンティック検索の 両方を使用して ハイブリッド検索を 実行します。フルテキスト検索用とセマンティック検索用の 2 つの別個のメソッドを作成し、いずれかのメソッドを選択してユーザーの入力に基づいてクエリを構築することで、検索ロジックを変更できます。

/Services/ElasticsearchService.csファイルのElasticsearchServiceクラスに次のメソッドを追加します。

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

どちらのメソッドもBuildHybridQueryメソッドと同様に動作しますが、全文検索またはセマンティック検索のみを実行します。

選択した検索方法を使用するようにSearchBooksAsyncメソッドを変更できます。

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

完全な申請書はここからご覧いただけます

まとめ

Blazor は、C# を使用して Web アプリケーションを構築できる効果的なフレームワークです。Elasticsearch は、検索アプリケーションを構築できる強力な検索エンジンです。両方を組み合わせることで、ESRE のパワーを活用して、セマンティック検索エクスペリエンスを短期間で実現し、堅牢な検索アプリケーションを簡単に構築できます。

よくあるご質問

Blazor とは何ですか?

Blazor は、開発者がクライアントまたはサーバー上で実行される Web アプリケーションを構築できるようにするために Microsoft によって作成された、オープン ソースの HTML、CSS、および C# ベースの Web フレームワークです。また、再利用可能なコンポーネントを作成してアプリケーションなどを構築することもできます。

Blazor を使用する利点は何ですか?

Blazor は他のフレームワークやライブラリに比べていくつかの利点があります。開発者はクライアント コードとサーバー コードに C# を使用でき、.NET エコシステムとシームレスに統合され、強力なデバッグ サポートが提供されます。

関連記事

LINQ to Elasticsearch ES|QL:C#を記述してElasticsearchをクエリ

Florian Bernd

Elasticsearch の NLP とベクトル検索によるチャットボット機能の強化

Priscilla Parodi

OpenSearchとElasticsearchの違い:ベクトル検索のパフォーマンス比較

Ugo Sangiorgi

AI盗作:Elasticsearchによる盗作検出

Priscilla Parodi

高度なRAGテクニックパート2:クエリとテスト

Han Xiang Choong

最先端の検索体験を構築する準備はできましたか?

十分に高度な検索は 1 人の努力だけでは実現できません。Elasticsearch は、データ サイエンティスト、ML オペレーター、エンジニアなど、あなたと同じように検索に情熱を傾ける多くの人々によって支えられています。ぜひつながり、協力して、希望する結果が得られる魔法の検索エクスペリエンスを構築しましょう。

はじめましょう