ChatGPTと仲良くなるための旅(3)

 GWのカクヨムAI小説企画に一本投げたいので、それまでにできるだけChatGPTの修行を積んでおきたい。っというわけで通勤時間にケータイ眺めながら週末の修行項目を探していた。クックブックのサンプルを一通りなめるところからかな~っとか思っていたが、めちゃくちゃ楽しそうな記事を2つ見つけてしまった。

  1. OSSベクトルDBのChromaを使ってQ&AボットをLangChainで作成する|mah_lab / 西見 公宏|note
  2. エージェント論文:Chat GPTによる人間社会のシミュラクラ|teftef|note

 どっちもnoteの記事(・∀・) こういう技術系の話はQiitaに多いというイメージがあったけどnoteにもいっぱいあった。1でトークンの壁をちょっとだけ乗り越える技を身につけた上で、2の手法を参考にすればAI小説ぽいものが書けるかも。2のほうは自分がイメージしていたAIに小説を叙述させる方法となんとなく似ていたのでちょっと嬉しい(もちろん自分の思いつきより全然詳しいし、なにより実際に25人分実装して実験しており偉すぎるので比べてはいけない)。

ふわふわアイデア

 このChatGPTシリーズの記事ははたしてこのブログに書く内容なのか…っと思わなくもないが(いちおうここはオンノベ感想ブログです☆)、個人ブログなのでま~いっか\(^O^)/っと開き直ってシコシコ書いております。

 そういうわけで今週の修行内容です。

ベクトルDBを使ってChatGPTにトークン上限以上の情報を参照させる

 ChatGPTくんと会話をすると、彼が会話の流れをそこそこ把握しており、なんだかいい感じの回答を返してくれがちであることに驚くことだろう。この「会話の流れ」というのは内部的には履歴データとして4096トークン分(日本語だとざっくり3000字くらい)記録される仕組みになっている。なので3000文字以内の会話のやりとりなら、そこそこ過去の会話を踏まえた回答を返してくれる。ただし3000文字を越えてくると、古いデータから忘れられてしまう。以前自分が乙女ゲーごっこをしていたときにChatGPTくんが設定を忘れがちだったのはこれのせいだと思われる。

 今回勉強するベクトルDBは、ここの記憶容量を増やしたかのように見せかける術だ。実際に増えているわけではないのでそこは注意だ。

 さっそくコードを書いてみよう。今回使うのは記事でも紹介されていたChromaだ。まずはpythonで使えるようにインストールする。

pip install chromadb

 なんかC++のSDKがないって怒られた。ちょっと前にMS系のSDKは容量めちゃくちゃ食うので一掃したからですね。また入れなきゃダメか…悲しい(‘_’)

error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/

 エラー文言のリンク先からDLする。案の定デカいッ 7GBあるッ…だがこれも修行のため。耐え忍ぶのみである。Cドライブの残容量は9GB。頑張れッ!

 ツールキットを入れたあとにVSCodeを再起動してpipのinstallをやり直すと成功。次はChromaのトップページにあるサンプルコードの末尾にprint文を入れて実行だ。

import chromadb
# setup Chroma in-memory, for easy prototyping. Can add persistence easily!
client = chromadb.Client()

# Create collection. get_collection, get_or_create_collection, delete_collection also available!
collection = client.create_collection("all-my-documents") 

# Add docs to the collection. Can also update and delete. Row-based API coming soon!
collection.add(
    documents=["This is document1", "This is document2"], # we handle tokenization, embedding, and indexing automatically. You can skip that and add your own embeddings as well
    metadatas=[{"source": "notion"}, {"source": "google-docs"}], # filter on these!
    ids=["doc1", "doc2"], # unique for each doc 
)

# Query/search 2 most similar results. You can also .get by id
results = collection.query(
    query_texts=["This is a query document"],
    n_results=2,
    # where={"metadata_field": "is_equal_to_this"}, # optional filter
    # where_document={"$contains":"search_string"}  # optional filter
)

print(results)
{'ids': [['doc1', 'doc2']], 'embeddings': None, 'documents': [['This is document1', 'This is document2']], 'metadatas': [[{'source': 'notion'}, {'source': 'google-docs'}]], 'distances': [[0.9026353359222412, 1.0358158349990845]]}

 実行できた。さて、Chromaのチュートリアルを見ながらこのコードの意味を勉強しよう(順番…)。documentsの部分にデータを入れて、metadatasとidsはあとからデータにフィルタをかけるときに便利だよ、みたいな位置付けのようだ。データを用意した状態で、query_textsに検索ワードを入れると、関連度の高いデータがn_resultsで指定した数だけ返ってくる。

 ではさっそくワンワン王国の設定を入れてみよう。こういうデータを用意して、documentsに使う。

