[Azure Search] で高速テキスト検索

今日はテキスト検索サービス`Azure Search`を導入してみます。

>
Azure Search は、あらゆる種類の情報を拡充して関連コンテンツを簡単に特定し、大規模に検索する人工知能 (AI) 機能が組み込まれた唯一のクラウド検索サービスです。Bing と Office にも 10 年以上にわたって利用されている Microsoft の自然言語スタック、および視覚、言語、音声の事前構築済み AI API が使用されます。イノベーションに費やす時間を増やし、複雑なクラウド検索ソリューションの保守に費やす時間を減らすことができます。

とのことです。　正直`Bing`のシェアは低いのですが、`Azure Search`に関しては Readヘビーなサービスには必要不可欠なサービスとなっています。

## サンプル
サンプルコードはこちらを参照
> https://github.com/Azure-Samples/search-dotnet-getting-started

## Azure Search SDK for C#
C#で利用する場合は下記のライブラリーを利用します。
> https://docs.microsoft.com/en-us/dotnet/api/overview/azure/search?view=azure-dotnet

```PowerShell
Install-Package Microsoft.Azure.Search
```

サーチのサービス単位は`Index`という単位になります。下記が`Index`の取得方法です。

```C#
using Microsoft.Azure.Search;
using Microsoft.Azure.Search.Models;

// A service endpoint and an api-key are required on a connection.
// Set them in a config file (not shown) and then connect to the client.
IConfigurationBuilder builder = new ConfigurationBuilder().AddJsonFile("appsettings.json");
IConfigurationRoot configuration = builder.Build();

SearchServiceClient serviceClient = CreateSearchServiceClient(configuration);

// Create an index named hotels
ISearchIndexClient indexClient = serviceClient.Indexes.GetClient("hotels");
```

appsettings.jsonに必要な情報を入力。appsettings.jsonの使い方はこちらを参照。
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-2.1&tabs=basicconfiguration

必要なAPI情報はポータルより入手してください。

##ポータルより必要な情報入手

