Featured image of post コードパペッティア【RAG】昨日を何度も尋ねる歴史家人形〜あふれる年代記と、共鳴する一節〜

コードパペッティア【RAG】昨日を何度も尋ねる歴史家人形〜あふれる年代記と、共鳴する一節〜

数日前の約束を忘れ、同じ確認を繰り返す歌劇場の記録人形クリオ。過去ログの全文詰め込みが人形から「今」を奪う理由を解き明かし、埋め込みとコサイン類似度で関連する記憶だけを引き出すRAGの仕組みをPythonで実装します。

宵の工房は、一日のうちでいちばん音の少ない時刻を迎えていました。

ガスランプをひとつ、またひとつと落としていくと、赤いベルベットの緞帳の襞に暗がりが溜まり、昼のあいだ忙しく回っていた歯車たちも、油の膜の下で眠たげに沈んでいきます。私は作業台の道具を布で包みながら、今日という日を仕舞う支度をしていました。

工房の奥では、マエストロが小さな作業灯だけを残して、真新しい蝋管に何かを刻んでいました。あの「記憶の蓄音機」の棚の前です。針が蝋の溝を彫っていく、囁くようなかすかな音。毎朝シリンダーを磨いているのは知っていましたが、宵にああして何かを刻んでいる姿を、私はこれまで、気に留めたことがありませんでした。

その囁きに、遠慮がちなノックの音が重なったのです。

第1幕: 予兆と調律 ── 二度目の「初めまして」

扉を開けると、外套の肩を宵闇に濡らした初老の紳士が立っていました。すり切れた黒い上着、胸ポケットに三本差した予備のペン、袖口にはインクの染みが年輪のように重なっています。そして紳士の手は、隣に立つ人形の手を、壊れ物を支えるようにそっと取っていました。

人形は、自分の足で敷居をまたぎました。

深緑のドレスに、胸元には小さな真鍮のオペラグラス。手には細い象牙の指し棒。首の後ろに、巻き鍵の頭が上品に覗いています。彼女は工房の中央まで進むと、裾を摘まんで、寸分の狂いもない一礼をしてみせました。

「初めまして。クリオと申します。オルフェウム座の記憶を、預かっております」

声にも、所作にも、どこにも狂いがありません。壊れた人形が持ち込まれる工房で、こんなに完璧な挨拶を受けたのは初めてのことでした。

「私はエイモスと申します。隣街の歌劇場、オルフェウム座で、座付きの文書係を四十年ばかり」紳士は帽子を胸に当て、恭しく頭を下げました。「ベルヴューの女将さんが、貸切の相談の折に教えてくださいました。人形の性根ではなく、糸の張り方を直してくださる工房があると。……助手さん、で、いらっしゃいますね」

「コレットと申します。あの、それで、クリオさんはどこの具合が――」

言いかけた、そのときでした。

クリオの姿勢が、ふっと緩んだのです。衣擦れのような駆動音が細く、細くなって、まぶたが静かに閉じられました。眠りに落ちる、というよりも、蝋燭の火がすうっと小さくなって消えるような、静かな止まり方でした。

「ああ――申し訳ありません。朝に巻いた分が、ちょうど尽きる頃合いでして」

エイモスさんは慣れた手つきで彼女の首の後ろの鍵をつまみ、こり、こり、と丁寧に巻きました。一巻きでおよそ半日。それがこの子の一日の長さなのだと、エイモスさんは言い添えました。

クリオのまぶたが開きます。彼女は居住まいを正し、私のほうへ向き直り――裾を摘まんで、寸分の狂いもない一礼をしてみせました。

「初めまして。クリオと申します。オルフェウム座の記憶を、預かっております」

一言一句、同じ挨拶でした。

さっきと同じ角度のお辞儀。同じ抑揚。同じ微笑み。それがあまりに完璧であるだけに、私は背筋のあたりが、すっと寒くなるのを感じました。ほんの数分前に交わしたはずの「初めまして」が、この子の中のどこにも残っていないのです。

「……こういうことなのです、助手さん」

エイモスさんは目を伏せました。クリオはといえば、二度目の挨拶を終えて、なぜ私たちが黙り込んだのか分からない様子で、行儀よく次の言葉を待っています。同じ口上を、もう一度きれいに演り直してみせたその健気さに、私は胸の内で、この子を「おさらいさん」と呼ぶことに決めました。稽古を何度でも最初から浚い直す、律儀なおさらいさん。ただしこの子のおさらいは、本人が選んでしているものではないのですけれど。

エイモスさんの話は、ゆっくりと、しかし正確に進みました。私が相槌を打つたび、彼は「さようです」と丁寧に頷いてから続けるのです。

クリオはオルフェウム座の開場のころから、桟敷の袖で来賓に劇場の歴史を語ってきた人形でした。試しに「三代前の支配人は、どんな方でしたか」と尋ねてみると、クリオは目を細め、まるで昨日会った人のことのように、その人柄と、彼が愛した演目と、引退の夜の逸話とを、よどみなく語ってみせました。積み込まれた劇場史の素養は、少しも損なわれていないのです。

「この子が忘れますのは、昨日、私どもと交わした言葉のほうなのです」

エイモスさんの声が、わずかに低くなりました。

事の起こりは、オルフェウム座の創立百周年でした。記念の式典でクリオは来賓に劇場史を語る大役を任されるはずでした。けれど式典の支度が始まると、日々の打ち合わせで決まっていく事柄――誰を壇上に呼ぶか、どの逸話を語るか、どの話題を避けるか――を、クリオは翌日にはすっかり忘れてしまう。決まったはずの段取りを、翌朝また最初から尋ね直す。片付いたはずの不具合の相談を、何度でも蒸し返す。

「若い座員たちは、もう申しております。クリオさんに伝えても無駄ですよ、と」エイモスさんは帽子の縁を、両手で静かに握り直しました。「支配人からは、百年の節目に、案内は新式のからくりに替えてはどうか、と。……退役、という言葉が、出ております」

