リモート開発メインのソフトウェア開発企業のエンジニアブログです

WordPress のデータを使って簡単な RAG を実装する(2)

本記事は全2回の第2回です。

以下のの前回の記事では、RAG についての簡単な説明と WordPress のデータを用いて簡単な RAG を実装する際の構成要素について説明しました。

WordPress のデータを使って簡単な RAG を実装する(1) – もばらぶエンジニアブログ

今回は、具体的な実装について説明します。

実装の概要

プログラム

今回は以下の3つのプログラムを作成します。

  • WordPress の記事をダウンロードするプログラム
  • ダウンロードした記事の埋め込み表現を作成し、ベクトルデータベースに保存するプログラム
  • 質問を受け付け回答を返すプログラム(以下の処理に分割できます)
    • 質問文の埋め込み表現を作成する
    • ベクトルデータベースに問い合わせして、関連する記事を取得する
    • 生成 AI にその記事をコンテキストとして渡すと共に質問を渡して、回答を得る

最後のプログラムでは LangChain という Python ライブラリを使用します。LangChain に関しては、割と最近、弊社の三浦が記事を書きましたので、もし良ければご覧ください。

PythonでChatGPT APIを使ってみる 第3回 LangChainの簡単な使用方法 – もばらぶエンジニアブログ

ミドルウェア、言語モデル等

前回の記事に書いたとおり、ベクトルデータベースには pgvector を使用します。

埋め込みの作成には HaggingFace の Sentence Transformers を使います、と書きましたが、Sentence Transformers はライブラリであり、使用する言語モデルを選ばなければいけません。日本語が使えるモデルについては以下のサイトを参考にしました。(2021年に書かれたページですが、2024年版が欲しいところです。)

sentence transformersで日本語を扱えるモデルのまとめ – Yellowback Tech Blog

また、回答の生成に使う生成 AI は、Llama 3.1 を使用します。と思ったのですが、つい昨日(※)に3.2がリリースされたようなので、3.2を使ってみます。

Llama 3.2: Revolutionizing edge AI and vision with open, customizable models

※: この部分を書いたのは9/26午前2時頃です。

事前準備、インストール等

Llama 3.2

Llama 3.2 は Ollama というものを使ってインストールできます。まずは、以下より Ollama をダウンロード、インストールします。

Ollama

その後、以下のコマンドで Llama 3.2 をダウンロードできます。

ollama run llama3.2

pgvector

pgvector は Docker を使うのが簡単でしょう。本題ではないので軽く触れるだけに止めます。

使う Docker イメージとしては pgvector/pgvector:pg16 です。docker run コマンドで手動で起動しても良いですが、Docker Compose を使った方が楽だと思います。

また、以下の SQL を実行して pgvector を有効化する必要があります。

CREATE EXTENSION IF NOT EXISTS vector;

そのためには、これを SQL ファイルに保存して、コンテナ内の /docker-entrypoint-initdb.d/ に配置する事で、コンテナ起動時に実行されます。詳しくは以下のページの “Initialization scripts” の項を参照してください。

postgres – Official Image | Docker Hub

Python 関連

結構色々なライブラリを使うので、pyenv-virtualenv 等を使って、他の環境に影響を及ぼさないようにした方が良いです。

pyenv/pyenv-virtualenv: a pyenv plugin to manage virtualenv (a.k.a. python-virtualenv)

また、使うライブラリは requirements.txt に記載した上で、以下のコマンドでインストールします。

pip install -r requirements.txt
Moba Pro

プログラム

次にプログラムの実装について説明します。コードを全部書くと長くなるので、ポイントに絞って書いていきます。

WordPress の記事をダウンロードするプログラム

WordPress の URL が https://wordpress.example.com だとすると、以下の URL を叩く事で記事の一覧が JSON で取得出来ます。

https://wordpress.example.com/wp-json/wp/v2/posts

その際、ヘッダーに WordPress のユーザー名・パスワードを含める必要があります。詳細は以下のドキュメントを参照してください。

記事の埋め込み表現を作成し、ベクトルデータベースに保存するプログラム

前項でダウンロードした JSON ファイルを Pandas で読み込みます。

import pandas as pd

df = pd.read_json('path/to/posts.json')

JSON の構造は上述のドキュメントだけだと分かりにくいので実際にダウンロードしたものを見た方が良いかと思いますが、 title.renderedcontent.rendered の2つを使えば良いと思います。

それらを連結して、LangChain の DataFrameLoader に渡します。ただ、それらには HTML タグが含まれているので、その後に Html2TextTransformer を使ってテキスト形式に変換します。テキスト形式とは言っても、Markdown に(?)変換されるようです。

from langchain_community.document_loaders import DataFrameLoader
from langchain_community.document_transformers import Html2TextTransformer

