PythonでベクトルインデックスとOpenAIを使って質問応答システムを構築する方法

Momento Vector IndexとOpenAI APIを使えば、Q&Aエンジンを簡単に構築できます

注:このチュートリアルでは、PythonとTypeScript(Node.js)の両方のコード例を紹介します。これはPythonのチュートリアルです。TypeScript版はこちら!

このステップバイステップのガイドでは、ニンジンという特定のトピックに焦点を当て、ゼロから質問応答システムを構築していきます。私たちの探求の中心は、質問応答を検索プロセスとして扱うというコンセプトです。このアプローチでは、ユーザーのクエリに対する答えを含むソースドキュメントまたはその中の特定のセクションを特定します。外部ライブラリによってもたらされる複雑さを排除して基本的なプロセスを明らかにすることで、このようなシステムの基本的な仕組みに関する貴重な洞察を提供することを目的としています。

どのようにこれを成し遂げるか、簡単に説明しよう:
・OpenAIとMomentoのクライアントを初期化する。
・Wikipediaからニンジンデータを取得し、処理する(チャンクを作成する)。
・OpenAIを使って埋め込みデータを生成する。
・埋め込みデータをMomento Vector Indexに格納する。
・保存されたデータを使って検索し、クエリに応答する。
・OpenAIのチャットの完了を利用して、洗練されたレスポンスを返す。

また、このブログのためにGoogle Colabを設置しており、ブログを読みながらクエリーを実行することができる!

環境設定

コーディングを始める前に、データを保存するためのインデックスをMomentoに作成し、プログラムでMomentoにアクセスするためのAPIキーを生成する必要があります。どちらも Momento Console で行うことができます。詳細はこちらのガイドを参照してください!以下のコードでは、インデックス名に mvi-openai-demo を使用し、次元数には 1536 を使用します (詳細は後ほど説明します!)。コサイン類似度は、ベクトルの大きさ(この場合は単語数)よりもベクトルの向きを重視します。

データと検索クエリの埋め込みを生成するために、OpenAIのAPIキーも必要です。

次に、必要なパッケージをインストールします。Pythonでは、openai、requests、momentoが必要です。

pip install momento openai

ステップ1:クライアントの初期化

まず、OpenAIとMomentoのクライアントを初期化することから始めます。ここでは、必要なパッケージとAPIキーを使って開発環境をセットアップします。このステップは、OpenAIとMomentoのサービスとの通信を確立するために重要です。私たちのQ&Aエンジンの基礎を作るのです。

コードを実行する前に、環境変数 OPENAI_API_KEYMOMENTO_API_KEY が設定されていることを確認してください!

import openai
import requests
from momento import CredentialProvider, PreviewVectorIndexClient, VectorIndexConfigurations
from momento.config import VectorIndexConfiguration
from momento.requests.vector_index import Item
from momento.responses.vector_index import Search, UpsertItemBatch
import os

# Setting up the API keys and clients
openai.api_key = os.environ['OPENAI_API_KEY']
VECTOR_INDEX_CONFIGURATION: VectorIndexConfiguration = VectorIndexConfigurations.Default.latest()
VECTOR_AUTH_PROVIDER = CredentialProvider.from_environment_variable('MOMENTO_API_KEY')
mvi_client = PreviewVectorIndexClient(VECTOR_INDEX_CONFIGURATION, VECTOR_AUTH_PROVIDER)
index_name = 'mvi-openai-demo'

ステップ2:ウィキペディアからデータを読み込む

まず、Wikipediaからニンジンに関するデータを抽出することから始める。このステップでは、外部のAPIコールを処理し、JSONレスポンスを解析する方法を示します。どのWikipediaページでもローカルで試してみてください!

def get_wikipedia_extract(url: str) -> str:
    response = requests.get(url)
    data = response.json()
    pages = data['query']['pages']
    extract = next(iter(pages.values()))['extract']
    return extract

url = "https://en.wikipedia.org/w/api.php?action=query&format=json&titles=Carrot&prop=extracts&explaintext";

では、これらのスニペットを実行して、Carrot wikipediaページの長さをサンプル・テキストで見てみよう。

extract_text = get_wikipedia_extract(url)
print('Total characters in carrot Wikipedia page: ' + str(len(extract_text)))
print('Sample text in carrot Wikipedia page:\n\n ' + extract_text[0:500])

Output:

Total characters in carrot Wikipedia page: 21534

The carrot (Daucus carota subsp. sativus) is a root vegetable, typically orange in color, though heirloom variants including purple, black, red, white, and yellow cultivars exist, all of which are domesticated forms of the wild carrot, Daucus carota, native to Europe and Southwestern Asia. The plant probably originated in Persia and was originally cultivated for its leaves and seeds. The most commonly eaten part of the plant is the taproot, although the stems and leaves are also eaten.

