ベクトルデータベースWeaviateのモジュールを試してみる

前回の記事で、ベクトルデータベースのWeaviateを紹介しました。今回は、Weaviateのモジュールを実際にさわって試してみたいと思います。

Weaviateのセットアップ

Docker Composeを使用して、ローカル環境でWeaviateを立ち上げます。

すばらしいことに、公式ドキュメントのDocker Composeのページでは、ウィザードに従い設定を選択するだけで、Docker Composeの設定ファイルを生成できます。

初期の設定として、Vectorizer & Retrieverモジュールに text2vec-openai を選択しました。Reader & Generatorモジュールは後で追加します。

以下が生成されたdocker-compose.ymlです。

---
version: '3.4'
services:
  weaviate:
    command:
    - --host
    - 0.0.0.0
    - --port
    - '8080'
    - --scheme
    - http
    image: semitechnologies/weaviate:1.19.1
    ports:
    - 8080:8080
    restart: on-failure:0
    environment:
      OPENAI_APIKEY: $OPENAI_APIKEY
      QUERY_DEFAULTS_LIMIT: 25
      AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
      PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
      DEFAULT_VECTORIZER_MODULE: 'text2vec-openai'
      ENABLE_MODULES: 'text2vec-openai'
      CLUSTER_HOSTNAME: 'node1'
...

このままではコンテナをdownしたときにデータが消えてしまうので、データの永続化のためにvolumeも設定します。

services:
  weaviate:
    volumes:
      - /var/weaviate:/var/lib/weaviate

また、今回はOpenAIのAPIを使うため、APIキーを発行して、環境変数OPENAI_APIKEYに設定しておきます。

Vectorizer & Retrieverモジュール

Vectorizerはオブジェクトのベクトル化を行うモジュールです。今回はOpenAIのEmbeddings APIを使うtext2vec-openai モジュールを使います。

Quickstart tutorials のページを読みながら、Jupyter Notebookでデータを登録するためのコードを書いていきます。今回サンプルに使うデータは、Zennの使い方ページの記事データです。

必要なパッケージをインストールします。

%pip install -U weaviate-client python-dotenv

環境変数を読み込みます。

%load_ext dotenv
%dotenv -o

Weaviate Clientを初期化します。

import json
import os
import weaviate

client = weaviate.Client(
    url = "http://" + os.environ.get("WEAVIATE_HOST"),
    additional_headers = {
        "X-OpenAI-Api-Key": os.environ.get("OPENAI_APIKEY")
    }
)

Classを作成してSchemaを定義します。

class_obj = {
    "class": "ZennArticle",
    "vectorizer": "text2vec-openai",
    "properties": [
        {
            "dataType": ["text"],
            "name": "title",
            "description": "title of article",
            "moduleConfig": {
                "text2vec-openai": {
                    "vectorizePropertyName": False,
                    "skip": False
                }
            }
        },
        {
            "dataType": ["text"],
            "name": "url",
            "description": "url of article",
            "moduleConfig": {
                "text2vec-openai": {
                    "vectorizePropertyName": False,
                    "skip": True,
                }
            }
        },
        {
            "dataType": ["text"],
            "name": "sentence",
            "description": "sentence of article",
            "moduleConfig": {
                "text2vec-openai": {
                    "vectorizePropertyName": False,
                    "skip": False,
                }
            }
        }
    ]
}

# 初回のみcreate_classを実行します
client.schema.create_class(class_obj)

ざっくり意味としては以下のようになります。

  • このClassのvectorizerにはtext2vec-openaiを使う
  • データのプロパティのうち、titleとsentence(本文の断片)はベクトル化する。urlはベクトル化しない。

データを登録します(データを加工する部分のコードは本質とは関係がないので割愛します)

with client.batch as batch:
    batch.batch_size=100

    for i, data in enumerate(article_json_data):
        # HTMLのテキストを200文字毎に分割する
        sentences = split_sentence(effective_doc(data["body_html"]), 200)

        for sentence in sentences:
            properties = {
                "title": data["title"],
                "url": "https://zenn.dev/zenn/articles/" + data["slug"],
                "sentence": sentence,
            }

            client.batch.add_data_object(properties, "ZennArticle")

これでデータの登録は完了です。

ポイントは、client.batch.add_data_objectに、ベクトルデータではなくdictオブジェクトをそのまま登録していることろです。内部的にはvectorizerのtext2vec-openaiモジュールが、OpenAPIのEmbeddings APIを実行してベクトルに変換しています。

Weaviate Consoleに接続して、Queryを実行してみます。

