使用 Blazor 和 Elasticsearch 构建搜索应用程序

132 阅读9分钟

作者:来自 Elastic Gustavo Llermaly

了解如何使用 Blazor 和 Elasticsearch 构建搜索应用程序,以及如何使用 Elasticsearch .NET 客户端进行混合搜索。

在本文中,你将学习如何利用 C# 技能使用 Blazor 和 Elasticsearch 构建搜索应用程序。我们将使用 Elasticsearch .NET 客户端运行全文、语义和混合搜索查询。

注意如果你熟悉旧版本的 Elasticsearch C# 客户端 NEST,请阅读这篇关于 NEST 客户端弃用和新功能的博客文章。NEST 是上一代 .NET 客户端,已被 Elasticsearch .NET 客户端取代。

什么是 Blazor?

Image:  ASP.NETCoreBlazorhostingmodels

Blazor 是一个基于 HTML、CSS 和 C# 的开源 Web 框架,由 Microsoft 创建,允许开发人员构建在客户端或服务器上运行的 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 模型,你必须拥有白金版或企业版许可证,并且至少拥有 4GB 大小的专用机器学习 (ML) 节点。在此处了解更多信息。

首先创建推理端点:



1.  PUT _inference/sparse_embedding/my-elser-model
2.  {
3.    "service": "elser",
4.    "service_settings": {
5.      "num_allocations": 1,
6.      "num_threads": 1
7.    }
8.  }


如果这是你第一次使用 ELSER,你可能会在模型在后台加载时遇到 502 Bad Gateway 错误。你可以在 Kibana 中的 Machine Learning > Trained Models 中检查模型的状态。部署后,你可以继续下一步。

索引数据

你可以在此处下载数据集,然后使用 Kibana 导入数据。为此,请转到主页并单击 “Upload data”。然后,上传文件并单击 Import。最后,转到 “Advanced” 选项卡并粘贴以下映射:



1.  {
2.     "properties":{
3.        "authors":{
4.           "type":"keyword"
5.        },
6.        "categories":{
7.           "type":"keyword"
8.        },
9.        "longDescription":{
10.           "type":"semantic_text",
11.           "inference_id":"my-elser-model",
12.           "model_settings":{
13.              "task_type":"sparse_embedding"
14.           }
15.        },
16.        "pageCount":{
17.           "type":"integer"
18.        },
19.        "publishedDate":{
20.           "type":"date"
21.        },
22.        "shortDescription":{
23.           "type":"text"
24.        },
25.        "status":{
26.           "type":"keyword"
27.        },
28.        "thumbnailUrl":{
29.           "type":"keyword"
30.        },
31.        "title":{
32.           "type":"text"
33.        }
34.     }
35.  }


我们将创建一个能够运行语义和全文查询的索引。semantic_text 字段类型将负责数据分块和嵌入。请注意,我们将 longDescription 索引为 semantic_text,如果你想将字段同时索引为 semantic_text 和 text,则可以使用 copy_to。

构建应用程序

API 密钥

我们需要做的第一件事是创建一个 API 密钥来验证我们对 Elasticsearch 的请求。API 密钥应该是只读的,并且只允许查询 books-blazor 索引。



1.  POST /_security/api_key
2.  {
3.    "name": "books-blazor-key",
4.    "role_descriptors": {
5.      "books-blazor-reader": {
6.        "indices": [
7.          {
8.            "names": ["books-blazor"],
9.            "privileges": ["read"]
10.          }
11.        ]
12.      }
13.    }
14.  }


你会看到类似这样的内容:



1.  {
2.    "id": "XXXXXXXXXXXXXXXXXXXXXXXX",
3.    "name": "books-blazor-key",
4.    "api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
5.    "encoded": "XXXXXXXXXXXXXXXXXXXXXXXX=="
6.  }


保存 encoded 响应字段的值,因为稍后会用到它。如果你在 Elastic Cloud 上运行,你还需要你的 Cloud ID。(你可以在此处找到它)。