どのウィキペディアのページでもローカルで試してみてください!

ステップ3:チャンクを作成するためのデータの前処理

私たちのQ&Aエンジンを構築するにあたり、私たちは質問応答を一種の検索としてアプローチしています。つまり、どのソースドキュメント(またはその一部)にユーザーのクエリに対する答えが含まれているかを特定します。この概念は私たちのプロセスの基本であり、データの扱い方に影響を与えます。

我々のシステムを効果的にするために、データをチャンクに前処理します。というのも、質問に対する回答の場合、回答はテキスト全体ではなく、ドキュメントの特定のセクションに存在することが多いからです。データを管理しやすいチャンクに分割することで、システムが関連する回答を見つけるためにスキャンできる、検索可能な小さな単位を効果的に作成することができます。このチャンキングプロセスは、膨大なテキストをセマンティック検索と検索に適した形式に変換するための重要なステップです。

私たちは、文字数でテキストを分割する単純なアプローチを選びました。しかし、チャンキングのサイズと方法がシステムの効果に大きな影響を与えることを理解することが重要です。大きすぎるチャンクは検索結果の関連性を薄め、小さすぎるチャンクは重要な文脈を見逃す可能性があります。

別のチャンキング方法では、tiktokenのようなトークナイザーを使って、テキスト埋め込みモデルに沿った境界に沿ってテキストを分割することもできる。これらの方法はより良い結果を生むかもしれませんが、外部ライブラリが必要です。デモでは、よりシンプルな方法を選びます。

def split_text_into_chunks(text: str, chunk_size: int = 600) -> list[str]:
    return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]

chunks = split_text_into_chunks(extract_text)

これで、作成されたチャンクの総数を見ることができます。

print('Total number of chunks created: ' + str(len(chunks)))
print('Total characters in each chunk: ' + str(len(chunks[0])))

Output:

Total number of chunks created: 36
Total characters in each chunk: 600

ステップ4:OpenAIでエンベッディングを生成する

私たちのQ&Aエンジン構築のアプローチでは、セマンティック検索における最先端の技術であるベクトル検索の力を活用することにしました。この手法は、ElasticsearchやLuceneで使われているような従来のキーワード検索アプローチとは大きく異なります。ベクトル検索は、キーワード検索では不可能な方法で概念や意味を捉え、言語の複雑さをより深く掘り下げます。

ベクトル検索を容易にするために、私たちの最初のタスクは、テキストデータをこの豊かな意味理解を具現化するフォーマットに変換することです。これは、OpenAIのtext-embedding-ada-002モデルを使用して埋め込みを生成することで実現します。このモデルは精度、コスト、スピードのバランスを取ることで知られており、テキスト埋め込みを生成するのに理想的な選択です。

def generate_embeddings(chunks: list[str]):
    response = openai.embeddings.create(input=chunks, model="text-embedding-ada-002")
    return response.data

ベクトルインデックスの次元数として1536を選択したことを思い出してください。この決定は、OpenAIが各チャンクの埋め込みを生成する際に、これらの埋め込みを1536の長さの浮動小数点ベクトルとして生成するという事実に基づいています。

embeddings_response = generate_embeddings(chunks)
print('Length of each embedding: ' + str(len(embeddings_response[0].embedding)))
print('Sample embedding: ' + str(embeddings_response[0].embedding[0:10]))

Output:

Length of each embedding: 1536

Sample embedding: 0.008307404,-0.03437371,0.00043777542,-0.01768263,-0.010926112,-0.0056728064,-0.0025742147,-0.023453956,-0.021114917,-0.020148791

ステップ5:モーメント・ベクトル・インデックスへのデータの格納

エンベッディングを生成したら、それをMomentoのVector Indexに格納します。これには、ID、ベクトル、メタデータを持つアイテムを作成し、MVIにアップサートします。Momento Vector Indexにデータを格納する際、決定論的なチャンクIDを使用することが重要です。これにより、同じデータが繰り返しインデックス付けされることがなくなり、ストレージ、検索効率、応答精度が最適化されます。データストレージを効率的に管理することは、スケーラブルで応答性の高いQ&Aシステムを維持するための鍵となります。

def upsert_to_mvi(embeddings: list, chunks: list[str]):

    metadatas = [{"text": chunk} for chunk in chunks]

    ids = [f"chunk{i + 1}" for i, _ in enumerate(embeddings)]

    items = [Item(id=id, vector=embedding.embedding, metadata=metadata) for id, embedding, metadata in zip(ids, embeddings, metadatas)]

    response = mvi_client.upsert_item_batch(index_name, items)

    if (isinstance(response, UpsertItemBatch.Success)):
        print("\n\nUpsert successful. Items have been stored.")
    elif isinstance(response, UpsertItemBatch.Error):
        print(response.message)
        raise response.inner_exception