「そんな……この子、こんなにちゃんと動いているのに」

「はい。動いてはおるのです」

エイモスさんの返事は、私の言葉を肯定しているのに、どうしてか、私の胸を突きました。

彼が講じてきた手立ても、聞かせてもらいました。エイモスさんは四十年間、劇場で交わされた約束事や出来事を、一冊また一冊と、年代順の綴じ込み台帳に書き留め続けてきました。革の鞄から取り出されたその一冊は、角が丸くなるまで使い込まれ、几帳面な文字がページの端まで整然と並んでいます。毎朝、彼はこの台帳から「今日入り用になりそうな決め事」を数節選び、クリオのゼンマイを巻く前に、声に出して読み聞かせてやるのだそうです。

「ですが、選りますのは、私の勘でございますから」

そこで一度、エイモスさんは言葉を切りました。しばらくして、告解のような小声で続けたのです。

先日の通し稽古でのこと。式典の主賓である男爵夫人について、亡くなられたご主人の逸話には触れない、と打ち合わせで決めてあった。ところがその朝の読み聞かせから、その一件だけが漏れた。クリオは稽古の席で、来賓役の座員に問われるまま、亡き男爵の思い出話を、それは美しく、滔々と語り始めてしまった――。

「私の選り抜きが至らぬばかりに。……退役の話が本決まりになりかけたのは、あの日からでございます」

「エイモスさんのせいでは――」

「いいえ、助手さん。それに、時の話もございます」彼は静かに首を振りました。「私も、この秋で退きます。私が退けば、朝の読み聞かせも終わる。そうなればこの子は、昨日のない日々を、ひとりで演り続けることになりましょう」

私はクリオの背中の小さな扉を開けさせてもらいました。中には、思考の元になる大規模言語モデル(LLM)(大量のテキストデータから言語のパターンを学習し、高度なテキスト生成や推論を行うための深層学習モデル)を据えた思考のゼンマイと、指示の糸――プロンプト(大規模言語モデル(LLM)に対して指示や制約を与え、特定の応答や挙動を促すための入力テキスト)が収められています。私は糸の張りを一本ずつ確かめ、歯車の噛み合わせを覗き、油の巡りを見ました。

どこも、壊れていないのです。

歯車は磨き上げられ、ゼンマイは滑らかで、糸には一箇所のほつれもない。昨日を忘れることは、この子の故障ではありませんでした。ゼンマイが解けて眠り、新しく巻かれるたびに、そのあいだに交わした言葉が白紙に戻る――それはこの子の、仕組みそのものなのです。

ふと思いついて、私は隣で聞いていたアンに尋ねてみました。

「アン。三日前の、帽子屋さんの山高帽のこと、覚えてる?」

「内張りの寸法直しでしょう。当て布で仕上げたわ」アンは扇の陰から私を一瞥しました。「……あら。私を試したの? いい度胸だわ」

即答でした。アンも、クリオと同じ自律人形のはずです。同じようにゼンマイで動き、同じように眠るのに、アンは三日前を覚えていられる。

「ねえアン、あなたはどうして、昨日を覚えていられるの?」

アンは扇を頬に当て、少しだけ首を傾げました。

「さあ。朝になれば、思い出せるようになっているのよ。ずっと、そういうものだと思っていたけれど」

本人にも分からない。その答えを、私はこのときは深く追いませんでした。追うべきだったのです。けれどこの晩の私の頭は、もう別の考えでいっぱいになっていました。

選んで読み聞かせるから、漏れるんだわ。

エイモスさんの勘がどれほど確かでも、勘は勘。抜けた一節が、あの気の毒な稽古の一件を招いたのなら――ぜんぶ、覚えさせてあげればいい。工具だるまさんのときは「道具は使う分だけ」で上手くいったけれど、あれは道具の話です。記憶は道具とは違う。どれが要るかなんて、前もって分かるものではないし、この子の四十年を、私が選り分けて捨てるなんて、できるはずがない――。

今にして思えば、それは「選ぶ」という仕事そのものから目を逸らす、いちばん楽で、いちばん優しい顔をした早合点でした。

第2幕: 絡糸と破綻 ── 床を這う巻物

その晩、私はエイモスさんから台帳を借り受けました。百周年の支度に関わる直近の綴りと、関わりのありそうな数年分。「どうか、お手柔らかに」と台帳を差し出すエイモスさんの手つきは、まるで眠った赤子を渡すときのようでした。

私は夜なべで写字にかかりました。一節、また一節。決め事をひとつ、出来事をひとつ。蝋燭を二度取り替え、インク壺がひとつ空になり、右手の小指の付け根には青黒い染みが刻印のように残りました。羊皮紙を何枚も糊で継ぎ足して、出来上がったのは一本の、長い長い「読み聞かせ巻物」です。夜明け前に最後の一節を写し終えたとき、巻物の端は作業台から滑り落ち、床を這って、工房の敷居の近くまで届いていました。

我ながら、壮観でした。この巻物さえあれば、クリオはもう何ひとつ取りこぼさない。私は床を横切る紙の川を眺めながら、確かにそう信じていたのです。

朝になって、エイモスさんの立ち会いのもと、最初の試しに掛かりました。クリオの鍵を巻き、目覚めた彼女に、巻物を頭から読み聞かせます。読み聞かせだけで、たっぷりと時間がかかりました。そのあいだクリオは目を開けたまま、身じろぎひとつしません。全部を読み終えて、エイモスさんが最初の問いを口にします。

「クリオ。男爵夫人をお迎えする段取りは、どうなっておりましたかな」

「存じております。開幕のご挨拶ののち、支配人が大階段の下までお迎えに上がり――」

答えは、正しいものでした。エイモスさんの顔がぱっと明るくなり、私も胸を撫で下ろし――かけて、止まりました。

