見出し画像

LangChainとOpenAI APIでお手軽Q&Aことはじめ

はじめに

ここ最近で大規模言語モデル(LLM)に関連するnoteを書いています。そこで今回は、今更ながらのLangChainの学習を兼ねて大規模言語モデル(LLM)の品質向上の工夫の余地を模索していきたいと思います。


前置き

今回は、初回ということで、ミニマムなRetrievalを利用したコード例を紹介するにとどめています。タイトルにお手軽Q&Aといったものの、gpt-3.5 turboなどは単体で高度なQ&Aを実現できるので、現時点ではまだ有効活用ができる段階ではありません。今後に実用的な活用方法を検証していく予定です。

Retrievalとは

Retrievalは「検索、情報の検索」などを意味しており、自然言語の質問に対して、関連するドキュメントを検索・取得する処理のことを指します。LangChainにはRetrievalクラスがあり、今回は、外部ファイルに記載された文章を大規模言語モデル(LLM)へのプロンプトの入力に用いるための処理として用いています。

必要なライブラリをインストール(WSL2環境)

$ python3 -m venv myenv
$ source myenv/bin/activate
(myenv) $ pip install langchain openai
(myenv) $ ls
apt-update.log  myenv
(myenv) $ code retrieval_test.py
(myenv) $ code sample.md

sample.md

## 侵襲VRデジタルレストラン概要:
- 脳に電極を繋いで食べた気になれる
- デジタル料理の価格:
  - 焼き肉: 300円
  - お寿司: 300円 
  - ハンバーガー: 100円
  - ビール: 100円
  - ソフトドリンク: 50円
- 電極: 
  - 当店独自の電極
  - Neuralink製の電極(+200円)
  - Paradromics製の電極(+200円)

Markdownファイルを取得するコード

Markdownファイルのテキスト取得するコードを抜粋すると以下になります。

from langchain_community.document_loaders import UnstructuredMarkdownLoader

# マークダウンファイルを読み込む
loader = UnstructuredMarkdownLoader("sample.md", encoding="utf-8")
documents = loader.load()
print(documents)

実行結果

(myenv) $ python3 retrieval_test.py
[Document(page_content='侵襲VRデジタルレストラン概要:\n\n脳に電極を繋いで食べた気になれる\n\nデジタル料理の価格:\n\n焼き肉: 300円\n\nお寿司: 300円\n\nハンバーガー: 100円\n\nビール: 100円\n\nソフトドリンク: 50円\n\n電極:\n\n当店独自の電極\n\nNeuralink製の電極(+200円)\n\nParadromics製の電極(+200円)', metadata={'source': 'sample.md'})]
'## 侵襲VRデジタルレストラン概要:\n- 脳に電極を繋いで食べた気になれる\n- デジタル料理の価格:\n  - 焼き肉: 300円\n  - お寿司: 300円 \n  - ハンバーガー: 100円\n  - ビール: 100円\n  - ソフトドリンク: 50円\n- 電極: \n  - 当店独自の電極\n  - Neuralink製の電極(+200円)\n  - Paradromics製の電極(+200円)\n'

UnstructuredMarkdownLoadeはMarkdownファイルを読み込みを可能にするLangChainのクラスです。前述のコードのように、load()メソッドで、Documentオブジェクトのリスト形式で結果が返ってきます。このオブジェクトは、LangChain特有のものです。Markdownファイル以外にも、TextLoaderクラス や PDFLoaderクラス を用いて loadしたものはDocumentオブジェクトで返されます。

なお、Documentオブジェクトは、テキストだけでなく、メタデータ(ファイル名やページ番号など)も含まれていて、それらを後続の処理でよしなに扱うことができるというものらしいです。

Markdownファイルを取得するコード

もう1つだけ、取得したファイルを分割する方法です。RAGなどでは前処理として chunkが必要になりますが、以下のように任意の指定サイズで chunk が可能です。

from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain.text_splitter import CharacterTextSplitter

# マークダウンファイルを読み込む
loader = UnstructuredMarkdownLoader("sample.md", encoding="utf-8")

# テキストを指定されたchunk_sizeで分割する
text_splitter = CharacterTextSplitter(chunk_size=10, chunk_overlap=0)
documents = loader.load_and_split(text_splitter=text_splitter)
for doc in documents:
    print(f"Metadata:{doc.metadata['source']} - Content: {doc.page_content}")

実行結果

