Skip to content

Latest commit

 

History

History
771 lines (581 loc) · 54.9 KB

File metadata and controls

771 lines (581 loc) · 54.9 KB

設計メモ — なぜこの形なのか

README は入口、api-reference.md / ai.md / operations.md は「使い方と運用手順」、 CLAUDE.md は「コードのどこに何があるか」を扱います。 このファイルはなぜその形にしたのか、特に実測して方針が変わったものを残します。 同じ検討を将来またゼロからやり直さないための記録です。

関連: FTS トークナイザの評価 / ソースの追加手順

土台に SQLite + FTS5 を選んだ理由

読み取り専用・少数クライアントなら数 ms〜数十 ms で十分で、「1 ソース = 1 ファイル」が 世代管理・ブルーグリーン切り替えとよく噛み合うためです。サーバープロセスを増やさずに済み、 .db をコピーするだけで別マシンへ配れます。

割り切っている点:

  • 3 文字未満の語は FTS で引けない(タイトル前方一致へ自動フォールバック)。 形態素トークナイザへの差し替えは候補 2 つを実測したうえで見送りました → FTS トークナイザの評価
  • ランキングは簡易(タイトル完全一致 → bm25 × 人気度)

移行トリガー: 検索精度に不満が出たら Meilisearch、同時接続・書き込み要件が出たら PostgreSQL + PGroonga。API 層があるので DB だけ差し替えられます。

検索の並び順

2 段構えです。タイトルが検索語と完全一致する文書を先に置き、それ以外を bm25 × 知名度で並べます。

なぜ完全一致を独立した段にするか

bm25 は「その語をよく含む文書」を上げる指標で、「その語そのものを説明している文書」を 特別扱いしません。むしろ長さ正規化があるぶん、長い記事は不利になります。本番 jawiki の 3 万文書で実測すると、京都 の検索結果は次のようになり、記事「京都」は 5 位以内に 入りませんでした。

京都市 / 近鉄京都線 / 山村美紗 / 京都駅 / 菊花賞

人気度の重みを上げても直りません(京都市も十分人気なので相対順位が変わらない)。 関連度でも人気度でもない別の軸なので、混ぜずに独立した段にしています。 副作用は無く、「浅草寺」「富士山」「夏目漱石」のように元から完全一致が 1 位だった クエリでは並びが 1 件も変わりません。必要なときだけ効く性質です。

なぜ人気度を掛け合わせるか

rank_score は全ソース共通で 0.0〜1.0 に正規化した知名度です(Wikipedia は月間 ページビュー、GeoNames は人口、OSM は地物種別と人口)。bm25 は「良い一致ほど小さい負値」 なので、bm25 × (1 + 0.4 × rank_score) とすると人気なほど上位へ動きます。

重み 0.4 は scripts/fts_lab.py で本番 jawiki 3 万件に対し 0〜2 を振って決めた実測値です。

重み 「ラーメン」の上位
0.0 札幌ラーメン / ラーメン二郎 / インスタントラーメン
0.4(採用) ラーメン二郎 / インスタントラーメン / 一蘭 / 天下一品
2.0 語の関連が薄い人気記事(「京都」で織田信長など)を拾い始める

正規化がなぜ要るかというと、桁が違う量を同じ式に入れられないからです。geonames は かつて人口の生値(最大 14 億)を rank_score に入れており、そのまま掛けると その 1 列だけで並びが決まってしまいます。API 側は使う直前に 0〜1 へ丸めるので、 入れ直していない古い DB では全件 1.0 に張り付いて実質 bm25 のみの並びに戻ります (壊れない)。

なお jawiki の rank_score は長らく 0.0 固定で、検索の並びに人気度が一切効いて いませんでした。XML ダンプに CirrusSearch の popularity_score 相当が無く、 ページビューは extra に入れたまま並びに使っていなかったためです。

読む量を「該当件数」に比例させる

巨大ソース(jawiki 150 万件・geonames 1,340 万件)では、費用が該当件数ではなく コーパス規模に比例する経路が 5 秒のクエリタイムアウトを超えます。実際に 504 に なっていた 3 つを schema_version 4 で直しました。いずれも新しい情報は持たず、 既にある内容の射影を足しただけです。

症状 原因 対処
tags?contains= が 504 タグ名を探すのに転置表 doc_tags(jawiki 764 万行・索引 300MB)を丸ごと読む 集計表 tag_counts(29 万行・12MB)
filter?bbox= が 13 秒 生成列 lat/lon は VIRTUAL で値を保存しないため被覆索引にならず、経度の判定に行本体を読み直す。費用がその緯度帯にある全文書数に比例 実体を持つ doc_coords(2.8 秒 → 0.04 秒)
filter?tag= が 33 秒 該当文書を全部読んでから並べる。totaldocs 側で数えると doc_id ごとに 41GB へ rowid 検索が飛ぶ 並び順を持つ idx_docs_rank + total は転置表から数える(33 秒 → 0.05 秒)

ここで得た教訓が 2 つあります。

生成列は索引を張れても被覆索引にはなりません。 schema_version 2 で「実体は extra(JSON)のまま、生成列 + 索引で引く」という形を選びましたが、VIRTUAL な生成列は 値を保存しないので、索引に載っていない列の判定には行本体が要ります。等価条件 (feature= / area=)なら索引だけで足りますが、2 列にまたがる範囲条件(bbox)は 成立しません

索引のヒントは該当件数で効きが逆転します。 INDEXED BY idx_docs_rank は該当が 多いときは劇的に速い(25 万件で 33 秒 → 0.05 秒)一方、少ないときは逆効果です (索引を端まで走査しても N 件見つからない。1 件のタグで 0.001 秒 → 0.044 秒)。

続き: 経路の選択には offset も要る