正しいのです。正しいのですが、答えが返るまでの間が、妙に長い。それに、よく見ていると、問いを受けるたびにクリオの瞳が、まるで長い手紙を最初の行から読み返すように、すうっと上から下へ流れるのです。一問ごとに、毎回、あの巻物ぜんぶを頭から浚い直している。そんな目の動きでした。しかも、その間は問いを重ねるごとに、少しずつ、確かに延びていきます。

「動いてる……わよね?」

誰にともなく、私は呟いていました。動いています。答えも合っています。なのに、初めて疑問の形になったその独り言に、私自身がいちばん驚いていました。目の前で確かに動いているものを、動いていると言い切れない。こんな感覚は、初めてでした。

続く問いで、違和感は形を持ち始めました。招待客の席次を尋ねられたクリオの答えに、なぜか、三年前の雨漏り改修の顛末が紛れ込んだのです。桟敷席の並びの説明の途中に、「なお、三階東側の天井裏は、防水の当て板を新調いたしまして」と、誰も尋ねていない修繕の記録が、丁寧な口調のまま挟み込まれる。エイモスさんが困ったように私を見ます。

私の講じた手は、こうでした。写しの順序をもう一度年代順に整え直すこと。読み聞かせを朝と昼の二度にすること。大事な節は、ゆっくり、大きな声で読んでやること。――お恥ずかしい話です。相手が人間の生徒なら、それは丁寧と呼ばれる手当てだったかもしれません。けれど私は、伝わらないときに人が人へすがる「徹底」を、そっくりそのまま、からくりに向けていたのです。読む量を増やせば増やすほど、クリオの目の往復は長くなり、答えの間延びはひどくなっていきました。

そして、通し稽古を模した問答の席で、それは起きました。

「では、クリオ。開幕のご挨拶のあと、最初にご紹介する逸話は、どちらでしたかな」

沈黙がありました。長い沈黙でした。クリオの瞳が、上から下へ、また上から下へ、幾度も幾度も往復します。それからおもむろに、彼女は口を開きました。

「オルフェウム座の緞帳が、初めて上がりました夜のことでございます。初代支配人は開演の鐘を自らの手で打ち鳴らし――」

「ク、クリオさん? いえ、その、最初にご紹介する逸話を――」

「――柿落としの演目は三夜連続の大入りとなり、二年目の春には楽団の編成が改められ、四年目の冬には大階段の絨毯が張り替えられ――」

止まりませんでした。

誰も尋ねていない百年が、開場の夜から順に、美しい発声で、正しい年代で、延々と語られ続けるのです。声は少しも乱れていません。所作は優雅なままです。壊れた歯車の軋みも、糸の千切れる音もしない。ただ、この子の中から「今」だけが、すっぽりと抜け落ちてしまっている。問いを重ねても、名を呼んでも、クリオの年代記は次の一節へ、また次の一節へと進んでいきました。歌う給仕さんのときとも、黙り込んだ案内人形さんのときとも違う。動き続けたまま壊れるという壊れ方を、私はこのとき初めて見たのです。

やがて、エイモスさんが静かに立ち上がりました。彼はクリオの傍らへ歩み寄ると、年代記を誦じ続ける彼女の首の後ろへ、そっと手を添えました。

「もう、よい。……もうよいのです、クリオ」

小さな留め金が、こと、と倒れる音がしました。ゼンマイの駆動が緩み、声が細くなり、まぶたが下りて、クリオは眠りに就きました。彼女を止める手立てが、眠らせることしかないのだという事実が、その静けさの中で、いちばん重く響きました。

「……式典では、これが、千人の御前で起こるところでした」

エイモスさんの声に、責める色はありませんでした。それがかえって堪えました。喉の奥がからからに乾いて、上手く声が出ません。床を這う巻物が、もう頼もしい紙の川には見えませんでした。私が夜なべで写したこの紙の山が、この子の「今」を、山の底に沈めてしまったのです。

第3幕: 示唆と編み直し ── 共鳴する一節

「あなた、あの子に毎朝百年を読ませて、そのうえで『今』のひと粒を拾えとおっしゃるの?」

沈黙を破ったのは、アンの冷ややかな声でした。彼女は眠るクリオの隣に立ち、床の巻物を扇の先で示しました。

「客人の声はたった一節。積まれた紙は幾千節。肝心のひと粒は、あなたの積んだ紙のちょうど真ん中あたりに沈むのよ。埋もれるに決まっているじゃない。それにね――関わりのない話は、黙って控えていてはくれないの。混ざるのよ。席次のお話に雨漏りが顔を出したでしょう。あれは行儀の悪い客と同じ。呼ばれてもいないのに、声だけは届いてしまうの」

言い返す言葉は、ありませんでした。埋もれて、混ざる。今日一日で私が見たものの正体を、アンは二言で言い当てていました。

そのとき、工房の奥で、椅子の引かれる音がしました。

マエストロが立ち上がったのです。彼は無言のまま、奥の暗がりへ――あの「記憶の蓄音機」の棚の前へ歩いていきました。天井まで届く棚に、何百本という蝋管が、背表紙のように整然と並んでいます。マエストロは手近な小箱から親指ほどの短い蝋管を選び取り、小さな手回しの装置に据えて、静かに回し始めました。

細い、細い旋律が流れました。

すると――棚のほうから、音が、返ってきたのです。

ジィ……、と。棚のあちこちで、ほんの数本の蝋管だけが、糸を弾いたように微かに唄い出しました。似た旋律を刻まれた管だけが、届いた調べに応えて震えている。他の何百本は、静まり返ったままです。マエストロはいちばん強く唄う一本へ迷いなく手を伸ばし、抜き取り、蓄音機の軸へ据えました。

針が、落ちます。

流れてきたのは――アンの声でした。

『内張りは張り替えより当て布がよくってよ。あの帽子屋さん、なかなか頭の形に個性がおありだもの』

三日前の、山高帽の一件。アンが扇を持ったまま、固まりました。私も、動けませんでした。目の前の棚と、毎朝の光景とが、頭の中で音を立てて繋がっていくのが分かりました。

