みなさん、こんにちは。サイオステクノロジー武井です。ここ最近、生成AIやRAG(Retrieval-Augmented Generation)のブームにより、ベクトル検索の機運が高まっていると感じます。Azure AI Search(Azureが提供するフルマネージドな検索サービス)でも、ついにベクトル検索がGAとなりました。そこで今回は、ベクトル検索の有用性をキーワード検索と比較して説明してみたいと思います。
ベクトル検索は、テキストや画像などのデータを多次元ベクトルに変換し、これを用いて意味的類似性に基づいた検索を行います。キーワード検索と比較して、ベクトル検索の優れた点は、単語の出現頻度や並びに依存しないため、文書やクエリの深い意味内容を捉え、より関連性の高い結果を提供できることです。特に、類似したトピックや意味内容を持つ文書を発見する際に強みを発揮します。
ちょっと迂遠にはなりますが、まずはキーワード検索とベクトル検索の概要についてそれぞれ説明し、そしてキーワード検索と比較したベクトル検索の有用性を説明するという順番で進めていきます。
キーワード検索
これは従来から使われている検索方法です。転置インデックスというものを作成し、その転置インデックスから検索をして、対象のドキュメントを引っ張ってくる方法です。
転置インデックス
例えば、以下の4つのドキュメントを考えてみます。
- ドキュメント1: 猫は素晴らしいペットです。猫はとても可愛いです。
- ドキュメント2: 犬は素晴らしいペットです。犬は忠実です。
- ドキュメント3: 鳥は素晴らしい歌を歌う。鳥は美しいです。
- ドキュメント4: 猫と犬は人気のペットです。
まずはこれを形態素解析します。形態素解析とは、文章を最小の意味を持つ単位(形態素)に分割し、それぞれの品詞を特定するプロセスです。例えば、日本語の文章では、単語を切り分けて名詞や動詞などに分類します。これは、テキストデータを解析する際の基本的な手順であり、自然言語処理の分野で広く用いられています。
これに従い、先程の文章を形態素解析するプロセスは以下の通りとなります。
同様に他のドキュメントも形態素解析を行い、その結果を以下に記載します。
- ドキュメント1: 猫 素晴らしい ペット 猫 とても 可愛い
- ドキュメント2: 犬 素晴らしい ペット 忠実
- ドキュメント3: 鳥 素晴らしい 歌 歌う 鳥 美しい
- ドキュメント4: 猫 犬 人気 ペット
形態素解析した結果をもとに転置インデックスを以下のように作成します。転置インデックスは、文書検索システムで使用されるデータ構造の一つで、各単語がどの文書に現れるかを記録するためのものです。例えば、今回の例だと、「猫」という単語はドキュメント1とドキュメント4に出現しますので、そのように記録します。そしてのこの転置インデックスに対して、「猫」という単語で検索すると、関連ドキュメントであるドキュメント1とドキュメント2を素早く見つけることができます。
猫 | ドキュメント1、ドキュメント4 |
素晴らしい |
ドキュメント1、ドキュメント2、ドキュメント3 |
ペット | ドキュメント1、ドキュメント2、ドキュメント4 |
とても |
ドキュメント1 |
可愛い |
ドキュメント1 |
犬 |
ドキュメント2、ドキュメント4 |
忠実 |
ドキュメント2 |
鳥 |
ドキュメント3 |
歌 |
ドキュメント3 |
歌う |
ドキュメント3 |
美しい |
ドキュメント3 |
人気 |
ドキュメント3 |
SQLのlike文ではだめなのかという疑問が湧くと思います。like文検索では全ての文書を検索しなければなりません。例えば、SQLで「ペット」でlike文検索(select * from docs where content like ‘%ペット%’)したとします。そうなると、文書全体を検索することになります。
一方で、転置インデックスを検索する場合は、「ペット」をキーに検索しますと、その単語に直接関連付けられた文書(ドキュメント1、ドキュメント2、ドキュメント4)を引っ張ってくることができます。これにより、検索時間が大幅に短縮され、大量のデータを扱う検索エンジンで特に有効です。
検索結果の順位付け
キーワード検索による検索結果の順位付けの方法について、ご説明します。Googleで検索を行うと、検索したキーワードに一番関連のありそうなWebサイトやドキュメントが上部に表示されますよね。これは「TF-IDF (Term Frequency-Inverse Document Frequency)」という技法によってランク付けされています。
TF-IDF(Term Frequency-Inverse Document Frequency)は、文書全体で単語の重要性を評価する方法です。
まず「TF」は、ある単語が一つの文書にどれだけ頻繁に現れるかを示します。つまり、その単語が文書内でよく使われているほど、TFの値は高くなります。
次に「IDF」は、その単語がどれだけ珍しいか、つまり他の文書にはあまり出てこないかを測ります。単語がほとんどの文書に出ない場合、IDFの値は高くなります。
TFとIDFを掛け合わせたものがTF-IDFの値で、これによって、その単語が特定の文書でどれだけ重要かを判断します。
これを数式で表すと以下の様になります。
数式ばかりでもイメージしにくいと思いますので、具体例を交えて説明します。では先程も登場した以下の4つのドキュメントに対して、「猫」「素晴らしい」の2つの言葉で検索をかけたときの結果に対する順位付けをTF-IDFでしてみようと思います。
- ドキュメント1: 猫は素晴らしいペットです。猫はとても可愛いです。
- ドキュメント2: 犬は素晴らしいペットです。犬は忠実です。
- ドキュメント3: 鳥は素晴らしい歌を歌う。鳥は美しいです。
- ドキュメント4: 猫と犬は人気のペットです。
「猫」という単語で検索した場合
まずは「猫」という言葉で検索した場合を考えてみます。最初にTFを求めます。ドキュメント1の猫という単語のTFは、
ドキュメント1ので猫という単語が登場した回数 /ドキュメント1の全ての単語数(重複除く、助詞除く)
なので、よって以下となります。
同様に他のドキュメントの場合も、求めてみます。
上記の結果から、ドキュメント1が最も猫という単語の出現頻度が高い文書であることがわかります。
次に、猫と言う単語のIDFを求めてみます。これは猫という単語がドキュメント全体の中でどれほど珍しいかを表す指標となります。
ここでは、猫という単語を含むドキュメント数が2つ(ドキュメント1とドキュメント4)であり、ドキュメントの総数は4つ(ドキュメント1〜4)なので、その対数(底は10としている)は0.301となります。
例えば、これがドキュメントの総数が10だったとします。その場合のIDFは0.7となり、ドキュメントの総数が4つだった場合と比べて、猫という単語を含むドキュメントはより珍しいものだということがわかります。
つまり、繰り返しになりますが、IDFは対象の単語を含むドキュメントが、ドキュメント全体の中でどのくらい珍しいものかを表す指標になります。これが高ければ高いほど「珍しい」ということになります。
TFとIDFの両方が算出されたので、TFとIDFをかけあわせたTF-IDFは以下のとおりとなります。
ドキュメント1の猫という単語のTF-IDF = 0.12
ドキュメント2の猫という単語のTF-IDF = 0
ドキュメント3の猫という単語のTF-IDF = 0
ドキュメント4の猫という単語のTF-IDF = 0.06
この結果から、「猫」という単語の検索結果は、ドキュメント1が最もユーザーの求めるものとマッチしてるということがわかるかと思います。
ただ、この結果だけからだと「IDF」の意義がわかりません。単純にTFだけを比較すれば良さそうな気もします。そこでもう一つの例を上げて、IDFの意義を解説してみることとします。
「素晴らしい」という単語で検索した場合
では、猫という単語で検索したときと同様に、「素晴らしい」という単語のドキュメントごとのTFを求めてみます。
同様に「素晴らしい」という単語のIDFを求めます。
「猫」のIDFが0.301でしたので、それよりはだいぶ低い値になっていることがわかります。これは「素晴らしい」という単語は、それが一般的な単語のために複数のドキュメントに登場します。そうすると、分母が大きくなるので、必然的にIDFは小さい値になり、その結果「素晴らしい」という単語は、ドキュメント全体の中では「猫」という単語ほど珍しくないということが数値的にわかります。
TFもIDFもわかったので、「猫」のときと同様にTF-IDFを求めます。
ドキュメント1の「素晴らしい」という単語のTF-IDF = 0.031
ドキュメント2の 「素晴らしい」 という単語のTF-IDF = 0.025
ドキュメント3の 「素晴らしい」 という単語のTF-IDF = 0.02
ドキュメント4の 「素晴らしい」 という単語のTF-IDF = 0
「猫」という単語のTF-IDFに比べると全体的に低い値になっています。これはIDFの値が「猫」という単語のそれに比べて全体的に低いからです。
「猫」という言葉で検索しても、「素晴らしい」という言葉で検索しても、同様にドキュメント1のTF-IDFの値が一番高いのですが、ユーザーの求める要求によりマッチしているのは、「猫」という言葉で検索したときのに出てきたドキュメント1と言えます。
というのは、一旦TF-IDFという数値で考えるのは抜きにして、感覚で考えてみても想像がつくと思います。「素晴らしい」という単語はあまりにも一般的すぎて多数のドキュメントに出てきます。それ故に、「素晴らしい」という単語が多数含まれているドキュメントがあったとしても、それがユーザーの求めているものとマッチしているかというと、それは少々疑問です。そしてそれが、IDFという数字に現れて、結果として「猫」のときのTF-IDFよりも全体的に低い数値になっているという形でデータにも表れております。
逆に「猫」という単語は、「素晴らしい」という単語に比べれば、それほど多くのドキュメントには登場しないことは想像がつくと思います。ゆえに、「猫」という単語が多く登場するドキュメントは、よりユーザーの望むものにマッチしていると言えます。よってTF-IDFは「素晴らしい」という単語で検索したときのそれよりも高い値になっています。
今までの結果を以下にまとめてみました。これをもとにさらに具体的に掘り下げてみたいと思います。
例えば、ドキュメント4について見てみます。
ドキュメント4のTFは「猫」で検索したときも、「素晴らしい」で検索したときも同じ値です。つまり、ドキュメント4は、その総単語数5個に対して、「猫」も「素晴らしい」も1個ずつあります。よって1/5 = 0.25になります。つまりドキュメント4における検索対象の単語の登場頻度は、「猫」で検索したときも、「素晴らしい」で検索したときも同じです。これだけ見ると、「猫」で検索したときも、「素晴らしい」で検索したときも、ユーザーにとってドキュメント4の価値は同じに見えます。しかし、IDFを考慮すると異なります。
ドキュメント4のIDFは、「猫」で検索したときは0.301、「素晴らしい」で検索したときは0.125になります。つまり、ドキュメントの総数4個の中で、「猫」という単語を含むドキュメントは2個(ドキュメント1、ドキュメント4)、「素晴らしい」という単語を含むドキュメントは3個(ドキュメント1、ドキュメント2、ドキュメント3)あります。微妙な差ではありますが、「猫」という単語を含むドキュメントの方が、ドキュメント全体の中で希少価値が高いということです。逆の言い方をすれば、「素晴らしい」という単語を含むドキュメントはたくさんあるので、ドキュメント全体の中ではあまり珍しくはないということになります。
そして、TFとIDFの積であるTF-IDFは、「猫」で検索したときは0.06、「素晴らしい」で検索したときは0.031になっています。TFとIDFを掛け合わせることで、TFだけでは図ることができない、ドキュメントの希少性を加味することができます。
つまり、これらの結果を総合しますと、「猫」で検索したときも「素晴らしい」で検索したときもTFは同じ値、つまりドキュメントの登場頻度は同じなのだけれど、IDFは「猫」で検索したときの方が高いので、「猫」という単語を含むドキュメントの方がその希少性が高く、その結果がTF-IDFにも出ております。「猫」で検索したときの方がTF-IDFが高いので、結果として、「猫」という単語で検索したときのドキュメント4の方が、「素晴らしい」という単語を検索したときのドキュメント4より、ユーザーが望んでいる情報を含むドキュメントである可能性が高くなります。
もっとわかりやすい例で考えると、例えば100万個ドキュメントがある中で、「ほげほげふがふが」という単語を含むドキュメントが一つしかなったとして、仮にユーザーが「ほげほげふがふが」という言葉で検索して見つかったドキュメントは、そのユザーにとってまさに欲しかったドキュメントにドンピシャな感じだと想像ができるでしょう。
TF-IDFはこのように、単語の重要性と希少性の両方を考慮することで、検索やドキュメント分析において非常に有用なツールとなります。
ベクトル検索
キーワード検索は、ドキュメント中に出てくる単語の希少性や出現頻度を考慮したものですが、ベクトル検索はまた違ったアプローチを取ります。言い換えますと、ベクトル検索は、キーワード検索と比べて、より意味を理解することができます。たとえば、キーワード検索では「リンゴ」と入力すれば、「リンゴ」が書かれたページを見つけますが、ベクトル検索なら「フルーツ」や「健康食品」のような関連する言葉のページも見つけられます。これはベクトル検索が単語の意味を把握し、それに基づいて検索するからです。そのため、もっと広い範囲の情報を見つけやすくなり、また、同じ意味の言葉が違う言語で書かれていても、ベクトル検索ではうまく検索できることが多いです。これにより、より幅広い情報にアクセスしやすくなります。
さて、ここでベクトル検索における比較を理解しやすくするために、例として「甘み」と「価格」という2つの要素を用いて、いくつかの食べ物について考えてみましょう。まず、各食べ物について、どれだけ甘いかという「甘み」、そしていくらかかるかという「価格」に基づいて数値を割り当てます。この数値を使って、それぞれの食べ物を2次元のグラフにプロットします。
このグラフ上で、各食べ物は点として表され、点の位置はその食べ物の「甘み」と「価格」の数値によって決まります。例えば、リンゴは甘さが中程度で価格も手頃なため、中間の位置に点が来ます。一方で、レモンはあまり甘くないため、甘みのスケールでは低い位置に、価格が低いため価格のスケールでも低い位置に点が来ます。
次に、これらの点をベクトルとして考え、原点から各点へと向かう線分を描きます。これがそれぞれの食べ物の「特徴ベクトル」です。ベクトルの角度が小さいほど、2つの食べ物は特徴が似ていると言えます。つまり以下の図でθ1とθ2を見てみます。
リンゴとイチゴは、甘みも価格も似ているためθ1は、小さい角度になります。一方でリンゴとハバネロは価格こそ似ていれど、甘み大きく異なるため、θ2は大きい角度になります。
この角度を計算することにより、これらの果物がどの程度似ているのかを比較しますが、コンピューターは分度器を持っていないので、角度を比較するための計算式が必要となります。そこでコサイン類似度を使います。ベクトルAとベクトルBの内積をベクトルAの長さとベクトルBの長さの積で割ったものになります。数式に表すと以下になります。
コサイン類似度は、2つのベクトルがどれだけ同じ方向を向いているかを測る数値で、1に近いほど似ていると言えます。
では、リンゴとイチゴのコサイン類似度を求めてみます。
0.999となり1にかなり近く、リンゴとイチゴは非常に似ているということがわかります。
では、θ2つまりリンゴとハバネロはのコサイン類似度はどうでしょうか?価格はそこそ近いですが、甘みは大きくかけ離れています。まぁ、イチゴに比べればハバネロはとても辛いので感覚的にわかりますが、先程のコサイン類似度を使って数値として求めてみます。
リンゴとイチゴのコサイン類似度よりも1から遠い値になりました。なるほど、やっぱりリンゴとイチゴのほうがお互い似ていて、リンゴとハバネロはあんまり似ていないということがデータでわかりました。
このように、コサイン類似度を計算することで、グラフ上の位置の違いを超えて、2つの食べ物がどれだけ「特徴」が似ているかを数値で表すことができるのです。
今までは「甘み」と「価格」という2つの次元で類似度を計算していましたが、この考え方は自然言語処理においても応用可能です。自然言語の場合、単語や文書を表すために、何千から何万にも及ぶ多次元ベクトルを使用します。これらの高次元ベクトルには、言語の複雑な特徴が埋め込まれており、文書や単語間の意味的類似度を計算する際に使用されます。この方法により、テキスト間の意味的な距離を定量的に捉えることができます。
ベクトル検索の有用性
テキスト検索、ベクトル検索の概念がわかったところで、ベクトル検索の有用性をテキスト検索と比較して、説明してみたいと思います。
以下のように、テキストデータとベクトルデータの両方のドキュメントをAzure AI Search(Azureが提供するフルマネージドな検索サービス)に登録して、それぞれキーワード検索、ベクトル検索を行い、その検索結果を比較します。
登録するドキュメントは以下になります。3つのフルーツとそれぞれの詳細な説明です。
名前 | 説明 |
いちご | いちごは赤くてとても甘い。春の食べ物で、よくケーキに乗せたりします。 |
バナナ | バナナは黄色い色で、甘いです。とても栄養を早く吸収できるので、疲労回復には持ってこいです。お通じもよくなります。 |
レモン | とても酸っぱいですが、ビタミンCが豊富で、お肌の調子が良くなります。 |
1. Embeddings API発行 / 2. ベクトル化データ取得
まずは、先に上げたフルーツの説明をベクトル化します。これにはAzure OpenAI Serviceで提供されているEmbeddigモデル(text-embedding-ada-002)を使います。既に、text-embedding-ada-002モデルのデプロイは完了し、そのデプロイ名はtext-embedding-ada-002-depolyであるとして、以下のAPIを発行します。
$ response=$(curl -s -X POST "https://hogehoge.openai.azure.com/openai/deployments/text-embedding-ada-002-depoly/embeddings?api-version=2023-05-15" \
-H 'Content-Type: application/json' \
-H 'api-key: XXXXXX' \
-d '{"input": "いちごは赤くてとても甘い。春の食べ物で、よくケーキに乗せたりします。"}')
$ embedding=$(echo $response | jq '.data[0].embedding')
embeddingという変数の中に、ベクトル化したデータが格納されます。
3. ドキュメント登録
まずはこの3つのドキュメントを登録するためのインデックスを作成します。インデックスの作成方法についての詳細は、こちらの公式ドキュメントを参照下さい。今回はベクトル検索の有用性を説明する記事ですので、Azure AI Searchの説明はまた別の機会に譲りたいと思います。
$ curl -X PUT "https://hogehoge.search.windows.net/indexes/fruits-vector?api-version=2023-11-01" \
-H "Content-Type: application/json" \
-H "api-key: XXXXXXXXX" \
-d '{
"name": "fruits-vector",
"fields": [
{"name": "FruitsId", "type": "Edm.String", "key": true, "filterable": true},
{"name": "FruitsName", "type": "Edm.String", "searchable": true, "filterable": false, "sortable": true, "facetable": false},
{"name": "Description", "type": "Edm.String", "searchable": true, "filterable": false, "sortable": false, "facetable": false, "analyzer": "ja.lucene"},
{"name": "DescriptionVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "sortable": false, "dimensions": 1536,"vectorSearchProfile": "my-vector-profile"}
],
"vectorSearch": {
"algorithms": [
{
"name": "my-hnsw-vector-config-1",
"kind": "hnsw",
"hnswParameters":
{
"m": 4,
"efConstruction": 400,
"efSearch": 500,
"metric": "cosine"
}
}
],
"profiles": [
{
"name": "my-vector-profile",
"algorithm": "my-hnsw-vector-config-1"
}
]
}
}'
上記のコマンドでは以下の3つのフィールドを作成しています。
FruitsId | ドキュメントのIDです。key: trueとすることで、このフィールドがインデックス内でユニークなキーとして機能するようになります。 |
FruitsName |
いちご、レモン等のフルーツの名前です。 |
Description | フルーツの説明です。ベクトル検索したときとの結果を比較するために、テキストでも格納しています。analyzer: ja:luceneとすることで、日本語のテキスト解析に適したLuceneアナライザーを使用するようになります。 |
DescriptionVector |
「Description」に格納したテキストのベクトル化したデータを格納するフィールドです。 |
ドキュメントを登録するために以下のAPIを発行します。embeddingの中には先程ベクトル化したデータが格納されています。
curl -X POST "https://hogehoge.search.windows.net/indexes/fruits-vector/docs/index?api-version=2023-11-01" \
-H "Content-Type: application/json" \
-H "api-key: XXXXXX" \
-d '{
"value": [
{
"@search.action": "upload",
"FruitsId": "4",
"FruitsName": "いちご",
"Description": "いちごは赤くてとても甘い。春の食べ物で、よくケーキに乗せたりします。",
"DescriptionVector": '$embedding'
}
]
}'
この作業を他の2つのドキュメントに対しても同様に行います。
4. キーワード検索
では、これらの登録されたドキュメントに対して、キーワード検索を行っています。そのクエリは以下とします。
エネルギーを補充するのに適したフルーツ
バナナが検索されることを想定していますが、とりあえず、以下のコマンドを実行して、キーワード検索してみます。searchFileldsによって、検索対象のフィールドをDescription(テキストデータが格納されているフィールド)に絞っています。
$ curl -X POST "https://hogehoge.search.windows.net/indexes/fruits-vector/docs/search?api-version=2023-11-01" \
-H "Content-Type: application/json" \
-H "api-key: XXXXXX" \
-d '{
"search": "エネルギーを補充するのに適したフルーツ",
"select": "FruitsId, Description",
"searchFields":"Description"
}'
{"@odata.context":"https://srch-vectorsearch.search.windows.net/indexes('fruits-vector')/$metadata#docs(*)","value":[]}
結果としては、0件でした。。。
「エネルギーを補充するのに適したフルーツ」を形態素解析してみます。
$ curl -X POST "https://hogehoge.search.windows.net/indexes/hotels-quickstart/analyze?api-version=2020-06-30" \
-H "Content-Type: application/json" \
-H "api-key: XXXXXX" \
-d '{
"analyzer":"ja.lucene",
"text": "エネルギーを補充するのに適したフルーツ"
}'
{
"@odata.context": "https://srch-vectorsearch.search.windows.net/$metadata#Microsoft.Azure.Search.V2020_06_30.AnalyzeResult",
"tokens": [
{
"token": "エネルギ",
"startOffset": 0,
"endOffset": 5,
"position": 0
},
{
"token": "補充",
"startOffset": 6,
"endOffset": 8,
"position": 2
},
{
"token": "適す",
"startOffset": 12,
"endOffset": 14,
"position": 6
},
{
"token": "フルーツ",
"startOffset": 15,
"endOffset": 19,
"position": 8
}
]
}
検索クエリ「エネルギーを補充するのに適したフルーツ」を形態素解析したところ、「エネルギ」「補充」「適す」「フルーツ」になりました。当然、登録したドキュメントの中に、これらを含む言葉はないため、キーワード検索では引っかかるドキュメントは0件になります。
5. ベクトル検索
では、今度は同じ検索ワード「エネルギーを補充するのに適したフルーツ」でベクトル検索してみます。まずは、検索ワードをベクトル化します。
$ response=$(curl -s -X POST "https://hogehoge.openai.azure.com/openai/deployments/text-embedding-ada-002-depoly/embeddings?api-version=2023-05-15" \
-H 'Content-Type: application/json' \
-H 'api-key: XXXXXX' \
-d '{"input": "エネルギーを補充するのに適したフルーツ"}')
$ embedding=$(echo $response | jq '.data[0].embedding')
ベクトル化したクエリで検索します。最もスコアが高い1件を取得するようにパラメーターを調整しています。
$ curl -X POST "https://hogehoge.search.windows.net/indexes/fruits-vector/docs/search?api-version=2023-11-01" \
-H "Content-Type: application/json" \
-H "api-key: XXXXXX" \
-d '{
"count": true,
"select": "FruitsId, Description",
"vectorQueries": [
{
"fields": "DescriptionVector",
"kind": "vector",
"vector": '$embedding',
"exhaustive": true,
"k": 1
}
]
}'
{
"@odata.context": "https://srch-vectorsearch.search.windows.net/indexes('fruits-vector')/$metadata#docs(*)",
"@odata.count": 1,
"value": [
{
"@search.score": 0.85401076,
"FruitsId": "2",
"Description": "バナナは黄色い色で、甘いです。とても栄養を早く吸収できるので、疲労回復には持ってこいです。お通じもよくなります。"
}
]
}
6. 比較
いかがでしょうか?キーワード検索では結果は0件でしたが、ベクトル検索では想定される回答「バナナは黄色い色で、甘いです。とても栄養を早く吸収できるので、疲労回復には持ってこいです。お通じもよくなります。」が返ってきました。
これは、ベクトル検索では「エネルギーを補充するのに適したフルーツ」という文脈の意味を理解し、それに最も近いドキュメントを取得できていることがわかります。
まとめ
本記事が、ベクトル検索の有用性を理解する一助になれば幸いです。