レシートをテキスト検索できるようにする

テキスト検索するためには今回は（も）Azure Searchを選択しました。

詳細ドキュメントはこちらを参照ください。
https://learn.microsoft.com/ja-jp/azure/search/

ということで、早速作成してみます。
今回はこちらのドキュメントを参考にしています。
https://learn.microsoft.com/ja-jp/azure/search/search-create-service-portal
https://learn.microsoft.com/ja-jp/azure/search/search-get-started-dotnet

下記の手順で現在Tableにあるレシート情報を検索できるようにします。

1. C#用Nugetライブラリーのインストール
1. データモデルの定義とインデックスの作成
1. ドキュメントを読み込む（＝検索インデックスにデータを挿入する）
1. インデックスの検索

それでは早速進めます。

# C#用Nugetライブラリーのインストール

Nugetでライブラリーをインストールします。
Install-Package Azure.Search.Documents -Version 11.4.0

# データモデルの定義とインデックスの作成

こちらがモデルになります。

```csharp

public class ReceiptSearchModel
    {
        [SimpleField(IsKey = true, IsFilterable = true)]
        public string ReceiptId { get; set; }
        [SimpleField(IsFilterable = true)]
        public string OwnerId { get; set; }
        [SimpleField(IsFilterable = true, IsSortable = true)]
        public DateTimeOffset? ReceiptTransactionDate { get; set; }
        [SearchableField(AnalyzerName = LexicalAnalyzerName.Values.JaLucene)]
        public string ReceiptText { get; set; }
        [SearchableField(AnalyzerName = LexicalAnalyzerName.Values.JaLucene, IsFacetable = true)]
        public string StoreName { get; set; }
        public string StoreAddress { get; set; }
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string StoreCountry { get; set; }
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string StorePrefecture { get; set; }
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string StoreLocality { get; set; }
        public string StoreTel { get; set; }
        public string StoreEmail { get; set; }
        [SimpleField(IsFilterable = true, IsSortable = true)]
        public int GrandTotal { get; set; }
        [SimpleField(IsFilterable = true)]
        public string Currency { get; set; }
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string PaymentMethod { get; set; }

        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string[] Tags { get; set; }
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string Category { get; set; }
    }
```

ここで作ったモデルをベースにIndexを作成します。

```csharp

 private void CreateSearchIndex(string indexName, SearchIndexClient adminClient)
        {
            FieldBuilder fieldBuilder = new FieldBuilder();
            var searchFields = fieldBuilder.Build(typeof(ReceiptSearchModel));

            var definition = new SearchIndex(indexName, searchFields);

            var suggester = new SearchSuggester("stores", new[] { "StoreName" });
            definition.Suggesters.Add(suggester);

            adminClient.CreateOrUpdateIndex(definition);
        }
```

このメソッドを実際にコールしてみます。

```csharp
public void InitializeSaerchIndex()
        {
            string apiKey = "{YOUR API KEY}";
            string indexName = "{YOUR INDEX NAME}";

            // Create a SearchIndexClient to send create/delete index commands
            Uri serviceEndpoint = new Uri($"{YOUR SERVICE URL}");
            AzureKeyCredential credential = new AzureKeyCredential(apiKey);
            SearchIndexClient adminClient = new SearchIndexClient(serviceEndpoint, credential);

            // Create a SearchClient to load and query documents
            SearchClient srchclient = new SearchClient(serviceEndpoint, indexName, credential);

            SearchIndexClient searchIndexClient = new SearchIndexClient(serviceEndpoint, credential);

            // Create index
            CreateSearchIndex(indexName, searchIndexClient);
        }
```

実際に↑を実装するコードは省略しますが、このコードを実装したあとにAzureポータルよりIndexが生成されていることを確認してみてください。

# ドキュメントを読み込む（＝検索インデックスにデータを挿入する）

次は先ほど作成したインデックスにデータを挿入します。　（IndexClientを生成するクラスは別に作ることをお勧めします。）