「毎朝、磨いていらしたのは……アンの、昨日……」

「……ああ。あれの昨日は、この棚にある」

マエストロは短くそう言っただけでした。日々の対話を、夜のうちに一節ずつ蝋管へ刻む。朝には棚を磨いて手入れをする。アンが眠りから覚めるたびに白紙へ戻るのだとしても、問いが届けば、応える一節が棚から唄う。宵の工房で私が聞き流していたあの囁きは、アンの一日を仕舞う音だったのです。

「……積むだけなら、紙の山にもできる。呼ばれた一節が差し出せて、初めて『覚えている』と言うのだ」

それだけ言うと、マエストロはもう作業台のほうへ戻り始めていました。私は棚に並ぶ何百本の背表紙を、ただ見つめていました。四十年ぶんの台帳を持つエイモスさんと、幾千節の巻物を写した私に、欠けていたもの。積むことではなく、呼ばれた一節が届くように仕舞うこと。

長い沈黙のあと、アンが扇を開きました。

「……あら。私の昨日は、ずいぶん埃っぽい場所にあるのね」

精いっぱいの毒でした。けれど扇の陰の横顔は、まんざらでもないように見えました。

それまで眠るクリオの傍らに黙って立っていたエイモスさんが、ここで初めて口を開きました。

「あの、助手さん。ひとつだけ、伺わせてください」その声は、記録者らしく慎重でした。「言い回しが違っても、同じ事柄だと、分かるものなのでしょうか。私の台帳の見出しは『先代男爵の追悼』。ですが皆が口にしますのは『男爵夫人のご主人のお話』。……私は、それで選り漏らしたのでございます」

ああ、と思いました。エイモスさんは、ご自分の失敗の正体を、誰よりも正確に見抜いていたのです。同じ出来事が、日によって、人によって、違う言葉で呼ばれる。見出しの文字を頼りに探すかぎり、その揺れは必ずどこかで人を裏切ります。そしてこの問いこそが、これから私が編む仕組みの、いちばんの勘所でした。

「分かるようにできるんです。文字の一致ではなくて、意味の近さで探しますから」

言葉にしながら、私の頭の隅で、もうひとつの疑問が首をもたげました。意味の近さで探して、関わりのある分だけを渡す――それは。

「これ……工具だるまさんの、道具棚と同じ……?」

「あら」アンが片眉を上げました。「道具の説明書きと、私の交わした言葉を、一緒にしないでちょうだい」

その通りでした。探す仕掛けそのもの――埋め込みにして、意味の近さで見比べる――は、あの棚とまったく同じです。それでも考えるほどに、選ぶものの正体が、まるで違うのです。あのときの棚に並んでいたのは道具の説明書き。一度拵えてしまえば、もう増えません。けれど記憶は、今夜も増える。現にマエストロは、今夜もアンの一日を刻むのです。それに、選んだ道具は手に持たせるものでしたが、選んだ一節は、話の続きとして聞かせるもの。同じ「選ぶ」でも、何を、いつ書き足しながら、どう手渡すのかが、ぜんぶ違う――。

その整理がついたとき、私の中で、編むべきものの形が決まりました。

編み直しは、私の作業台からではなく、エイモスさんの鞄から始まりました。

まず、彼の台帳です。四十年の記録を、一節ずつに切り分けます。一つの決め事、一つの出来事で、一節。長い記録をこうして検索しやすい断片に切り分ける前処理を、チャンク分割(長い文書や記録を、検索に適した大きさの断片に切り分けること)と呼びます。エイモスさんが台帳から一節を読み上げ、私がそれを記憶の器に刻む。二人で手分けした写経のような時間でしたが、昨夜の写字とは、手応えがまるで違いました。昨夜の私は「全部をひとつに繋ぐ」ために写した。今夜の私たちは「一節ずつ呼び出せるように仕舞う」ために刻んでいるのです。

「私の台帳が、そのまま使えるのでございますね」

エイモスさんの声が、少しだけ高くなりました。ええ、と私は頷きました。四十年の記録は、何ひとつ無駄ではなかったのです。悪かったのは記録ではなく、引き方のほうでした。

ここから先は、道具の名前で話を進めます。

今回の仕組みの土台は、LLMとの対話がステートレス(呼び出しのたびに白紙から始まり、前回までのやり取りを自動では覚えていない性質。文脈は毎回、入力として渡し直す必要がある)である、という事実です。クリオのゼンマイが巻き直されるたびに白紙へ戻るのは、この性質の現れでした。だから「覚えさせる」とは、毎回の入力に、何かしらの記録を添えてやることに他なりません。問題は、何を添えるかです。

昨夜の私の答えは「全部」でした。そして全部を添える設計は、二重に壊れます。第一に、長い文脈の先頭と末尾は拾われやすい一方で、中程に置かれた情報ほど見落とされやすくなる――lost in the middle(長い入力の中間に埋もれた情報への言語モデルの参照精度が、先頭・末尾に比べて大きく落ちる現象)として知られる性質があるからです。関わりのある一節が幾千節の真ん中に沈めば、正しく拾われる保証は薄れていきます。第二に、関わりのない記録はただ黙って場所を取るのではなく、雑音として応答に混ざり込み、今の指示への追従そのものを乱します。文脈に置かれた文字列は、モデルにとってどれも、次の言葉を選ぶ材料の候補になるからです。ここは読み飛ばしなさい、という印を付けておくことはできません。席次に雨漏りが顔を出したのが、まさにそれでした。おまけに、対話が増えるほど毎回運ぶ量は際限なく膨らんでいく。埋もれて、混ざって、重くなる。三拍子です。

