CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

Azure AI Searchでベクトル検索と日本語キーワードのフィルターを組み合わせる

こんにちは。テックラボの高橋です。

今回はAzure AI Searchでベクトル検索とキーワードのフィルターの組み合わせを、日本語データに対して適用する方法を検証します。

テキスト クエリのフィルター - Azure AI Search | Microsoft Learn

ドキュメントによると、

フィルターは、filterable という属性が付いているフィールドの英数字の内容に適用されます。

とのことです。

英語版ドキュメントだと以下となります。

Filters apply to alphanumeric content on fields that are attributed as filterable.

果たして日本語データに対してフィルターすることはできるのでしょうか?

※ 結論から述べると、特に特有の設定を行わずとも日本語キーワードのフィルターは可能でした。ベクトル検索とフィルターの組み合わせも問題ありませんでした。

データ

検証に利用したのは以下のような形式の数十行のダミーデータです。

番号 料理 説明 材料 タグ 日付 評価
1 カレー 辛い料理 カレーのルー,肉,じゃがいも,にんじん,たまねぎ インド,日本 20241130 5
2 シチュー まろやかな料理 シチューのルー,牛乳,肉,じゃがいも,にんじん,たまねぎ 日本 20241101 3
3 肉じゃが 美味しい料理 しょうゆ,みりん,だし,肉,じゃがいも,にんじん,たまねぎ 日本 20240120 2
4 料理ではない 20231231 1

インデックスは以下のような設定で作成します。

name type key searchable filterable retrievable sortable facetable
id Edm.String true true true true true false
name Edm.String false true true true false false
description Edm.String false true true true false false
ingredients Collection(Edm.String) false true true true false true
tags Collection(Edm.String) false true true true false true
date Edm.String false true true true true false
rating Edm.Int32 false false true true true true

設定の意味については後述します。

環境

Azure AI Search Standardプラン

クエリタイプ

ベクトル検索とフィルターの組み合わせを検証する前に、一旦Azure AI Searchの検索機能を概観していきます。

Azure AI Search APIにはクエリタイプというパラメータがあります。

そのうち、デフォルト値であるsimpleを指定すると、Azure AI Searchは単純な検索を行います。

動作を確認してみましょう。

単純な検索

本記事では、クエリとその結果は全てREST API経由で確認します。

クエリ

{
   "top": 3,
   "search": "肉",
   "select": "id, name, description, ingredients, tags",
   "searchFields": "name, description, ingredients, tags",
   "count": true
}

結果(JSON抜粋。以下も同様)

  "@odata.count": 6,
  "value": [
    {
      "@search.score": 1.3415415,
      "id": "2",
      "name": "シチュー",
      "description": "まろやかな料理",
      "ingredients": [
        "シチューのルー",
        "牛乳",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ],
      "tags": [
        "日本"
      ]
    },
    {
      "@search.score": 1.1630437,
      "id": "3",
      "name": "肉じゃが",
      "description": "美味しい料理",
      "ingredients": [
        "しょうゆ",
        "みりん",
        "だし",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ],
      "tags": [
        "日本"
      ]
    },
    {
      "@search.score": 0.46697134,
      "id": "23",
      "name": "オムライス",
      "description": "子どもから大人まで大人気",
      "ingredients": [
        "ご飯",
        "鶏むね肉",
        "ケチャップ",
        "ピーマン",
        "玉子"
      ],
      "tags": [
        "日本"
      ]
    }
  ]

searchFieldsで指定したフィールドの値に"肉"という漢字が含まれているレコードが取得できています。

Luceneクエリ

queryTypeを"full"に指定することによって、Luceneクエリを利用することができます。

クエリ

{
   "top": 3,
   "search": "tags:インド AND 肉",
   "queryType": "full",
   "select": "id, name, description, ingredients, tags, date",
   "searchFields": "name, description, ingredients, tags",
   "count": true
}

結果

  "@odata.count": 1,
  "value": [
    {
      "@search.score": 0.6613929,
      "id": "1",
      "name": "カレー",
      "description": "辛い料理",
      "ingredients": [
        "カレーのルー",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ],
      "tags": [
        "インド",
        "日本"
      ]
    }
  ]

tags:インド AND 肉 のクエリが結果に反映されているようです。

フィルター

filter を用いるとOdataフィルター式を利用してクエリに条件を付与することができます。

例えば、ratingが3より大きいレコードを習得するクエリは以下になります。

クエリ

{
   "top": 3,
   "search": "",
   "filter": "rating gt 3",
   "queryType": "full",
   "select": "id, name, rating",
   "searchFields": "name, description, ingredients, tags, date, rating",
   "count": true
}

このクエリを実行してみましょう。

結果

  "@odata.count": 7,
  "value": [
    {
      "@search.score": 1.0,
      "id": "23",
      "name": "オムライス",
      "rating": 5
    },
    {
      "@search.score": 1.0,
      "id": "5",
      "name": "ラーメン",
      "rating": 4
    },
    {
      "@search.score": 1.0,
      "id": "18",
      "name": "ドーナツ",
      "rating": 4
    }
  ]

