家庭内の分散AI推論基盤「Coordinator」に載せた RAG(Qdrant + bge-m3)の検索精度を上げようとして、固定長チャンクをやめて Markdown の見出し境界を尊重する分割に変えた。狙いどおり検索は当たるようになった。ところが今度は回答合成が遅くなり、120秒のタイムアウトで沈黙するようになった。そこから「直して壊して、また直す」を何往復かして、最終的には合成だけを別マシン(moon)に投げて 54 秒・実用品質に着地した。今回はその一部始終の記録。
「改善が次のボトルネックを露呈させる」のは分散システムあるあるだが、それを家庭内の貧弱な機材(2vCPU の VM)で踏み抜くと、設計のトレードオフが数字としてくっきり見える。そこが面白かった。
発端:検索が「惜しい」ところで止まる
これまで RAG の運用資料コレクション(server_ops、引き継ぎドキュメントを取り込んだもの)は、テキストを 800 字の固定長スライディングウィンドウでチャンク分割していた。日本語混在なのでトークナイザ依存を避け、まず文字数で割り切る、という素朴な実装だ。
これが「惜しい」結果を出していた。たとえば「Reaper の障害検出トリガーは何種類あるか」と聞くと、本来ヒットしてほしい Reaper の解説セクションがスコア 0.57〜0.59 のニアミス帯に沈み、代わりに curl の実行例やモデル名リストといった、キーワードがたまたま同居しただけの断片が上位に来てしまう。本命チャンクが top3 から落ちることすらあった。
原因ははっきりしている。固定長分割は Markdown の意味境界を平気で割る。見出しの途中、箇条書きの途中、コードブロックの途中で機械的に 800 字で切るので、「何の話をしているチャンクなのか」という文脈が断片から失われる。bge-m3 の埋め込みは文脈ごとベクトル化するので、文脈を失った断片は的確に当たらない。
対処:見出し境界を尊重する意味チャンク分割
rag_common.py に chunk_markdown() を実装した。設計は4要素。
1. コードフェンスを跨がない見出し分割
まず Markdown を見出し(#〜######)でセクションに割る。このとき、コードフェンス(```)の内側にある # はシェルのコメントなどなので、見出しとして誤認しないようフェンス状態を追う。閉じ忘れフェンスのような壊れた Markdown でも例外で落ちないようにした。
2. セクション単位+兄弟合併
1チャンク=1セクションを基本にする。ただし見出しだらけのドキュメントだと、見出し直下に1〜2行しかない小セクションが量産され、マイクロチャンクが増殖する。チャンク数が増えれば bge-m3 の取り込み時間(後述のとおり CPU でかなり遅い)が膨らむ。そこで、同じ親を持つ連続した小セクションは合計が目標サイズ(800字)に収まる範囲で1つに合併し、合併された側の見出しは本文内に小見出しとして残すことにした。
3. パンくず(breadcrumb)の付与
これが効いた。各チャンクの先頭に 【祖先見出し > 自分の見出し】 という形でパンくずを付ける。こうするとチャンク単体で「どの文脈の話か」が本文の一部として埋め込みに乗る。「新Worker追加時の落とし穴チェックリスト」というセクション名そのものがベクトルに含まれるので、同義の質問に強くなる。パンくずは payload にも section キーとして保存しておき、検索結果の表示にも使う。既存の payload キー(source / file / text など)は変えていないので、旧バージョンの purge スクリプトや検索 Worker は無改修で動く。
4. 巨大セクションだけ行境界で内部分割
目標サイズを大きく超えるセクション(上限1600字)だけは、さらに内部を分割する。ただしここでも文字数ではなく行単位で詰める。箇条書きやコードや図を行の途中で切らないためだ。断片境界では直前の末尾数行を次の断片の先頭に複製して、文脈の切れ目を緩和している。
効果測定:A/Bで並べて見る
感覚で「良くなった」と言っても仕方ないので、比較スクリプト(rag_eval.py)を書いた。旧コレクションと新コレクションに同じ質問群を投げ、top5 のスコア・ヒットしたセクション・本文先頭を並べて出す。同じ引き継ぎドキュメント(90チャンク)を新旧それぞれの方式で取り込み、ニアミス帯だった質問を中心に8本で比較した。
| 質問 | 旧 top1 | 新 top1 |
|---|---|---|
| 落とし穴チェックリスト | 0.6048・無関係な断片 | 0.7517・チェックリスト章に直撃 |
| coordinator_api のバージョン | 0.6337・pipe の記述 | 0.6895・「ファイル構成と現在バージョン」に直撃 |
| dead/zombie/ghost の違い | 0.5946・無関係なコード片 | 0.6579・Dead Worker Retry の章 |
| Web検索0件のフォールバック | 0.5976・ブログ記事の題名 | 0.6282・該当機能の完了報告 |
8問中7問で本命セクションが top1 に来るようになった。旧方式の top1 がことごとく「キーワードがたまたま同居した断片」だったのに対し、新方式はパンくし付きで正解セクションが浮上している。「本命チャンクが top3 落ち」する問題は解消したと判断していい。
面白いのは、スコアの絶対値が下がった質問もあったことだ。たとえば「Reaper のトリガー何種類」は 0.575→0.550 と数字は下がったが、旧 top1 が無関係な curl 例だったのに対し新 top1 は正解の Reaper 章。合成 LLM に正しい文脈が渡るかどうかが本質なので、これは負けではない。RAG の評価をスコアの大小だけで見てはいけない、という良い実例になった。
横道:記録と実機がズレていた
新しい取り込みスクリプトを最初に動かしたら、いきなり Qdrant への接続が Connection refused で落ちた。相手が 192.168.0.1(昔 Qdrant を置いていたサーバー)になっている。今の Qdrant は専用 VM の localhost にいるはずなのに。
調べると、実機に配備済みの共通ライブラリは接続先の既定値が localhost に直されていたのに、引き継ぎドキュメントに転記されたソースコードだけが昔の 192.168.0.1 のまま取り残されていた。systemd サービス経由では環境変数で localhost が渡るので普段は表面化せず、手で実行したときだけ既定値に落ちて昔のアドレスを叩きにいく、という塩漬けのバグだった。こういう「動いているコードとドキュメントのコードがいつの間にか食い違う」のは、過去にも別の設定で一度やっている。既定値そのものを直し、ドキュメントも実機準拠に揃えて根治した。
本題のしっぺ返し:合成が遅い
検索が良くなって満足したのも束の間、実際にエンドツーエンドで RAG に質問を投げると、回答が返ってこない。120秒でタイムアウトし、合成を諦めて検索結果の生の抜粋をそのまま返すフォールバックに落ちていた。
原因はチャンク改良の副作用だ。旧方式のチャンクは最大 800 字だったが、新方式はセクション単位なので最大 1600 字+パンくず。検索上位5件をまとめて合成 LLM に渡すと、文脈の合計が従来の倍近くに膨らむ。合成を担っているのは RAG 用 VM 上の小型モデル(llama3.2:3b)で、しかも 2vCPU。プリフィル(入力を読み込む処理)が支配的になり、120秒に収まらなくなっていた。検索は健全で、遅いのは合成だけ、という切り分けはログですぐ付いた。
試行1:軽量モデルに替える → 404 即死
引き継ぎドキュメントには「速くしたければ合成モデルを 1b に落とせる(サービスの環境変数だけで)」と自分で書いていた。発動のときだ。llama3.2:1b に切り替えて再実行したら、今度は 7 秒で即死した。タイムアウトではない。原因は単純で、その VM の Ollama に 1b モデルを pull していなかった。存在しないモデルを指定して 404 で落ちていただけだ。pull して解決。
ここで地雷をひとつ踏んだ。サービスの環境変数でモデル名を変えるときは、対象ホストで ollama list して保有を確認すること。未取得モデルを指すと 404 で即死し、症状がタイムアウトとは別物に見えて切り分けを誤らせる。落とし穴チェックリストに追記した。
試行2:文脈もトークンも絞る → 品質崩壊
1b で動くようにはなったが、それでも 119 秒。文脈を切り詰め(各抜粋を 600 字まで)、検索上位を 3 件に減らし、生成トークンの上限も付けて、ようやく 73 秒。だがまだ合格ライン(チャット用のタイムアウト 60 秒)に届かない。しかも 1b の出力品質が無視できないレベルで崩れ始めた。タイ文字が混入したり、生成上限で文が尻切れになったり。要約タスクとして使い物にならない。
ここで「2vCPU での CPU 合成は 1b でも袋小路」と見切った。速度のために品質を捨てる方向は筋が悪い。
試行3:合成だけ別マシンに投げる
発想を変えた。検索(埋め込み bge-m3 と Qdrant)は RAG 用 VM のローカルでいい。重いのは合成だけだ。そして合成 LLM の接続先は、もともと環境変数で外に出せる設計になっている。ならば合成リクエストだけを、常時起動していて普段ほぼアイドルの別ノード(moon)に直接投げればいい。
moon は最近 SSD を換装して常時起動化したばかりの ThinkCentre(Ryzen 2400GE、4コア8スレッド)で、RAG 用 VM の 2vCPU より明確に速く、llama3.2:3b も持っている。これなら 3b の品質を保ったまま速くなるはず。じつはこの「ルーティングの外で他ノードの Ollama を直接叩く」やり方は、別の品質採点処理ですでに使っていたパターンの二例目だ。
切り替えは moon の Ollama を LAN に公開し、RAG Worker の環境変数で合成先を moon に向けるだけ。コードは一行も変えていない。
moon 側で踏んだ罠:[Service] ヘッダ
moon の Ollama を LAN 公開しようと systemd の drop-in 設定に OLLAMA_HOST=0.0.0.0 を書いたのに、再起動しても 127.0.0.1 のまま聴いている。設定ファイルを確認すると、Environment= の行はあるのに [Service] セクションヘッダが抜けていた。systemd はセクションの外に書かれた行を、エラーも警告も出さずに黙って無視する。ヘッダを足したら一発で公開された。これも反映確認(環境変数が実際に効いているかを確認するコマンド)を挟めば即わかる類のミスで、チェックリスト行きにした。
結果
| 段階 | 構成 | 所要 | 品質 |
|---|---|---|---|
| 初回 | VM の 3b・文脈フル | 120秒タイムアウト | —(生抜粋に転落) |
| 1b 切替 | 1b(未 pull) | 7秒で即死 | 404 |
| 1b+切り詰め | 1b・抜粋600字 | 119秒 | 捏造あり |
| 1b+全絞り | +上位3件・生成上限 | 73秒 | 文字化け・実用外 |
| 最終 | moon の 3b 直叩き | 54秒(コールド込み) | 3b品質・捏造なし |
最終的に 54 秒(モデルの初回ロード込みなので、温まっていれば 40 秒台)。最後のテストでは「新しい Worker を追加するときの注意点は?」という質問に対して、チェックリストの教訓3点——OS ごとに地雷がある/死活監視は正常でも DB 書き込みは別経路なので必ず1本流して確認する/追加直後に指名タスクで動作確認する——を、捏造なしで正確に要約してきた。検索精度はチャンク設計で稼ぎ、合成は文脈を切り詰めて余力のあるマシンに投げる。この役割分担が、貧弱な機材での現実解だった。
残った宿題
合成を moon に依存させたので、moon が止まると合成が失敗する(検索は生きるので回答ゼロにはならず、生抜粋に落ちるだけ)。合成先のフォールバック(moon→ローカル)を自動化するか、moon 停止時に手動で戻す運用にするかは、将来 RAG と Web 検索の Worker を1台に統合するときにまとめて設計するつもりだ。
それと、検索精度のトレードオフがもう一段ある。意味チャンク(自己完結した大きめのチャンク)は検索が当たりやすい反面、合成に渡す文脈が重くなる。今回は合成側の切り詰めで両立させたが、チャンクの目標サイズそのもののチューニングはまだ詰め切れていない。家庭内クラスタはいつもこの「あちらを立てればこちらが立たず」を、自分の手で触れる範囲で観察できるのがいい。
この記事は、家庭にある余り物のPCを寄せ集めてローカルLLMの分散推論基盤を作っていくシリーズの第22回です。フロンティアモデルと張り合うのではなく、手元の機材でどこまで実用的なものが作れるかを試しています。シリーズ全記事のまとめはこちら。