そこで、添えるものを「全部」から「関わりのある数節」へ変えます。この設計を RAG(Retrieval-Augmented Generation)(外部の記録から関連する情報を検索して取り出し、プロンプトに加えてから生成させることで、モデル自身が覚えていない事柄にも根拠を持って答えさせる手法)と呼びます。検索の物差しになるのが Embedding(埋め込み)(テキストの意味を、数百から数千個の数値が並んだベクトルに写し取ったもの。意味の近い文章ほど、ベクトル同士も近い向きを指す)です。そして二つのベクトルの向きがどれだけ揃っているかを、コサイン類似度(二つのベクトルのなす角の余弦で意味の近さを測る指標。値は−1から1の範囲を取り、1に近いほど近い)で測ります。文字の一致ではなく埋め込みの近さで探すこの方式がセマンティック検索(キーワードの一致ではなく意味の近さで探す検索方式。言い換えや表記の揺れに強い)で、エイモスさんの「見出しが違えば選り漏らす」問題への、まっすぐな答えになります。『先代男爵の追悼』と『男爵夫人のご主人の話』は、文字はまるで違っても、意味のベクトルはほとんど同じ向きを指すからです。

埋め込みを大量に蓄えて、近い順に探し出せるようにした保管庫をベクトルデータベース(埋め込みベクトルを蓄積し、与えたベクトルに近いものから順に高速に検索できるよう設計されたデータストア)と呼びます。本式には専用の製品を使いますが、仕組みを見るには小さなインメモリの実装で十分です。マエストロの蓄音機だって、劇場一軒ぶんの記憶なら、あの棚一本で足りているのですから。

設計は、こう決めました。記憶の器 MemoryStore には、経路を二つだけ持たせます。書き込みの add と、読み出しの searchadd は一節を埋め込みと一緒に仕舞います。ここが道具棚との違いの急所で、道具の索引は一度組んだら増えませんでしたが、この add は会話のたびに呼ばれ続けます。今夜の対話が、今夜のうちに明日の記憶になる。マエストロが宵ごとに蝋管を刻むのと、同じ経路です。

search は、問いの埋め込みと仕舞われた全節の埋め込みをコサイン類似度で見比べて、近い順に並べ、上位の数件だけを返します。ここでひとつ、実装上の約束事があります。埋め込みのベクトルは、長さを1に揃えて(正規化して)返す前提にしました。長さが1同士なら、内積――コードの np.dot ――の値が、割り算をひとつも挟まずに、そのままコサイン類似度になるからです。そしてもしこの前提が破れて範囲の外の値が生まれれば、後述する MemoryMatch の型検証が黙っていません。この「上位k件だけ取る」方式が top-k(類似度の高い順に上位k件のみを検索結果として採用する方式)です。ただし、top-k だけでは足りません。そこにもうひとつ、スコア閾値(類似度がこの値に届かない記録は、件数枠が余っていても採用しない足切りの線)を重ねます。なぜ足りないのかは――このあと、模擬公演で私自身が思い知ることになります。

埋め込みへの変換そのものは、EmbedFn という差し替え可能な関数として外から受け取る作りにしました。本番では埋め込みモデルのAPI(たとえば OpenAI の embeddings エンドポイント)を呼ぶ関数を渡し、模擬公演では、決めた言葉に決めたベクトルを返す作り物に差し替える。工具だるまさんの検証で確立した、検証を運任せにしないための型です。

書き上げた before と after を、並べて掲載します。

before.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# file: agents/code-puppeteer/tests/memory-semantic-rag/before.py
"""歌劇場の記録人形クリオの記憶実装(Before): 会話履歴のベタ貼り

台帳(chronicle)の全節を毎回頭から連結し、今の問いを末尾に置いた単一の
ユーザーメッセージでLLMを呼ぶ。問いに関わりのない節も含め、すべてを毎回
運ぶため、対話が増えるほど1回の読み込み量が際限なく膨らむ。
"""
import sys
import os
from typing import List

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class ArchivistAgent(BaseAgent):
    """歌劇場の記録人形クリオ(Before)。

    台帳(chronicle)の全節を毎回連結し、今の問いとともに単一のユーザー
    メッセージへ詰め込む。関連度で選び出す仕組みは持たない。
    """

    def __init__(self, name: str, system_prompt: str, chronicle: List[str], model: str = "gpt-4o"):
        super().__init__(name, system_prompt, model)
        self.chronicle: List[str] = list(chronicle)

    def run(self, user_input: str) -> str:
        all_entries = "\n".join(self.chronicle)
        combined_message = f"これまでの記録:\n{all_entries}\n\n今の問い: {user_input}"
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": combined_message},
        ]
        # 関連度による絞り込みは一切行わない。台帳が増えるほど、この1回の
        # 呼び出しで運ぶ文字数も際限なく増え続ける。
        response = self.client.chat.completions.create(model=self.model, messages=messages)
        answer = response.choices[0].message.content or ""
        self.record(f"問い: {user_input}\n答え: {answer}")
        return answer

    def record(self, entry: str) -> None:
        """今回の対話を台帳に追記する(書き残す行為自体はAfterのstore.add()と対称)"""
        self.chronicle.append(entry)

after.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# file: agents/code-puppeteer/tests/memory-semantic-rag/after.py
"""歌劇場の記録人形クリオの記憶実装(After): Semantic Memory Retrieval。

会話記憶をEmbeddingで蓄積し(MemoryStore.add)、コサイン類似度+top-k+スコア閾値で
関連する一節だけを文脈に差し込む(MemoryStore.search)。Beforeと同じメッセージ骨格のまま、
差し込む記録だけが「全節」から「関連節」に変わる(単一差分)。
"""
import sys
import os
from typing import Callable, List

import numpy as np
from pydantic import BaseModel, Field

current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class MemoryRecord(BaseModel):
    """記憶の一節(蝋管1本)"""

    content: str
    embedding: List[float]


class MemoryMatch(BaseModel):
    """検索結果1件(一節と、その関連度)"""

    record: MemoryRecord
    score: float = Field(ge=-1.0, le=1.0)  # コサイン類似度は理論上[-1, 1]