filterが機能しているようです。

コレクションの値に対してもフィルターしてみます。

OData コレクション フィルター - Azure AI Search | Microsoft Learn

tags/any(t: t eq '日本')という形式で、 Pythonのlambda式のようなイメージでクエリできます。

クエリ

{
   "top": 3,
   "search": "",
   "filter": "tags/any(t: t eq '日本')",
   "queryType": "full",
   "select": "id, name, tags",
   "searchFields": "name, description, ingredients, tags, date, rating",
   "count": true
}

結果

 "@odata.count": 8,
  "value": [
    {
      "@search.score": 1.0,
      "id": "3",
      "name": "肉じゃが",
      "tags": [
        "日本"
      ]
    },
    {
      "@search.score": 1.0,
      "id": "23",
      "name": "オムライス",
      "tags": [
        "日本"
      ]
    },
    {
      "@search.score": 1.0,
      "id": "2",
      "name": "シチュー",
      "tags": [
        "日本"
      ]
    }
  ]

コレクションに対するフィルターもできましたし、日本語に関しても機能していますね。

また、search.ismatch()関数を用いて、ODataフィルター式内でフルテキスト検索を利用することもできます。

learn.microsoft.com

クエリ

{
    "search": "*",
    "queryType": "full",
    "select": "name, ingredients",
    "searchFields": "ingredients",
    "filter": "search.ismatch('肉')",
    "top": 100,
    "count": true
}