上の切り替えを「総件数 100 件」の一点で決めていたのが不足でした。2 経路の費用は 形が違います

  • 既定(名指し無し): 該当を全部 docs から読んで並べる。total 件の行読みoffset には依らない
  • INDEXED BY idx_docs_rank: 並び順の索引を上から走り、該当を offset+limit 件 拾った時点で打ち切る。doc_count * (offset+limit) / total 件の索引走査total には依らず、頁が深いほど伸び、末尾では索引の全走査に落ちる

総件数だけで選ぶと後者の「末尾で破綻する」性質を見落とします。実際、336 件のタグは offset=250 まで 0.665 秒で返るのに offset=300(= 該当を全部拾いに行く頁)で 150 万件の全走査になり 504 でした。131 件のタグを limit=131 で一度に取る形も同じです。 1 リクエストで全件取ろうとすると静かに取りこぼす(504 を「0 件」と読むと、その タグが丸ごと落ちる)という、いちばん質の悪い出方をします。

いまは両者の費用を見積もって安い方を選びます(rank_index_hint())。行読みと索引走査の 単価比 DOC_ROW_VS_INDEX_COST は配信機での実測(1 行 132µs 対 索引 1 件 3µs 前後)から 32 に置いています。行の太さで変わる値ですが、境目付近はどちらの経路でも同程度の費用に なるので、正確さより、「末尾の頁で全走査に落ちない」ことが効きます

条件が複数あるときは索引の集合を交差させる

bboxfeature を併用すると密な地域で 5 秒前後かかっていました。原因は doc_id IN (座標の集合) AND feature IN (…) という書き方です。SQLite は片側の索引だけで 駆動し、もう片方の判定に行本体を読みます(全国の amenity=restaurant 10 万行)。

feature/area は VIRTUAL な生成列ですが、索引には計算済みの値が入っているので、 「doc_id を出すだけ」なら索引の中で終わります。そこで条件ごとに doc_id の集合を作り、 INTERSECT で交差させる形に変えました(build_doc_id_set())。行を読むのは交差した 分だけになり、同じ条件で 0.81 秒 → 0.14 秒、全国の飲食店の一括抽出(条件 1 つ)は 504 → 0.09 秒です。総件数も集合を数えるだけで済み、docs を 1 行も読みません。

全文検索も「該当件数ぶん docs を読む」形だった

searchdocs_fts MATCH … JOIN docs ORDER BY <bm25 と人気度> でしたが、並び替えに rank_scoretitle が要るため、該当した文書を全部 docs から読んでいました。 osm_japan の「東京都」は 17 万件が該当し(施設の本文に「所在: 東京都…」が入るため)、 上位 5 件を返すのに 17 万行を読んで 504。都道府県名が軒並み引けなかったのはこれです。

いまは ①bm25 だけで上位 N 件の doc_id を取り(FTS 索引の中で完結)②その N 件だけ docs と突き合わせる、の 2 段にしています。加えてタイトル完全一致は idx_docs_title から 直接拾って候補に足します(候補の外に落ちても「東京都」で記事「東京都」が出るように)。

ここで +docs_fts.rowid IN (…) の単項 + が要ります。付けないと SQLite は候補 1 件 ごとに FTS の rowid 検索を選び、そのたびに該当語の doclist(17 万件)をたどり直します (候補 300 件で 0.87 秒)。+ は「この条件を索引に使うな」の指示で、doclist を 1 回 流すだけになり 0.013 秒。候補の絞り込みだけ入れて 2 倍遅くなったのがこれでした。

カテゴリは本文検索で列挙してはいけない

filter?tag= を足す前は、カテゴリの全記事を search?q=Category:ラーメン店 の フレーズ検索で代用していました。これは静かに取りこぼします

wikitext でソートキー付きに書かれたカテゴリ([[Category:ラーメン店|らあめんしろう]])は、 本文をプレーンテキスト化した時点でソートキー側しか残らず、カテゴリ名が消えるためです。 実データでは 115 件中 16 件(ラーメン二郎ほか)が漏れていました。

docs.tags は wikitext のリンク先から取っているのでソートキーの有無に関わらず拾えます。 doc_tags はその転置表で、filter?tag= はこれを引きます。

地理データの守備範囲(geonames と osm の分担)

「全世界の地名に答える」用途と「特定の国を店舗レベルまで掘る」用途は、データ源を 分けています。

geonames osm_<地域>
範囲 全世界(約 1,200 万件) 取り込んだ地域のみ
ダンプ 約 400MB + 別名 191MB 国単位で 2〜5GB(大陸単位は 32GB)
持っているもの 地名・座標・国/行政区・人口・多言語別名・timezone 地名 + 店舗/施設/交通 の詳細、住所・電話・営業時間
持っていないもの 店舗・レストラン・営業時間 取り込んでいない国のすべて

大陸単位の OSM(europe-latest.osm.pbf)を 1 ソースとして取り込む案は廃止しました。 pbf だけで 32GB、ノード座標索引が 100GB 超、構築に 1 日以上かかるうえ、実測で osm_japan の内訳は 73% が店舗・施設の裾(地名・行政界・自然・観光は合計 27%)で、 「全世界の地名」を得る手段としては過剰でした。逆に地名だけに絞れば、それは GeoNames が 80 分の 1 のサイズで既に提供しているものになります。

したがって推奨構成は:

  • 全世界の問い合わせgeonames(1 ソースで賄う)
  • 店舗レベルの詳細が要る国 → その国だけ osm_<国> を取り込む
  • 事物の解説<lang>wiki(座標と wikidata の Q 番号を持つ)

geonames<lang>wiki はどちらも extra.wikidata に Q 番号を持つので、 filter?wikidata=Q90 で相互に引き当てられます。

Wikipedia は CirrusSearch ではなく XML ダンプから作る

旧実装は CirrusSearch ダンプの text フィールドを docs.body にそのまま使っていました。 ところがこのフィールドは Wikipedia の折りたたみ(collapsible)セクション ({{hidden begin}}{{hidden end}} 等)を検索インデックスから除外しており、 例えば「ブラタモリ」の放送回一覧表のような内容が本文に一切含まれない欠落がありました。