{
  Get {
    ZennArticle(
      nearText: {
        concepts: ["Publicationを削除すると記事はどうなりますか?"]
      }
      limit: 3
    ) {
      title
      url
      sentence
    }
  }
}

結果

{
  "data": {
    "Get": {
      "ZennArticle": [
        {
          "sentence": "Publicationの設定はすべて削除されます。Publicationに投稿された記事の紐付けは解除されます。(Publicationの設定で有料バッチの受付をOFFにしている場合、記事の紐付けが解除されるとユーザーの設定に切り替わります。)。Publicationのフォローは解除されます。Publicationの削除が実行された後、削除された情報を復元することはできません。必要事項。",
          "title": "Publicationの使い方",
          "url": "https://zenn.dev/zenn/articles/how-to-use-publication"
        },
        {
          "sentence": "オーナーは、Publicationの記事の管理ページから、記事の紐付けを解除することができます。 Publicationの削除。Publicationの削除は お問合せフォーム の「その他のお問い合わせ」より申請してください。以下の注意事項を確認いただいた上、必要事項をお問合せフォームより送信してください。運営が内容を確認し、通常は2~3営業日程度で対応します。!。注意事項。",
          "title": "Publicationの使い方",
          "url": "https://zenn.dev/zenn/articles/how-to-use-publication"
        },
        {
          "sentence": "Publicationを脱退した後も、Publicationに投稿した記事の紐付けはそのまま残ります。Publicationから記事の紐付けを解除する場合は、自身の記事の管理ページより、Publicationへの紐付けを解除します。(GitHubデプロイを利用している場合は、frontmatterのpublication_nameの項目を削除します。)。 Publicationの記事を管理する。",
          "title": "Publicationの使い方",
          "url": "https://zenn.dev/zenn/articles/how-to-use-publication"
        }
      ]
    }
  }
}

QueryのnearTextで指定した文章に関連するデータオブジェクトが取得できていることが分かります。

Queryパラメーターについて

少しだけQueryのパラメーターを紹介します。

デフォルトでは以下の検索パラメーターが提供されています。

  • nearVector: ベクトルを入力値として、類似度の高いデータを検索します。
  • nearObject: オブジェクトのUUIDで指定されたオブジェクトと類似度の高いデータを検索します。
  • hybrid: Dense VectorとSparse Vectorの両方を使って検索します。
  • bm25: Sparse Vectorを使ってキーワード検索をします。
  • group: 意味的に類似したデータをグループ化します。

(Sparse Vectorはまだ対応してないって書いてあったけど、動きますね。要確認)

Vectorizerモジュールを追加することで以下の検索パラメーターが追加されます。

  • nearText: テキスト(一文または複数文)を入力値として、類似度の高いデータを検索します。(上記の例でも使用しました)

また、次に紹介するReaderモジュールを追加することで、以下の検索パラメーターが追加されます。

  • ask: 質問を入力値として、類似度の高いデータを検索し、回答の文章を生成します。

詳しくは、GraphQL - Vector search parametersを参照してください。

また、ベクトル以外にもオブジェクトのプロパティで絞り込みを行うことができます。Pineconeと違ってベクトルの指定なしでも使えるので、純粋に登録されているデータを検索することもできます。

詳しくは、GraphQL - Filtersを参照してください。

Readerモジュール

次に、Readerモジュールとして Question Answering - OpenAIを使ってみます。

docker-compose.ymlを編集して、qna-openaiモジュールを追加します。

-      ENABLE_MODULES: 'text2vec-openai'
+      ENABLE_MODULES: 'text2vec-openai,qna-openai'

次に、Jupyter Notebookで、ClassのSchemaにqna-openaiを使えるようにするための設定をします。modelがtext-davinci-002なので、内部ではCompletion APIを使うようですね。

+     "moduleConfig": {
+         "qna-openai": {
+           "model": "text-davinci-002",
+           "maxTokens": 1000,
+           "temperature": 0.0,
+           "topP": 1,
+           "frequencyPenalty": 0.0,
+           "presencePenalty": 0.0
+         }
+     },
client.schema.update_config("ZennArticle", class_obj)

update_configを実行してみたのですが、module configは変更できないようです。

UnexpectedStatusCodeException: Update class schema configuration! Unexpected status code: 422, with response body: {'error': [{'message': 'module config is immutable'}]}.

あらためて、新しいClassを作成します。(データも登録し直します。)

client.schema.delete_class(class_obj["class"])
client.schema.create_class(class_obj)

Question Answeringでは、askというプロパティに質問を設定してQueryを実行します。