結果

 "@odata.count": 6,
  "value": [
    {
      "@search.score": 1.3415415,
      "name": "シチュー",
      "ingredients": [
        "シチューのルー",
        "牛乳",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ]
    },
    {
      "@search.score": 1.1630437,
      "name": "肉じゃが",
      "ingredients": [
        "しょうゆ",
        "みりん",
        "だし",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ]
    },
    {
      "@search.score": 0.46697134,
      "name": "オムライス",
      "ingredients": [
        "ご飯",
        "鶏むね肉",
        "ケチャップ",
        "ピーマン",
        "玉子"
      ]
    },
    {
      "@search.score": 0.2876821,
      "name": "ポトフ",
      "ingredients": [
        "牛肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ]
    },
    {
      "@search.score": 0.16948202,
      "name": "カレー",
      "ingredients": [
        "カレーのルー",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ]
    },
    {
      "@search.score": 0.16948202,
      "name": "タコス",
      "ingredients": [
        "トルティーヤ",
        "肉",
        "野菜",
        "チーズ",
        "サルサソース"
      ]
    }
  ]

Luceneクエリでは表現できない条件を利用したい場合は、Odataフィルター式を用いると良さそうです。

フィールドの属性設定

インデックスのフィールドの属性には以下のような意味があります。

learn.microsoft.com

  • searchable 検索可能
  • filterable フィルター可能
  • retrievable 並べ替え可能
  • sortable ソート可能
  • facetable ファセット可能

ドキュメントによると、設定方法がRESTかSDKかによってデフォルト値が異なるようです。 インデックスを作成する場合には注意してください。

ソート

インデックスに格納したデータでは、ratingのフィールドをsortable: trueに設定しています。

デフォルトだと、クエリの結果はsearch.score() descでソートされていますが、 あらためてリクエストパラメータにorderbyの項目を指定してみます。

クエリ

{
   "top": 5,
   "search": "日本",
   "filter": "rating gt 3",
   "queryType": "full",
   "select": "id, name, rating",
   "searchFields": "name, description, ingredients, tags, date",
   "orderby": "rating desc, search.score() desc",
   "count": true
}

結果

  "@odata.count": 3,
  "value": [
    {
      "@search.score": 0.98382175,
      "id": "1",
      "name": "カレー",
      "rating": 5
    },
    {
      "@search.score": 0.77095735,
      "id": "23",
      "name": "オムライス",
      "rating": 5
    },
    {
      "@search.score": 1.113083,
      "id": "5",
      "name": "ラーメン",
      "rating": 4
    }
  ]

"orderby": "rating desc, search.score() desc",と指定したため、 search.scoreよりもrating指定が優先されています。

ファセット

ファセット ナビゲーション カテゴリ階層を追加する - Azure AI Search | Microsoft Learn

"ファセット"はカテゴリや属性を表す用語のようです。

今回はフィールドのtags, ingredients, ratingにをfacetable : trueと指定してみました。

クエリにfacetsを指定することによって、データの構造をレスポンスに付与することができます。

クエリ

{
    "top": 1,
    "search": "*",
    "queryType": "simple",
    "select": "id, name",
    "searchFields": "",
    "filter": "",
    "facets": ["tags"], 
    "orderby": "",
    "count": true
}

結果

 "@search.facets": {
    "tags": [
      {
        "count": 8,
        "value": "日本"
      },
      {
        "count": 4,
        "value": "アメリカ"
      },
      {
        "count": 2,
        "value": "イタリア"
      },
      {
        "count": 2,
        "value": "メキシコ"
      },
      {
        "count": 1,
        "value": "インド"
      },
      {
        "count": 1,
        "value": "スペイン"
      },
      {
        "count": 1,
        "value": "タイ"
      },
      {
        "count": 1,
        "value": "フランス"
      },
      {
        "count": 1,
        "value": "ベトナム"
      },
      {
        "count": 1,
        "value": "ベルギー"
      }
    ]
  },

デフォルトではカテゴリのうちcount上位10件を返却します。

"facets": ["tags", "ingredients,count:100", "rating"] のような形式で、countを指定して件数を指定することもできます。

ベクトル検索との組み合わせ

ここで、text_vectorというフィールドを作成し、各レコードに対して1536次元のベクトルデータを格納します。

格納するデータは、格納する元データをazure open aiの text-embedding-ada-002を用いてベクトル化したものです。

ベクトル検索はベクトルデータをクエリとしてDBの別のベクトルデータを検索する仕組みです。Azure AI Searchは、ベクタライザーを指定することでクエリ文字列のベクトル化をAzure AI Search側で行うことができます。

今回はベクタライザーの指定も行うため、Azure ポータルのImport and vectorize dataから作成したインデックスを参考に、設定を流用しました。ベクトルフィールドの構築については、ぜひ以下の記事を参照ください。

techblog.cccmkhd.co.jp

techblog.cccmkhd.co.jp

techblog.cccmkhd.co.jp

ベクトル化を構成する - Azure AI Search | Microsoft Learn

ベクトル検索

ベクトルフィールドを追加したインデックスに対しては、以下のようなクエリを実行することができます。

クエリ

{
   "count": true,
   "select": "id, name, ingredients",
   "vectorQueries": [
      {
         "kind": "text",
         "text": "おにくのりょうり",
         "fields": "text_vector",
         "k": 3,
         "exhaustive": true
      }
   ]
}

結果は以下です。

 "@odata.count": 3,
  "value": [
    {
      "@search.score": 0.8443059,
      "id": "23",
      "name": "オムライス",
      "ingredients": [
        "ご飯",
        "鶏むね肉",
        "ケチャップ",
        "ピーマン",
        "玉子"
      ]
    },
    {
      "@search.score": 0.8385514,
      "id": "21",
      "name": "ギョウザ",
      "ingredients": [
        "豚肉",
        "キャベツ",
        "ニラ",
        "餃子の皮"
      ]
    },
    {
      "@search.score": 0.8341187,
      "id": "20",
      "name": "ビーフストロガノフ",
      "ingredients": [
        "牛肉",
        "クリーム",
        "オニオン",
        "パプリカ",
        "ストック"
      ]
    }
  ]

おにくと漢字を開いてクエリを投げましたが、に関連する結果が返ってきています。ベクトル検索に成功しているようです。

フィルターした結果のベクトル検索

最後に、この結果をfilterableなフィールドでフィルターします。

vectorFilterModeは規定でpreFilterになっています。 つまり、キーワードによるフィルター処理の後、その結果に対してベクトル検索が行われます。

クエリ

{
   "count": true,
   "select": "id, name, ingredients, tags",
   "filter": "tags/any(t: t eq '日本')",
   "vectorFilterMode": "preFilter",
   "vectorQueries": [
      {
         "kind": "text",
         "text": "おにくのりょうり",
         "fields": "text_vector",
         "k": 3,
         "exhaustive": true
      }
   ]
}

結果

  "@odata.count": 3,
  "value": [
    {
      "@search.score": 0.8443059,
      "id": "23",
      "name": "オムライス",
      "ingredients": [
        "ご飯",
        "鶏むね肉",
        "ケチャップ",
        "ピーマン",
        "玉子"
      ],
      "tags": [
        "日本"
      ]
    },
    {
      "@search.score": 0.8385514,
      "id": "21",
      "name": "ギョウザ",
      "ingredients": [
        "豚肉",
        "キャベツ",
        "ニラ",
        "餃子の皮"
      ],
      "tags": [
        "中国",
        "日本"
      ]
    },
    {
      "@search.score": 0.8325942,
      "id": "2",
      "name": "シチュー",
      "ingredients": [
        "シチューのルー",
        "牛乳",
        "肉",
        "じゃがいも",
        "にんじん",
        "たまねぎ"
      ],
      "tags": [
        "日本"
      ]
    }
  ]

無事にフィルターした結果に対してベクトル検索することができました。

おわりに

以上、Azure AI Searchの検索機能を概観しながら、ベクトル検索と日本語によるキーワードフィルターの組み合わせを検証してみました。

RAGの流行でベクトル検索がよく用いられていますが、本記事で検証したAzure AI Searchにはベクトル検索以外にも数多くの機能があるため、このようなシンプルな機能を組み合わせると要件によりマッチしたシステムが組めるかもしれません。

全文検索のLuceneの設定や、ハイブリッド検索、セマンティックランカーなどの機能には今回触れていないため、今後はこちらも調査を進めていければと思います。

参考

Azure AI Search ドキュメント | Microsoft Learn

OData の search.score 関数リファレンス - Azure AI Search | Microsoft Learn

storedプロパティの説明