そのため標準 XML ダンプ + wikitext 解析(mwparserfromhell)に切り替えています。 折りたたみテンプレートは通常のテンプレート呼び出しとして wikicode 木に残るため、 中身(表を含む)が本文へ自然に含まれます。{{Dts|年|月|日}} のようなテンプレートは 完全展開せずパラメータ値の連結として残るので(例:「2015 4 11」)、日付表示は 整形されませんが検索対象としては機能します。

代償として、XML ダンプには CirrusSearch の popularity_score 相当が無いため、 人気度は別途ページビューダンプを突合して得ています(上の「検索の並び順」参照)。

脚注・画像の説明・マジックワードは落とす

strip_code()タグの中身を本文として残すので、<ref> を消さずに変換すると 注釈や出典の題名が地の文に流れ込みます。画像の埋め込みも説明文とパラメータが残り、 __NOTOC__ のような動作切り替えは表示されないのに文字として残ります。

NIFRELは、日本の大阪府吹田市千里万博公園内所在地の実際は、吹田市の千里丘陵にある
万博記念公園の中。住所表記は「吹田市千里万博公園内」 に所在する博物館。
内部に復元されたアイヌ民族の伝統住居「チセ」|300px|thumb 北海道博物館は…
__NOTOC__ 多聞院(たもんいん)は、埼玉県所沢市中富にある…

これはこの 3 種を落とすだけで直るので、_drop_non_prose()<ref>/<references> と 画像・音声・動画のリンクを wikicode から外し、_clean_text() でマジックワードを消してから プレーンテキスト化しています(opening / body / links のすべてに効きます)。

  • 木から取り除くのではなく中身を空にします —— code.remove() はノード列の走査を伴い、 脚注が数百ある記事(ja の「靖国神社」は 191 個)では O(n^2) になって抽出全体が 3 倍以上遅くなります(実測 0.20s → 0.66s / 20 記事)。中身を空にすれば strip_code() の結果は同じで、費用はほぼ増えません(同 0.24s)
  • 座標の抽出は落とす前に行う —— 消した中に {{Coord}} があっても座標を失わないため
  • 会社の Infobox は引数名に接頭辞が付きます(基礎情報 会社本社緯度度 / 本店緯度度)。素の 緯度度 だけを見ていると、店・メーカーの記事の座標がまとめて 落ちます(飲食・食品の記事を調べたところ、度分秒を持つ 30 件のうち 19 件が落ちていました)。 本社を先に見て、空なら本店を使います —— 飲食店の記事では本社が空で本店だけ 埋まっていることがあります
  • インフォボックスの座標は、地図の中心({{Maplink}}coord)より先に採ります。 テンプレートは記事に出てくる順に見るので、インフォボックスが先頭にある通常の記事では 自然にそうなります。地図の中心は縮尺に合わせた点で、記事の主題の位置ではありません
  • マジックワードは決まった語だけを消す —— __[A-Z]+__ の形で消すと、 プログラミング記事の __CONSTANT__ のような地の文まで落ちる
  • 出典の中のリンクは links に入りません(記事の関連リンクではないため)
  • 記事側の markup が壊れているものは直せません。例えば ja の「靖国神社」は {{旧字体|'''…}}'''}} の位置が崩れており、テンプレートとして解析できないため {{旧字体|…}} の文字列が残ります
  • 既存の DB は作り直すまで直りません(取り込み時に確定するテキストなので)。 この修正は 2026-08-11 で、それ以前に作った世代のファイルには効いていません

OSM は pyosmium で 3 パス読む

Geofabrik が 2026 年に .osm.bz2 の配布を終了し .osm.pbf のみになったため、 標準ライブラリの xml.etree では読めなくなりました。osm 系ソースに限り pyosmium (libosmium バインディング)への依存を許容しています(wikipedia 系の mwparserfromhell と並ぶ 2 つめの例外)。

OSM は node → way → relation の順に並ぶため、3 パスで読みます:

  1. 対象 relation が参照する way ID と、行政境界 relation のメンバー way を集める
  2. 境界 way のノード座標から点内包判定器を組み立てる(extra.area 用。 OSM_AREA_ADMIN_LEVEL=0 で省略可)
  3. ノード座標解決込みで node/way/relation を走査し Doc を生成する

extra.area を bbox 近似ではなくポリゴンの点内包判定で決めているのは、県境をまたいだ 取りこぼし・混入を避けるためです。

取り込むのは「名前付き地物」だけです(name タグ必須)。交通インフラは railway=railhighway=residential のような線形地物を除くため、キーではなく 値を列挙して絞っています。同名の店舗より駅が上に来るよう rank_score を高く 置いているのも、「博多駅」で同名のラーメン店を掴む取り違えを防ぐためです。

住所補間・逆ジオコーディングはできません(必要なら公式の nominatim-docker を別途立ててください)。

メモリ方針

取り込みは潤沢メモリのマシン、配信は数百 MB の小型機。この非対称性が設計の要です。

取り込み側: 「足りると確認できたときだけ」実行する

取り込み系コンテナに mem_limit は課していません。上限で締めると、足りないときに OOM killer が数時間かけた構築を最後に殺すだけだからです(実際に 1GiB で締めて osm の ノード座標索引が入りきらず落ちました)。代わりに ingest は開始前にメモリを検査し、 足りなければダウンロードもせず即座に中止します。

取り込み中に参照する巨大な対応表(リダイレクト・ページビュー・wikidata の Q 番号。 jawiki ではそれぞれ 160〜190 万件)は、メモリではなくディスク上の一時 SQLite に持ちます。 常駐メモリは SQLite のページキャッシュ上限(約 32MiB/表)に固定され、コーパス規模に よらず一定です。素朴に dict で抱えていた版では合計 GB 級になり、メモリ 8GB 級のホストで 他コンテナごと OOM で落ちる事故がありました。

