Home About
LangChain , LLM

LangChain RetrievalQA を使って兼好法師に質問する

現代語訳 徒然草 (吉田兼好著・吾妻利秋訳)LangChainをつかって 吉田兼好に人生の悩みを質問してみる。

現代語訳 徒然草 をデータとして RetrievalQA を使うことで、 最終的に次のような質問から回答を得ることができました。

・・・なかなか興味深い。

環境

$ python --version
Python 3.9.17

また Open AI の API が使える状態で 環境変数 OPENAI_API_KEY がセットされていること。

徒然草データの準備

現代語訳 徒然草 (吉田兼好著・吾妻利秋訳)から 迷惑にならないように一段ずつ時間をあけて取得してデータを用意しました。

最終的には第一段から第二百四十三段までを段ごとに別ファイルとして保存。 001.txt から 243.txt までのファイル名で、それぞれに現代語訳のみを入れたテキストファイルを作成しました。 ここでは、それらのファイルが Datasource/ ディレクトリ以下に入っていることとして説明します。

なお、このエントリーの主題は LangChain や RetrievalQA の使い方のシェアなので、 これらデータ準備方法の詳細説明は割愛します。

このアプリケーションの概要

次のような流れで吉田兼好との対話(QA)を行います。

ステップ1:

ステップ2:

ステップ3:

テキストを読み込んで適当な長さの文に分割する

LangChain のDocument Loadersを使います。

venv 環境をつくって必要なライブラリを入れます。

$ python -m venv ./venv-tsurezure
$ source  ./venv-tsurezure/bin/activate
(venv-tsurezure) $ pip install langchain_community

main.py に次のコードを書きます。

試しに第七十九段TextLoader を使って取得してみます。

import os
from langchain_community.document_loaders import TextLoader

current_dir = os.path.dirname(__file__)
file = os.path.join(current_dir, "Datasource", "079.txt")
loader = TextLoader(file, autodetect_encoding=True)
docs = loader.load()
for doc in docs:
    print("---")
    print(doc.page_content)

実行。

(venv-tsurezure) $ python main.py
---

何事に関しても素人のふりをしていれば良い。知識人であれば、自分の専門だからと言って得意げな顔で語り出すことはない。中途半端な田舎者に限って、全ての方面において、何でもかんでも知ったかぶりをする。聞けば、こちらが恥ずかしくなるような話しぶりだが、彼等は自分の事を「偉い」と思っているから、余計にたちが悪い。  
  
自分が詳しい分野の事は、用心して語らず、相手から何か質問されるまでは黙っているに越したことはない。

079.txt 全体が一つのドキュメントとしてロードされました。

「段」全体を一つのドキュメントとして扱わないで、分割してみることにします。 そのために、試しに TokenTextSplitter を使ってみます。

必要なライブラリを入れる。

(venv-tsurezure) $ pip install langchain
(venv-tsurezure) $ pip install tiktoken

main.py に テキスト分割処理を追記。

import os

from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import TokenTextSplitter

current_dir = os.path.dirname(__file__)
file = os.path.join(current_dir, "Datasource", "079.txt")
loader = TextLoader(file, autodetect_encoding=True)
docs = loader.load()

splitter = TokenTextSplitter(
    chunk_size=100,
    chunk_overlap=10,
    encoding_name="cl100k_base",
    add_start_index=True)

splitted_docs = splitter.transform_documents(docs)
for doc in splitted_docs:
    print("---")
    print(doc.page_content)
    print(doc.metadata["start_index"])

この段階で TokenTextSplitter のオプションとしてなぜ encoding_name="cl100k_base" を指定する必要があるのか。 文を分割しているだけだから、どこでこのオプションが効いてくるのであろうか? どうやらトークン数(チャンクサイズ)の計算方法の指定なのかな。

https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb

chunk_size というのはトークン数のサイズのことなのかも。 そのトークン数のカウント方法を指定するのが encoding_name で cl100k_base は OpenAI の gpt-4, gpt-3.5-turbo, text-embedding-ada-002, text-embedding-3-small, text-embedding-3-large に対応しているので、それを指定しているということかな。

実行。

(venv-tsurezure) $ python main.py
---

何事に関しても素人のふりをしていれば良い。知識人であれば、自分の専門だからと言って得意げな顔で語り出すことはない。中途半端な田舎者に限って、全ての方面において、何でもかんでも知ったかぶり
0
---
かんでも知ったかぶりをする。聞けば、こちらが恥ずかしくなるような話しぶりだが、彼等は自分の事を「偉い」と思っているから、余計にたちが悪い。

