プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

OpenAI API で RAG をやってみた

こちらの記事で、 RAG (といってもアップロードしたファイルを参考にして回答するというやつ)を試しましたので、次は OpenAI のツールではなく、独自に RAG を構成してみようかと思います。

環境は前と同じく

  • wsl
  • VSCode
  • Python 3.13.1

です。

なお、以下の関連記事がありますので、こちらもよろしく。

LlamaIndex を使った RAG のサンプル

自分で RAG を作るとして何を題材にしようかな?というところですが、 RAG の存在を知ったときから一度やってみたいなと思っていた FAQ ページにある内容をチャット形式で応答できるようなものを作ってみようと思います。

一口に RAG っていってもいろんなやり方があるようです。あれこれ見てみると、 LlamaIndex というライブラリというかフレームワークというか、これを使うと簡単に出来そうだったので、まずはこれでやってみます。なお、コードの大半は ChatGPT で聞きながら書いたものです。

LlamaIndex 全体の概念としては、

改めてLlamaIndexを試してみる

などが直観的にわかりやすかったです。

ちなみになんで、 Llama (動物のラマ)なんだろうかと思って調べても、これだ!というのは見つからなかったんですが、下記の記事によると

LlamaIndexとは?RAGの構築を実現するライブラリの機能やメリット、構築手順を徹底解説! - AI Market

Meta 社の生成 AI である LLaMa (Learge Language Model Meta AI) に敬意を表して、ということだそうです。

あと、作業前に必要なものをインストールしておきます。

pip install llama-index faiss-cpu openai llama-index-vector-stores-faiss

なお、 OpenAI API のキーは別途環境変数に定義済みとします。

FAQ データを用意

FAQ データのサンプルに何かいいものないかな?と思って、あれこれ考えたのですが、とりあえず伊勢市観光協会さんのよくある質問を使わせてもらうことにしました。

データ構造はこんな感じにしてみました。