$ python3 retrieval_test.py
Created a chunk of size 14, which is longer than the specified 10
Created a chunk of size 16, which is longer than the specified 10
Created a chunk of size 12, which is longer than the specified 10
Created a chunk of size 12, which is longer than the specified 10
Created a chunk of size 20, which is longer than the specified 10
Metadata:sample.md - Content: 侵襲デジタルレストラン概要:
Metadata:sample.md - Content: 脳に電極を繋いで食べた気になれる
Metadata:sample.md - Content: デジタル料理の価格:
Metadata:sample.md - Content: 焼き肉: 300円
Metadata:sample.md - Content: お寿司: 300円
Metadata:sample.md - Content: ハンバーガー: 100円
Metadata:sample.md - Content: ビール: 100円
Metadata:sample.md - Content: ソフトドリンク: 50円
Metadata:sample.md - Content: 電極:
Metadata:sample.md - Content: 当店独自の電極
Metadata:sample.md - Content: Neuralink製の電極(+200円)
Metadata:sample.md - Content: Paradromics製の電極(+200円)

指定されたchunk_sizeを目安にテキストを分割しますが、改行などの境界が考慮されるようで、chunk_sizeの文字数で分割されるとは限らないようです。ただし、日本語の句読点は当然考慮されません。また、chunk_overlapに正の値に設定すると、そのchunk数だけ前後の文字(chunk)を重複させることができるので、半端なところでchunkされて、文脈が損なわれることを予防することも可能なようです。

GPT-3.5 Turboを利用して回答させる

動作検証を兼ねて以下のコードを実行します。以前の記事で実施したIn-Context Learningのような追加情報の読み取りを行います。

コード

import os
import json
import openai
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain_openai import ChatOpenAI
from langchain.prompts.chat import ChatPromptTemplate
from langchain.chains import LLMChain

openai.api_key = os.getenv("OPENAI_API_KEY")

# マークダウンファイルを読み込んでドキュメントに分割
loader = UnstructuredMarkdownLoader("sample.md", encoding="utf-8")
text_splitter = CharacterTextSplitter(chunk_size=1024, chunk_overlap=0)
documents = loader.load_and_split(text_splitter=text_splitter)

# 分割されたドキュメントを連結して、コンテキストを作成
context = "\n".join([doc.page_content for doc in documents])

prompt_template = "以下のコンテキストのみを使用して、質問に答えてください:\n\n{context}\n\n質問: {question}"

# プロンプトテンプレートからChatPromptTemplateを作成
prompt = ChatPromptTemplate.from_template(prompt_template)
model = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

chain = LLMChain(llm=model, prompt=prompt)

question = "侵襲VRデジタルレストランでNeuralink製の電極でお寿司を2個食べたらお会計はいくらになりますか?"

# LLMChainを呼び出して、質問とコンテキストを渡し、結果を取得
result = chain.invoke({"question": question, "context": context})
print(json.dumps(result, indent=2, ensure_ascii=False))

実行結果

$ python3 retrieval_test.py
{
"question": "侵襲VRデジタルレストランでNeuralink製の電極でお寿司を2個食べたらお会計はいくらになりますか?",
"context": "侵襲VRデジタルレストラン概要:\n\n脳に電極を繋いで食べた気になれる\n\nデジタル料理の価格:\n\n焼き肉: 300円\n\nお寿司: 300円\n\nハンバーガー: 100円\n\nビール: 100円\n\n ソフトドリンク: 50円\n\n電極:\n\n当店独自の電極\n\nNeuralink製の電極(+200円)\n\nParadromics製の電極(+200円)",
"text": "お会計は800円になります。お寿司2個が300円ずつで600円、Neuralink製の電極が200円×2で400円です。合計で800円になります。"
}

800円は合っているものの、計算過程に誤りがありました。。。残念。

一応、importしている内容の補足も以下に記載します。

OpenAIのEmbeddingsを使用して回答させる

OpenAIのEmbeddingsも試してみます。ここではテキストをベクトル化し、ベクトルストアに格納します。ベクトルストアを実現するために Faissというライブラリをインストールします。

$ pip install faiss-cpu

OpenAIEmbeddingsとベクトルストア

ここで気になったので色々と調べてみました。OpenAIEmbeddingsはLangChainでOpenAI のEmbeddingsモデルを利用するためのクラスです。まず、OpenAIEmbeddingsを用いることで、テキストに対して品質の高いベクトル化を行います(*1)。

ベクトル化のイメージ

OpenAI Blog - New embedding models and API updates

ここでの品質とは、単語が適切にベクトル空間へ配置されることです。適切な配置がされることで、単語の意味や関係性をより捉えやすくなるということになります。

Faiss は密度の高いベクトルを高速な検索(近似最近傍探索)を実現するライブラリです。Faissではありませんが、02-01 Google を支えるベクトル近傍検索技術と Vertex Matching Engine という動画の1:56~9:55辺りがすごくわかりやすかったです。