![image.png](https://qiita-image-store.s3.amazonaws.com/0/241909/3548cf33-9320-cc8e-2137-3ae5b7582ddc.png)

# Search用のモデル作成

Searchに使うモデルを構築します。

```csharp

namespace AzureSearch.SDKHowTo
{
    using System;
    using Microsoft.Azure.Search;
    using Microsoft.Azure.Search.Models;
    using Microsoft.Spatial;
    using Newtonsoft.Json;

    public partial class Hotel
    {
        [System.ComponentModel.DataAnnotations.Key]
        [IsFilterable]
        public string HotelId { get; set; }

        [IsSearchable, IsSortable]
        public string HotelName { get; set; }

        [IsSearchable]
        [Analyzer(AnalyzerName.AsString.EnLucene)]
        public string Description { get; set; }

        [IsSearchable]
        [Analyzer(AnalyzerName.AsString.FrLucene)]
        [JsonProperty("Description_fr")]
        public string DescriptionFr { get; set; }

        [IsSearchable, IsFilterable, IsSortable, IsFacetable]
        public string Category { get; set; }

        [IsSearchable, IsFilterable, IsFacetable]
        public string[] Tags { get; set; }

        [IsFilterable, IsSortable, IsFacetable]
        public bool? ParkingIncluded { get; set; }

        // SmokingAllowed reflects whether any room in the hotel allows smoking.
        // The JsonIgnore attribute indicates that a field should not be created 
        // in the index for this property and it will only be used by code in the client.
        [JsonIgnore]
        public bool? SmokingAllowed => (Rooms != null) ? Array.Exists(Rooms, element => element.SmokingAllowed == true) : (bool?)null;

        [IsFilterable, IsSortable, IsFacetable]
        public DateTimeOffset? LastRenovationDate { get; set; }

        [IsFilterable, IsSortable, IsFacetable]
        public double? Rating { get; set; }

        public Address Address { get; set; }

        [IsFilterable, IsSortable]
        public GeographyPoint Location { get; set; }

        public Room[] Rooms { get; set; }
    }
}
```

# Indexの作成、Documentの挿入、Indexの削除

それでは早速Indexの作成、削除、検索データの挿入をしてみます。

フルコードは下記を参照
https://github.com/Azure-Samples/search-dotnet-getting-started/blob/master/DotNetHowTo/DotNetHowTo/Program.cs

コードを抜粋して説明していきます。

```csharp
#define HowToExample

namespace AzureSearch.SDKHowTo
{
    using System;
    using System.Linq;
    using System.Threading;
    using Microsoft.Azure.Search;
    using Microsoft.Azure.Search.Models;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Spatial;

    class Program
    {
        // This sample shows how to delete, create, upload documents and query an index
        static void Main(string[] args)
        {
            IConfigurationBuilder builder = new ConfigurationBuilder().AddJsonFile("appsettings.json");
            IConfigurationRoot configuration = builder.Build();

            SearchServiceClient serviceClient = CreateSearchServiceClient(configuration);

            string indexName = configuration["SearchIndexName"];

            Console.WriteLine("{0}", "Deleting index...\n");
            DeleteIndexIfExists(indexName, serviceClient);

            Console.WriteLine("{0}", "Creating index...\n");
            CreateIndex(indexName, serviceClient);

            ISearchIndexClient indexClient = serviceClient.Indexes.GetClient(indexName);

            Console.WriteLine("{0}", "Uploading documents...\n");
            UploadDocuments(indexClient);

            ISearchIndexClient indexClientForQueries = CreateSearchIndexClient(indexName, configuration);

            RunQueries(indexClientForQueries);

            Console.WriteLine("{0}", "Complete.  Press any key to end application...\n");
            Console.ReadKey();
        }
}
```

まず主となる`SearchServiceClient`を生成します。

```csharp

SearchServiceClient serviceClient = CreateSearchServiceClient(configuration);
```

そしてIndexの名前を指定する値を設定

```csharp

string indexName = configuration["SearchIndexName"];
```

## Indexの削除

いきなり消すのかい、と思いながらもこれから先、生成するときにすでにあるIndexだと問題があるので一旦同じIndex名をもつIndexがある場合はまず削除します。

```csharp

DeleteIndexIfExists(indexName, serviceClient);
```

DeleteIndexIfExistsというプライベートファンクションが下記のように定義されています。

```csharp
private static void DeleteIndexIfExists(string indexName, SearchServiceClient serviceClient)
{
  if (serviceClient.Indexes.Exists(indexName))
  {
    serviceClient.Indexes.Delete(indexName);
  }
}
```

シンプルに、あるなら削除というコマンドです。

# Indexの作成

次にIndexを生成します。

```csharp

CreateIndex(indexName, serviceClient);
```

CreateIndexというプライベートファンクションが下記のように定義されています。

```csharp
private static void CreateIndex(string indexName, SearchServiceClient serviceClient)
{
  var definition = new Index()
  {
    Name = indexName,
    Fields = FieldBuilder.BuildForType<Hotel>()
  };

  serviceClient.Indexes.Create(definition);
}
```

`Fields = FieldBuilder.BuildForType<Hotel>()` ここでフィールドの指定をしています。先ほどあったHotelクラスをベースにIndexのフィールドを生成します。

あとは`serviceClient.Indexes.Create(definition)`で生成を実行します。

次に指定したIndexを更新します。`SearchIndexClient indexClient = serviceClient.Indexes.GetClient(indexName)`　でindexClientを生成し、更新する対象とします。

```csharp
ISearchIndexClient indexClient = serviceClient.Indexes.GetClient(indexName);

Console.WriteLine("{0}", "Uploading documents...\n");
UploadDocuments(indexClient);
```

## Documentの挿入

`Azure Search`では1つのデータを`Document`と呼んでいます。

`UploadDocuments`というプライベートファンクションが下記のように定義されています。　`hotels`というデータの中に`Room`という複数のデータが存在しています。

```csharp

// Upload documents in a single Upload request.
private static void UploadDocuments(ISearchIndexClient indexClient)
{
　　var hotels = new Hotel[]
   {
     new Hotel()
     {
       HotelId = "1",
       HotelName = "Secret Point Motel",
       Description = "The hotel is ideally located on the main commercial artery of the city in the heart of New York. A few minutes away is Time's Square and the historic centre of the city, as well as other places of interest that make New York one of America's most attractive and cosmopolitan cities.",
       DescriptionFr = "L'hôtel est idéalement situé sur la principale artère commerciale de la ville en plein cœur de New York. A quelques minutes se trouve la place du temps et le centre historique de la ville, ainsi que d'autres lieux d'intérêt qui font de New York l'une des villes les plus attractives et cosmopolites de l'Amérique.",
       Category = "Boutique",
       Tags = new[] { "pool", "air conditioning", "concierge" },
       ParkingIncluded = false,
       LastRenovationDate = new DateTimeOffset(1970, 1, 18, 0, 0, 0, TimeSpan.Zero),
       Rating = 3.6,
       Location = GeographyPoint.Create(40.760586, -73.975403),
       Address = new Address()
       {
         StreetAddress = "677 5th Ave",
         City = "New York",
         StateProvince = "NY",
         PostalCode = "10022",
         Country = "USA"
       },
       Rooms = new Room[]
       {
         new Room()
         {
           Description = "Budget Room, 1 Queen Bed (Cityside)",
           DescriptionFr = "Chambre Économique, 1 grand lit (côté ville)",
           Type = "Budget Room",
           BaseRate = 96.99,
           BedOptions = "1 Queen Bed",
           SleepsCount = 2,
           SmokingAllowed = true,
           Tags = new[] { "vcr/dvd" }
         },
         new Room()
         {
           Description = "Budget Room, 1 King Bed (Mountain View)",
           DescriptionFr = "Chambre Économique, 1 très grand lit (Mountain View)",
           Type = "Budget Room",
           BaseRate = 80.99,
           BedOptions = "1 King Bed",
           SleepsCount = 2,
           SmokingAllowed = true,
           Tags = new[] { "vcr/dvd", "jacuzzi tub" }
         },
         new Room()
         {
           Description = "Deluxe Room, 2 Double Beds (City View)",
           DescriptionFr = "Chambre Deluxe, 2 lits doubles (vue ville)",
           Type = "Deluxe Room",
           BaseRate = 150.99,
           BedOptions = "2 Double Beds",
           SleepsCount = 2,
           SmokingAllowed = false,
           Tags = new[] { "suite", "bathroom shower", "coffee maker" }
         }
       }
     },
     new Hotel()
     {
       HotelId = "2",
       HotelName = "Twin Dome Motel",
       Description = "The hotel is situated in a  nineteenth century plaza, which has been expanded and renovated to the highest architectural standards to create a modern, functional and first-class hotel in which art and unique historical elements coexist with the most modern comforts.",
       DescriptionFr = "L'hôtel est situé dans une place du XIXe siècle, qui a été agrandie et rénovée aux plus hautes normes architecturales pour créer un hôtel moderne, fonctionnel et de première classe dans lequel l'art et les éléments historiques uniques coexistent avec le confort le plus moderne.",
       Category = "Boutique",
       Tags = new[] { "pool", "free wifi", "concierge" },
       ParkingIncluded = false,
       LastRenovationDate =  new DateTimeOffset(1979, 2, 18, 0, 0, 0, TimeSpan.Zero),
       Rating = 3.60,
       Location = GeographyPoint.Create(27.384417, -82.452843),
       Address = new Address()
       {
         StreetAddress = "140 University Town Center Dr",
         City = "Sarasota",
         StateProvince = "FL",
         PostalCode = "34243",
         Country = "USA"
       },
       Rooms = new Room[]
       {
         new Room()
         {
           Description = "Suite, 2 Double Beds (Mountain View)",
           DescriptionFr = "Suite, 2 lits doubles (vue sur la montagne)",
           Type = "Suite",
           BaseRate = 250.99,
           BedOptions = "2 Double Beds",
           SleepsCount = 2,
           SmokingAllowed = false,
           Tags = new[] { "Room Tags" }
         },
         new Room()
         {
           Description = "Standard Room, 1 Queen Bed (City View)",
           DescriptionFr = "Chambre Standard, 1 grand lit (vue ville)",
           Type = "Standard Room",
           BaseRate = 121.99,
           BedOptions = "1 Queen Bed",
           SleepsCount = 2,
           SmokingAllowed = false,
           Tags = new[] { "jacuzzi tub" }
         },
         new Room()
         {
           Description = "Budget Room, 1 King Bed (Waterfront View)",
           DescriptionFr = "Chambre Économique, 1 très grand lit (vue sur le front de mer)",
           Type = "Budget Room",
           BaseRate = 88.99,
           BedOptions = "1 King Bed",
           SleepsCount = 2,
           SmokingAllowed = false,
           Tags = new[] { "suite", "tv", "jacuzzi tub" }
         }
       }
     },
     new Hotel()
     {
       HotelId = "3",
       HotelName = "Triple Landscape Hotel",
       Description = "The Hotel stands out for its gastronomic excellence under the management of William Dough, who advises on and oversees all of the Hotel’s restaurant services.",
       DescriptionFr = "L'hôtel est situé dans une place du XIXe siècle, qui a été agrandie et rénovée aux plus hautes normes architecturales pour créer un hôtel moderne, fonctionnel et de première classe dans lequel l'art et les éléments historiques uniques coexistent avec le confort le plus moderne.",
       Category = "Resort and Spa",
       Tags = new[] { "air conditioning", "bar", "continental breakfast" },
       ParkingIncluded = true,
       LastRenovationDate = new DateTimeOffset(2015, 9, 20, 0, 0, 0, TimeSpan.Zero),
       Rating = 4.80,
       Location = GeographyPoint.Create(33.84643, -84.362465),
       Address = new Address()
       {
         StreetAddress = "3393 Peachtree Rd",
         City = "Atlanta",
         StateProvince = "GA",
         PostalCode = "30326",
         Country = "USA"
       },
       Rooms = new Room[]
       {
         new Room()
         {
           Description = "Standard Room, 2 Queen Beds (Amenities)",
           DescriptionFr = "Chambre Standard, 2 grands lits (Services)",
           Type = "Standard Room",
           BaseRate = 101.99,
           BedOptions = "2 Queen Beds",
           SleepsCount = 4,
           SmokingAllowed = true,
           Tags = new[] { "vcr/dvd", "vcr/dvd" }
         },
         new Room ()
         {
           Description = "Standard Room, 2 Double Beds (Waterfront View)",
           DescriptionFr = "Chambre Standard, 2 lits doubles (vue sur le front de mer)",
           Type = "Standard Room",
           BaseRate = 106.99,
           BedOptions = "2 Double Beds",
           SleepsCount = 2,
           SmokingAllowed = true,
           Tags = new[] { "coffee maker" }
         },
         new Room()
         {
           Description = "Deluxe Room, 2 Double Beds (Cityside)",
           DescriptionFr = "Chambre Deluxe, 2 lits doubles (Cityside)",
           Type = "Budget Room",
           BaseRate = 180.99,
           BedOptions = "2 Double Beds",
           SleepsCount = 2,
           SmokingAllowed = true,
           Tags = new[] { "suite" }
         }
       }
     }
   };

   var batch = IndexBatch.Upload(hotels);

   try
   {
     indexClient.Documents.Index(batch);
   }
   catch (IndexBatchException e)
   {
     // Sometimes when your Search service is under load, indexing will fail for some of the documents in
     // the batch. Depending on your application, you can take compensating actions like delaying and
     // retrying. For this simple demo, we just log the failed document keys and continue.
     Console.WriteLine("Failed to index some of the documents: {0}",
       String.Join(", ", e.IndexingResults.Where(r => !r.Succeeded).Select(r => r.Key)));
   }

   Console.WriteLine("Waiting for documents to be indexed...\n");
   Thread.Sleep(2000);
}
```

`hotels`という変数を指定し、`batch`処理指定をします。

```csharp
var batch = IndexBatch.Upload(hotels);
```

そしてその`bactch`処理を下記で実行します。

```csharp
indexClient.Documents.Index(batch);
```

これで指定した3つのホテルは`Azure Search`に`Index`されました。

# 検索

本コードでは下記の部分で検索を実行しています。

```csharp
ISearchIndexClient indexClientForQueries = CreateSearchIndexClient(indexName, configuration);

RunQueries(indexClientForQueries);

Console.WriteLine("{0}", "Complete.  Press any key to end application...\n");
Console.ReadKey();
```

ここに定義されている`RunQueries`ファンクションは下記の通り。　下記のコードでは複数の検索を実行しています。

1. `motel`というキーワードでホテル名だけを返す検索。
2. 一泊100ドル以下のホテルのみ、`hotelId` と `description`を返す検索。
3. `LastRenovationDate`順に並べ、上位２個の検索結果のみを返す検索。
4. `hotel`というキーワードでホテル名だけを返す検索。

```csharp

private static void RunQueries(ISearchIndexClient indexClient)
{
  SearchParameters parameters;
  DocumentSearchResult<Hotel> results;

  Console.WriteLine("Search the entire index for the term 'motel' and return only the HotelName field:\n");

  parameters = new SearchParameters()
  {
    Select = new[] { "HotelName" }
  };

  results = indexClient.Documents.Search<Hotel>("motel", parameters);

  WriteDocuments(results);

  Console.Write("Apply a filter to the index to find hotels with a room cheaper than $100 per night, ");
  Console.WriteLine("and return the hotelId and description:\n");

  parameters = new SearchParameters()
  {
    Filter = "Rooms/any(r: r/BaseRate lt 100)",
    Select = new[] { "HotelId", "Description" }
  };

  results = indexClient.Documents.Search<Hotel>("*", parameters);

  WriteDocuments(results);

  Console.Write("Search the entire index, order by a specific field (lastRenovationDate) ");
  Console.Write("in descending order, take the top two results, and show only hotelName and ");
  Console.WriteLine("lastRenovationDate:\n");

  parameters = new SearchParameters()
  {
    OrderBy = new[] { "LastRenovationDate desc" },
    Select = new[] { "HotelName", "LastRenovationDate" },
    Top = 2
  };

  results = indexClient.Documents.Search<Hotel>("*", parameters);

  WriteDocuments(results);

  Console.WriteLine("Search the hotel names for the term 'hotel':\n");

  parameters = new SearchParameters()
  {
    SearchFields = new[] { "HotelName" }
  };
  
  results = indexClient.Documents.Search<Hotel>("hotel", parameters);

  WriteDocuments(results);
}

private static void WriteDocuments(DocumentSearchResult<Hotel> searchResults)
{
  foreach (SearchResult<Hotel> result in searchResults.Results)
  {
    Console.WriteLine(result.Document);
  }

  Console.WriteLine();
}
```

## Search メソッド
今回使っているSDKのSearchメソッドは下記です

```csharp

public static Microsoft.Azure.Search.Models.DocumentSearchResult<T> Search<T> 
(
this Microsoft.Azure.Search.IDocumentsOperations operations, 
string searchText, 
Microsoft.Azure.Search.Models.SearchParameters searchParameters = null, 
Microsoft.Azure.Search.Models.SearchRequestOptions searchRequestOptions = null
);
```
詳しくは下記を参照ください。
https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.search.documentsoperationsextensions.search?view=azure-dotnet#Microsoft_Azure_Search_DocumentsOperationsExtensions_Search__1_Microsoft_Azure_Search_IDocumentsOperations_System_String_Microsoft_Azure_Search_Models_SearchParameters_Microsoft_Azure_Search_Models_SearchRequestOptions_

## ホテル名で検索

下記は `motel`というキーワードでホテル名だけを返す検索です。

```csharp
parameters = new SearchParameters()
{
  Select = new[] { "HotelName" }
};

results = indexClient.Documents.Search<Hotel>("motel", parameters);

WriteDocuments(results);
```

ここではまず`SearchParameters`を生成しています。その際に選択するフィールド`HotelName`に指定しています。

Searchメソッドで`motel`をキーワードに指定し、先ほど生成した`SearchParameters`を渡しています。

## フィルターでホテルを検索

下記は一泊100ドル以下のホテルのみ、`hotelId` と `description`を返す検索になります。

```csharp
parameters = new SearchParameters()
{
  Filter = "Rooms/any(r: r/BaseRate lt 100)",
  Select = new[] { "HotelId", "Description" }
};

results = indexClient.Documents.Search<Hotel>("*", parameters);

WriteDocuments(results);
```

ここでも同じく、`SearchParameters`を生成します。その際に`Filter`プロパティをつあって一泊100ドル以下というフィルターを設定しています。ここで使われるフィルターは`OData`で指定をします。 `OData`に関してはこちらを参照してください。
https://docs.microsoft.com/en-us/azure/search/query-odata-filter-orderby-syntax

## 検索結果の並び替え

下記は`LastRenovationDate`順に並べ、上位２個の検索結果のみを返す検索になります。

```csharp
parameters = new SearchParameters()
{
  OrderBy = new[] { "LastRenovationDate desc" },
  Select = new[] { "HotelName", "LastRenovationDate" },
  Top = 2
};

results = indexClient.Documents.Search<Hotel>("*", parameters);

WriteDocuments(results);
```

ここでも同じく`SearchParameters`の中で`OrderBy`フィールドを指定することによって検索結果の順番を指定しています。そして`Top`フィールドで検索結果の上位２個だけを返すように指定しています。



## SearchParameters

今回の例を見てもわかるように、`SearchParameters`を使うと様々な検索を指定することができます。`SearchParameters`の詳細は下記を参照してください。
https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.search.models.searchparameters?view=azure-dotnet

# 次

次回は今回作ったIndexにデータを流し込みするIndexerの生成を見てみます。
[[Azure Search] Indexerを作る](https://qiita.com/syantien/items/0c8857a79fccad1a0f20) :point_right_tone1:

そして、さらに複雑な検索パターンも見てみたいと思います。　TypeAheadを使った検索スジャストなども見ていきたいと思います。