「既定設定ではどのソースも 12GiB のマシンで構築できる」という方針をテストで担保して います(test_no_source_requires_more_than_12gb_by_default)。RAM 索引が 12GiB に 収まらない国はディスク索引を既定にすることで、この不変条件を保っています。

配信側: 数百 MB で動く

chiezo-app は読み取り専用の immutable SQLite を開くだけなので、1GB 級の小型機でも 配信できます。効いてくるのはメモリではなくディスク(jawiki.db 約 42GB の空き)です。

この前提があるため、配信側の常駐メモリを増やす変更は採りません。形態素トークナイザを 見送った理由もこれです(接続ごとにモデルを持ち、8 接続で +926MiB になる) → FTS トークナイザの評価

「覚える」(notes)はなぜ Chiezo に置くのか

「AI に覚えておいてほしいこと」の置き場は、CLAUDE.md でも AI 側の記憶ファイルでも 外部のドキュメントサービスでも成立します。それでも Chiezo に置く理由は 1 つです。

常時ロードされるものは、件数に対して破綻する

CLAUDE.md や記憶ファイルは毎セッション全部がコンテキストに載ります。便利なのは 「勝手に読まれる」からですが、それは同時に「関係ない話のときにも払い続ける」という ことでもあります。

常駐コスト 100 件 1000 件
CLAUDE.md / 記憶ファイル 全部 インデックスだけで数千字 数万字
Chiezo の notes ツール定義 2 つ(数百字) 変わらない 変わらない

Chiezo は元から「ツール定義だけ常駐して、中身は引いたときに載る」形をしています。 つまりこの用途は既存の性質がそのまま効くのであって、新しい仕組みではありません。 外部のドキュメントサービスにも同じ性質はありますが、そちらは引くたびに外部 API を 叩くことになり、オフラインで完結するという Chiezo の存在理由と噛み合いません。

置き場を /data と分ける理由(性能)