太郎は人間だ。
太郎は冷血漢だ。
太郎は人となじめない性格だ。
太郎はいつも犬と遊んでいる。
太郎は20歳の男性だ。
太郎は犬たちを従えている。
次郎は犬だ。
次郎は犬のなかで一番賢い。
次郎は老犬だ。
次郎は三郎が苦手だ。
次郎はレイコが嫌いだ。
三郎は犬だ。
三郎は優しい。
三郎はジャーキーが大好き。
三郎は次郎が大好き。
三郎は太郎が大好き。
三郎はレイコが大好き。
三郎は幽霊が嫌い。
レイコは人間だ。
レイコは太郎の姉だ。
レイコは太郎のことが嫌いだ。
レイコは都会で暮らしている。
レイコは会社員だ。
レイコは25歳の女性だ。

 コードはこんな感じ。上のワンワン王国の設をinput.txtに書いておいて、それをPythonに読ませる。Pythonは読んだデータを配列にして、ChromaのDB作成関数に渡す。最後に「レイコについて」という質問文を投げると、関連度の高い5つのデータが返るはずだが…。

import re
import chromadb
from chromadb.config import Settings

def split_paragraphs(text):
    # 改行文字で段落に分割
    paragraphs = re.split('\n+', text.strip())
    return paragraphs

# テキストファイルを読み込み、文字コードをUTF-8に設定する
with open('input.txt', encoding='utf-8') as f:
    text = f.read()

paragraphs = split_paragraphs(text)
metas = [{"source": "wanwan"} for _ in range(len(paragraphs))]
docIds = ["wan01" for _ in range(len(paragraphs))]

# データ収集のオプトアウト設定有効化
client = chromadb.Client(Settings(anonymized_telemetry=False))

# Create collection. get_collection, get_or_create_collection, delete_collection also available!
collection = client.create_collection("all-my-documents") 

# Add docs to the collection. Can also update and delete. Row-based API coming soon!
collection.add(
    documents=paragraphs, # we handle tokenization, embedding, and indexing automatically. You can skip that and add your own embeddings as well
    metadatas=metas, # filter on these!
    ids=docIds, # unique for each doc 
)

# Query/search 2 most similar results. You can also .get by id
results = collection.query(
    query_texts=[f"レイコについて"],
    n_results=5,
    # where={"metadata_field": "is_equal_to_this"}, # optional filter
    # where_document={"$contains":"search_string"}  # optional filter
)

# documentsキーの値を出力
documents = results.get("documents")
if documents is not None:
    print(documents)

#print(results)

 実行結果。じゃかじゃん!

[['レイコは都会で暮らしている。', 'レイコは25歳の女性だ。', 'レイコは人間だ。', 'レイコは太郎のことが嫌いだ。', 'レイコは会社員だ。']]

 レイコの情報がちゃんと返ってきた(・∀・) 太郎の場合はどうだろう。query_textsを「太郎について」に変えてみる。

[['太郎はいつも犬と遊んでいる。', '太郎は犬たちを従えている。', '三郎は優しい。', 'レイコは太郎の姉だ。', '三郎は幽霊が嫌い。']]

 むむ。。。関係ない三郎の情報が混ざってきた。もしかしてだが、太「郎」、三「郎」みたいに名前が似てしまっているので、情報が混ざっちゃっているのかもしれない。気になったので、inputデータの「太郎」を「ジョンソン」に差し替えてもう一回実行してみた。

[['レイコはジョンソンの姉だ。', 'レイコはジョンソンのことが嫌いだ。', 'レイコは25歳の女性だ。', 'ジョンソンは冷血漢だ。', '三郎はジャーキーが大好き。']]

 三郎がどうしても食い込んでくるw 日本語より英語のほうが良いとかもあるのかもしれない。難しい。難しいが、とにかく今回の目的であるChromaをさわってみるというのは達成できたのでヨシとする。

これができると何が嬉しいの?

 これができると、たとえばinput.txtの中身をめちゃくちゃ長い小説にしたり、なんかの辞書データにしたり、社内ナレッジを置いたりする。ユーザが質問文を入力したら、(トークン4096に引っかからなさそうなギリギリの回答個数n_resultsを狙いつつ)いったんこのChromaDBに検索ワードを投げ込んで、返ってきた複数のデータを参考情報としてChatGPTに渡す。そうすると、さもChromaDBにぶっこんだ内容を全文検索してきたかのように回答ができる…という仕組みみたいです。トークンの上限を超えられるわけではないし、n_resultsの数によっては正答にたどり着けなかったりするけど、素の状態でChatGPTを動かすよりはマシだよねってことですね。

(本当はここに概念の図を入れたかったが力尽きた。そのうち追記する。。。)

 次回はエージェントを頑張って動かしてみよう。LangChain使わなくてもできるかなあ…。

 旅は続くッ