# 埋め込み取得を注入可能にする型エイリアス。本番は埋め込みAPI呼び出し、テストは決定的なフェイクに差し替える。
EmbedFn = Callable[[str], List[float]]


class MemoryStore:
    """記憶の蓄音機。書き込み経路(add)と読み込み経路(search)を分離する
    (拵えたら増えない道具目録と異なり、対話のたびに増え続ける記憶を扱う)。"""

    def __init__(self, embed_fn: EmbedFn):
        self.embed_fn = embed_fn
        self.records: List[MemoryRecord] = []

    def add(self, content: str) -> MemoryRecord:
        """一節を埋め込みつきで追記する(書き込み経路)"""
        record = MemoryRecord(content=content, embedding=self.embed_fn(content))
        self.records.append(record)
        return record

    # score_thresholdの既定値0.35に万能な正解はない。実データのスコア分布を見ながら調整するパラメータ。
    def search(self, query: str, top_k: int = 3, score_threshold: float = 0.35) -> List[MemoryMatch]:
        """クエリに関連する記録を降順に走査し、閾値を割ったら打ち切って残りからtop_k件を返す"""
        if not self.records:
            return []
        query_vec = np.array(self.embed_fn(query))
        scored = [(record, float(np.dot(np.array(record.embedding), query_vec))) for record in self.records]
        scored.sort(key=lambda pair: pair[1], reverse=True)
        matches: List[MemoryMatch] = []
        for record, score in scored:
            if score < score_threshold:
                break
            matches.append(MemoryMatch(record=record, score=score))
        return matches[:top_k]


class ArchivistAgent(BaseAgent):
    """歌劇場の記録人形クリオ(After)。クラス名はBeforeと同一(=同じ人形)。今の問いに関わる一節だけを
    store.search()で抽出し、単一のユーザーメッセージへ差し込む。応答後はstore.add()で追記する。"""

    def __init__(self, name: str, system_prompt: str, store: MemoryStore, model: str = "gpt-4o"):
        super().__init__(name, system_prompt, model)
        self.store = store

    def run(self, user_input: str) -> str:
        matches = self.store.search(user_input)
        related_entries = "\n".join(m.record.content for m in matches)
        combined_message = f"関わりのある記録:\n{related_entries}\n\n今の問い: {user_input}"
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": combined_message},
        ]
        response = self.client.chat.completions.create(model=self.model, messages=messages)
        answer = response.choices[0].message.content or ""
        self.store.add(f"問い: {user_input}\n答え: {answer}")
        return answer

見比べていただきたいのは、run() の骨格です。組み立てるメッセージの形は、before も after もまったく同じ。「記録を添えて、今の問いを置く」。応答のあとに対話を書き残すことも、recordadd とで対称です。変わったのはただ一点、添える記録が「台帳の全節」なのか、「検索で選ばれた関わりのある節」なのか。今回の編み直しの正体は、この一点に尽きます。

その一点がクリオの一日をどう変えるのか、羊皮紙に二つの流れを並べて描いてみました。床を這ったあの巻物と、共鳴で一節を選ぶ棚との違いです。

台帳の全節をベタ貼りするBeforeと、検索で関わりのある節だけを差し込むAfter(RAG)の文脈組み立て対比

台帳がどれだけ育っても、after の側では、クリオが一度に読む量だけが育ちません。あの晩の三拍子は、この非対称の中で、静かにほどけていくのです。

もうひとつ、search の中身に目を留めてください。類似度の高い順に並べたら、まず閾値を割った時点で打ち切り、残った候補から top-k 件を取る。二段構えです。片方だけではいけないのか――私も最初は、そう思っていました。

模擬公演が、その甘さを教えてくれました。

検証はいつものように、試験用オルゴール――本物の思考のゼンマイの身代わりを務める模擬環境――で行います。埋め込みも、決めた言葉に決めたベクトルを返す作り物に差し替えて、検証が運任せにならないようにしました。

まず before の構造から。関わりのある一節を、わざと無関係な節の山の真ん中に沈めた台帳を用意して問いを投げると、組み立てられたメッセージには、雨漏りの顛末も、先代舞台監督の退任の辞も、何もかもが漏れなく含まれていました。問いと関わりのある一節は、その山のちょうど中程に埋まっています。さらに台帳の節数を倍にすると、一回の呼び出しで運ぶ文字数も、ほぼ倍に膨らみました。埋もれて、混ざって、重くなる――昨日私が目で見たものが、数字でも確かめられたわけです。

次に after です。「男爵夫人にまつわる話は?」という問いに、夫人についての決め事の一節だけが返り、雨漏りも舞台監督も静かに見送られました。ここまでは、思った通り。

そのうえで、私はひとつ意地悪をしてみました。足切りの線を外して――閾値を事実上効かない値まで下げて――同じ問いを投げたのです。すると、top-k の枠が三つあるせいで、関わりのある一節のほかに、雨漏りと舞台監督の二節が、枠を埋めるようにくっついて返ってきました。関わりが薄かろうと、順位さえつけば上位三件は三件。

「これじゃ……小さなベタ貼りだわ」

思わず声が出ました。選んで渡しているつもりで、枠があるばかりに無関係な記録で数合わせをしてしまう。昨日の巻物と同じ過ちが、ずっと小さな姿で、検索の中に忍び込んでいたのです。足切りの閾値を戻すと、返ってくるのは関わりのある一節だけになりました。top-k が「多すぎを防ぐ」線で、閾値が「関係ないものを混ぜない」線。二段構えの意味が、身に沁みて分かりました。

忘れないうちに、問いが一節へたどり着くまでの道筋を、二本の線の居場所ごと控えに残しておきます。足切りの線を外したときの成り行きも、戒めとして点線で描き添えました。

searchの流れ。コサイン類似度で採点し降順に整列、スコア閾値で打ち切ってからtop-kで絞る二段構えと、閾値を外すと無関係な節が混入する分岐