自分が詳しい分野の事は、用心して語らず、相手から何
84
---
語らず、相手から何か質問されるまでは黙っているに越したことはない。


175

テキスト分割のオプションとして chunk_size=100, chunk_overlap=10 を指定したので、 100のチャンクサイズごとに分割されて一つの「段」が3つに分割されました。 それぞれ分割された文章は 10チャンクサイズ分オーバーラップ(重ねて取得)されています。 0, 84, 175 は start_index の値で、分割された文のそのドキュメント(ここでは 079.txt を指す)の出現開始位置を記録している。

今度は別のテキスト分割機能 CharacterTextSplitter を使って文の長さではなく、「。」で分割してみます。

main.py

CharacterTextSplitter をインポートします。

#from langchain.text_splitter import TokenTextSplitter
from langchain.text_splitter import CharacterTextSplitter

splitter インスタンスを生成している部分を TokenTextSplitter から CharacterTextSplitter に変更します。

#splitter = TokenTextSplitter(
#    chunk_size=100,
#    chunk_overlap=10,
#    encoding_name="cl100k_base",
#    add_start_index=True)

splitter = CharacterTextSplitter(
    chunk_size=50,
    chunk_overlap=10,
    add_start_index=True,
    separator="。")

どうなるか実行してみます。

(venv-tsurezure) $ python main.py
Created a chunk of size 54, which is longer than the specified 50
Created a chunk of size 54, which is longer than the specified 50
---
何事に関しても素人のふりをしていれば良い
1
---
知識人であれば、自分の専門だからと言って得意げな顔で語り出すことはない
22
---
中途半端な田舎者に限って、全ての方面において、何でもかんでも知ったかぶりをする
58
---
聞けば、こちらが恥ずかしくなるような話しぶりだが、彼等は自分の事を「偉い」と思っているから、余計にたちが悪い
98
---
自分が詳しい分野の事は、用心して語らず、相手から何か質問されるまでは黙っているに越したことはない
159

chunk_overlap オプションを指定したもののオーバーラップされている部分はありません。 ただ指定しないとエラーになるので指定しています。 (それは chunk_size の指定値とデフォルトの chunk_overlap 値との兼ね合いで決まるから、指定値によりエラーにならない場合もあります。 細かいことはさておき chunk_overlap に適当な値を指定しておきます。)

「。」で分割では、少し文の単位が小さすぎる気がします。 徒然草現代語訳はひとつの「段」の中では、意味上の塊が空行で分割されているので、「。」に代えて改行を示す 「\n」 を指定して分割してみます。

splitter = CharacterTextSplitter(
    chunk_size=50,
    chunk_overlap=0,
    add_start_index=True,
    separator="\n")
    #separator="。")

もうオーバーラップ関係ないから chunk_overlap オプションは 0 を指定しています。

CharacterTextSplitter は encoding_name を指定しなくていいのだろうか?(わからない) このライブラリの内部で暗黙に(またはデフォルトとして)何かの encoding_name が指定されているのであろう。

(venv-tsurezure) $ python main.py
Created a chunk of size 154, which is longer than the specified 50
---
何事に関しても素人のふりをしていれば良い。知識人であれば、自分の専門だからと言って得意げな顔で語り出すことはない。中途半端な田舎者に限って、全ての方面において 、何でもかんでも知ったかぶりをする。聞けば、こちらが恥ずかしくなるような話しぶりだが、彼等は自分の事を「偉い」と思っているから、余計にたちが悪い。
1
---
自分が詳しい分野の事は、用心して語らず、相手から何か質問されるまでは黙っているに越したことはない。
159

実際に分割された文のチャンクサイズが chunk_size オプションで指定した 50 を越えた文として分割された場合に Created a chunk of size 154, which is longer than the specified 50 のような警告が出ます。 そういえば、「。」で分割していたときもこの手の警告が出ていましたね。

これで徒然草で書かれている内容の意味の単位で文を分割できるようになりました。

すべての「段」をロードしてテキスト分割する

今まではこのように 第七十九段のファイルのみを対象に処理をしてきました。

file = os.path.join(current_dir, "Datasource", "079.txt")
loader = TextLoader(file, autodetect_encoding=True)

今度は、 ./Datasource/ にある 243件のファイル全部をロードしてテキスト分割します。 そのために TextLoader だけでなく DirectoryLoader を使います。

TextLoader のインポート部分に DirectoryLoader も追加。

#from langchain_community.document_loaders import TextLoader
from langchain_community.document_loaders import DirectoryLoader, TextLoader