创建 Blazor 项目

首先安装 Blazor 并按照官方说明创建示例项目。

创建项目后,文件夹结构和文件应如下所示:

1.  BlazorApp/
2.  |-- BlazorApp.csproj
3.  |-- BlazorApp.sln
4.  |-- Program.cs
5.  |-- appsettings.Development.json
6.  |-- appsettings.json
7.  |-- Properties/
8.  |   `-- launchSettings.json
9.  |-- Components/
10.  |   |-- App.razor
11.  |   |-- Routes.razor
12.  |   |-- _Imports.razor
13.  |   |-- Layout/
14.  |   |   |-- MainLayout.razor
15.  |   |   |-- MainLayout.razor.css
16.  |   |   |-- NavMenu.razor
17.  |   |   `-- NavMenu.razor.css
18.  |   `-- Pages/
19.  |       |-- Counter.razor
20.  |       |-- Error.razor
21.  |       |-- Home.razor
22.  |       `-- Weather.razor
23.  |-- wwwroot/
24.  |-- bin/
25.  `-- obj/ 

模板应用程序包含用于样式设置的 Bootstrap v5.1.0

通过安装 Elasticsearch .NET 客户端完成项目设置

dotnet add package Elastic.Clients.Elasticsearch

完成此步骤后,你的页面应如下所示:

文件夹结构

现在,我们将按如下方式组织文件夹:



1.  BlazorApp/
2.  |-- Components/
3.  |   |-- Pages/
4.  |   |   |-- Search.razor
5.  |   |   `-- Search.razor.css
6.  |   `-- Elasticsearch/
7.  |       |-- SearchBar.razor
8.  |       |-- Results.razor
9.  |       `-- Facet.razor
10.  |-- Models/
11.  |   |-- Book.cs
12.  |   `-- Response.cs
13.  `-- Services/
14.      `-- ElasticsearchService.cs


文件说明:

  • Components/Pages/Search.razor:包含搜索栏、结果和过滤器的主页。
  • Components/Pages/Search.razor.cs:页面样式。
  • 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 的连接和查询。

初始配置

让我们从一些清理开始。

删除文件:

  • Components/Pages/Counter.razor
  • Components/Pages/Weather.razor
  • Components/Pages/Home.razor
  • Components/Layout/NavMenu.razor
  • Components/Layout/NavMenu.razor.css

检查 /Components/_Imports.razor 文件。你应该有以下导入:



1.  @using System.Net.Http
2.  @using System.Net.Http.Json
3.  @using Microsoft.AspNetCore.Components.Forms
4.  @using Microsoft.AspNetCore.Components.Routing
5.  @using Microsoft.AspNetCore.Components.Web
6.  @using static Microsoft.AspNetCore.Components.Web.RenderMode
7.  @using Microsoft.AspNetCore.Components.Web.Virtualization
8.  @using Microsoft.JSInterop
9.  @using BlazorApp
10.  @using BlazorApp.Components


将 Elastic 集成到项目中

现在,让我们导入 Elasticsearch 组件:



1.  @using System.Net.Http
2.  @using System.Net.Http.Json
3.  @using Microsoft.AspNetCore.Components.Forms
4.  @using Microsoft.AspNetCore.Components.Routing
5.  @using Microsoft.AspNetCore.Components.Web
6.  @using static Microsoft.AspNetCore.Components.Web.RenderMode
7.  @using Microsoft.AspNetCore.Components.Web.Virtualization
8.  @using Microsoft.JSInterop
9.  @using BlazorApp
10.  @using BlazorApp.Components
11.  @using BlazorApp.Components.Elasticsearch @* <--- Add this line *@


我们将从 /Components/Layout/MainLayout.razor 文件中删除默认侧边栏,以便为我们的应用程序提供更多空间:



1.  @inherits LayoutComponentBase

3.  <div class="page">
4.      <main>
5.          <article class="content">
6.              @Body
7.          </article>
8.      </main>
9.  </div>

11.  <div id="blazor-error-ui">
12.      An unhandled error has occurred.
13.      <a href="" class="reload">Reload</a>
14.      <a class="dismiss">🗙</a>
15.  </div>


现在让我们输入用户机密的 Elasticsearch 凭证:



1.  dotnet user-secrets init
2.  dotnet user-secrets set ElasticsearchCloudId "your Cloud ID"
3.  dotnet user-secrets set ElasticsearchApiKey "your API Key"


使用这种方法,.Net 8 将敏感数据存储在项目文件夹之外的单独位置,并使用 IConfiguration 接口使其可访问。这些变量将可供使用相同用户机密的任何 .Net 项目使用。

然后,让我们修改 Program.cs 文件以读取机密并安装 Elasticsearch 客户端:

首先,导入必要的库:



1.  using BlazorApp.Services;
2.  using Elastic.Clients.Elasticsearch;
3.  using Elastic.Transport;


  • BlazorApp.Services:包含 Elasticsearch 服务。
  • Elastic.Clients.Elasticsearch:导入 Elasticsearch 客户端 .Net 8 库。
  • Elastic.Transport:导入 Elasticsearch 传输库,它允许我们使用 ApiKey 类来验证我们的请求。

其次,在 var app = builder.Build() 行之前插入以下代码:



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

9.      if (string.IsNullOrEmpty(cloudId) || string.IsNullOrEmpty(apiKey))
10.      {
11.          throw new InvalidOperationException(
12.              "Elasticsearch credentials are missing in configuration."
13.          );
14.      }

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


此代码将从用户机密中读取 Elasticsearch 凭据并创建 Elasticsearch 客户端实例。

ElasticSearch 客户端初始化后,添加以下行以注册 Elasticsearch 服务:

builder.Services.AddScoped<ElasticsearchService>();

下一步是在 /Services/ElasticsearchService.cs 文件中构建搜索逻辑:

首先,导入必要的库和模型:



1.  using BlazorApp.Models;
2.  using Elastic.Clients.Elasticsearch;
3.  using Elastic.Clients.Elasticsearch.QueryDsl;


其次,添加ElasticsearchService类、构造函数和变量:



1.  namespace BlazorApp.Services
2.  {
3.      public class ElasticsearchService
4.      {
5.          private readonly ElasticsearchClient _client;

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

10.          public ElasticsearchService(
11.              ElasticsearchClient client,
12.              ILogger<ElasticsearchService> logger
13.          )
14.          {
15.              _client = client ?? throw new ArgumentNullException(nameof(client));
16.              _logger = logger;
17.          }
18.      }
19.  }


配置搜索

现在,让我们构建搜索逻辑:



1.  private static Action<RetrieverDescriptor<BookDoc>> BuildHybridQuery(
2.      string searchTerm,
3.      Dictionary<string, List<string>> selectedFacets
4.  )
5.  {
6.      var filters = BuildFilters(selectedFacets);

8.      return retrievers =>
9.          retrievers.Rrf(rrf =>
10.              rrf.RankWindowSize(50)
11.                  .RankConstant(20)
12.                  .Retrievers(
13.                      retrievers =>
14.                          retrievers.Standard(std =>
15.                              std.Query(q =>
16.                                  q.Bool(b =>
17.                                      b.Must(m =>
18.                                              m.MultiMatch(mm =>
19.                                                  mm.Query(searchTerm)
20.                                                      .Fields(
21.                                                          new[]
22.                                                          {
23.                                                              "title",
24.                                                              "shortDescription",
25.                                                          }
26.                                                      )
27.                                              )
28.                                          )
29.                                          .Filter(filters.ToArray())
30.                                  )
31.                              )
32.                          ),
33.                      retrievers =>
34.                          retrievers.Standard(std =>
35.                              std.Query(q =>
36.                                  q.Bool(b =>
37.                                      b.Must(m =>
38.                                              m.Semantic(sem =>
39.                                                  sem.Field("longDescription")
40.                                                      .Query(searchTerm)
41.                                              )
42.                                          )
43.                                          .Filter(filters.ToArray())
44.                                  )
45.                              )
46.                          )
47.                  )
48.          );
49.  }

51.  public static List<Action<QueryDescriptor<BookDoc>>> BuildFilters(
52.      Dictionary<string, List<string>> selectedFacets
53.  )
54.  {
55.      var filters = new List<Action<QueryDescriptor<BookDoc>>>();

57.      if (selectedFacets != null)
58.      {
59.          foreach (var facet in selectedFacets)
60.          {
61.              foreach (var value in facet.Value)
62.              {
63.                  var field = facet.Key.ToLower();
64.                  if (!string.IsNullOrEmpty(field))
65.                  {
66.                      filters.Add(m => m.Term(t => t.Field(new Field(field)).Value(value)));
67.                  }
68.              }
69.          }
70.      }

72.      return filters;
73.  }


  • BuildFilters 将使用用户选择的方面构建搜索查询的过滤器。
  • BuildHybridQuery 将构建结合全文和语义搜索的混合搜索查询。

接下来,添加搜索方法:



1.  public async Task<ElasticResponse> SearchBooksAsync(
2.      string searchTerm,
3.      Dictionary<string, List<string>> selectedFacets
4.  )
5.  {
6.      try
7.      {
8.          _logger.LogInformation($"Performing search for: {searchTerm}");

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

13.          var response = await _client.SearchAsync<BookDoc>(s =>
14.              s.Index("elastic-blazor-books")
15.                  .Retriever(retrieverQuery)
16.                  .Aggregations(aggs =>
17.                      aggs.Add("Authors", agg => agg.Terms(t => t.Field(p => p.Authors)))
18.                          .Add(
19.                              "Categories",
20.                              agg => agg.Terms(t => t.Field(p => p.Categories))
21.                          )
22.                          .Add("Status", agg => agg.Terms(t => t.Field(p => p.Status)))
23.                  )
24.          );

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

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

36.              var elasticResponse = new ElasticResponse
37.              {
38.                  TotalHits = hits,
39.                  Documents = response.Documents.ToList(),
40.                  Facets = facets,
41.              };

43.              return elasticResponse;
44.          }
45.          else
46.          {
47.              _logger.LogWarning($"Invalid response: {response.DebugInformation}");
48.              return new ElasticResponse();
49.          }
50.      }
51.      catch (Exception ex)
52.      {
53.          _logger.LogError(ex, "Error performing search");
54.          return new ElasticResponse();
55.      }
56.  }

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

64.      foreach (var aggregation in aggregations)
65.      {
66.          if (
67.              aggregation.Value
68.              is Elastic.Clients.Elasticsearch.Aggregations.StringTermsAggregate termsAggregate
69.          )
70.          {
71.              var facetName = aggregation.Key;
72.              var facetDictionary = ConvertFacetDictionary(
73.                  termsAggregate.Buckets.ToDictionary(b => b.Key, b => b.DocCount)
74.              );
75.              facets[facetName] = facetDictionary;
76.          }
77.      }

79.      return facets;
80.  }

82.  private static Dictionary<string, long> ConvertFacetDictionary(
83.      Dictionary<Elastic.Clients.Elasticsearch.FieldValue, long> original
84.  )
85.  {
86.      var result = new Dictionary<string, long>();
87.      foreach (var kvp in original)
88.      {
89.          result[kvp.Key.ToString()] = kvp.Value;
90.      }
91.      return result;
92.  }


  • SearchBooksAsync:将使用混合查询执行搜索,并返回包含用于构建方面的聚合的结果。
  • FormatFacets:将聚合响应格式化为字典。
  • ConvertFacetDictionary:将把方面字典转换为更易读的格式。

下一步是创建模型,这些模型将表示 Elasticsearch 查询命中中返回的数据,这些数据将作为结果打印在我们的搜索页面中。

我们首先创建文件 /Models/Book.cs 并添加以下内容:



1.  namespace BlazorApp.Models
2.  {
3.      public class BookDoc
4.      {
5.          public string? Title { get; set; }
6.          public int? PageCount { get; set; }
7.          public string? PublishedDate { get; set; }
8.          public string? ThumbnailUrl { get; set; }
9.          public string? ShortDescription { get; set; }
10.          public LongDescription? LongDescription { get; set; }
11.          public string? Status { get; set; }
12.          public List<string>? Authors { get; set; }
13.          public List<string>? Categories { get; set; }
14.      }

16.      public class LongDescription
17.      {
18.          public string? Text { get; set; }
19.      }
20.  }


然后,在 /Models/Response.cs 文件中设置 Elastic 响应并添加以下内容:



1.  namespace BlazorApp.Models
2.  {
3.      public class ElasticResponse
4.      {
5.          public ElasticResponse()
6.          {
7.              Documents = new List<BookDoc>();
8.              Facets = new Dictionary<string, Dictionary<string, long>>();
9.          }

11.          public long TotalHits { get; set; }
12.          public List<BookDoc> Documents { get; set; }
13.          public Dictionary<string, Dictionary<string, long>> Facets { get; set; }
14.      }
15.  }


配置基本 UI

接下来,添加 SearchBar 组件。在文件 /Components/Elasticsearch/SearchBar.razor 中添加以下内容:



1.  @using System.Threading.Tasks

3.  <form @onsubmit="SubmitSearch">
4.    <div class="input-group mb-3">
5.      <input type="text" @bind-value="searchTerm" class="form-control" placeholder="Enter search term..." />
6.      <button type="submit" class="btn btn-primary input-btn">
7.        <span class="input-group-svg">
8.          Search
9.        </span>
10.      </button>
11.    </div>
12.  </form>

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

18.    private string searchTerm = "";

20.    private async Task SubmitSearch()
21.    {
22.      await OnSearch.InvokeAsync(searchTerm);
23.    }
24.  }


该组件包含一个搜索栏和一个用于执行搜索的按钮。

Blazor 允许在同一文件中使用 C# 代码动态生成 HTML,从而提供了极大的灵活性。

之后,在文件 /Components/Elasticsearch/Results.razor 中,我们将构建显示搜索结果的结果组件:



1.  @using BlazorApp.Models

3.  @if (SearchResults != null && SearchResults.Any())
4.  {
5.    <div class="row">
6.    @foreach (var result in SearchResults)
7.      {
8.        <div class="col-12 mb-3">
9.          <div class="card">
10.            <div class="row g-0">
11.              <div class="col-md-3 image-container">
12.                @if (!string.IsNullOrEmpty(result?.ThumbnailUrl))
13.                {
14.                  <img src="@result?.ThumbnailUrl" class="img-fluid rounded-start" alt="Thumbnail">
15.                }
16.                else
17.                {
18.                  <div class="placeholder">
19.                    @result?.Title
20.                  </div>
21.                }
22.              </div>

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

30.                  <div class="details-container">
31.                    <div class="">

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

40.                      @if (result?.Categories?.Any() == true)
41.                      {
42.                        <p class="card-text p-second">
43.                          Categories: <small class="text-muted">@string.Join(", ", result.Categories)</small>
44.                        </p>
45.                      }
46.                    </div>
47.                    <div class="numPages-status">
48.                      @if (result?.PageCount != null)
49.                      {
50.                        <p class="card-text p-first">
51.                          Pages: <small class="text-muted">@result.PageCount</small>
52.                        </p>
53.                      }

55.                      @if (result?.Status != null)
56.                      {
57.                        <p class="card-text p-second">
58.                          Status: <small class="text-muted">@result.Status</small>
59.                        </p>
60.                      }
61.                    </div>
62.                  </div>

64.                  <div class="long-text-container">
65.                    <p class="card-text"><small class="text-muted">@result?.LongDescription?.Text</small></p>
66.                  </div>
67.                  @if (!string.IsNullOrEmpty(result?.PublishedDate))
68.                  {
69.                    <div class="date-container">
70.                      <p class="card-text">
71.                        Published Date: <small class="text-muted small-date">@FormatDate(result.PublishedDate)</small>
72.                      </p>
73.                    </div>
74.                  }
75.                </div>
76.              </div>
77.            </div>
78.          </div>
79.        </div>
80.      }
81.    </div>
82.  }
83.  else if (SearchResults != null)
84.  {
85.    <p>No results found.</p>
86.  }

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

92.    private string FormatDate(string? date)
93.    {
94.      if (DateTime.TryParse(date, out DateTime parsedDate))
95.      {
96.        return parsedDate.ToString("MMMM dd, yyyy");
97.      }
98.      return "";
99.    }
100.  }


最后,我们需要创建方面来过滤搜索结果。

注意:方面是允许用户根据特定属性或类别(例如产品类型、价格范围或品牌)缩小搜索结果范围的过滤器。这些过滤器通常以可点击的选项形式呈现,通常以复选框的形式呈现,以帮助用户优化搜索并更轻松地找到相关结果。在 Elasticsearch 上下文中,方面是使用聚合创建的。

我们通过将以下代码放入文件 /Components/Elasticsearch/Facet.razor 中来设置方面:



1.  @if (Facets != null)
2.  {
3.    <div class="facets-container">
4.    @foreach (var facet in Facets)
5.      {
6.        <h3>@facet.Key</h3>
7.        @foreach (var option in facet.Value)
8.        {
9.          <div>
10.            <input type="checkbox" checked="@IsFacetSelected(facet.Key, option.Key)"
11.              @onclick="() => ToggleFacet(facet.Key, option.Key)" />
12.            @option.Key (@option.Value)
13.          </div>
14.        }
15.      }
16.    </div>
17.  }

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

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

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

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

36.      if (!facetValues.Remove(facetValue))
37.      {
38.        facetValues.Add(facetValue);
39.      }

41.      OnFacetChanged.InvokeAsync(selectedFacets);
42.    }

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


该组件从 author、categories 和 status 字段的 terms 聚合中读取内容,然后生成一个过滤器列表以发送回 Elasticsearch。

现在,让我们将所有内容放在一起。

在 /Components/Pages/Search.razor 文件中:



1.  @page "/"
2.  @rendermode InteractiveServer
3.  @using BlazorApp.Models
4.  @using BlazorApp.Services
5.  @inject ElasticsearchService ElasticsearchService
6.  @inject ILogger<Search> Logger

8.  <PageTitle>Search</PageTitle>

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

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

15.          <SearchBar OnSearch="PerformSearch" />
16.      </div>

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

21.  <div class="px-4">

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

28.      <div class="results-facet-container">
29.          <div class="facets-container">
30.              <Facet Facets="facets" OnFacetChanged="OnFacetChanged" />
31.          </div>
32.          <div class="results-container">
33.              <Results SearchResults="searchResults" />
34.          </div>
35.      </div>
36.  </div>

38.  @code {
39.      private string currentSearchTerm = "";
40.      private long totalResults = 0;
41.      private List<BookDoc> searchResults = new List<BookDoc>();
42.      private Dictionary<string, Dictionary<string, long>> facets = new Dictionary<string, Dictionary<string, long>>();
43.      private Dictionary<string, List<string>> selectedFacets = new Dictionary<string, List<string>>();

45.      protected override async Task OnInitializedAsync()
46.      {
47.          await PerformSearch();
48.      }

50.      private async Task PerformSearch(string searchTerm = "")
51.      {
52.          try
53.          {
54.              currentSearchTerm = searchTerm;

56.              var response = await ElasticsearchService.SearchBooksAsync(currentSearchTerm, selectedFacets);
57.              if (response != null)
58.              {
59.                  searchResults = response.Documents;
60.                  facets = response.Facets;
61.                  totalResults = response.TotalHits;
62.              }
63.              else
64.              {
65.                  Logger.LogWarning("Search response is null.");
66.              }

68.              StateHasChanged();
69.          }
70.          catch (Exception ex)
71.          {
72.              Logger.LogError(ex, "Error performing search.");
73.          }
74.      }

76.      private async Task OnFacetChanged(Dictionary<string, List<string>> newSelectedFacets)
77.      {
78.          selectedFacets = newSelectedFacets;
79.          await PerformSearch(currentSearchTerm);
80.      }
81.  }


我们的页面正在运行!

如你所见,该页面功能齐全,但缺少样式。让我们添加一些 CSS,使其看起来更有条理、响应更快。

让我们开始替换布局样式。在 Components/Layout/MainLayout.razor.css 文件中:



1.  .page {
2.    position: relative;
3.    display: flex;
4.    flex-direction: column;
5.  }

7.  main {
8.    flex: 1;
9.  }

11.  #blazor-error-ui {
12.    background: lightyellow;
13.    bottom: 0;
14.    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
15.    display: none;
16.    left: 0;
17.    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
18.    position: fixed;
19.    width: 100%;
20.    z-index: 1000;
21.  }

23.  #blazor-error-ui .dismiss {
24.    cursor: pointer;
25.    position: absolute;
26.    right: 0.75rem;
27.    top: 0.5rem;
28.  }


在 Components/Pages/Search.razor.css 文件中添加搜索页面的样式:



1.  .input-group .input-group-svg {
2.    background: transparent;
3.    border: transparent;
4.    pointer-events: none;
5.  }

7.  .results-facet-container {
8.    display: flex;
9.    margin-top: 1rem;
10.    overflow-x: auto;
11.  }

13.  .search-details-container {
14.    display: flex;
15.    justify-content: space-between;
16.    margin-top: 1rem;
17.  }

19.  .searchbar-container {
20.    padding-top: 2rem;
21.    display: flex;
22.    flex-direction: column; 
23.    flex-grow: 1;
24.    height: 100%;
25.    max-width: 100%; 
26.  }

28.  .searchbar-container h4 {
29.    margin: 0;
30.  }

32.  .top-row {
33.    margin-top: -1.1rem;
34.    position: relative; 
35.    background-color: hsl(216, 29%, 67%);
36.    border-bottom: 1px solid #d6d5d5;
37.    display: flex;
38.    align-items: center;
39.    height: 100%;
40.    padding: 0 1rem;
41.  }

43.  .top-row a {
44.    margin-left: auto;
45.    margin-top: -4rem; 
46.    color: #000000;
47.    text-decoration: none;
48.  }

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

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

59.    .top-row ::deep a,
60.    .top-row ::deep .btn-link {
61.      margin-left: 0;
62.    }
63.  }

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

72.    .top-row,
73.    article {
74.      padding-left: 2rem !important;
75.      padding-right: 1.5rem !important;
76.    }
77.  }


我们的页面开始看起来更好了:

让我们做最后的润色:

创建以下文件:

  • Components/Elasticsearch/Facet.razor.css
  • Components/Elasticsearch/Results.razor.css

并添加 Facet.razor.css 的样式:



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

9.  .results-facet-container {
10.    display: flex;
11.    margin-top: 1rem;
12.    overflow-x: auto;
13.  }

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


对于 Results.razor.css:



1.  .image-container {
2.    display: flex;
3.    justify-content: center;
4.    align-items: center;
5.    height: 100%;
6.    padding: 1rem;
7.    box-sizing: border-box;
8.  }

10.  .image-container img {
11.    max-width: 100%;
12.    height: auto;
13.    border-radius: 0.5rem;
14.  }

16.  .placeholder {
17.    display: flex;
18.    justify-content: center;
19.    align-items: center;
20.    height: 100%;
21.    width: 100%;
22.    background-color: #f0f0f0;
23.    border: 1px solid #ccc;
24.    font-size: 0.9rem;
25.    color: #888;
26.    text-align: center;
27.    padding: 1rem;
28.    border-radius: 0.5rem;
29.  }

31.  .card-body {
32.    padding: 1rem;
33.  }

35.  .details-container {
36.    display: flex;
37.    justify-content: space-between;
38.    padding: 1.5rem 0;
39.  }

41.  .date-container {
42.    margin-top: 1rem;
43.    display: flex;
44.    justify-content: flex-end;
45.  }

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


最终结果:

要运行该应用程序,你可以使用以下命令:

dotnet watch

你做到了!现在,你可以使用搜索栏在 Elasticsearch 索引中搜索书籍,并按作者、类别和状态筛选结果。

执行全文和语义搜索

默认情况下,我们的应用程序将使用全文语义搜索执行混合搜索。你可以通过创建两个单独的方法(一个用于全文,另一个用于语义搜索)来更改搜索逻辑,然后选择一种方法根据用户的输入构建查询。

将以下方法添加到 /Services/ElasticsearchService.cs 文件中的 ElasticsearchService 类:



1.  private static Action<QueryDescriptor<BookDoc>> BuildSemanticQuery(
2.      string searchTerm,
3.      Dictionary<string, List<string>> selectedFacets
4.  )
5.  {
6.      var filters = BuildFilters(selectedFacets);

8.      return query =>
9.          query.Bool(b =>
10.              b.Must(m => m.Semantic(sem => sem.Field("longDescription").Query(searchTerm)))
11.                  .Filter(filters.ToArray())
12.          );
13.  }

15.  private static Action<QueryDescriptor<BookDoc>> BuildMultiMatchQuery(
16.      string searchTerm,
17.      Dictionary<string, List<string>> selectedFacets
18.  )
19.  {
20.      var filters = BuildFilters(selectedFacets);

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

27.      return query =>
28.          query.Bool(b =>
29.              b.Should(m =>
30.                      m.MultiMatch(mm =>
31.                          mm.Query(searchTerm).Fields(new[] { "title", "shortDescription" })
32.                      )
33.                  )
34.                  .Filter(filters.ToArray())
35.          );
36.  }


这两种方法的工作方式与 BuildHybridQuery 方法类似,但它们仅执行全文或语义搜索。

你可以修改 SearchBooksAsync 方法以使用所选的搜索方法:



1.  public async Task<ElasticResponse> SearchBooksAsync(
2.      string searchTerm,
3.      Dictionary<string, List<string>> selectedFacets
4.  )
5.  {
6.      try
7.      {
8.          _logger.LogInformation($"Performing search for: {searchTerm}");

10.          // Modify the query builder to use the selected search method.
11.          var multiMatchQuery = BuildMultiMatchQuery(searchTerm, selectedFacets); // For full text search
12.          var semanticQuery = BuildSemanticQuery(searchTerm, selectedFacets); // For semantic search

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

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

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

38.              var elasticResponse = new ElasticResponse
39.              {
40.                  TotalHits = hits,
41.                  Documents = response.Documents.ToList(),
42.                  Facets = facets,
43.              };

45.              return elasticResponse;
46.          }
47.          else
48.          {
49.              _logger.LogWarning($"Invalid response: {response.DebugInformation}");
50.              return new ElasticResponse();
51.          }
52.      }
53.      catch (Exception ex)
54.      {
55.          _logger.LogError(ex, "Error performing search");
56.          return new ElasticResponse();
57.      }
58.  }


你可以在此处找到完整的应用程序。

结论

Blazor 是一个有效的框架,允许你使用 C# 构建 Web 应用程序。Elasticsearch 是一个强大的搜索引擎,允许你构建搜索应用程序。结合两者,你可以轻松构建强大的搜索应用程序,利用 ESRE 的强大功能在短时间内创建语义搜索体验

准备好自己尝试一下了吗?开始免费试用

Elasticsearch 集成了 LangChain、Cohere 等工具。加入我们的高级语义搜索网络研讨会,构建你的下一个 GenAI 应用程序!

原文:Blazor and Elasticsearch: How to build a search app — Search Labs