df['title_content'] = df['title']['rendered'] + ' ' + df['content']['rendered']

post_loader = DataFrameLoader(df, page_content_column='title_content')
docs = post_loader.load()

html2text = Html2TextTransformer()
text_docs = html2text.transform_documents(docs)

その後、WordPress 1投稿を複数に分割したり、様々な前処理をするという選択肢もありますが、今回は1投稿をそのまま1つの文書として扱う事にします。

あとは、埋め込みに使う言語モデルを用意して、埋め込みを作成する文章と共に PGVector.from_documents に渡すだけです。

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores.pgvector import PGVector

pg_connection_string = PGVector.connection_string_from_db_params(
    driver="psycopg2",
    host="localhost",
    port=5432,
    database="wordpressrag",
    user="postgres",
    password="postgres",
)

embeddings = HuggingFaceEmbeddings(model_name="paraphrase-xlm-r-multilingual-v1")

db = PGVector.from_documents(
    embedding=embeddings,
    documents = text_docs,
    collection_name='example_com', # 何でも良いです
    connection_string=pg_connection_string,
)

実行後の PostgreSQL にはテーブルが2つ出来ています。テーブルの中身は以下の通りです。

wordpressrag=# \d
                  List of relations
 Schema |          Name           | Type  |  Owner
--------+-------------------------+-------+----------
 public | langchain_pg_collection | table | postgres
 public | langchain_pg_embedding  | table | postgres
(2 rows)

wordpressrag=# select * from langchain_pg_collection;
     name      | cmetadata |                 uuid
---------------+-----------+--------------------------------------
 example_com   | null      | 53fb1fc1-f9e6-4cbf-ada1-d77c413554e6
(1 rows)

wordpressrag=# \d langchain_pg_embedding;
               Table "public.langchain_pg_embedding"
    Column     |       Type        | Collation | Nullable | Default
---------------+-------------------+-----------+----------+---------
 collection_id | uuid              |           |          |
 embedding     | vector            |           |          |
 document      | character varying |           |          |
 cmetadata     | json              |           |          |
 custom_id     | character varying |           |          |
 uuid          | uuid              |           | not null |
Indexes:
    "langchain_pg_embedding_pkey" PRIMARY KEY, btree (uuid)
Foreign-key constraints:
    "langchain_pg_embedding_collection_id_fkey" FOREIGN KEY (collection_id) REFERENCES langchain_pg_collection(uuid) ON DELETE CASCADE

質問を受け付け回答を返すプログラム

LangChain の以下のドキュメントが割とそのまま使えそうなので、「4. Retrieval and Generation: Retrieve」の項以降にまずは目を通しておいてください。

Build a Retrieval Augmented Generation (RAG) App | 🦜️🔗 LangChain

流れをおさらいしておくと、以下の通りです。

  1. 質問文の embeddings を作成
  2. それに近い文書をベクトルデータベースから取得(複数件)
  3. 取得した文書を連結したもの(コンテキスト)と質問文を合わせて LLM に投げる

まずは、以下の通り、retriever というものを生成します。RAG の R の部分ですね。

store = PGVector(
    collection_name='example_com',
    connection_string=pg_connection_string, # 前項のプログラムと同じ定義
    embedding_function=embeddings, # 前項のプログラムと同じ定義
)
retriever = store.as_retriever(search_kwargs={"k": 4}) # デフォルトが4、変更したければここを変える

retriever は質問文の embedding と近い文章を取得するのですが、その際に何件取得するかというパラメータが k です。

次に生成 AI のモデル(LLM)を用意します。 num_ctx はデフォルトだと2048ですが、Llama 3.2の最大コンテキスト長である131072を指定します。

from langchain_ollama import OllamaLLM

llm = OllamaLLM(model="llama3.2", num_ctx=131072)

後は、LLM に渡すプロンプトのテンプレートを以下のように生成します。

from langchain_core.prompts import PromptTemplate

template = """以下のコンテキストのみを参照して質問に答えてください。
コンテキスト: {context}

質問: {question}
"""

custom_rag_prompt = PromptTemplate.from_template(template)

最後に、前述の構成要素を以下のように | でくっつけます。LangChain という名の通り、各処理をチェインして1つの大きな処理を作成できます。シェルスクリプトで複数のコマンドをパイプで繋ぐのと似たような感じです。

from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

def format_docs(docs):
    """文書を連結して返す。コンテキスト用の文字列を生成するために使う。"""
    return "\n\n".join(doc.page_content for doc in docs)


rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | custom_rag_prompt
    | llm
    | StrOutputParser()
)

result = rag_chain.invoke("WordPress のデータを使って RAG を実装する方法を教えて。"):