Faissを利用すると、ベクトル化されたテキストを専用のデータ構造(ベクトルストア)に保存できます。このベクトルストアは、ベクトルデータ用に最適化されたインデックス構造を持ち、さらに近似最近傍探索(*2)という検索技術を用いることで、類似度の高速検索を可能にしています(すごい)。LangChainのFAISSクラスを使えば、簡単にFaissを利用できます。

OpenAIのEmbeddingsとGPT-3.5 turboの回答

以下のコード(処理)は先ほどのGPT-3.5 turboで回答させることとほぼ同じです。EmbeddingsとFaissで高速検索を実現しています。残念ながら、これらのすごい技術の本領を全く引き出せていないサンプルコードです。

コード

import os
import openai
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain.chains import RetrievalQA
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain.chains.question_answering import load_qa_chain

openai.api_key = os.getenv("OPENAI_API_KEY")

# マークダウンファイルを読み込んでドキュメントに分割
loader = UnstructuredMarkdownLoader("sample.md", encoding="utf-8")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=1024, chunk_overlap=0)
texts = text_splitter.split_documents(documents)

# OpenAIのEmbeddingsモデルを使用してテキストをベクトル化
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.from_documents(texts, embeddings)

# gpt-3.5-turboモデルの設定
llm = ChatOpenAI(temperature=0)

# Q&Aタスク用の一連の処理(チェーン)を設定
qa_chain = load_qa_chain(llm, chain_type="stuff")

# ベクトルストアを検索可能にして、LLMへ渡すための一連の処理
qa_system = RetrievalQA(combine_documents_chain=qa_chain, retriever=vectorstore.as_retriever())

question = "侵襲VRデジタルレストランでNeuralink製の電極でお寿司を2個食べたらお会計はいくらになりますか?"
answer = qa_system.invoke(question)
print(answer)

実行結果

$ python3 retrieval_test.py
{'query': '侵襲VRデジタルレストランでNeuralink製の電極でお寿司を2個食べたらお会計はいくらになりますか?', 'result': 'Neuralink製の電極は+200円です。お寿司1個が300円なので、2個で600円になります。Neuralink製の電極を追加すると、合計で800円になります。'}

800円も計算過程も合っていました。

RetrievalQAの補足

RetrievalQAは、前述のOpenAIのEmbeddingsとFaiss、LLM(GPT-3.5 turboなど)を使ったベクトル検索ベースのQ&Aシステムを簡単に構築できるLangChainのクラスです。

たとえば、今回でいえば、Embeddings→Faiss→LLMを繋げたQ&Aシステムを数行のコードで実装することができます。これをうまく活用して、外部情報などを参照させつつ、LLMと組み合わせて良い感じにすることが今後の狙いでもあります。

Q&Aタスク用の一連の処理(チェーン)

qa_chain = load_qa_chain(llm, chain_type="stuff")
qa_system = RetrievalQA(combine_documents_chain=qa_chain, retriever=vectorstore.as_retriever())

load_qa_chainメソッドを使用して、Q&Aタスク用の一連の処理(チェーン)を作成します。このQ&Aチェーンとは、関連ドキュメント / テキストデータを検索し、それらのデータを適切に処理して回答を生成する一連の流れのことです。

chain_type

chain_typeは、質問応答システムにおける、検索されたドキュメントをLLMへ入力する方法です。load_qa_chainメソッドの引数にそのtypeを指定します。chain_type=”stuff" は、検索されたドキュメントを単純に連結して、一度にLLMへ入力するタイプです。

chain_type一覧

まとめ

このように、RetrievalQAクラスの引数に、前述のQ&Aタスク用の一連の処理(チェーン)や、FAISSのベクトルストアなどを結合することで、入力された質問に対して「関連ドキュメントの検索と回答」を実現できるようになります。

おわりに

LangChainを使えば、比較的シンプルなコードで、外部情報の参照が可能になります。今回紹介したコードは、ミニマムなものですが、次回は、もう少し工夫を取り入れたチェーンやプロンプトなどを追加して実用性を上げていきたいと思います。


参考文献


余談と注釈

(*1)性能の高いEnbeddingモデルはたくさんあるようです。こちらのリーダーボードではOpenAIのモデル(text-embedding-3-large)は9位でした。


(*2)難しくて、厳密に、最近傍でなくてもよいので高速に解を求める、以上のことはわかりませんでした(参考:近似最近傍探索の最前線近似最近傍探索ライブラリVoyagerで類似単語検索を試す

この記事が気に入ったらサポートをしてみませんか?