点線の先に待っているのは、あの晩の巻物の、いちばん小さな姿です。

それからもうひとつ、腑に落ちたことがあります。工具だるまさんの棚が top-k だけで済んでいたのは、拾った数本が、あくまで候補だったからです。最後の一本は、依頼の言葉を読み取ったあの子自身が選び直していました。けれど記憶の一節は、候補ではありません。話の続きとして、そのまま聞かせる文脈です。紛れ込んだ一節は、誰にも選び直されないまま、まっすぐ答えを汚す。足切りの線が今回はじめて要るのは、そのためでした。

残りの検証も、ひとつずつ確かめました。対話のあとに store へ一節書き足されること。書き足された今夜の対話が、直後の関わりのある問いで、もう検索に掛かってくること。記憶がまだ一節もない空の器で検索しても、静かに空の答えが返ること。求める件数が総節数を超えても壊れないこと。そして、類似度の値がありえない範囲――一を超えるような値――で器に注がれそうになったときには、型の検めがきちんと撥ねつけること。

最後の確認が済んだとき、工房の蓄音機の針が、こと、と溝に落ちました。たった一節だけが澄んで流れ、終わって、針が上がる。あとには、静寂だけが残りました。幾千節を頭から流し切るのではなく、呼ばれた一節だけが鳴って、終わる。それがこの夜いちばんの、合格の音でした。

私たちはクリオの鍵を巻きました。読み聞かせは、もういりません。目覚めた彼女の書見台の下には、小さな共鳴の棚――彼女だけの、記憶の蓄音機が据えられています。エイモスさんが、昨日と同じ問いを口にしました。

「クリオ。男爵夫人をお迎えする段取りは、どうなっておりましたかな」

「開幕のご挨拶ののち、支配人が大階段の下までお迎えに上がります。お席は二階正面の貴賓席、ご案内は私が承ります」

間を置かない、澄んだ返答でした。あの、長い手紙を頭から読み返すような目の往復は、もうどこにもありません。エイモスさんは何度も細かく頷いて、それから、目元を袖口でそっと押さえました。

帰り際、エイモスさんは鞄から二枚の封筒を取り出し、私とアンへ差し出しました。百周年式典の、特等席への招待状でした。

「式典で、あの子の晴れ姿を。……それが何よりの御礼になると存じますので」

第4幕: 開演と調和 ── 百年から、一節

式典の夜のオルフェウム座は、建物ぜんたいが灯りの器になったようでした。

大階段には真紅の絨毯が走り、シャンデリアの光が振り子のように揺れて、正装の来賓たちのグラスに小さな星を浮かべています。どこかで調弦の音。楽団が今夜の序曲をさらっているのでしょう。私とアンは招待状を手に、ホワイエの人波の端に立っていました。マエストロは劇場に残りました。「舞台は、人形と、それを世話する者のものだ」と、それだけでした。

クリオは、ホワイエの中央にいました。

深緑のドレスに象牙の指し棒。来賓に問いかけられるたび、書見台の下の小さな棚が音もなく回り、彼女は百年の記録の中から、その問いに関わる一節だけを取り出して、短く、美しく語るのです。開場の夜を尋ねた老紳士には開場の夜を。楽団の変遷を尋ねた夫人にはその変遷だけを。誰も尋ねていない雨漏りの話は、今夜、一度も顔を出しません。

「歴史のことでしたら、クリオさんに伺うのが一番早いんですよ」

若い座員が、来賓を次々とクリオの前へ案内していました。ひと月前には「伝えても無駄」と言っていた面々です。現金なものだと思いつつ、その現金さが、私にはたまらなく嬉しいのでした。

やがて、人波が割れました。今夜の主賓――男爵夫人が、クリオの前に立ったのです。

「クリオさん。わたくしがこの桟敷に最後に参りましたのは、ずいぶん昔のことになってしまって」

私は知らず、拳を握っていました。夫人の話題。あの稽古の日、この子が触れてはならない一節を滔々と語ってしまった、その相手です。

クリオは一礼し、静かに語り始めました。

「よくお運びくださいました。この劇場は、奥様が二階の桟敷から白い花を投げてくださった春の宵を、今も覚えております。舞台の上の歌姫の足元へ、まっすぐに届きましたこと。あの晩の演目から、お聞かせいたしましょうか」

夫人の目が細められ、扇の向こうで、ふ、と息が緩みました。亡きご主人の話題には、針の先ほども触れない。それでいて、よそよそしい回避の匂いもしない。ただ夫人のための一節だけが、百年の中から選ばれて、そこにあるのでした。

種を明かせば、こういうことです。「ご主人の逸話には触れない」という、あの打ち合わせの決め事そのものが、一節としてクリオの棚に刻まれています。そして夫人にまつわる問いが届いたとき、その決め事は、夫人の話題と強く共鳴する一節として、埋もれようのない上位でクリオの手元へ届くのです。避けるべきことは、検索から漏れるのではありません。関わりが深いほど、関わりのある場面で、まっさきに引かれてくる。そして届いた決め事が守られるのは、添えられた文脈が短くなったおかげでもあります。幾千節の底に沈んでいた指図が、今は数節の中に、くっきりと立っているのですから。

式典が中程に差しかかったころ、エイモスさんが柱の陰でクリオの様子に目を留めました。ゼンマイの、緩む頃合いです。彼はクリオをそっと柱の陰へ導き、首の後ろの鍵を、こり、こり、と巻きました。

私は息を詰めて見ていました。巻き直し。白紙に戻る境目。あの宵、工房の敷居の内側で「初めまして」を二度聞いた、あの境目です。

クリオのまぶたが開きました。彼女は居住まいを正し、隣のエイモスさんを見上げて――にこりと微笑んだのです。

「――続きを、いたしましょうね」

初めまして、ではありませんでした。