以上で、実装は完了です。(細かい部分は省略して記載しているので、そのままでは動作しないかもしれませんが、ご了承ください。)

その他、細かい情報

ここでは、書き切れなかった細かい情報を記載していきます。

プロンプト

Llama の開発元である Meta のページには、Llama 3.x のプロンプトのテンプレートが記載されています。

Llama 3.2 | Model Cards and Prompt formats

また、Hugging Face のサイトにも Llama 3.x のプロンプトについて説明があります。

Welcome Llama 3 – Meta’s new open LLM

ちなみに、Llama 2 の場合は違ったテンプレートでした。

TheBloke/Llama-2-7B-Chat-GGUF · Hugging Face

ただ、いくつか試してみましたが、そのテンプレート通りで無くても普通に答が返ってきましたし、むしろそっちの方が良い精度で答が返ってきたように思ったため、前項のプログラムに書いたとおりのテンプレートにしました。

以下の一番下のコメントにも、プロンプトの違いは結果にあまり影響しないと書いてありました。

Llama 3 Preferred RAG Prompting Format (xml tags vs. markdown vs. something else) · Issue #450 · meta-llama/llama-recipes

もう少し複雑なチェイン

上の例だと、LLM からの回答の文字列だけが返ってきますが、ベクトルデータベースから取得した文書のメタデータ(URL、タイトル)などが知りたい場合などもあると思います。そうした場合も、ちょっとした修正で対応できます。

# 入力は {"docs": retriever から取得した文書の配列, "question": 質問文そのまま}
prompt_input_builder = {
    "question": itemgetter("question"),
    "context": lambda x: format_docs(x["docs"]),
}

# 入力は {"docs": retriever から取得した文書の配列, "question": 質問文そのまま}
answer_chain = {
    "answer": prompt_input_builder | custom_rag_prompt | model | StrOutputParser(),
    "docs": lambda x: x["docs"]
}

rag_chain = (
    {"docs": retriever, "question": RunnablePassthrough()}
    | answer_chain
)

result = rag_chain.invoke("WordPress のデータを使って RAG を実装する方法を教えて。"):
# result['answer'] には回答が入る
# result['docs'] にはベクトルデータベースからの結果が入る

(このコードも動作確認はしていませんので、間違えていたらすみません。)

Llama 3.2 の情報

Llama 3.2 の概要情報は、以下のコマンドで確認できます。コンテキスト長はこのコマンドで調べました。

# ollama show llama3.2
  Model
    architecture        llama
    parameters          3.2B
    context length      131072
    embedding length    3072
    quantization        Q4_K_M

  Parameters
    stop    "<|start_header_id|>"
    stop    "<|end_header_id|>"
    stop    "<|eot_id|>"

  License
    LLAMA 3.2 COMMUNITY LICENSE AGREEMENT
    Llama 3.2 Version Release Date: September 25, 2024

考察・感想

検索フェーズでどういう文書が引っ張ってこられるかが重要

今回記事を書くにあたり色々試してみましたが、質問文に対する答が含まれている文書を検索フェーズでちゃんと取ってこられるかが、RAG 全体の精度に一番影響すると思います。言い方を変えると、検索フェーズで正解の文書を取ってこられれば、生成フェーズで LLM が間違った答を生成する事は少ない、という感じです。逆に、検索フェーズで関連性の低い文書を取ってくると、いくら LLM が賢くてもどうしようもないです。

今回の RAG の実装における検索フェーズでは、埋め込み表現を作成して、単純にベクトル同士の類似度が高いものを取ってくるという手法をとりましたが、実務上はかなり色んなチューニングの余地がありそうです。

生成 AI が理解しやすい文章の方が回答の精度が上がる

前項で書いたとおり、検索フェーズで正しい文書を取ってこられれば、回答の精度はそこまで悪くなる事はありません。とは言え、分かりにくい文章だったりすると、生成 AI が内容を誤って理解してしまい、結果として間違った答を生成してしまう事があります。

今までは、例えば社内イントラネットとかを考えると、これまでは情報をどう構造化するか、どう整理するかで、必要な情報が見つかりやすいかどうかが決まっていたと思います。一方、今後は、(RAG に限らず)生成 AI が業務で使われる頻度が高まるにつれて、生成 AI が理解しやすい文章を書くというスキルが人間に求められるかもしれません。

まとめ

LangChain、Hugging Face、Llama などを使って、WordPress の投稿データを対象とした RAG を簡単に実装する事が出来ました。とは言え、今回の実装は簡単なものでそのままだと精度があまり高くありません。実務で使うには、今回行っていないようなチューニングを地道にやっていく必要がありそうです。

← 前の投稿

手を動かしてAPI取得の理解が深まった話

次の投稿 →

WordPress のデータを使って簡単な RAG を実装する(1)

コメントを残す