会話を覚えるAIに『何を覚えないか』を先に教えた ― 抽出モデル6本の計測バトルと、few-shotが弱いモデルを壊した話

家庭内の分散AI推論基盤に、ついに「会話を覚える」機能を入れ始めた。やりたいことは単純で、私が過去に下した決定や設定方針――「psqlは必ず -h localhost を付ける」「ハイブリッド検索は不採用で確定」のような恒久的な事実――を、別のスレッドでも勝手に思い出してくれる仕組みだ。スレッド単位のRAG、と言ってもいい。

ところが、この機能には最初から見えている地雷があった。自動抽出のノイズだ。会話から「覚えるべきこと」を軽量LLMに選ばせると、判断を間違えて雑談やどうでもいい一回限りの依頼まで記憶してしまう。前回まで散々苦しめられたRAGの検索ノイズ(メタ情報の節がdense検索を汚す問題)を、今度は会話ストア側で再現するだけになりかねない。

なので今回も方針は同じだ。「計測してから信じる」。作る前に測れる仕組みを先に作る。結果として、このセッションの本丸は「どのモデルで抽出するか」ではなく「何を覚えないかをどう測って担保するか」になった。

まず「使えるか」を測る ― retrievalを先に作る

会話メモリは「貯める(producer)」と「使う(consumer)」の両輪でできているが、貯める仕組みを自動化する前に、まずクリーンな手入力データで「ちゃんと引けるか」を検証する計測ループを組んだ。これがPhase 1だ。1Bの誤抽出ノイズを載せる前に retrieval 単体の素の精度を確定させておけば、後で問題が出たときに「抽出が悪いのか検索が悪いのか」を切り分けられる。

格納先は既存のRAGで使っている専用VM(rag-tools)のQdrantに、新コレクション thread_memory を追加。埋め込みはRAGと同じ bge-m3(1024次元)。既存の blog_articles / server_ops には一切触らない。検索フィルタはこうした。

should = [ scope == "global",
           (scope == "thread" AND thread_id == 当該スレッド) ]

つまり「global なメモリは全スレッドから引ける/thread なメモリはそのスレッド限定」。スレッド間の漏れをQdrantのフィルタで物理的に遮断する。

クリーンな種データを9件入れ、わざと言い換えたクエリで検索した。種に「redis==7.4.0に固定する」と書いてあるなら、クエリは「redisのバージョン固定してたけどいくつ?」と聞く。識別子をそのまま投げたら検索を測ったことにならないからだ。

結果は hit@1 が 9/9(MRR 1.000)。言い換えても全部1位で引けた。スレッド隔離も実機で確認――別スレッドから「チャンク分割の方針」を検索しても、意味的に一番近い別スレッド限定メモリは返らず、globalだけが返ってきた。フィルタは効いている。

「注入しないライン」を negative probe で決める

Phase 1で一番大事だったのはここだ。consumer を作ると、すべてのプロンプトに対して毎回 retrieval が走る。だがほとんどのプロンプトには関連メモリなんて存在しない。関連が無いのに毎回それっぽい何かを注入したら、それこそノイズだ。

そこで「関連メモリが無いはずのクエリ」を意図的に混ぜて、そのスコアがどこまで上がるかを測った。しかも雑な無関係(天気や料理)ではなく、「esxiワーカーの切り分け手順」「Qdrantの復元手順」のような“惜しい無関係”――いかにも引っかかりそうな運用クエリ――を選んだ。本番で誤注入するのはこういうやつだからだ。

指標
signal floor(当たりの最小トップスコア) 0.558
noise ceiling(無関係の最大トップスコア) 0.471
margin +0.087

きれいに分離した。当たりは最低でも0.558、無関係は最高でも0.471。間に隙間がある。しきい値を0.51あたりに置けば「関連あり→注入/無関係→何もしない」が成立する。これがconsumerを作るときの注入ゲートの根拠になる。小さなストアでの暫定値だし、メモリが増えれば noise ceiling は上がりやすいので、計測ハーネスで何度でも測り直す前提だ。

本丸は「何を覚えるか」より「何を覚えないか」

retrievalが通ったので、いよいよ producer ――会話から要約を抜き出す抽出器を作る。{"should_remember": bool, "summary": "...", ...} をLLMにJSONで出させる。小型モデルでも構文が崩れないよう、Ollamaの format:"json" を使う。

抽出器の品質を測る土俵も作った。ラベル付きの会話サンプルを流して、should_remember の混同行列を出す。覚えるべきもの(決定・設定・再利用コマンド)と、覚えるべきでないもの(挨拶・一回限りの依頼・状態確認・一般知識の質問)を用意し、特に false positive(覚えるべきでないのに覚えた数)を最重要指標とした。FN(取りこぼし)は私が言い直せば回復するが、FPはストアを汚す。precision優先だ。

モデル6本を、計測で落とす

ここからがこのセッションの山場だった。手元のローカルモデルを順に土俵に上げ、落としていく。

モデル FP(通常/際どい) 要約の質 判定
llama3.2:1b 4/6 崩壊(「無効化」を「有効化」と反転) 脱落
llama3.2:3b 3/6 良い FPが高く脱落
LFM2.5-1.2B-JP 3/6 崩れ(謎の「PSCALV」を生成) 脱落
gemma3:12b 0/6 ・ 1/6 クリーン 満点だが重い
qwen2.5:7b 0/6 ・ 0/6 クリーン 採用