{
  Get {
    ZennArticle(
      ask: {
        question: "Publicationにメンバーを追加するには?",
        properties: ["sentence"]
      }, 
      limit:1
    ) {
      title
      url
      sentence
      _additional {
        answer {
          hasAnswer
          property
          result
          startPosition
          endPosition
        }
      }
    }
  }
}

結果

{
  "data": {
    "Get": {
      "ZennArticle": [
        {
          "_additional": {
            "answer": {
              "endPosition": 0,
              "hasAnswer": true,
              "property": "",
              "result": " Publicationのメンバー管理ページから、ZennのユーザーをPublicationのメンバーに招待することができます。",
              "startPosition": 0
            }
          },
          "sentence": "✅。✅。Publicationの他のメンバーの記事を紐付け解除。✅。。 メンバーを追加する。オーナーは、Publicationのメンバー管理ページから、ZennのユーザーをPublicationのメンバーに招待することができます。招待を受け取ったユーザーは、招待メールに記載されたリンクから、招待を承認することができます。 メンバーを除外する。",
          "title": "Publicationの使い方",
          "url": "https://zenn.dev/zenn/articles/how-to-use-publication"
        }
      ]
    }
  }
}

Publicationにメンバーを追加するには?という質問に対して、Publicationのメンバー管理ページから、ZennのユーザーをPublicationのメンバーに招待することができます。という回答が得られました。

データオブジェクトのsentenceプロパティの値から、質問に対する答えを生成していることが分かります。limitの数を増やしても、1つのデータオブジェクトに対して1つの回答を生成するようです。

Generatorモジュール

最後に、GeneratorモジュールとしてGenerative Search - OpenAIを使ってみます。

docker-compose.ymlを編集して、generative-openaiモジュールを追加します。

-      ENABLE_MODULES: 'text2vec-openai'
+      ENABLE_MODULES: 'text2vec-openai,generative-openai'

次に、Jupyter Notebookで、ClassのSchemaにgenerative-openaiを使えるようにするための設定をします。設定項目は、modelが異なること以外はRenderモジュールと同じですね。こちらはChat APIを使うようです。

+     "moduleConfig": {
+         "generative-openai": {
+           "model": "gpt-3.5-turbo",
+           "temperatureProperty": 0.0,
+           "maxTokensProperty": 1000,
+           "frequencyPenaltyProperty": 0.0,
+           "presencePenaltyProperty": 0.0,
+           "topPProperty": 1,
+         }
+     },

クエリを実行してみます。Generatorモジュールの場合は、_additional.generateというプロパティに生成したい文章の指示を与えるようです。{sentence}のようにデータオブジェクトの値を埋め込むことができます。

{
  Get {
    ZennArticle(
      nearText: {
        concepts: ["Publicationを削除すると記事はどうなりますか?"]
      }
      limit: 1
    ) {
      sentence
      _additional {
        generate(
          singleResult: {
            prompt: """
            以下の文章を大阪弁にしてください:

            {sentence}
            """
          }
        ) {
          singleResult
          error
        }
      }
    }
  }
}

結果

{
  "data": {
    "Get": {
      "ZennArticle": [
        {
          "_additional": {
            "generate": {
              "error": null,
              "singleResult": "\"パブリケーションの設定は全部消えてまうわ。パブリケーションに投稿した記事の紐付けも解除されてまうわ。(パブリケーションの設定で有料バッチの受付をOFFにしてる場合、記事の紐付けが解除されたらユーザーの設定に変わっちゃうで)。パブリケーションのフォローも解除されてまうわ。パブリケーションを削除したら、もう復元できへんで。必要なことやで。\""
            }
          },
          "sentence": "Publicationの設定はすべて削除されます。Publicationに投稿された記事の紐付けは解除されます。(Publicationの設定で有料バッチの受付をOFFにしている場合、記事の紐付けが解除されるとユーザーの設定に切り替わります。)。Publicationのフォローは解除されます。Publicationの削除が実行された後、削除された情報を復元することはできません。必要事項。"
        }
      ]
    }
  }
}

nearTextで検索したデータオブジェクトに対して、プロンプトで結果を加工することができました。

また、groupedResultを使うと、複数のデータオブジェクトの内容から文章を生成できます。

{
  Get {
    ZennArticle(
      nearText: {
        concepts: ["markdown"]
      }
      limit: 10
    ) {
      sentence
      _additional {
        generate(
          groupedResult: {
            task: "markdownに使える埋め込みを5つ教えて下さい"
          }
        ) {
          groupedResult
          error
        }
      }
    }
  }
}

