『霧走町 流氷に眠る』の入力の仕組みには、外に名前のある方法が三つ入っています。 hybrid sparse-dense retrieval、intent classification、retrieval-based response selection。 前回は、一つの入力を最初から最後までたどりました。 今回は、この三つの名前ごとに、どこが標準どおりでどこを変えたかを書きます。
三つとも、まったく同じ形で使っているわけではありません。 名前を借りた範囲と、このゲームの都合で変えた部分を、分けて読めるようにしておきます。
先に、三つの並びを一言で。 文字と意味の二系統で候補を出して、採点する。 有限個の意図のどれかへ着地させる。 その意図に書いてある台詞から、一つを選ぶ。 最初が hybrid sparse-dense retrieval、真ん中が intent classification、最後が retrieval-based response selection です。
どれか一つだけを入れても、自由入力の推理ゲームにはなりませんでした。 意味だけで当てると固有名詞に弱く、文字だけで当てると言い換えに弱い。 当てたあとに台詞を生成すると、事件の事実がプレイ中に増えてしまう。 三つを順につなぐことで、ようやく一往復が成り立っています。
[[SS: 候補生成・採点・融合・着地・段の選択の五段を描いた図 / 画像: まだ無い:候補生成・採点・融合・着地・段の選択の五段を描いた図1枚(投稿前に描く)]]
最初の系統は、文字の並びで比べる疎な照合です。 正規化した入力から、1文字・2文字・3文字の並びを取ります。 それに TF-IDF で重みを付け、言い換え一本ずつとのコサインを取ります。 形態素解析はしません。 辞書を持たずに済み、誤字や助詞落ち、語順の入れ替わりに強くなります。
弱いのは、文字は似ているのに意味が違う問いと、意味は同じなのに文字が重ならない問いです。 「昨日の夜はどこにいた」と「昨日の夜は誰といた」は、文字がほとんど同じです。 でも、聞いていることは違います。 こちらを見分けるのは、固有名詞や材料の語の一致と、次の意味の照合の仕事になります。
二つ目の系統は、文の埋め込みで比べる密な照合です。 multilingual-e5-small の int8 量子化版を ONNX で動かし、384次元のベクトルでコサインを取ります。 言い換えの側は、事前に焼いた表を持っています。 入力は、表に無ければ実行時に計算します。
「昨晩は何をしていましたか」には、「どこ」も「いた」も入っていません。 それでも、佐伯の「事件の夜の居場所」という意図に着きます。 語が重ならない言い換えを拾うのは、こちらの系統の得意な仕事です。
弱点もはっきりしています。 この系統のモデルは、無関係な文どうしでもそれなりに高いコサインを返します。 肯定と否定の文も、ほとんど同じ距離に置きます。 そのまま混ぜると、雑談まで拾ってしまいました。 誤って受理する入力が、目に見えて増えたのです。
疎と密を合わせる方法として、外でよく使われるのは Reciprocal Rank Fusion です。 スコアの値を捨てて順位だけで混ぜるので、尺度の違う二つを安全に統合できます。 このゲームは、それを使っていません。
使っているのは、重み付きの加算です。 埋め込みのコサインは、0.82を零点にして0から1へ引き延ばします。 それを、表層のコサインと7対3で混ぜます。 そのうえで、合成した値と表層単独の値の大きいほうを採ります。
順位ではなく値で混ぜているのには、理由があります。 この後に、「答えてよいか」を値の閾値で決めるからです。 順位だけで融合すると、採用と棄権の判断をどの尺度でするかが決まりません。 表層単独との max を取るのには、別の理由があります。 前置きのついた長い文で埋め込みが話題を取り違え、表層では解けていた例まで落ちたためです。 RRF が避けている尺度の違いの問題は、零点の引き延ばしと閾値の調整で吸収しています。 手で決めた値なので、校正された確率ではありません。
二つ目の名前は intent classification です。 自由な文を、あらかじめ決めた有限の意図のどれかへ割り当てます。 この作品の意図は二百を超え、言い換えは合わせて二千を超えます。
ただ、汎用の意図分類器を一つ学習させているわけではありません。 意図ごとに、言い換えを十本から二十本ほど書きます。 入力に一番近い言い換えの値を、その意図の値にしているだけです。 分類というより、言い換えを索引にした近傍検索に近い作りです。
一つの意図は、言い換えのほかに次のものを持っています。
話者、場所、章で、その意図がいま答えられるかを絞ります。 目の前にいない人の意図は、ここで外れます。 entities は、その話題の材料になる語の ID です。 入力にその別名が入っていれば、一つにつき0.15、二つまで加点します。 excludes は、主張が反転する語を含む入力で、その意図を候補から外すための語です。 減点ではなく除外にしたのには、理由があります。 埋め込みが肯定と否定をほぼ同じ距離に置くので、引いても言い方次第で戻ってくるからです。
意図分類の標準の形には、「該当なし」を返す選択肢が付いていることが多いです。 このゲームでは、その「該当なし」を何段かに分けました。 採用線に少し届かなければ、聞き返し。 ここでは無効でも他所でなら通るなら、別の人や場所への誘導。 どこでも通らなければ、人物らしい受け流しです。
この判断の中身は、次の技術記事で扱います。 ここでは、着地しないことも分類の結果の一つとして扱っている、という点だけ。 黙って最寄りの意図に丸めるより、確かめるほうが会話として自然でした。 丸めた先が違う話題なら、プレイヤーが言っていないことで話が進んでしまいます。
三つ目の名前は retrieval-based response selection です。 返事を生成せず、用意した候補から選びます。
意図が決まると、ResolveStage がその意図の段を一つ選びます。
段には役割があり、役割ごとの順位で決まります。
材料を挙げたときの段が一番上で、条件なしの進行の段とはぐらかしの段が次。
言い方で差し替わる段と、関係性で差し替わる段は、はぐらかされている間だけ効きます。
選ばれた段の script が、台本の一場面を指しています。
それが、そのまま画面に再生されます。
二度目に同じ段へ来たときは、repeat か repeats に書いた短い言い直しを出します。
同じ台詞を丸ごと再生すると、同じ話を繰り返しているように見えるからです。
言い直しは、何度目かで選び分けられます。
繰り返すほど相手の態度が硬くなる描写も、データの側で書けます。
どの経路を通っても、画面に出る文は制作時に書いた文です。 プレイ中に組み立てられる文は、聞き返しの型に話題名を差し込んだものくらいです。 事件の事実は、一つも増えません。
この三つの仕組みのうち、一番手間がかかっているのは言い換えの側です。 照合の式は一度書けば終わりですが、言い換えは意図ごとに、人が指示して書かせ、読んで採ります。 丁寧な聞き方、ぶっきらぼうな聞き方、短い聞き方、材料の名前を入れた聞き方。
言い換えを足せば、その意図は拾いやすくなります。 同時に、近い意図の入力まで引き寄せ始めます。 一つ足すたびに、ほかの意図が取られていないかを検査で確かめています。 その検査の話は、後の技術記事で書きます。
この仕組みは、昔の資料で RAG と呼んでいた時期があります。 いまは、その呼び方を使っていません。
RAG は、検索した結果を言語モデルに渡して、新しい文章を生成させる方式です。 このゲームは、検索したものをそのまま出していて、言語モデルに渡す段がありません。 似ているのは前半の検索だけで、後半がまるごと違います。
生成しないので、登場人物が事件について嘘の事実を作ることがありません。 同じ状態と同じ入力からは、同じ返事が出ます。 途中で保存して、同じ状態へ戻ることもできます。 代わりに、書いていないことは言えません。 その線の内側で、言い換えの幅を広げていくのが、このゲームの入力欄の仕事です。
この町が気になったら、Steamのウィッシュリストに入れておいてもらえるとうれしいです。
[[STEAM_LINK: https://store.steampowered.com/app/5287530/?utm_source=gamewith&utm_medium=devlog&utm_campaign=gamewith04&utm_content=09]]
C = max(CosA, CosA * (1 - embedWeight) + Scaled * embedWeight); // embedWeight = 0.7, embedFloor = 0.82id / speaker / where / phase / entities / excludes / kind / paraphrases / stages / confirmTextフォローすると、開発ログや新作ゲームの公開が通知されます