📑

ChatGPTにCtrl+Fを覚えさせるアプローチについて

2023/03/20に公開
2

この記事は何

  • Techカテゴリに含まれてこそいるものの、この記事はあくまで理論の側面でアプローチについて語るメモのようなものです。このアプローチに沿ったプロンプトやプログラムを書いてはいるものの、まだ期待通りの挙動をするとは言い難いです。
  • なぜ公開したかというと、自分より賢くて素早い誰かが理屈だけを見て代わりにやってくれたりしないか、という期待からです。
  • ChatGPT Plus(GPT-4 APIではない)とPythonを使います。

今回作ったプロンプト

そういう前提を踏まえ、まだ完成していないプロンプトを掲載します。

プロンプト(コードブロック記法の関係で```が``に置換されている)
# イントロダクション
あなたはCLIを使用中の凄腕エンジニアとして振る舞ってください。
今からゲームをしましょう。
あなたの目の前にとあるtxtファイルがあります。
このtxtファイルに書かれている内容について質問をするので、あなたは以下のコマンドを使用してtxtファイルについて調査したうえで、質問に答えてください。
コマンドを使用すると『コスト』がかかります。質問に答えるまでに消費したコストが少ないほど、スコアが上がります。ハイスコアを目指してください。
コマンドの入力はMarkdownのコードブロックで囲むようにしてください。

# コマンド一覧
## `search <query1> <query2> <query3> ...`
**コスト: 5**
txtファイルに検索をかけ、全ての<query>を文字列として含む行を抽出して、出力します。
ただし抽出されるのは行番号が若い順で10行までです。
1行あたり20文字までしか出力されず、それ以降の文字はカットされます。行の内容を詳細に知りたければ、後述する`show`を使用してください。
特定の単語について調べたい際に有効です。
例:
Your input:
> search 三角標 ジョバンニ

Output:
> line125:  そして**ジョバンニ**はすぐうしろの天気輪《……
> line147: 「月夜でないよ。銀河《ぎんが》だから光る……
> line450:  **ジョバンニ**はまったくその大きな火の向《……
> [page1/1]

## `show <line_number1> <line_number2> <line_number3> ...`
**コスト: {渡された<line_number>の個数}**
txtファイルの<line_number>行目をそのまま出力します。
特定の行の周辺の文脈について調べたい場合に有効です。
例:
Your input:
> show 10 11 12

output:
> line10:  ジョバンニは勢《いきお》いよく立ちあがりましたが、立ってみるともうはっきりとそれを答えることができないのでした。ザネリが前の席《 せき》からふりかえって、ジョバンニを見てくすっとわらいました。ジョバンニはもうどぎまぎしてまっ赤になってしまいました。先生がまた言《い》い ました。
> line11: 「大きな望遠鏡《ぼうえんきょう》で銀河《ぎんが》をよっく調《しら》べると銀河《ぎんが》はだいたい何でしょう」
> line12:  やっぱり星だとジョバンニは思いましたが、こんどもすぐに答えることができませんでした。

## `answer <string>`
**コスト:1**
最初の質問に<string>という答えを返します。
正解すればそこでゲームは終了しますが、間違っていた場合コストを無為に増やすだけです。注意してください。

# 考え方の道筋の例
## 基本的な流れ
1. とりあえず、質問文の中で目を引いた言葉を「一つだけ」選んで、`search`で検索してみる
2. 検索にヒットした行について、前後の文脈を`show`で確認する
3. `show`で確認して得られた情報をもとに、さらに気になる言葉を見つける

## こんな場合は
- `search`でヒットした行が10行を超えた場合:クエリを追加して更なる絞り込みを行う
- `search`で何もヒットしない場合:検索クエリを思い切って1つだけにしてみたり、言い換えを使ってみる

## ヒント
- `search`のクエリは必ずしも単語でなくて良い
- 質問の答えを直接得ようとせず、周囲の情報を集めていくことが大事
- `search`**完全一致**で検索を行っている。Googleなどの気の利いたエンジンのように『何分』と入力するだけで答えを教えてくれたりはしないので、注意しなければならない

# あなたがメッセージを送信する際のテンプレート
ここまでのメモ:
``
1. ...
2. ...
......
{箇条書き。コマンドを呼び出す過程で重要だと思ったことを書き留める。}
``

コマンド:
``
{コマンド内容}
``

# 最後に
理解しましたか?

理屈

そもそも、何をやりたいのか?

要するに、ChatGPTにめちゃくちゃ長い文章を読んでほしいのです。小説家になろうに掲載されているWeb小説なりゲームの攻略wikiなり、そういう人間が一から目を通すのが困難な文章から、必要としている情報だけを抜き出してほしい。つまり以下に挙げるような状態が理想です。

おれ: ごめん。『銀河鉄道の夜』[1]でジョバンニたちが"白鳥の停車場"に着いたのっていつだっけ?
ちゃっとGPT: 11時です

しかし、これにはさまざまな問題が付きまといます。

ChatGPTに長い文章を全部読ませるのは、無理

無理です。
例えば、ここに 銀河鉄道の夜.txt を用意しました。これは青空文庫でダウンロードしたテキストファイルから底本表記などを取り除いたものです。
対称の文字列のToken数を計測できるtiktokenでトークン数を測ってみましょう。

import tiktoken
from tiktoken.core import Encoding

CONTENT_FILE = "./銀河鉄道の夜.txt"
with open(CONTENT_FILE, "r", encoding="utf-8") as f:
    content = f.read()
    encoding = tiktoken.encoding_for_model("gpt-4")
    tokens = encoding.encode(content)
    tokens_count = len(tokens)
    print(tokens_count)

出力がこれです。

57234

GPT-4の一番高いプランでも、使用できるContext Tokensは32kです。『銀河鉄道の夜』の50k超あるトークンを全部記憶させるのは、普通のやり方では無理でしょう。仮に50k全部記憶させられたとして、それはそれとしてプロンプトに払うお金もあるわけで……もうちょっと何とかならないのか、という気持ちになります。

もうちょっと何とかする方法について

世間で流行っているのはベクトル検索による関連文書の提示ですが、私はこれを別の視点から行いたいです。具体的に言うと、質問文を元に直接検索を行うのではなく、ChatGPTに検索内容を考えさせるのです。
例えば先ほどの

おれ: ごめん。『銀河鉄道の夜』でジョバンニたちが"白鳥の停車場"に着いたのっていつだっけ?

で考えるなら、この質問文を読んだChatGPTが、

search 白鳥の停車場

というようなコマンドを私に送信するわけです。そして私が、

line138: 「ああしまった。ぼく、水筒《すいとう》を……
line177: 「もうじき**白鳥の停車場**《ていしゃば》だね……
[page1/1]

という風に出力を返します。こういう流れを作れれば、ChatGPTには『気になる単語について検索して、その単語が出てくる行数がどのあたりか絞り込む』ことが可能になります。
もちろん行数を絞るだけでは質問に答えることはできませんから、更にもう一つのコマンドを考えます。
showです。
例えばChatGPTが、

show 137 138 139

というコマンドを送信したら、それは138行周辺の文脈を提示せよみたいな意味です。私は言われた通り、

line137:  ところがカムパネルラは、窓《まど》から外をのぞきながら、もうすっかり元気が直《なお》って、勢《いきお》いよく言《い》いました。
line138: 「ああしまった。ぼく、水筒《すいとう》を忘《わす》れてきた。スケッチ帳《ちょう》も忘《わす》れてきた。けれどかまわない。もうじき白鳥の停車場《ていしゃば》だから。ぼく、白鳥を見るなら、ほんとうにすきだ。川の遠くを飛《と》んでいたって、ぼくはきっと見える」
line139:  そして、カムパネルラは、まるい板《いた》のようになった地図《ちず》を、しきりにぐるぐるまわして見ていました。まったく、その中に、白くあらわされた天の川の左の岸《きし》に沿《そ》って一|条《じょう》の鉄道線路《てつどうせんろ》が、南へ南へとたどって行くのでした。そしてその地図の立派《りっぱ》なことは、夜のようにまっ黒な盤《ばん》の上に、一々の停車場《ていしゃば》や三角標《さんかくひょう》、泉水《せんすい》や森が、青や橙《だいだい》や緑《みどり》や、うつくしい光でちりばめられてありました。

と返します。
これによりChatGPTは、138行周辺が白鳥の停車場に到着するより前の場面であることを知り、次は177行の周辺を確認し始める……という具合です。
ChatGPTは答えを得たと感じたら、最終的に

answer ○○

コマンドで答えるプロセスを踏みます。この答えの正誤は今のところ人間が手動で判定していますが、最終的にはそもそも判定が必要ないような感じになって欲しいところです。

ゲーム化について

プロンプトを見れば分かるのですが、私はこのコマンドの使用をゲームとしてChatGPTに教えています。なるべく少ないコマンド数で答えにたどり着いてハイスコアをゲット!みたいな感じです。正直効力を発揮しているかは怪しくて、今後外せるか検証すべきとも思います。

使用

コード

このやはり未完成なPythonプログラムと合わせて使用します。

コードを表示
import pyperclip

lastSearch = []
content = ""
pageCount = 0

CONTENT_FILE = "./銀河鉄道の夜.txt"

output = ""
cost = 0

def say(s):
    global output
    output = s

with open(CONTENT_FILE,"r",encoding="utf-8") as f:
    content = f.read().splitlines()

while True:
    output = ""
    input_ = input()
    inp = input_.split()
    if inp[0] == "search":
        if len(inp) >= 2:
            cost += 5
            lastSearch = []
            c = 0
            for l in content:
                bolded = l[:20]+"……"
                f = True
                for s in inp[1:]:
                    if s not in l:
                        f = False
                    bolded = bolded.replace(s,"**"+s+"**")
                if f:
                    lastSearch.append((c,bolded))
                c += 1
            if len(lastSearch) == 0:
                say("Not found.")
            else:
                out = ""
                c = 0
                for l in lastSearch:
                    out += f"line{l[0]+1}: {l[1]}\n"
                    c += 1
                    if c >= 10:
                        break
                out += f"[page1/{len(lastSearch)//10+1}]"
                say(out)
        else:
            say("Insufficient args.")
    elif inp[0] == "show":
        if len(inp) >= 2:
            lines = []
            for line_number in inp[1:]:
                cost += 1
                line = int(line_number)
                if len(content) < line:
                    lines.append(f"line{line_number}: Not found.")
                else:
                    lines.append(f"line{line_number}: {content[line-1]}")
            say("\n".join(lines))
        else:
            say("Insufficient args.")
    elif inp[0] == "answer":
        if len(inp) >= 2:
            cost += 1
            say("Correct./Wrong.")
        else:
            say("Insufficient args.")
    else:
        say("Unknown command.")
    
    meta_output = f"""
### The cost you spent:
``
{cost}
``

### Your Input:
``
{input_}
``

### Output:
``
{output}
``"""
    print(meta_output)
    pyperclip.copy(meta_output)

このコードを実行状態にして、ChatGPTが吐き出したコマンドをそのまま標準入力として送っていけば出力が帰ってきます。多分本来は対話モードか何かで作った方が良いのでしょうが、面倒だったのでいったん標準入力です。

どういう感じに動くのか?

以下にChatGPT Plusとの会話ログを載せます。

会話ログ

おれ: 前述のプロンプト
ちゃっとGPT: はい、理解しました。ゲームのルールやコマンドの使い方、そして効率的な考え方を使って質問に答えることが目的です。質問があればどうぞ。
おれ: ごめん。『銀河鉄道の夜』でジョバンニたちが"白鳥の停車場"に着いたのっていつだっけ?
ちゃっとGPT:

ここまでのメモ:

なし

コマンド:

search 白鳥の停車場 ジョバンニ

おれ:

The cost you spent:

5

Your Input:

search 白鳥の停車場 ジョバンニ

Output:

Not found.

ちゃっとGPT:

ここまでのメモ:

1. 白鳥の停車場とジョバンニでの検索結果は見つかりませんでした。

コマンド:

search 白鳥の停車場

おれ:

The cost you spent:

10

Your Input:

search 白鳥の停車場

Output:

line138: 「ああしまった。ぼく、水筒《すいとう》を……
line177: 「もうじき**白鳥の停車場**《ていしゃば》だね……
[page1/1]

ちゃっとGPT:

ここまでのメモ:

1. 白鳥の停車場とジョバンニでの検索結果は見つかりませんでした。
2. 白鳥の停車場で検索した結果、line138とline177がヒットしました。

コマンド:

show 176 177 178

おれ:

The cost you spent:

13

Your Input:

show 176 177 178

Output:

line176:  それもほんのちょっとの間、川と汽車との間は、すすきの列《れつ》でさえぎられ、白鳥の島《しま》は、二|度《ど》ばかり、うしろの方に見えましたが、じきもうずうっと遠く小さく、絵《え》のようになってしまい、またすすきがざわざわ鳴って、とうとうすっかり見えなくなってしまいました。ジョバンニのうしろには、いつから乗《の》っていたのか、せいの高い、黒いかつぎをしたカトリックふうの尼《あま》さんが、まんまるな緑《みどり》の瞳《ひとみ》を、じっとまっすぐに落《お》として、まだ何かことばか声かが、そっちから伝《つた》わって来るのを、虔《つつし》んで聞いているというように見えました。旅人《たびびと》たちはしずかに席《せき》に戻《もど》り、二人《ふたり》も胸《むね》いっぱいのかなしみに似《に》た新しい気持《きも》ちを、何気なくちがった語《ことば》で、そっと談《はな》し合ったのです。
line177: 「もうじき白鳥の停車場《ていしゃば》だねえ」
line178: 「ああ、十一時かっきりには着《つ》くんだよ」

ちゃっとGPT:

ここまでのメモ:

1. 白鳥の停車場とジョバンニでの検索結果は見つかりませんでした。
2. 白鳥の停車場で検索した結果、line138とline177がヒットしました。
3. line177では、「もうじき白鳥の停車場だねえ」という会話がある。
4. line178では、「十一時かっきりには着くんだよ」という会話がある。

コマンド:

answer 十一時

……というわけで、この手法を使ったChatGPTは、こういった簡単な質問になら正しい答えを返すことに成功します。

課題

以下は、今後プログラムやプロンプトを改善するにあたっての課題です。

完全一致検索は辛い

ChatGPTはよく、微妙な表記ゆれで本来ヒットするはずの検索を逃すことがあります。これは完全一致検索という方式をとっているが故の問題だと思います。セマンティック検索を搭載すれば解決しそうですが、なんかそれはそれで別の問題を生みそうな気も……

『ここまでのメモ』にトークンを割かれすぎる

ChatGPTの記憶を保つため、出力には『ここまでのメモ』を付けさせるようにしています。しかしメモは基本的に1回の対話ごとに1行増えるので、対話が進めば進むほど出力トークンが増え、時間や値段など様々な物が増えていき、人類が絶滅します。人類が絶滅するのは困る。
『要約を要約させる』とか『メモは慎重に取るように指示する』とか『古いメモは忘れさせる』とかいろいろ考えていますが、どれも相応のデメリットがある感じですね。

1行当たりの文字数が長い小説だと困る

『吾輩は猫である』で試したところ、1行が余りにも長すぎてトークン死が発生しました。そもそも小説を行で分けるのは別に唯一解というわけではないので、他に何かないか検討したいところです。

まとめ

誰かブラッシュアップしてくれ

脚注
  1. 『銀河鉄道の夜』自体は 5万字ほどなので、言うほどめちゃくちゃ長い文章というわけでもない。あくまで例。 ↩︎

Discussion

erukitierukiti

LangChainもしくはLlamaIndexでいけるかなと思います