_faq_dict_1 = [
    {
        "question": "観光バスの駐車場について教えてください。",
        "answer": "らくらく伊勢もうでHPを参照ください。伊勢市営内宮前駐車場は有料、外宮バス駐車場は無料です。予約はできません。",
        "tags": {"カテゴリ": "faq", "サブカテゴリ": "伊勢神宮"},
        "references": [
            {
                "title": "らくらく伊勢もうで",
                "url": "http://www.rakurakuise.jp/content/bus.php",
            },
        ],
    },
    {
(略)
]

1つ1つの質問とその回答は、辞書型として、 question と answer というキーで持つようにしました。あと、ここのよくある質問のページはカテゴリーに分かれているので、それを tags として持たせています(今のところ特に使ってはないです)。また、質問によっては、参考情報への URL が(複数)あるので、それも refrences としてリストで持つようにしました。

で、この個別の質問に対する辞書を、リストで保持しておきます。

埋め込みとベクトルデータの生成

作成した上記のデータを RAG で使えるようにします。とりあえず動くものでよければ、 FAISS というインメモリのベクトルデータベースを使うのが簡単なようです。

まず、最初に FAQ データを読み込みます。といっても、上記の内容を定義した別ファイル (faq_dict.py) からリストを呼び出すだけなんですけどね。

from llama_index.core.schema import TextNode

from faq_dict import load_faq_dict

# FAQ(よくある質問)の読み込み
faq_dict = load_faq_dict()

# FAQデータをノードに変換
nodes = []
for faq in faq_dict:
    content = f"Q: {faq['question']}\nA: {faq['answer']}"
    metadata = faq.get("tags", {})
    metadata["references"] = faq.get("references", [])
    nodes.append(TextNode(text=content, metadata=metadata))

FAQ データを読み込んだらそれを Llamaindex の TextNode というのに変換してやります。その際、 question と answer はペアにして内容として渡して、 tags と references は辞書にまとめて metadata として渡します。

LlamaIndex のサンプルとかを見ていると、よく、文書を指す Document から VectorStoreIndex を作成するサンプルを見かけますが、 Node というのはその内部で使われているもうちょっと低レベルのオブジェクトのようです。

これのあとに、

dim = 1536
faiss_index = faiss.IndexFlatL2(dim)
vector_store = FaissVectorStore(faiss_index=faiss_index)

Settings.llm = OpenAI(model="gpt-4.1")
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
index = VectorStoreIndex(nodes, vector_store=vector_store)

いわゆるベクトル DB として、 FAISS を利用して、 OpenAI の埋め込みモデル(ここでは、 text-embedding-3-small)を指定することで、 VectorStoreIndex を作成しています(詳細はいまいちわかってないんですが、そのあたりはご愛嬌ということで)。

ここまでできたら、

query_engine = index.as_query_engine(similarity_top_k=2)

と呼ぶことで、内部で OpenAI API をたたいて、質問に対して回答してくれるエンジンを返してくれます。

最後は、これをループでまわすことで、応答を行わせます。

# 問い合わせへの対応
while True:
    query = input("質問を入力してください(終了するには 'exit' と入力): ")
    if query.lower() == "exit":
        break
    # print(f"質問: {query}")
    response = query_engine.query(query)
    # response = query_engine.chat(query)
    print(f"回答: {response}")
    if response.metadata:
        for d in response.source_nodes[0].metadata.get("references", []):
            print(f"参考URL: {d.get("title", "")} - {d.get("url", "")}")
    # print(f"回答のソース: {response.source_nodes}")

さすがフレームワーク、中身知らなくても簡単に使えますね。

テスト

で、動かしてみます。

.venvmor@DESKTOP-DE7IL4F:~/tmp/openai/samples/ragSample$ python llamaindex_sample.py 
質問を入力してください(終了するには 'exit' と入力): 外宮の駐車場について教えてください
回答: 外宮参拝者用の無料駐車場があります。
参考URL: 駐車場の満空情報(らくらく伊勢もうで) - http://www.rakurakuise.jp/jam_map_03.html
参考URL: グーグルマップ(駐車場案内) - https://www.google.co.jp/maps/search/%E9%A7%90%E8%BB%8A%E5%A0%B4/@34.4893363,136.7050439,16.91z?hl=ja
質問を入力してください(終了するには 'exit' と入力): 

それなりに動いているっぽいですね。ただ、公開されている情報なんで、 ChatGPT とかで同じ質問をしても、検索によりそれなりに答えが返ってきます。なので、テストという点では題材がちょっと微妙でしたね。

あと、そのうちに、公開情報なら RAG 作るまでもないようになるかもしれないかな?とも思ったのですが、そのあたりはどうなんでしょうかね?

VectorStoreIndex の保存と読み込み

さて、出来上がったものは、このままでもいいんですが、毎回 VectorStoreIndex を作るのって、手間だし、そのたびごとに OpenAI API たたくのでお金もかかるしで、避けたいところです。 FAISS はインメモリDBとのことなんですが、ファイルへ書き出すことで永続化もできるとのことです。

ということで、VectorStoreIndex を作ってファイルへ書き出す処理を関数にしてみます。

import faiss
from llama_index.core import StorageContext, VectorStoreIndex, load_index_from_storage
from llama_index.core.schema import TextNode
from llama_index.core.settings import Settings
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.vector_stores.faiss import FaissVectorStore

from faq_dict import load_faq_dict

# FAISS Vector Store の永続化のテスト
# https://docs.llamaindex.ai/en/stable/examples/vector_stores/FaissIndexDemo/

# 永続化用フォルダ
_PARSIST_DIR = "./faiss_index"

# vector storeの次元数
_DIMENSION = 1536


def _set_llama_index_settings():
    """
    Set global settings for LlamaIndex.
    """
    Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
    Settings.llm = OpenAI(model="gpt-4.1")

_set_llama_index_settings は LlamaIndex を利用する際の設定処理を書いておきます。これは vector store を作成するときも、読み込んで利用するときも同じ設定を使いたため、関数化しています。

インデックスを作成して保存する関数は、こんな感じになります。

def create_faiss_index():
    """
    Create a FAISS index with the specified dimension.
    """
    # LlamaIndex 設定
    _set_llama_index_settings()

    # FAQ(よくある質問)の読み込み
    faq_dict = load_faq_dict()

    # FAQデータをノードに変換
    nodes = []
    for faq in faq_dict:
        content = f"Q: {faq['question']}\nA: {faq['answer']}"
        metadata = faq.get("tags", {})
        metadata["references"] = faq.get("references", [])
        nodes.append(TextNode(text=content, metadata=metadata))

    faiss_index = faiss.IndexFlatL2(_DIMENSION)
    vector_store = FaissVectorStore(faiss_index=faiss_index)
    storage_context = StorageContext.from_defaults(vector_store=vector_store)

    index = VectorStoreIndex(nodes, storage_context=storage_context)

    index.storage_context.persist(persist_dir=_PARSIST_DIR)

VectorStoreIndex の storage_context.persist というメソッドを呼び出すことで指定したディレクトに必要なデータ一式を保存してくれるそうです。なお、 FaissVectorStore にも persist というメソッドがあるのですが、こちらは vector store のみをファイルに書き出すのに対して、 storage_context.persist はメタデータなども含めて利用している情報一式を保存してくれるとのことです。

次は、ディレクトリへ書き出し内容を読み込んで、 RAG を行う準備をする部分を関数にしておきます。

def load_faiss_index():
    """
    Load the FAISS index from the specified directory.
    """
    # LlamaIndex 設定
    _set_llama_index_settings()

    vector_store = FaissVectorStore.from_persist_dir(_PARSIST_DIR)
    storage_context = StorageContext.from_defaults(
        vector_store=vector_store, persist_dir=_PARSIST_DIR
    )
    index = load_index_from_storage(storage_context=storage_context)
    return index


def load_query_engine():
    """
    Load the query engine from the FAISS index.
    """
    # LlamaIndex 設定
    _set_llama_index_settings()

    index = load_faiss_index()
    query_engine = index.as_query_engine(similarity_top_k=2)
    return query_engine

最後の load_query_engine を呼び出せば、応答を行うためのオブジェクトを得ることができます。

インデックスを保存する関数を呼び出して、一度だけ永続化しておけば、

if __name__ == "__main__":

    ## FAISSインデックスの作成
    #create_faiss_index()
    #print(f"FAISS index created and stored in '{_PARSIST_DIR}' directory.")

    # 以下テスト

    # インデックスのクエリエンジンを作成
    # index = load_faiss_index()
    # query_engine = index.as_query_engine(similarity_top_k=2)

    query_engine = load_query_engine()

    # 問い合わせへの対応
    while True:
        query = input("質問を入力してください(終了するには 'exit' と入力): ")
        if query.lower() == "exit":
            break
        response = query_engine.query(query)
        print(f"回答: {response}")

こんな感じで何度でも保存した vector store を利用することができます。

まとめ

とりあえず、ローカルですが簡単に RAG を実装することができました。もちろん、細かいところや内部の処理などは理解が追い付いていないのですが、なんとも簡単に動くものができてしまうあたりに、生成 AI のすごさというか恐ろしさというか、なんともいえないものを感じますね。

次は、これを元に API 化してみようかなと思います。これも、生成 AI の力で簡単にできるといいな。

あと、今回は手を出していないのですが、 LlamIndex の query engine を使った場合でも、プロンプトのカスタマイズができるとのことです。

LlamaIndexモジュールガイドを試してみる: Prompts

なので、追々これとかも試してみたいと思います。にしてもやりたいこといっぱいで、時間がなくなってきたぞ。