```csharp

 public void SyncSearchIndex()
        {
            // Get source doc.
            var receipts = GetFullCustomerReceipts();

            var dataList = new List<IndexDocumentsAction<ReceiptSearchModel>>();

            if (receipts != null)
            {
                foreach (var receipt in receipts)
                {
                    dataList.Add(IndexDocumentsAction.Upload(new ReceiptSearchModel
                    {
                        OwnerId = receipt.PartitionKey ?? "",
                        StoreAddress = receipt.StorePrintAddress ?? "",
                        StoreName = receipt.StorePrintName ?? "",
                        Category = receipt.ExpenseCategory ?? "",
                        Currency = "JPY",
                        ReceiptId = receipt.RowKey,
                        PaymentMethod = receipt.DespoitMethod.ToString(),
                        ReceiptTransactionDate = receipt.TerminalTransactionDateTime,
                        GrandTotal = decimal.ToInt32(receipt.GrandTotal),
                        ReceiptText = receipt.OcrText ?? "",
                        Tags = new string[] { "receipt" }
                    }));
                }

                try
                {
                    string apiKey = "{YOUR API KEY}";
                    string indexName = "{YOUR INDEX NAME}";

                    // Create a SearchIndexClient to send create/delete index commands
                    Uri serviceEndpoint = new Uri($"{YOUR SERVICE URL}");
                    AzureKeyCredential credential = new AzureKeyCredential(apiKey);
                    SearchIndexClient adminClient = new SearchIndexClient(serviceEndpoint, credential);

                    // Create a SearchClient to load and query documents
                    SearchClient searchClient = new SearchClient(serviceEndpoint, indexName, credential);

                    IndexDocumentsBatch<ReceiptSearchModel> batch = IndexDocumentsBatch.Create(dataList.ToArray());

                    IndexDocumentsResult result = searchClient.IndexDocuments(batch);
                }
                catch (Exception)
                {
                    // If for some reason any documents are dropped during indexing, you can compensate by delaying and
                    // retrying. This simple demo just logs the failed document keys and continues.
                    Console.WriteLine("Failed to index some of the documents: {0}");
                }
            }

        }
```

このコードを実装したあとに、該当インデックスのドキュメント数が増えていることを確認してみてください。
特定のDBに対してインデクサーを付けることによって自動的に同期するようにすることもできます。詳細はこちらを参照ください。https://learn.microsoft.com/ja-jp/azure/search/search-indexer-tutorial

# インデックスの検索

それでは最後にインデックスの検索をしてみます。検索方法に関しては詳細はこちらを参照ください。　https://learn.microsoft.com/ja-jp/azure/search/search-query-overview#types-of-queries

下記のコードでインデックスの検索を実装します。

```csharp
   public SearchResults<ReceiptSearchModel> RunQueries(string ownerId, string keyword)
        {
            SearchOptions options;
            SearchResults<ReceiptSearchModel> response;

            // Query 1
            Console.WriteLine("Query #1: Search on empty term '*' to return all documents, showing a subset of fields...\n");

            options = new SearchOptions()
            {
                IncludeTotalCount = true,
                Filter = "OwnerId eq '" + ownerId + "'",
                OrderBy = { "ReceiptTransactionDate" }
            };
            string apiKey = "{YOUR API KEY}";
            string indexName = "{YOUR INDEX NAME}";

            // Create a SearchIndexClient to send create/delete index commands
            Uri serviceEndpoint = new Uri($"{YOUR SERVICE URL}");
            AzureKeyCredential credential = new AzureKeyCredential(apiKey);
            SearchIndexClient adminClient = new SearchIndexClient(serviceEndpoint, credential);

            // Create a SearchClient to load and query documents
            SearchClient searchClient = new SearchClient(serviceEndpoint, indexName, credential);

            response = searchClient.Search<ReceiptSearchModel>(keyword, options);

            return response;

        }
```

コントロールから呼ぶときは、下記のようにしています。

```csharp
 public async Task<IActionResult> Search(string culture, string keyword)
        {
            var user = await _userManager.GetUserAsync(User);
            var searchResults = _receiptServices.RunQueries(user.Id, keyword);

            var view = new UsrReceiptSearchViewModel()
            {
                SearchResult = searchResults,
                Description = "",
                Culture = culture,
                Keyword = keyword
            };

            return View(view);
        }
```

Viewはこちらです。（抜粋）

```csharp
 @if (Model.SearchResult.TotalCount > 0)
            {
                <table class="table table-bordered table-striped">
                    <tr>
                        <th>日付</th>
                        <th>
                            場所
                        </th>
                        <th>
                            金額
                        </th>
                    </tr>
                    @foreach (var item in Model.SearchResult.GetResults())
                    {
                        <tr>
                            <td>
                                <a href="/@Model.Culture/usr/receipt/@item.Document.ReceiptId">
                                    <div class="font-xs">
                                        @item.Document.ReceiptId
                                    </div>
                                    @item.Document.ReceiptTransactionDate.Value.ToString("yyyy-MM-dd")
                                </a>

                            </td>
                            <td>
                                @item.Document.StoreName
                            </td>
                            <td class="text-end">
                                &yen;@item.Document.GrandTotal.ToString("N0")
                            </td>
                        </tr>
                    }
                </table>
            }
            else
            {
                <div>該当データはありませんでした。</div>
            }
```

# まとめ

本日は簡易にAzure Searchの利用方法をまとめてみました。テキスト検索は奥深く、今後はさらに深い内容にふれていきたいと思います。

# 採用中です

レシートローラーでは紙レシート削減をミッションにデジタルレシートサービスを展開しています。一緒に開発進めてくれるエンジニア大募集中です。興味のある方はこちらの求人情報を確認ください。

https://receiptroller.com/ja/jobs