眠る前の今夜が、もう彼女の棚に刻まれていて、目覚めた彼女の手元に、ちゃんと戻ってきている。ただそれだけのことが、私は涙が出るほど嬉しかったのです。隣でアンが「泣くほどのこと?」という顔をしましたが、その扇の動きも、心なしかゆっくりでした。

「助手さん」

いつの間にか、エイモスさんが隣に来ていました。ホワイエの光の中で、彼は少し改まった調子で尋ねたのです。

「記録は、これからも毎日増えてまいります。あの子の棚も、一年経てば三百六十五夜ぶん。……いずれまた、同じことになりは、いたしませんでしょうか」

「量のことなら、大丈夫です。棚がどれだけ増えても、一度の問いで取り出すのは、関わりの深い数節だけ。あの子が一度に読む量は、もう増えません。探すほうの手間は棚の側の仕事ですし、蔵書が本当に幾千、幾万となったら、そこは本式のベクトルデータベースが速さを受け持ってくれます」

それから私は、正直に付け加えました。この仕組みは、意味の近さしか知らないこと。だから、古い決め事と新しい決め事が食い違ったとき、どちらが「今」正しいのかまでは、選べないこと。そして、足切りの線は両刃だということ。低すぎれば小さなベタ貼りへ逆戻りし、高すぎれば、今度は機械のほうが選り漏らします。エイモスさんの勘に起きたことは、形を変えれば、機械の勘にも起こりうる――線の引き加減に万能の正解はなく、実際の様子を見ながら整えていく、その綱引きもこれからの仕事だということ。

「完成では、ありません。でも――増えることを、怖がらなくてよくなりました」

「増えることを、怖がらなくてよい」エイモスさんはその言葉を、台帳に書き留めるみたいに、ゆっくりと繰り返しました。「四十年、書き溜めてまいりました。書いたものが多すぎて仇になるのかと、あの晩は思いましたが……書いてよかったのでございますなあ」

式典の終わり、エイモスさんは私の手に、小さな包みを載せました。開けると、親指ほどの蝋管が一本、絹の詰め物にくるまれていました。

「クリオと選びました。この劇場が、あなたに差し上げる一節でございます」

後日、工房の蓄音機で掛けてみたその蝋管には、式典の夜のホワイエのざわめきと、グラスの触れ合う音と、それからクリオの声が刻まれていました。『こちらは、私の糸を編み直してくださった、お若い方』――そして、拍手。私はその一節を、二度だけ聴いて、大切に棚へ仕舞いました。

帰りの馬車の中で、アンはずっと窓の外を見ていました。街灯が流れて、彼女の磁器の頬を、光がゆっくりと撫でていきます。

「……私の昨日も、ああやって、誰かが仕舞ってくれていたのね。知らなかったわ」

それきり、いつもの毒は出てきませんでした。私も、何も言いませんでした。窓の外を流れていく夜を、二人で黙って見ていました。

工房に戻ると、奥の作業灯がひとつだけ点いていました。マエストロが、蓄音機の棚の前で、真新しい蝋管に今夜の分を刻んでいるのです。針が蝋の溝を彫る、囁くようなかすかな音。あの宵とまったく同じ絵でした。ただひとつ違うのは、今の私には、その囁きが何の音なのか分かるということです。あれは、この工房がアンの一日を仕舞う音。呼ばれた一節が、いつか届くようにするための、静かな手入れの音です。

私は作業台の前に座り、革表紙の「操り人形師の覚書」を開きました。

けれど今夜は、六ページ目にペンを置く前に、することがありました。私は端切れの羊皮紙を細く切って、小さな耳をこしらえると、これまでの五つのページの縁に、一枚ずつ糊で貼っていったのです。耳にはそれぞれ、一言だけ。「器」。「小道具」。「振り分け」。「数える」。「棚」。いつかの私が、いつかの夜に、探しものをするときのために。書き残した昨日は、呼び出せてこそ、なのですから。

それから、六ページ目に書きました。

「覚えていることと、思い出せることは、違う。書き残した昨日は、呼び出せてはじめて、今日の私を助けてくれる」

ペンを置く前に、ふと思いました。クリオの棚は、これから毎晩、一本ずつ増えていきます。十年経てば、あの小さな棚に、幾千の夜が並ぶのでしょう。増え続ける記憶の手入れは、どうしたらいいのだろう――その問いはまだ、今の私の手には余る気がして、私はそっと、覚書を閉じました。


🎭 操り人形師の覚書(Colette’s Puppeteer Log)

  • 調整したからくり(モジュール/関数名): ArchivistAgent(Before: 台帳chronicleの全節を毎回プロンプト先頭へ連結 / After: MemoryStoreadd/search)による意味検索で関連する一節のみを文脈挿入)
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 呼び出しごとに文脈を持たないステートレスな性質。長い文脈の中程に埋もれた情報ほど拾われにくく(lost in the middle)、関わりのない記録は雑音として今の指示への追従そのものを乱す
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • PydanticによるMemoryRecord/MemoryMatchの型定義(scoreField(ge=-1.0, le=1.0)で範囲を検証)、EmbedFn注入による決定的な検証、書き込み(add=会話のたび追記)と読み出し(search=コサイン類似度+top-k+スコア閾値の二段構え)の経路分離
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • 「記憶は選べないもの」という思い込みから台帳の全節を毎朝読み聞かせ、肝心の一節を幾千の無関係な節の中程に沈め、雑音で今の問いへの追従を乱したこと。閾値のないtop-kが無関係な節で数合わせをする「小さなベタ貼り」も同じ根を持つ
  • 模擬公演の記録(検証のポイント):
    • Beforeの全量混入と読み込み量の線形増大の実測、Afterで関わりのある一節のみが返ること、閾値の有無による無関係な節の混入の対比、対話の追記が直後の検索に掛かること、空の記憶と件数超過の境界、類似度の範囲外値に対する型検証の拒絶
  • マエストロの言葉:
    • 「……積むだけなら、紙の山にもできる。呼ばれた一節が差し出せて、初めて『覚えている』と言うのだ」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。