QwenとQdrantで長期記憶付きStackChanの設計を固めた
目次
前回までに、Qwen3-ASRとQwenをつないだ音声チャットサーバーをCoreS3から非同期で呼び出すところまで動かしたので、次はセッションをまたぐ記憶を足したい。
今は会話履歴を次のリクエストへ渡しているだけなので、セッションを切れば前の話は消える。
過去ログを毎回全部Qwenへ送れば、会話のたびに入力が増え続ける。
「前に買った富士通のPC」「この前決めた構成」のような話が出たときだけ、昔の会話を探せればいい。
StackChanWorldが提供する会話APIへは切り替えず、今のバックエンドへSQLite、Qwen3-Embedding-0.6B、Qdrantを加える。
元の会話はSQLiteに残す
会話をどの単位でベクトルにするかで迷った。
1発話ずつ入れると、「それ」「あれ」「そうだった」のような単独では意味が薄いベクトルが増える。
逆に1セッションを丸ごと1件にすると、途中で話題が変わった会話を検索しにくい。
数往復の話題を1個の検索用メモリにまとめ、元の発話はSQLiteへ残し、Qdrantには要約のベクトルと検索用の項目だけを入れる。
flowchart TD
A[元の発話] --> B[SQLite]
B --> C[話題ごとに要約]
C --> D[Qwen3-Embedding]
D --> E[Qdrant]
E --> F[memory_idで<br/>元の発話へ戻る]
検索用メモリの項目
{
"memory_id": "mem_01...",
"topic": "StackChanの音声バックエンド",
"summary": "RTX 3050 TiノートでQwen3-ASRをCPU、Irodori-TTSをGPUに置き、CoreS3との音声往復まで動かした。",
"entities": ["StackChan", "CoreS3", "RTX 3050 Ti", "Qwen3-ASR", "Irodori-TTS"],
"memory_type": "episode",
"occurred_at": "2026-07-23T01:00:00+09:00",
"source_turn_ids": [381, 382, 383],
"status": "active"
}
Qdrantの要約で候補を探し、候補が複数出たときや、数字・日付を答えるときは source_turn_ids からSQLiteの発話を取り直す。
Qdrantの登録に失敗しても元ログは残るので、SQLite側に pending を持たせて再登録できる。
Qdrantを消してもSQLiteの元ログから索引を作り直せる。
0.6B版をCPUで動かす
Irodori-TTSを常駐させた音声チャットサーバーでは、VRAM使用量が3,945/4,096 MiBだった。
この状態でQwen3-EmbeddingをGPUへ追加する余裕はないので、0.6B版をCPUで動かす。
Qwen3 Embeddingの公式リポジトリには0.6B、4B、8Bがあり、0.6B版は最大1024次元。
保存する出力次元を短くできるMatryoshka Representation Learning(MRL)にも対応している。
1024次元を32ビット浮動小数点数(float32)で保存すると、ベクトル本体は1件4096バイトになる。
Qwen3-ASRと同居させたときのRAM使用量と処理時間を、保存容量より先に測る。
既存のFastAPIから直接呼ぶか、埋め込みモデルだけ別プロセスにするかも、その結果で決める。
Qdrantはローカルモードから始める
Qdrantはベクトルと一緒にJSONの補助情報(payload)を持てる。
フィルターのドキュメントにあるAND、OR、NOTの条件で、固有名詞(entities)、日時、記憶の種類(memory_type)、状態(status)を絞れる。
Pythonクライアントにはサーバーを立てないローカルモードもあり、QdrantClient(path="path/to/db") でディスクへ残せる。
最初はこれで足りそうだ。
検索するかどうかもQwenに決めさせる
「前に」「この前」「覚えてる」といった単語を見つけたら検索する方法は簡単だが、「前に進んで」にも反応する。
一方で「RTX 3050 Tiでは何を動かしたっけ」には、その単語がない。
独立した分類器を毎回呼ぶ案も考えた。
ただ、記憶を使わない雑談にも判定用のQwen呼び出しが増えてしまう。
以前、ModelScope経由のQwenで関数呼び出しを試した。
そのときは tool_calls を受け取り、ローカル関数の結果を戻して返答を続けられた。
長期記憶には同じ処理を使い、search_memory と read_memory_source の2ツールを用意する。
記憶が要らない会話なら、最初のQwen呼び出しだけで返答まで進む。
flowchart TD
A[Qwen3-ASRで文字起こし] --> B[固定した名前・口調と直近履歴を付けてQwenへ]
B --> C{search_memoryを呼ぶ?}
C -->|呼ばない| D[返答]
C -->|呼ぶ| E[Qwen3-Embeddingで検索文をベクトル化]
E --> F[Qdrantから候補を取得]
F --> G{元発話が要る?}
G -->|要る| H[SQLiteから取得]
G -->|要らない| I[検索結果をQwenへ返す]
H --> I
I --> D
D --> J[Irodori-TTS]
J --> K[CoreS3で再生]
retrieval_score にはQdrantの類似度を入れ、検索順位にだけ使う。
値が 0.91 でも、内容が正しい確率を示す「91%の確信」にはならない。
元発話を取り直した候補には、別の真偽値 source_checked を付ける。
0.6 × 類似度 + 0.25 × 固有名詞の一致 + 0.15 × 新しさ という固定式では、「昔話した内容」の検索でも新しい記憶が有利になる。
日時の指定があればその範囲を使い、指定がない場合だけ同点候補の新しい方を優先する。
固有名詞と記憶の種類は、Qdrantのフィルターで先に狭める。
検索用メモリはバックグラウンドで作る
過去の記憶が必要な会話では、Qdrantの候補をQwenへ戻してから返答する。
元の発話をSQLiteへ書いたら、返答音声の生成と検索用メモリの作成を並行して始める。
flowchart TD
A[発話をSQLiteへ保存] --> B[返答を続行]
A --> C[バックグラウンドキュー]
C --> D[話題分割と要約]
D --> E[ベクトル化]
E --> F[Qdrant登録]
PCをすぐ落とすと未処理分が残るので、次回起動時に pending を再投入する。
挨拶、相づち、同じ内容の言い直しは元ログだけに残し、出来事、決定、継続しそうな好みを検索用メモリにする。
実装後は、相づちを除外できたか、出来事や好みを拾えたかを実際の会話ログで数える。
人格設定と記憶を分ける
| 層 | 中身 | 扱い |
|---|---|---|
人格(Persona) | 名前、一人称、口調、返答の長さ | 設定変更まで保持 |
会話(Session) | 直近の会話と現在の話題 | 会話中だけ保持 |
記憶(Memory) | 過去の出来事、好み、決定 | セッションをまたいで保持 |
身体(Body) | 表情、口パク、サーボ動作 | CoreS3が実行 |
人格設定は別ファイルで管理し、検索した会話は返答を作るときだけQwenへ加える。
CoreS3へ返すデータには、返答、表情ID、動作IDの3項目を持たせる。
happyなら手元の笑顔画像、nodなら動作確認済みのサーボ動作という対応にする。
Qwenが返すのはIDまでで、サーボ角度はCoreS3側の対応表で管理する。
{
"reply": "前にRTX 3050 Tiのノートで試したやつだね。",
"emotion": "happy",
"action": "nod"
}
Qwenの内部状態を保存する案
話題ごとの要約を1本の埋め込みベクトルにせず、Qwen内部のアテンション値や隠れ状態をそのまま取っておく案も考えた。
今回使っているQwen LLMはModelScopeのAPI越しなので、これらの内部状態を取得できない。
ローカルモデルで取得するとしても、Transformersのモデル出力仕様ではアテンション値が各層の batch × heads × sequence × sequence、隠れ状態が batch × sequence × hidden size になる。
どちらもモデルとトークン数に依存し、会話ごとの固定長ベクトルにはならない。
TransformersのKVキャッシュ仕様では、生成済みトークンのキーと値を再利用して、次のトークンを出す計算を減らしている。
「富士通のPCについて話した回」を昔の会話から探す機能にはならない。
10ターン、50ターンごとにまとめて採点する
最初の案には、返答を毎回採点する別のQwenも入れていた。
採点用の呼び出しが1回増えれば、普通の一言でもTTSの開始が遅くなる。
実行時の採点は外し、10ターン、50ターンのテスト会話を終えてからまとめて採点する。
検索の誤発火と検索漏れを数え、候補が複数あるときに聞き返せるか、訂正後の記憶を優先できるかも同じテストに入れる。