結果

{
  "data": {
    "Get": {
      "ZennArticle": [
        {
          "_additional": {
            "generate": {
              "error": null,
              "groupedResult": "1. 外部コンテンツ埋め込み用のモーダル\n2. コードブロック\n3. ダイアグラム\n4. リンクカード\n5. 数式のブロック挿入"
            }
          },
          "sentence": " オンラインエディターではモーダルから挿入可能。オンラインのエディターでは「+」ボタンを押すことで、外部コンテンツ埋め込み用のモーダルを表示できます。 その他の埋め込み可能なコンテンツ。オンラインエディターの埋め込みの選択肢としては表示されませんが、以下の埋め込み記法もサポートしています。 blueprintUE。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "Head。。Text。Text。Text。Text。Text。Text。。 コードブロック。コードは「```」で挟むことでブロックとして挿入できます。以下のように言語を指定するとコードへ装飾(シンタックスハイライト)が適用されます。```js。```。シンタックスハイライトには Prism.js を使用しています。? 対応言語の一覧 →。 ファイル名を表示する。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "blueprintUE を埋め込むには、公開されているページのURLをそのまま括弧の中に入力します。 ダイアグラム。2021/06/08〜、mermaid.js によるダイアグラム表示に対応しました。コードブロックの言語名をmermaidとすることで自動的にレンダリングされます。は以下のように表示されます。他にもシーケンス図やクラス図が表示できます。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "2022/04〜より、GitHub上のソースコードファイルを埋め込めるようになりました。GitHub上のファイルへのURLまたはパーマリンクだけの行を作成すると、その部分にGitHubの埋め込みが表示されます。上記のリンクは、以下のように表示されます。https://github.com/octocat/Hello-World/blob/master/README。 行の指定。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "文法は mermaid.js に従っていますので、どのように書けばよいかは公式サイトの文法を参照してください。!。mermaid.js側で破壊的変更が行われた場合、表示が変更されたり、適切に表示されなくなる可能性があります。 制限事項。Zenn で mermaid.js 対応を行うにあたり、いくつか制限事項を設定させていただいています。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": " メッセージ。!。メッセージをここに。!。警告メッセージをここに。 アコーディオン(トグル)。タイトル。表示したい内容。!。「detail」ではなく「details」です。 要素をネストさせるには。外側の要素の開始/終了に : を追加します。タイトル。!。ネストされた要素。 コンテンツの埋め込み。 リンクカード。URL だけが貼り付けられた行があると、その部分がカードとして表示されます。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "? Zenn のmarkdown記法 →。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "Ctrl + P(プレビュー):markdownがどのように表示されるかをチェックできます。もう一度ショートカットを実行すると、エディターに戻ります。Ctrl + S(内容の保存):変更内容を保存します。Ctrl + I(埋め込み):ツイートや YouTube、CodePen、SpeakerDeck などの埋め込みコンテンツを挿入するためのモーダルが表示されます。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "便利ですが、数が多くなるとノードの接続が多くなり、ブラウザ側での描画に負荷が生じる可能性があるため、&の数を10に制限させていただきます。こちらも超えた場合はダイアグラムの代わりにエラーメッセージが表示されます。脚注。脚注の内容その 1 ↩︎。脚注の内容その 2 ↩︎。"
        },
        {
          "_additional": {
            "generate": null
          },
          "sentence": "$$で記述を挟むことで、数式のブロックが挿入されます。たとえば。は以下のように表示されます。!。$$の前後は空の行でないと正しく埋め込まれないことがあります。 インラインで数式を挿入する。$a\\ne0$というように$ひとつで挟むことで、インラインで数式を含めることができます。たとえばのようなイメージです。 引用。引用文。引用文。 注釈。注釈を指定するとページ下部にその内容が表示されます。"
        }
      ]
    }
  }
}

「markdown」に関連があるデータオブジェクトを検索し、10件の検索結果からmarkdownに使える埋め込みを5つ教えて下さいという指示を与えて、1. 外部コンテンツ埋め込み用のモーダル\n2. コードブロック\n3. ダイアグラム\n4. リンクカード\n5. 数式のブロック挿入という結果が得られました。ここまでやってくれるのはなかなか便利ですね。

おわりに

Vectorizer & Retriever、Reader、Generatorモジュールをそれぞれ試しました。Vectorizerはデータやクエリのベクトル化を肩代わりしてくれるので便利ですね。ReaderやGeneratorもアプリケーションの要件次第では使えるのではないでしょうか。参考になれば幸いです。