loader を生成する部分は次のように変更します。

loader = DirectoryLoader(
    os.path.join(current_dir, "Datasource"),
    glob="*.txt",
    recursive=False,
    loader_cls=TextLoader,
    loader_kwargs={"autodetect_encoding": True})

docs = loader.load()

./Datasource/ ディレクトリ内の .txt 拡張子のファイルを処理対象に設定。 実際にテキストをロードするクラスは今まで使ってきた TextLoaderloader_cls=TextLoader として指しています。

これで徒然草全部の「段」をまとめてロードした上で分割処理までできるようになりました。

分割したテキストの埋め込み(Embedding)を計算して永続化(データベースに保存)

埋め込み計算用に OpenAIEmbeddings を、 そして埋め込み計算した結果を永続化(データベースへ保存)するために Chroma をそれぞれインポートします。

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

これらのライブラリをインストールします。

(venv-tsurezure) $ pip install langchain-openai langchain-chroma

肝心の埋め込み計算とその結果の永続化処理のコードを記述。

Chroma.from_documents(
    splitted_docs,
    persist_directory=os.path.join(current_dir, "chroma_db"),
    embedding=OpenAIEmbeddings())

ここまで書いてきたコード main.py 全体は次の通りです。

import os

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.text_splitter import CharacterTextSplitter

current_dir = os.path.dirname(__file__)

loader = DirectoryLoader(
    os.path.join(current_dir, "Datasource"),
    glob="*.txt",
    recursive=False,
    loader_cls=TextLoader,
    loader_kwargs={"autodetect_encoding": True})

docs = loader.load()

splitter = CharacterTextSplitter(
    chunk_size=50,
    chunk_overlap=0,
    add_start_index=True,
    separator="\n")

splitted_docs = splitter.transform_documents(docs)

Chroma.from_documents(
    splitted_docs,
    persist_directory=os.path.join(current_dir, "chroma_db"),
    embedding=OpenAIEmbeddings())

実行。

(venv-tsurezure) $ python main.py

完了すると、カレントディレクトリに chroma_db ディレクトリが作成されています。 その中に埋め込みの計算結果が保存されています。

いよいよ兼好法師に質問する

永続化したデータを読み込んだ上で、質問します。 このためのコードは main.py ではなく query.py へ記述していきます。

粋な老人の振る舞いとはどうのようなものですか? という質問をすることを考えます。

いきなり Q and A するのではなく、まずはこの質問に類似した文を db.similarity_search を使って標準出力させてみます。

import os

from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

current_dir = os.path.dirname(__file__)
db = Chroma(
    persist_directory=os.path.join(current_dir, "chroma_db"),
    embedding_function=OpenAIEmbeddings())

q = "粋な老人の振る舞いとはどうのようなものですか?"

docs = db.similarity_search(q)
for doc in docs:
    print("---")
    print(doc.page_content)

それでは実行します。

(venv-tsurezure) $ python query.py
---
一方、老人は、やる気がなく、気持ちも淡泊で細かいことを気にせず、いちいち動揺しない。心が平坦だから、意味の無い事もしない。健康に気を遣い、病院が大好きで、面倒な事に関わらないように注意している。年寄りの知恵が若造に秀でているのは、若造の見てくれが老人よりマシなのと同じである。
---
一芸に秀でた老人がいて、「この人が死んだら、この事を誰に聞いたらよいものか」と、言われるまでになれば、年寄り冥利に尽き、生きてきた甲斐もある。しかし、才能を持て余し続けたとしたら、一生を芸に費やしたようで、みみっちくも感じる。隠居して「呆けてしまった」と、とぼけていればよい。
---
聖なる古き良き時代の時代の政治の方針を忘れてしまって、一般市民が困って嘆いていることや、国に内乱が起こりそうなことも知らないで、何もかも究極に豪華なものを用意して、自分のことを偉いと勘違いし「ここは狭くて窮屈だ」というような態度をしている人を見ると、気分が悪くなるし、自分のことしか考えていない厭な野郎だと思う。
---
貧乏人が見栄を張れば泥棒になるしかなく、老人が土木作業をやり続ければ病気で死ぬのが世の常である。

それなりには、良い具合いにこの質問に関連した文を抽出できているようです。

それでは RetrievalQA を使って兼好法師への質問して回答を見てみましょう。

query.py に OpenAIRetrievalQA をインポートします。

from langchain_openai import OpenAI
from langchain.chains import RetrievalQA

RetrievalQA と OpenAI の LLM を使って Q and A するコード。

qa_chain = RetrievalQA.from_llm(
    llm=OpenAI(),
    retriever=db.as_retriever())