llama3.2:1bは判別も要約もダメで、しかも「無効化する設定にした」を「有効にする設定を追加」と意味を反転させた。これは一番危険なタイプの間違いだ。日本語チューニング済みで期待していたLFM2.5は、判別がFP半分・要約も日本語が崩れて脱落。

本命だと思っていた gemma3:12b は、品質採点の判事として既に夜間バッチで使っている実績がある。抽出も「アイドル時間に大きいモデルで判定する」同じ性質の仕事なので、最有力候補だった。実際、通常サンプルも際どいサンプルも要約クリーンで文句なし。

ところが qwen2.5:7b が、その12bと完全に同点だった。12bより軽く、しかも抽出を投げる先のmoon(中古ThinkCentre・常時起動)に優しい。同じ性能なら軽い方を選ばない理由がない。

few-shotが、弱いモデルを壊した

途中、判別を上げようとして抽出プロンプトに few-shot の例文(「これは覚える/これは覚えない」の見本)を足した。直感的には精度が上がるはずだ。ところが llama3.2:3b で測り直すと、FPが3/6から5/6に悪化した

ログを見て理由がわかった。「おはよう」「さっきのコマンド貼って」のような覚えるべきでない入力に対して、モデルが few-shot に書いた“覚える例”のJSONをそのまま丸写しして出力していた。入力を読まずに、文脈中の目立つJSONをコピーしていたのだ。format:json と few-shot の組み合わせで、弱いモデルほどこの劣化が起きる。

教訓は「プロンプトは足せば良くなるわけではない」。few-shotは強いモデルには効くが弱いモデルには毒で、そして採用候補(qwen2.5:7b / gemma3:12b)は few-shot 無しで既にFP 0だった。不要なら入れない。few-shotはOFFで確定した。

「断定形だけ覚える」で、最後の1個を消す

最後に、通常サンプルでは満点でも、際どいサンプルで両モデルが1個ずつFPを出した。中身を見ると、どちらも「まだ決めていない検討」や「過去作業の再要約依頼」――本質的にグレーな境界だった。

  • qwen2.5:7bが外した例:「RAGのtop_kって5から3に下げた方がいいかな?」を決定として記憶
  • gemma3:12bが外した例:「さっき直したesxiの件、もう一回まとめて」を記憶

どちらも「断定していない」のに覚えてしまっている。そこで抽出指示に一文足した――「疑問形・相談形・再要約の依頼は決定ではない。断定形で決めた・設定した・宣言した場合だけ覚える」。観測された2つのFPを、覚えるべき真の例(全部断定形だった)を傷つけずに狙い撃ちできる、安全な一押しだ。

結果、qwen2.5:7bは通常・際どいの両セットで FP 0・recall 100%。取りこぼしを増やさずにFPだけが消えた。抽出器の構成はこれで確定した。

出荷 ― /routeには触らない

抽出器が決まったので、producerを常駐化した。worker_memory.py が専用キュー tasks:memory_ingest を待ち受け、ジョブが来たら moon の qwen2.5:7b で抽出し、覚えるべきなら thread_memory に書く。結果を誰にも返さない fire-and-forget だ。抽出をmoonに直叩きするのは、RAG合成・品質採点に続く「ルーティングを通さずOllamaを直接使う」3例目になる。

重要なのは、コアルーター(/route)には一切手を入れていないこと。/routeは全リクエストが通る心臓部で、過去にOpenWebUIの内部タスクが紛れ込んで誤爆した事故もあった。慎重に扱いたい。なので給餌は当面、完了済みタスクをDBから拾って投入するバッチ(夜間cron・重複排除はRedisの集合で済ませDBスキーマは変えない)にした。

実データで20件流してみた。直近の私のテストクエリ(「PS5の最新ゲーム調べて」を何度も、「自己紹介して」)が中心で、これらは全部「一回限りの質問」――正しく全部 pass(記憶せず)された。誤記憶ゼロ。/route無改修のまま、今夜から実会話の決定や設定だけが静かに貯まり始める。

学び

このセッションの収穫を3つ。

1. ボトルネックはモデルサイズではなく判別だった。 最初は「もっと賢いモデルが要るのでは」と思ったが、3bの要約は元々良く、問題は should_remember の判別だけ。そして判別は、モデルを大きくするよりプロンプトを締める方が効いた(断定形ルール)。一方で、締めすぎ(few-shot)は弱モデルを壊した。締める場所を計測で特定するのが肝だった。

2. 完璧な抽出は要らない。2段で守る。 際どい境界のFPはゼロにしきれない。だがそれでいい。仮に微妙なメモリが入っても、Phase 1で決めた retrieval しきい値 0.51 が第2の砦になる。無関係なクエリで0.51を超えなければ、そもそも注入されない。producer(抽出)と consumer(検索ゲート)の二段構えなら、片方が完璧でなくても破綻しない設計だ。

3. また「計測してから信じる」だった。 retrievalを先に作ってしきい値を数字で出す。抽出器はモデル6本を同じ土俵で測って落とす。few-shotの是非も推測せず切り替えて測る。勘で決めた箇所がひとつもない。前回ハイブリッド検索で学んだこの規律は、今回も全部の判断を支えてくれた。

残るは consumer ――/route でメモリを検索して文脈に注入し、pipeから thread_id を流す配線だ。これはコアルーターを触る作業なので、貯まったメモリを横目に、次回じっくりやることにする。貯めてからの方が、使う側の効果も測りやすい。