notes だけは /data ではなく /notes に置いています。registry.data_dir_fingerprint()/data/*.db の mtime と size を 5 秒ごとに見て、変化があれば全ソースを再走査 (各 DB の COUNT(*) を含む)するためです。同じ場所に置くと、メモを 1 件書くたびに jawiki 150 万件の COUNT が走ります。分ければ干渉せず、/data の read-only マウントも 崩さずに済みます。

immutable=1 で開いてはいけない

読み取り側の接続は、notes だけ mode=ro に落としています。immutable=1 は 「このファイルは開いている間 1 バイトも変わらない」という宣言で、SQLite はそれを 信じてロックも WAL の確認も一切しません。追記される DB をこれで開くと、読み手が 中途半端なページを掴んで壊れた結果や例外を返します。巨大な /data 側(42GB)は 今までどおり immutable のままです。

スキーマは写しを持ち、テストでずれを止める

notes の DDL は app/notes.py にあります。実体は ingest の core.py と同じものですが、 app は ingest を import しません(コンテナも依存関係も別)。写しがずれると 「notes だけ filter が 409」「tags が空」のように静かに壊れるので、 tests/test_notes.py が ingest の core.CORE_SCHEMA_DDL から作った DB と sqlite_master を突き合わせて落とします(app/known_sources.py と同じ精神で、 複製そのものは許し、腐りをテストで止める)。

想起の主役はおそらく全文検索ではない

「さっき話したあの件」「先月お願いしたあれ」のような指され方では、語が一致しません。 trigram の全文検索は当たらないので、recallq を省いて時系列だけで引ける形に してあります(updated_at の索引は notes だけが持つ)。MCP のツール説明にも 「曖昧なときは q を作らず since で引け」と書いてあります。ここが実際に効くかは 使ってみないと分からない部分です。

長期記憶と短期記憶を分ける

Chiezo の知識には性格の違う 2 つがある。ためた知識(公開ダンプ)は大量・不変で関連度で引き、 覚えたこと(notes)は少量・可変で時系列で引く。前者を長期記憶、後者を短期記憶と呼んでいる。 分けたのは比喩が気に入ったからではなく、コード上の区別が先にあったからで、 名前が付いたのは後になってからだった。

  • 置き場が別(corpus/notes/)。走査と再登録の対象が最初から違う
  • 開き方が別(immutable=1mode=ro)
  • 書き込みの経路が別(ingest のブルーグリーンだけ / app/notes.py だけ)
  • 想起の軸が別(bm25 × 人気度 / updated_at 降順)

一方で引く口は同じにしてある(コアスキーマ共通・ソース種別を意識しない設計)。 これは崩さない。短期側だけが余分に持つのは「書き込みの口」と「時系列の recall」の 2 つだけ。

管理画面を 1 つの表に混ぜていたときは、notes の行にも再構築ボタンが出ていた。押しても 取り込み側は unknown source を返すだけだが、確認ダイアログは「ダンプの取得からやり直します」 と言う —— 唯一書き込めるソースで、消えたと読める文言がいちばん危ない行に出ていた。 節を分けたのはこれを消すためでもある。

「使う」層はなぜ 2 段の RAG か

/v1/ask は「質問 → 検索クエリを組み立てる → Chiezo を引く → 抜粋だけを根拠に答える」の 3 段で、LLM を呼ぶのはそのうち 1 段目と 3 段目です。素朴に見える形をいくつか捨てています。

質問文をそのまま全文検索に入れてはいけない

app/fts.py は入力を空白区切りで切り、各語をフレーズにして AND 結合します。 日本語は空白で切れないので、「浅草寺はどこにある?」は 1 個の長いフレーズになり、 trigram 索引では何にもマッチしません。質問を検索語に直す仕事が必ず要るので、 そこを LLM の 1 段目に置いています(「浅草寺はどこにある?」→ 浅草寺)。

これはソースを ?source=jawiki で固定したときも同じです。ソースを絞っても質問文は 質問文のままなので、指定があっても 1 段目は省きません(絞るのはソースの候補だけ)。

ツール呼び出しループにしない

MCP と同じ道具(search / doc / filter)を LLM に渡して自分で引かせる形は賢いのですが、 この層が想定するのは手元で動く小型モデル(4B 級)です。そのクラスではツール呼び出しの 安定性が足りず、引数を間違える・同じ検索を繰り返す・止まらない、が普通に起きます。 何をどう引くかは Chiezo 側が決め打ちで回し、LLM には「検索語を出す」「抜粋から答える」の 2 つだけを任せます。大きいモデルを使えるなら、そもそも MCP 経由で直接引かせる方が 筋がよいので、この層はそちらの代わりではなく別の使い方です。

クエリ生成が壊れても回答まで到達させる

小型モデルの JSON 出力は当てになりません(コードフェンスで包む・説明文を足す・途中で切れる)。 そこで parse_plan() は ①素直な JSON ②{…} の抜き出し ③"q" / "source" の拾い出し、と 諦めながら落ち、それでも駄目なら質問文からいちばん長い断片を検索語にします。 最後の経路は当たりが悪い劣化経路ですが、1 段目の失敗で 500 を返すより、当たれば 答えられるぶんましという判断です。どのクエリで引いたかは応答の queries に必ず載るので、 劣化していることは呼び出し側から見えます。

「抜粋だけを根拠に」は既定であって制約ではない

Chiezo は AI のための知識ベースで、ローカル LLM はそれを使う側です。モデルが持っている 知識を封じることが目的ではありません。grounded=1(既定)で抜粋の外を禁じているのは、 小型モデルが幻覚するのを抑えるためであって、設計思想ではない。用途によっては grounded=0 で補わせた方が有用なので、切り替えられるようにしてあります。

ただし grounded=1 で抜粋が 0 件のときは、推論を走らせずに定型文を返します。 プロンプトで禁じるだけでは守られないことを実測したためです(下記)。

実測: 小型モデルはどこで壊れるか

GPU なし・8 スレッドの CPU、gemma-3-1b-it Q4_K_M(ctx 2048)で測りました。

項目 実測
常駐メモリ 723MiB
プロンプト処理 139 tok/s
生成 37 tok/s
/v1/ask の通し(LLM 2 回) 3 秒

速度は問題になりませんでした。壊れたのはクエリ生成です。 3 問とも失敗しています:

質問 1B が作った検索語 結果
浅草寺はどこにあり、何で知られていますか?(ソース自動) geonames / 浅草寺 ソースの選択を誤り 0 件(正解は jawiki)
同上(source=jawiki 固定) 何で知られていますか 主語を落として 0 件
雷門について教えて(source=jawiki 固定) 雷門について教えて 質問文そのまま = 劣化経路と同じで 0 件

さらに 2 問は抜粋 0 件のまま自分の知識で答えました(内容は概ね正しいが出典なし)。 「抜粋に書かれていないことは答えない」という指示は、このクラスのモデルでは守られません。 だから has_no_basis() で経路ごと断つ形にしてあります。

同じ理由で grounded=0・抜粋 0 件のときは、根拠が無くても [1] を書くのも観測しました。 プロンプトで念を押してはいますが守られる保証はないので、references が空なら本文中の 番号は無意味、というのを呼び出し側との契約にしてあります(ai.md にも明記)。

得られた線引き:

  • 1B はこの用途に使えない。速いが、質問を検索語に直せずソースも選べない
  • 4B が実用の下限。ただし CPU では、上の 139 tok/s から換算して既定の抜粋 6000 字 (日本語で 4,000〜5,000 トークン)のプロンプト処理だけで 90〜140 秒かかる見積もりになる。 CPU で使うなら CHIEZO_ANSWER_MAX_CHARS を下げること
  • 実用は GPU から。8GB VRAM に 8B Q4 が全層載れば通しで数秒に収まる

余談: モデルを tmpfs に置いて機械を落とした

検証中に GGUF(2.5GB)を /tmp へダウンロードしてホストごと停止させました。/tmp が tmpfs、つまり RAM だったためです。空き 3.1GB の環境にファイルの実体がそのまま載り、 モデルのロードと合わせて swap ごと押し流しました。教訓は 2 つ:

  • モデルの置き場は実ディスクであることを確かめる(compose では ./models にマウント)
  • 推論コンテナにはメモリ上限を課す(--memory 2g --memory-swap 2g)。取り込み側で mem_limit を課さない方針(上記「メモリ方針」)とは判断が逆になる。取り込みは 「途中で殺されると数時間が無駄になる」のに対し、推論はいつ殺しても失うのは 1 回の回答だけで、ホストを巻き添えにする損の方がはるかに大きいため

VRAM を使い切るとホストごと止まる(WSL2 + Windows)

GPU で動かし始めてから、会話の最中に Windows ごとフリーズ(ディスク 100%)しました。 wsl --shutdown で復帰します。原因は「メモリ不足」ですが、足りなくなった場所が 2 段 ずれているので、順に見ないと分かりません。

実測(VRAM 12GB の GPU。画面描画に 2GB 弱が常時使われている状態):

VRAM 使用 空き
ctx 32768 11.3GB 0.9GB
ctx 16384 8.8GB 3.4GB

コンテキスト長は KV キャッシュとして VRAM を食います。 8B の重みは Q4 で約 5GB なので 「12GB なら余裕」と見えますが、32k のコンテキストがさらに数 GB を占めます。しかも llama.cpp は起動時にまとめて確保するので、動かした瞬間から空きが 1GB を切っていました

そこにブラウザなどが GPU メモリを要求すると、Windows(WDDM)は割り当てをシステムメモリへ 退避します。退避先のホスト RAM は、WSL2 の VM が既に数 GB 握っています(SQLite の ページキャッシュも含めて、VM は簡単には返さない)。結果 Windows 側がページファイルへ 書き出し始めてディスクが飽和し、UI ごと固まります。wsl --shutdown で直るのは、 VM が抱えていた数 GB が一気に返るからです。

つまり WSL の swap を切っても直りません。実際この現象は「swap を 0 にしたのに起きた」 という形で出ました。足りなくなっているのは VM の中の匿名メモリではなく、 ① GPU のメモリと ② Windows 側の物理メモリだからです。

対処は 3 つで、いずれも「使い切らない」ことに尽きます。

  • VRAM を 2GB 以上余らせる(既定のコンテキスト長を 16k に下げました)。画面を描いていない GPU なら 32k でも構いません
  • 推論コンテナのメモリ上限を VM の大きさに合わせる(VM のメモリが 8GB 程度なら 6g はほぼ全部を 1 コンテナに渡すのと同じ)
  • WSL2 側で VM の上限を明示する(.wslconfigmemoryautoMemoryReclaim)。 既定ではホスト RAM の半分まで伸び、ページキャッシュを抱えたまま返しません

メモリ方針の「足りると確認できたときだけ取り込む」と同じ話が GPU にもある、 ということです。「載る」と「動かして安全」は別で、載るぎりぎりの設定は、同じ機械で 動いている他のもの(ここでは画面)を巻き添えにします。

続き: ホスト側のメモリも同じで、こちらは即死する

VM のスワップを切ったあと、会話の最中に推論サーバが落ちるようになりました。原因は はっきりしていて、VM 全体の OOM killer が llama-server を選んだからです。

oom-kill: constraint=CONSTRAINT_NONE, global_oom, task=llama-server
Out of memory: Killed process (llama-server) anon-rss:3770840kB

重みは VRAM にあるのに、ホスト側の常駐が 3.7GB まで育っていたのが効いています。 最初はサーバースロット(--parallel、既定 4)を疑って 1 に落としましたが、それでは 止まりませんでした。犯人は別で、--cache-ram の既定値です。

llama-server は過去のプロンプトの KV スナップショットをホスト RAM に持ち、その上限が 既定で 8192MiB(8GiB)あります。会話を続けるほどここが育つので、VM のメモリが 8GB の環境では上限に達する前にホストが尽きます。「モデルは VRAM に載っているのだから ホスト側は空くはず」という直感が外れるのがこの機能です。

512MiB に絞ったうえで 10 ターンの会話を通したところ、ホスト側の常駐は 1.23〜1.26GiB で 完全に横ばいになり、落ちなくなりました。キャッシュから外れたぶんはプロンプトを読み直す だけで、プロンプト処理は 3,000 tok/s 以上出るので体感差はほとんどありません。 スロットを 1 にしたのはそのまま残してあります(1 人で使うのに 4 つは要らない)。

あわせて推論コンテナに上限(4GiB)を課しています。上限があると、枯渇したときに 「VM 全体の OOM killer が誰かを選ぶ」ではなく「そのコンテナの中で殺される」に変わります。 巻き添えの範囲を決められることのほうが、上限そのものより大事です。

スワップを切ってある環境では、この失敗は遅くなるのではなく即死として出ます。復帰は restart: unless-stopped が数秒でやるので、会話は 1 回失敗するだけです。粘って機械全体を 道連れにするより、この形のほうが望ましい — 取り込み(数時間が無駄になる)と推論 (失うのは 1 回の回答)で判断が逆になる、というメモリ方針の続きです。

agent モード: 道具をモデルに引かせる

rag モードは search を 1 回叩いて終わりなので、Chiezo の強い道具に手が届きません。 filter(タグ・地物種別・行政区・bbox・wikidata での一括抽出)、tags(実在するカテゴリ名の 確認)、links(関連文書をたどる)は、Claude Code からは MCP で使えるのに、この層からは 使えない、という非対称がありました。そのため次のような問いには原理的に答えられません:

質問 必要な引き方
カテゴリ「○○」の記事は何件ある? tags?contains= で正式な名前 → filter?tag=total
京都府の博物館を挙げて filter?feature=tourism=museum&area=京都府
浅草寺の最寄り駅は? jawiki の doc で座標 → osm の filter?bbox=…&feature=railway=station
Q90 は jawiki と osm でどう対応する? 両ソースに filter?wikidata=Q90

ツール呼び出しが安定するモデルを GPU で常用できるようになったのを機に実装しました(app/agent.py/v1/ask?mode=agent)。着手の条件に置いていた「ツール呼び出しが安定するモデルを常用できる 環境」がここで揃ったためです。既定は rag のままで、mode=agent と明示したときだけ使います。

道具は MCP から借りる(書き写さない)

MCP サーバーの list_tools() を OpenAI の function 形式へ写し、実行は call_tool() に投げます。 定義を書き写すと REST・MCP・agent の三重管理になって必ずずれますし、借りれば説明文 (「タグの列挙は filter で」等、Chiezo を正しく引くための知識)もそのまま付いてきて、 Claude Code から使うときと同じ道具立てになります。システムプロンプトの前半に置く 使い方の説明も、MCP の instructions をそのまま使っています。

渡すのは読み取り専用の道具だけです。notes の remember は書き込みなので外しました — 質問に答えた副作用でメモが増えるのは、利用者から見て予想外の変化になるからです。

上限は 3 つ。「ツール結果が積み上がる」ことの帰結

ツール呼び出しは毎ターン結果が文脈に積み上がるので、上限が無いとモデルの窓も待ち時間も 読めなくなります。そこで ステップ数(既定 6)・1 回の結果の長さ(既定 3000 字)・ 全体の締め切り(既定 180 秒)の 3 つで抑えました。必要な文脈長は ステップ数 × 結果の長さで見積もれます(既定なら 16k 以上)。

予算を使い切ったときにそこで打ち切らないのが要点です。調べただけで何も答えないまま 終わるのを避けるため、道具を渡さずにもう 1 回だけ聞いて答えを書かせます。

もう 1 つ、同じ引数の呼び出しは実行し直さず、前回の結果を返します。当初はエラーを 返していましたが、実測するとモデルは 1 回の応答に同じ呼び出しを 2 つ並べて出してきます (0 件だったので投げ直した、ではない)。そこにエラーを返すと、手元に結果があるのに 「失敗した」と受け取って別の検索を足しに行き、ステップを空費します。外へは 1 回しか 出さずに前回の結果を返し、繰り返しであることだけ添えるのが正解でした。

最終回答はストリーミングしない

ツール呼び出しは応答を途中まで読まないと「道具を呼んだのか答えたのか」が分からず、 ストリームの断片から復元するのは壊れやすい実装になります。代わりにステップの進捗 (どの道具を何の引数で呼び、何件返ったか)を逐次流すことにしました。数十秒待たせる画面で 見たいのは、実のところそちらでもあります。

出典は番号引用ではなくタイトル参照

rag は抜粋に [1] を振ってから渡せますが、agent が見るのは道具の生の応答なので番号を振る 先がありません。代わりに、道具の応答に出てきた文書を出現順に集めて references にします。 何を見て答えたかは残るが、本文の番号と 1 対 1 では対応しない、という割り切りです。

道具の失敗はモデルに返す

404 の candidates や 409 の移行案内には、モデルが次の手を決めるのに要る情報が入っています。 実測でも、source: "wikipedia"(実在しない)で呼んで unknown source: wikipedia を受け取り、 次のステップで jawiki に直す動きが観測できました。1 回の失敗でループを落とす理由はありません。

思考タグの残骸は受け側で落とす

Qwen3 のような hybrid thinking のモデルは、推論サーバの設定次第で思考の中身や閉じタグだけが content に残ります(実測: --reasoning-budget 0 で本文の先頭に </think> が付いた)。 推論サーバは LAN 上の別マシンかもしれず Chiezo が設定を握っていないので、 受け側で落とすことにしました(answer.content_of)。

覚えさせると、内容ではなく見出しが残る

会話で調べ物をしたあと「その情報を記憶して」と頼むと、保存されたのは 「東京近郊の観光スポットを調べた」の 1 行だけでした(15 文字)。Chiezo 側は渡された 本文をそのまま格納しており、切り詰めてはいません。モデルが 1 行に要約して渡していた のが原因です。

ここでも効いたのは説明文でした。remember の説明は「いつ使うか」しか書いておらず、 text に何を入れるべきかを書いていませんでした。「内容そのものを書く。見出しや 『〜を調べた』のような要約ラベルではなく、後から読んで意味が通る文章にする」を 足したところ、挙げた固有名詞が残るようになりました(15 文字 → 124 文字)。

もう 1 つ、会話に特有の穴がありました。「これを覚えて」の「これ」は直前の自分の 回答ですが、モデルはそれを要約して保存します。道具の説明では届かない話なので、 agent のシステムプロンプト側に「要約せず、挙げた項目・数値・固有名詞をそのまま入れる (後から読む人には会話が残っていない)」を足しました。

それでも 8B は完全な転記まではしません(「各施設の設立年と特徴を記載」で済ませる)。 説明で直せるのは「何を入れるか」までで、「どれだけ丁寧に書くか」はモデルの質、 という線引きが見えます。

実測: GPU + 8B で何が解けて何が解けないか

VRAM 12GB の GPU / Qwen3-8B Q4_K_M(全層 GPU・コンテキスト 32k・思考オフ)、本番データ (jawiki 150 万件・osm_japan 155 万件・geonames 1,339 万件)に対する実測です。

項目 実測
VRAM 11.4GB(32k のコンテキスト込み。12GB では 8B Q4 がほぼ上限)
プロンプト処理 3,300〜3,800 tok/s
生成 72〜78 tok/s
rag の 1 問(LLM 2 回) 2.5 秒
agent の 1 問(道具 1〜4 回 + LLM 2〜5 回) 2〜8 秒

速度はもう論点ではありません(CPU + 1B の実測が「速いが質問を検索語に直せない」だった のに対し、GPU + 8B は「速くて、たいてい正しく引ける」)。残る論点は引き方の質です。

grounded=1 で 7 問投げた結果:

質問 結果
浅草寺はどこにありますか? doc 1 回で正答
京都府にある博物館を挙げて filter?feature=tourism=museum&area=京都府total=186
カテゴリ「東京都の寺」は何件? fields の誤りを自分で直して total=144
富士山の標高は? searchdoc で正答
あるチェーン店の店舗数は? ⚠️ 4 手かけて「分かりません」= 正しい降参(下記)
浅草寺の最寄り駅は? ❌ 座標 → bbox の連鎖をせず、自分の知識で答えて誤り
Q90 は jawiki と osm でどう対応する? ❌ 専用の wikidata を使わず tag で代用して 0 件

見えたことが 4 つあります。

1. ツールの説明文の穴は、そのまま失敗になる。 最初の実行で「京都府の博物館」は feature=博物館 / feature=museum と書いて 0 件でした。featurekey=value 形式で あることが MCP のツール説明に書かれていなかったのが原因で、モデルの落ち度というより 説明の不備です(REST のドキュメントには書いてありました)。説明を直したら 1 手で解けました。 これは MCP 経由の Claude Code にも効く修正で、道具を借りている設計の副産物です。

2. 道具のエラーは材料になる。 source: "wikipedia"(実在しない)→ unknown source を 受けて jawiki に直す、fields: "total"(存在しない列)→ unknown fields を受けて正しい 列名に直す、という自己修正が繰り返し観測できました。失敗を握り潰さずモデルへ返す判断は 効いています。

3. 3 手以上の連鎖はまだ組み立てられない。 「最寄り駅」は doc で座標 → filter?bbox=…&feature=railway=station が正解ですが、8B は doc まで見て そこで満足し、続きを自分の知識で埋めました(しかも誤り)。1〜2 手の問いと、3 手以上の 問いのあいだに段差があります。

4. grounded=1 は「道具を引いたか」までしか守れない。 根拠が 1 件も無ければ定型文を返す 仕組みは効きますが、道具は引いたが、その結果に答えが無いときには効きません (「最寄り駅」がまさにそれで、doc の結果を根拠として数えたまま作文しました)。 抜粋を渡して答えさせる rag と違い、agent は「取れたもの」と「答え」の対応を Chiezo 側から 検証できない、という構造的な差です。

店舗数の問いを⚠️にしたのは、Chiezo 側に数える手段が無いからです。 filtertotal は属性(タグ・地物種別)の集合にしか効かず、全文検索(search)は 上位数件を返すだけで総数を持ちません。osm_japan には店舗が入っているので、 「search に総件数があれば解ける問い」ではあります。次にやるなら候補は 2 つ:

  • search に総件数を持たせる(FTS の該当件数を数える。費用の見積もりが要る)
  • OSM の feature 値を列挙する口(tags の地物版)。上の失敗 1 と 3 は 「どんな値が使えるか調べる手段が無い」ことに由来している

「使う」層はなぜ Chiezo 本体の機能ではないのか

この整理が、以下いくつかの判断の土台になっています。

Chiezo は AI のための知識ベースです。それを使う AI は普段 Claude Code ですが、 ローカル LLM を Chiezo に同居させれば、Chiezo だけで完結して使える。「使う」層は その「使う側」であって、知識ベースの機能ではありません。

この線引きから 3 つ出てきます。

  • 既定で無効なのは自然。使う側を同梱するかは環境ごとの選択で、知識ベースの機能なら 「既定で切ってある」は不自然ですが、使う側なら当然です
  • web 検索を足しても存在理由と矛盾しない。Chiezo が外部 API を叩かせないために あるのは変わらず、知識ベース本体は今も外へ出ません(ingest がダンプを取る以外)。 外へ出うるのは使う側で、それは Claude Code が Chiezo と web 検索の両方を持っているのと 同じ関係です。ただし出る以上は、どれが web 由来か分かること・本文を取りに行かないこと・ 自分でレート制限をかけることを守ります(websearch.py)
  • 話す相手は Chiezo ではなく AI。画面の見出しは AI(Qwen3-8B)と話す のように モデル名を名乗り、システムプロンプトにも「Chiezo はあなたが引く知識であって、あなた自身では ない」と書いてあります。ここを曖昧にすると、知識ベースと、それを使うモデルの区別が 利用者からもモデルからも消えます

会話にすると何が要るか

1 問 1 答(/v1/ask)を会話(/v1/chat)にしたとき、効いたのは 3 つでした。

履歴はクライアントが持つ。 サーバーは会話の状態を持たず、messages を毎回まるごと 受け取ります。読み取り専用・LAN 内・複数ワーカーという前提を崩さないためで、MCP を ステートレスにしたのと同じ判断です。副作用として、ブラウザを閉じれば会話も消えます (残したければ chiezo_memory に「覚えて」と言う、という住み分け)。

rag はクエリ生成の段にも履歴が要る。 「さっきの寺の最寄り駅は?」を検索語に直すには 直前の話が要ります。回答の段だけに履歴を渡しても、検索が空振りしてしまう。

過去のターンの道具のやり取りは積み直さない。 agent モードで前ターンの検索結果まで 文脈に積むと、際限なく伸びるうえ、モデルが古い結果を根拠にし始めます。積むのは発言だけで、 何を引くかは毎ターン引き直させます。

そのうえで、「普通に会話している感じ」になるのは mode=agent + grounded=0 の組み合わせ です。実測(Qwen3-8B)で、雑談(「こんにちは、いい天気ですね」)には道具を呼ばず 1.0 秒で 返し、事実が要る問い(「浅草寺と清水寺、観光するならどっち?」)では自分で 5 手引いてから 比較して答えました。ただし素の既定は rag + grounded=1 のままにしてあります — 小さな機械では agent が成立しないので、潤沢な環境が .env(CHIEZO_ASK_DEFAULT_MODE / _GROUNDED)で倒す形にしました。

0 件で黙らない — 「そのソースにその属性は無い」と言う

agent モードで会話していて、いちばん質を落としていたのは幻覚ではなく空振りでした。 モデルは jawiki に area=京都府 を付けて検索し、0 件を見て、絞り込みは付けたまま検索語だけ 変えてまた 0 件、を繰り返します。area / feature を持つのは地物のソース(osm・geonames) だけなので、この組み合わせは条件として正しいのに必ず 0 件です。

ツールの説明文に「wikipedia は feature / area を持たない」と書いても、8B は読み落として 同じことをしました。そこで API 側で 400 を返すようにしました(require_attributes)。 実測では、エラーを受けたモデルはその場で osm_japan に切り替えて先へ進みました。

得た教訓は一般化できます: 「正しいが必ず空になる問い合わせ」は、空で返さず理由を返す。 人間は 0 件を見て自分で気づけますが、モデルは同じ壁に何度もぶつかります。同じ理由で、 道具の失敗(404 の候補、409 の移行案内)もモデルに素通しする設計にしてあります。

推論を app プロセスに入れない

配信側が数百 MB で動く前提(上の「メモリ方針」)は、この層でも崩しません。 app/answer.py がするのは OpenAI 互換の /chat/completions を叩くことだけで、 モデルは別コンテナか LAN 上の別マシンにいます。CHIEZO_LLM_URL 未設定が既定で、 その場合この層は丸ごと無効です。1 つの環境変数が機能フラグを兼ねているので、 「既定では起動しない」が設定の書き忘れではなく既定の状態として守られます。

ストリーミング(?stream=1)では、クエリ生成と検索を流し始める前に済ませています。 SSE はヘッダを送った後でステータスコードを変えられないため、そこで失敗すると 200 OK の中にエラーを埋めるしかなくなるからです。