upsert_to_mvi(embeddings_response, chunks)

Output:

Upsert successful. Items have been stored.

ステップ6:検索と問い合わせへの対応

このステップでは、Q&Aエンジンの中核機能である、Momento Vector Indexを使用した回答の検索をハイライトします。このプロセスでは、最も関連性が高く、文脈上適切な結果を確実に見つける技術である、テキスト埋め込みを使用してインデックス化されたデータを検索します。

前のステップでテキストのスニペットをインデックス化する際、まずOpenAIのモデルを使用して、これらのテキストスニペットをベクトル表現に変換しました。この変換は、データをMomento Vector Indexに効率的に格納し、検索するための準備の鍵となりました。

さて、クエリのタスクに目を向けると、同様の前処理ステップを適用することが極めて重要です。この例では、「ニンジンとは何ですか」というユーザーの質問もベクトルに変換する必要があります。これにより、インデックス内でベクトルからベクトルへの検索を行うことができます。

検索の有効性は、前処理の一貫性にかかっている。インデックス作成時に使用された埋め込みモデルとプロセスは、クエリにも適用されなければなりません。これにより、クエリのベクトル表現がインデックスに格納されているベクトルと一致することが保証されます。

def search_query(query_text: str) -> list[str]:
    query_vector = openai.embeddings.create(input=query_text, model="text-embedding-ada-002").data[0].embedding
    search_response = mvi_client.search(index_name, query_vector=query_vector, top_k=2, metadata_fields=["text"])
    if isinstance(search_response, Search.Success):
        return [hit.metadata['text'] for hit in search_response.hits]
    elif isinstance(search_response, Search.Error):
        print(f"Error while searching on index {index_name}: {search_response.message}")
        return []

まずは「ニンジンとは何か」を簡単に検索してみましょう:

query = "What is a carrot?"
texts = search_query(query, index_name)
if texts:
    print("\n=========================================\n”)
    print("Embedding search results:\n\n" + "\n".join(texts))
    print("\n=========================================\n")

このクエリの出力は次のようになります:

The carrot (Daucus carota subsp. sativus) is a root vegetable, typically orange in color, though heirloom variants including purple, black, red, white, and yellow cultivars exist, all of which are domesticated forms of the wild carrot, Daucus carota, native to Europe and Southwestern Asia. The plant probably originated in Persia and was originally cultivated for its leaves and seeds. The most commonly eaten part of the plant is the taproot, although the stems and leaves are also eaten. The domestic carrot has been selectively bred for its enlarged, more palatable, less woody-textured taproot.

The carrot is a biennial plant in the umbellifer family, Apiaceae. At birth, it grows a rosette of leaves while building up the enlarged taproot. Fast-growing cultivars mature within about three months (90 days) of sowing the seed, while slower-maturing cultivars need a month longer (120 days). The roots contain high quantities of alpha- and beta-carotene, lycopene, anthocyanins, lutein, and are a good source of vitamin A, vitamin K, and vitamin B6. Black carrots are one of the richest sources of anthocyanins (250-300 mg/100 g fresh root weight), and hence possesses high antioxidant ability

ご覧のように、Momento Vector Indexでベクトルをインデックス化し、元のテキストをメタデータとしてアイテムに格納しました。「ニンジンとは何ですか」という質問に対して、テキストをベクトルに変換し、MVIでベクトル検索を行い、メタデータに格納されている元のテキストを返しました。裏側ではベクトル同士のマッチングを行っているが、ユーザーから見るとテキスト同士の検索に見える。

ステップ 7: 冗長すぎますか?チャットコンプリートを使用して、クエリのレスポンスを向上させましょう

これまで、私たちのアプローチは質問応答を主に検索タスクとして扱ってきました。ユーザーのクエリを受け取り、検索を実行し、答えを含む可能性のある情報のスニペットを提示してきました。この方法は、関連するデータを取得するには効果的ですが、結果をふるいにかけ、答えを抽出する責任はユーザーにあります。探している情報を正確に特定することなく、参考書のページを提供するようなものです。

ユーザー体験を単なる検索から直接的な回答生成へと高めるために、OpenAIのGPT-3.5のような大規模言語モデル(LLM)を導入します。LLMは、単に情報を検索するだけでなく、情報を合成し、簡潔で文脈に関連した答えを提供する能力を持っています。これは、検索結果のページを提供することから、ユーザーのクエリに対する明確で簡潔な回答を提供することへの大きな飛躍です。

def search_with_chat_completion(texts: list[str], query_text: str):
    text = "\n".join(texts)
    prompt = ("Given the following extracted parts about carrot, answer questions pertaining to"
              " carrot only from the provided text. If you don't know the answer, just say that "
              "you don't know. Don't try to make up an answer. Do not answer anything outside of the context given. "
              "Your job is to only answer about carrots, and only from the text below. If you don't know the answer, just "
              "say that you don't know. Here's the text:\n\n----------------\n\n")
    chat_response = openai.chat.completions.create(model="gpt-3.5-turbo", messages=[
        {"role": "system", "content": prompt + text},
        {"role": "user", "content": query_text}
    ])
    return chat_response.choices[0].message

そして、同じクエリ「ニンジンとは何か」を使って反応を比べてみよう。

chat_completion_resp = search_with_chat_completion(texts, query)
print("\n=========================================\n")
print("Chat completion search results:\n\n" + chat_completion_resp.content)
print("\n=========================================\n")

Output:

A carrot is a root vegetable that is typically orange in color, although there are also other colored variants such as purple, black, red, white, and yellow. 

では、より具体的な質問、例えば “ニンジンの早生栽培者の成熟速度は?”のアウトプットを素早く比較してみましょう。

query = "how fast do fast-growing cultivators mature in carrots?"
texts = search_query(query)
if texts:
    print("\n=========================================\n")
    print("Embedding search results:\n\n" + texts[0])
    print("\n=========================================\n")

    chat_completion_resp = search_with_chat_completion(texts, query)
    print("\n=========================================\n")
    print("Chat completion search results:\n\n" + chat_completion_resp.content)
    print("\n=========================================\n")

Output:

未加工のセマンティック検索結果と比較して、チャット完了応答がいかに簡潔で正確であるかに注目してください。

=========================================
Embedding search results:

The carrot is a biennial plant in the umbellifer family, Apiaceae. At birth, it grows a rosette of leaves while building up the enlarged taproot. Fast-growing cultivars mature within about three months (90 days) of sowing the seed, while slower-maturing cultivars need a month longer (120 days). The roots contain high quantities of alpha- and beta-carotene, lycopene, anthocyanins, lutein, and are a good source of vitamin A, vitamin K, and vitamin B6. Black carrots are one of the richest sources of anthocyanins (250-300 mg/100 g fresh root weight), and hence possesses high antioxidant ability.

=========================================


=========================================
Chat completion search results:

Fast-growing cultivars of carrots mature within about three months (90 days) of sowing the seed.

=========================================

結論

このガイドでは、私たちは一から質問応答システムを構築する旅に出ました。私たちのアプローチの背後にある重要なアイデアは、質問応答を検索問題として扱うことでした。テキスト埋め込みとベクトル検索を使用することで、従来のキーワードベースのアプローチを凌駕する、ニュアンスとセマンティックに富んだ最先端の検索を実現しました。ここまでのステップを簡単に振り返ってみましょう:

・クライアントの初期化: OpenAIとMomentoのクライアントをセットアップし、システムの基礎を固める。
・データの取得と処理: ウィキペディアからデータを抽出・加工し、埋め込み生成の準備をした。効率的な検索のためにデータの塊を作ることの意義について学びました。
・エンベッディングの生成: OpenAIのtext-embedding-ada-002モデルを利用して、テキスト埋め込みを生成し、コーパスをセマンティック検索に適した形式に変換した。これらの埋め込み値の長さがベクトルインデックスの次元数をどのように指示するかを学んだ。
・MVIへの格納:埋め込みデータをMomentoのVector Indexに格納し、効率的な検索を実現。インデックスのアイテムIDにUUIDを使用すると、同じデータの再インデックスが繰り返されるという、よくある落とし穴について学びました。
・クエリの検索と応答: 最も関連性の高い回答を見つけるために、セマンティック検索用のベクトルインデックスを活用した検索機能を実装。ベクトル間の検索を実行し、アイテムのメタデータに保存されているテキストを使用してユーザーに表示します。
・チャット補完による回答の強化: 簡潔で正確な回答を生成するために、OpenAIのチャットコンプリートを使用して、洗練されたレイヤーを追加しました。ここでは、大規模言語モデルが回答の精度を向上させるだけでなく、文脈に関連し、一貫性があり、ユーザーフレンドリーな形式で表示されることを確認しました。

最後に、私たちのハンズオンアプローチは、Q&Aエンジンを構築する仕組みを深く掘り下げるものですが、そのような取り組みには複雑さが伴うことを認識しています。Langchainのようなフレームワークは、この複雑さの多くを抽象化し、OpenAIからエンベッディングを連結したり、ベクターストアを変更したりするプロセスを単純化する、より高いレベルの抽象化を提供します。Langchainは、多くの開発者にとって、複雑なAI駆動アプリケーションの構築、変更、保守を容易にする選択ツールです。