q = "粋な老人の振る舞いとはどうのようなものですか?"
a = qa_chain.invoke( q )
print( a )

実行。

(venv-tsurezure) $ python query.py
{'query': '粋な老人の振る舞いとはどうのようなものですか?', 'result': ' 粋な老人の振る舞いとは、やる気があり、気持ちが濃厚で細かいことを気にしないで、動揺せずに心が平坦で、健康に気を遣い、自分の限界を知り、出来ないことはやらないことだと考えて行動することです。'}

どうなんでしょうか。 たしかに、徒然草にある文でこの質問に関連した文をまとめたような回答が出ているといえばその通りなのかもしれません。

別の質問をしてみましょう。

q = "人生の楽しみとはなんですか?回答は徒然草の作者 吉田兼好の文体でお願いします。"
a = qa_chain.invoke( q )
print( a["result"] )

雰囲気が出るように、質問文の最後に「回答は徒然草の作者 吉田兼好の文体でお願いします。」を追加してみました。

実行。

(venv-tsurezure) $ python query.py
 世の中の快楽をむさぼることに忙しく、究極の悟りを思わない限り、人間として生まれてきた以上、何が何でも世間を捨てて山籠もり生活を営むことが理想である。
また、暇をもてあまし一日中放心状態でいられることさえ、とてものんきなことに思えてくる。
永遠に存在することのできない世の中で、ただ口を開けて何かを待っていても、ろくな事など何もない。
長生きをしたとしても、四十歳手前で死ぬのが見た目にもよい。
そう考えると、人生に刺激がないとか、死にたくないと思っていたら、千年生きても人生など

読みやすいように改行は手動で入れています。

回答文が前よりは兼好法師風になっている気がします。 それから、途中で文がきれてしまいました。 何か適切にオプションを追加しなければならないのかも。 とりあえず、 「100文字程度でまとめてください」と質問文の最後に追加してみよう(プロンプトエンジニアリング)。

PromptTemplate を使え

このような定型文を追加するための機能が LangChain には備わっています。 それを使うことにします。

from langchain.prompts import PromptTemplate
q = "人生の楽しみとはなんですか?"
prompt = PromptTemplate.from_template( "{query}回答は徒然草の作者 吉田兼好の文体で、100文字程度でまとめてください。" )
qx = prompt.format(query=q)

これで qx人生の楽しみとはなんですか?回答は徒然草の作者 吉田兼好の文体で、100文字程度でまとめてください。 という文字列が生成されます。

それでは、この機能を使って今度は 素敵な女性とはどういう人ですか? という質問をしてみます。

prompt = PromptTemplate.from_template( "{query}回答は徒然草の作者 吉田兼好の文体で、100文字程度でまとめてください。" )

q = "素敵な女性とはどういう人ですか?"
qx = prompt.format(query=q)

a = qa_chain.invoke( qx )
print( a["result"] )

実行。

(venv-tsurezure) $ python query.py
 美しい顔をしているが、実は自分勝手で欲深く、世の中の仕組みが分からず、メルヘンの世界の住人である。都合が悪くなると黙り、謙虚な素振りを見せるが、実際は何も考えていない。男を惑わせる女心に引け目を感じる必要はない。女性には、恋を楽しむことが大切であると言える。亀山天皇の時代にも、女達は男をからかうために、ホトトギスの声で相手の格付けをした。しかし、男性は女心を理解することは滅多にない。

どうなんでしょうか。

素敵な女性とは?という質問に 「美しい顔をしているが、実は自分勝手で欲深く、世の中の仕組みが分からず、メルヘンの世界の住人である。」といきなり回答しています。 さらに「都合が悪くなると黙り、謙虚な素振りを見せるが、実際は何も考えていない。」と続きます。 これは吉田兼好的なひねくれおじさん回答なのでしょうか。

追伸、気が滅入った場合について質問してみた

このような曖昧模糊とした質問にも(言われなければ人間が書いたとしか思えない)回答が出てくるのは興味深いですね。

まとめ

main.py

import os
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.text_splitter import CharacterTextSplitter

current_dir = os.path.dirname(__file__)

loader = DirectoryLoader(
    os.path.join(current_dir, "Datasource"),
    glob="*.txt",
    recursive=False,
    loader_cls=TextLoader,
    loader_kwargs={"autodetect_encoding": True})

docs = loader.load()

splitter = CharacterTextSplitter(
    chunk_size=50,
    chunk_overlap=0,
    add_start_index=True,
    separator="\n")

splitted_docs = splitter.transform_documents(docs)

Chroma.from_documents(
    splitted_docs,
    persist_directory=os.path.join(current_dir, "chroma_db"),
    embedding=OpenAIEmbeddings())

query.py

import os
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_openai import OpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

current_dir = os.path.dirname(__file__)
db = Chroma(
    persist_directory=os.path.join(current_dir, "chroma_db"),
    embedding_function=OpenAIEmbeddings())

qa_chain = RetrievalQA.from_llm(
    llm=OpenAI(),
    retriever=db.as_retriever())

#prompt = PromptTemplate.from_template( "{query}回答は徒然草の作者 吉田兼好の文体で、100文字程度でまとめてください。" )
prompt = PromptTemplate.from_template( "{query}回答は徒然草の作者 吉田兼好らしい文体で、100文字程度でまとめてください。" )

q = "粋な老人の振る舞いとはどうのようなものですか?"
qx = prompt.format(query=q)

a = qa_chain.invoke( qx )
print( a["result"] )

ライブラリバージョンの確認用の requirements.txt 。

aiohttp==3.9.4
aiosignal==1.3.1
annotated-types==0.6.0
anyio==4.3.0
asgiref==3.8.1
async-timeout==4.0.3
attrs==23.2.0
backoff==2.2.1
bcrypt==4.1.2
build==1.2.1
cachetools==5.3.3
certifi==2024.2.2
charset-normalizer==3.3.2
chroma-hnswlib==0.7.3
chromadb==0.4.24
click==8.1.7
coloredlogs==15.0.1
dataclasses-json==0.6.4
Deprecated==1.2.14
distro==1.9.0
exceptiongroup==1.2.0
fastapi==0.110.1
filelock==3.13.4
flatbuffers==24.3.25
frozenlist==1.4.1
fsspec==2024.3.1
google-auth==2.29.0
googleapis-common-protos==1.63.0
greenlet==3.0.3
grpcio==1.62.1
h11==0.14.0
httpcore==1.0.5
httptools==0.6.1
httpx==0.27.0
huggingface-hub==0.22.2
humanfriendly==10.0
idna==3.7
importlib-metadata==7.0.0
importlib_resources==6.4.0
jsonpatch==1.33
jsonpointer==2.4
kubernetes==29.0.0
langchain==0.1.16
langchain-chroma==0.1.0
langchain-community==0.0.32
langchain-core==0.1.42
langchain-openai==0.1.3
langchain-text-splitters==0.0.1
langsmith==0.1.47
markdown-it-py==3.0.0
marshmallow==3.21.1
mdurl==0.1.2
mmh3==4.1.0
monotonic==1.6
mpmath==1.3.0
multidict==6.0.5
mypy-extensions==1.0.0
numpy==1.26.4
oauthlib==3.2.2
onnxruntime==1.17.3
openai==1.17.1
opentelemetry-api==1.24.0
opentelemetry-exporter-otlp-proto-common==1.24.0
opentelemetry-exporter-otlp-proto-grpc==1.24.0
opentelemetry-instrumentation==0.45b0
opentelemetry-instrumentation-asgi==0.45b0
opentelemetry-instrumentation-fastapi==0.45b0
opentelemetry-proto==1.24.0
opentelemetry-sdk==1.24.0
opentelemetry-semantic-conventions==0.45b0
opentelemetry-util-http==0.45b0
orjson==3.10.0
overrides==7.7.0
packaging==23.2
posthog==3.5.0
protobuf==4.25.3
pulsar-client==3.5.0
pyasn1==0.6.0
pyasn1_modules==0.4.0
pydantic==2.7.0
pydantic_core==2.18.1
Pygments==2.17.2
PyPika==0.48.9
pyproject_hooks==1.0.0
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.1
regex==2023.12.25
requests==2.31.0
requests-oauthlib==2.0.0
rich==13.7.1
rsa==4.9
shellingham==1.5.4
six==1.16.0
sniffio==1.3.1
SQLAlchemy==2.0.29
starlette==0.37.2
sympy==1.12
tenacity==8.2.3
tiktoken==0.6.0
tokenizers==0.15.2
tomli==2.0.1
tqdm==4.66.2
typer==0.12.3
typing-inspect==0.9.0
typing_extensions==4.11.0
urllib3==2.2.1
uvicorn==0.29.0
uvloop==0.19.0
watchfiles==0.21.0
websocket-client==1.7.0
websockets==12.0
wrapt==1.16.0
yarl==1.9.4
zipp==3.18.1

以上です。

Liked some of this entry? Buy me a coffee, please.