🌿

要求工学でチャットボットを設計した——農家エンジニアのチャットボット開発記⑥

に公開

起:Graph RAGは完成した。でも、なんか遅い。

前回までの5本で、Neo4j × LangChain × Z.aiを使ったGraph RAGチャットボットを本番公開するところまで進んだ。

農家エンジニアのチャットボット開発記シリーズ1-4

https://zenn.dev/hiroakikody/articles/cc88cc89516e22

デプロイが成功し、190品のクレソン料理データベースからHybrid Searchで関連レシピを引っ張ってきて、GLM-4.7-Flashが回答を生成する。TTFB(最初の文字が届くまでの時間)は14〜20秒。やっと動いた、と思っていた。

しかしClaudeと話しているうちに、ひとつの不満が言語化された。

「おはよう」に40秒かけるのはおかしくないか?

Renderのログを眺めると、どんな質問が来ても同じパイプラインを通っていた。

ユーザーの入力

OpenAI Embeddingでベクトル化(APIコスト発生)

Neo4j Hybrid Search(3〜5秒)

コンテキスト3件をSystemプロンプトに埋め込む

GLM-4.7-Flash呼び出し(30〜40秒)

ユーザーに返答

「こんにちは」も「熊本の料理は?」も「ありがとう」も、全部このフルコースを通っている。

Claudeとこの問題を議論したとき、ひとつの気づきがあった。「おはよう」→「おはよう」、「ありがとう」→「お役に立てて嬉しいです」のような定型的なやりとりは、Neo4jやLLMを呼ぶ必要がそもそもない。さらに「熊本のクレソン料理は?」のような、地域・季節・用途の明確なキーワードがある質問は、LLMを使わずにCypherクエリで直接Neo4jから取得できる。

せっかく知識グラフを持っているのに、全部LLMに丸投げしているのはもったいない。

そこで「質問の種類を3つに分類し、それぞれ適切なパイプラインに振り分けるTool Selectorを実装しよう」というプランが立った。


承:要求工学5ステップで設計する

ここで私が試したのが、昨年履修した「要求工学」の手法をAIとの対話に活かすことだった。

大学院で要求工学を学んで以来、プロンプトに活かせないかと試行錯誤している。今回は5つのステップで要求仕様を固めてから実装に進んだ。

Step 1:課題抽出

まず「なぜTool Selectorが必要か」を構造化した。

全質問を3つのTypeに分類する設計を立てた。

TYPE_0: 挨拶・感謝・雑談(例:こんにちは、ありがとう)
         → Neo4jの固定文ノードから即時返答
         → LLM不使用・TTFB目標 0.5秒

TYPE_1: 地域・季節・用途のキーワードがある質問
         (例:熊本の料理は?、秋向けのレシピは?)
         → Cypherテンプレートで直接Neo4j検索
         → LLM不使用・TTFB目標 3秒

TYPE_2: 意味的な検索が必要な曖昧な質問
         (例:体に良い料理は?、余ったらどうする?)
         → 現在のRAGパイプライン(変更なし)
         → TTFB目標 20秒

Step 2:シナリオ技法でパターンを収集する

Tool Selectorのキーワード辞書を設計するには、実際にどんな質問が来るかを先に列挙する必要があった。

複数のペルソナを設定してLLMにロールプレイさせ、質問パターンを収集するアプローチを取った。Claude.ai側で5ペルソナ(主婦・一人暮らし・飲食店スタッフ・シニア・観光客)を演じて40問を収集し、Z.ai側でKilo Codeを使ってさらに5ペルソナ(農家・料理研究家・中高生・外国人・ダイエット中の社会人)から50問を収集した。

合計90問を分析した結果がこうだった。

Type 件数 割合
TYPE_0(定型応答) 29件 32%
TYPE_1(構造化検索) 30件 33%
TYPE_2(意味検索) 31件 34%

全質問の約65%はLLMを使わずに返答できる計算になる。

Step 3:KAOSゴールツリーで意図を可視化する

要求工学で特に好きな手法がKAOS(Knowledge Acquisition in autOmated Specification)のゴール指向分析だ。ゴールを「達成すべきこと(Achieve)」と「避けるべきこと(Avoid)」に分解してツリー構造で表現する。

今回はKAOSゴールツリーをPythonのNetworkXライブラリでグラフ化した。

nodes = [
    ("ROOT", "Achieve\nクレソンを手に取った個人客が\n料理法を見つけられる", "achieve_root"),
    ("TTFB", "Achieve\nTool Selectorで全質問の75%を\n3秒以内に返す", "achieve"),
    ("AV1",  "Avoid\n全質問にRAGを適用する\n→ 40秒・トークン浪費", "avoid"),
    ("AV2",  "Avoid\n誤ったツールへの振り分け\n→ 回答品質の低下", "avoid"),
    ...
]

テキストで書くだけより、グラフとして出力することでClaudeとの意思疎通がしやすくなった。「回避ゴール(Avoid)からルートゴールへのパスが存在する」という構造を視覚的に確認できるからだ。

Step 4:Alloyで分類ミスが起きないことを証明する

ここが今回最も挑戦的だった部分だ。

Tool Selectorの核心的な問いは「分類ミスが起きないか」だ。「挨拶」と分類すべき質問が誤って「RAG」に落ちたり、複数のTypeに同時に分類されたりしないか。

これをAlloy(MITが開発した形式仕様記述言語)で書いて、SAT ソルバーに検証させた。

-- 検証①: 全質問は必ず1つのTypeに分類される(排他性)
assert ExclusiveClassification {
    all q: UserQuestion |
        one t: ToolType | q.classified_as = t
}

-- 検証②: TYPE_0の質問にはRAGを適用しない(回避ゴール)
assert NoRAGForGreeting {
    all q: UserQuestion |
        q.classified_as = TYPE0
            implies q.classified_as != TYPE2
}

-- 検証③: キーワードなし質問はTYPE_2に落ちる
assert EmptyKeywordFallsToRAG {
    all q: UserQuestion |
        no q.keywords implies q.classified_as = TYPE2
}

check ExclusiveClassification for 10
check NoRAGForGreeting for 10
check EmptyKeywordFallsToRAG for 10

Alloy 6のGUIで実行した結果がこうだった。

No counterexample found. ExclusiveClassification may be valid.
No counterexample found. NoRAGForGreeting may be valid.
No counterexample found. EmptyKeywordFallsToRAG may be valid.

3チェック全て反例なし。コードを書く前に「仕様に矛盾がない」ことを数学的に確認できた。

テストコードは「書いたケースしか確認できない」のに対し、Alloyは「存在しうる全ケースを探索して反例を探す」アプローチだ。反例が見つからなかったことで「設計が正しい」という確信を持って実装に進める。

Step 5:要求仕様書をINVとして明文化する

Alloy検証の結果をAGENTS.md(Kilo Code用プロジェクト説明書)の不変条件(INV)として追記した。

INV_7: Tool SelectorはTYPE_0・TYPE_1・TYPE_2の
        いずれか1つに質問を分類する(排他性)
        Alloy検証(ExclusiveClassification)で証明済み。

INV_8: TYPE_0(挨拶・定型応答)にはRAGを適用しない
        Alloy検証(NoRAGForGreeting)で証明済み。

INV_9: キーワードが検出されない質問はTYPE_2(RAG)に落とす
        Alloy検証(EmptyKeywordFallsToRAG)で証明済み。

INV_10: Tool Selectorのキーワード辞書はNeo4jのQueryPatternノードで管理する
         Pythonコードにキーワードをハードコードしない。

INV_10が後で効いてくる。


転:KAOSもAlloyも、ちゃんと役に立った

正直に書く。KAOS・Alloyといった形式手法は「学術的すぎて実際のプロジェクトでは使えないのでは」と思っていた部分があった。

今回やってみて、その認識が変わった。

KAOSのNetworkX可視化は、Claudeとの対話を明確にした。「回避ゴール(Avoid)はどこから来ているか」「達成ゴール(Achieve)の順番は正しいか」を、テキストで議論するより図で確認する方がずっと早かった。

Alloyの形式検証は、実装前の不安を取り除いた。「分類が重複しないか?」という問いに対して、コードを書かずに答えが出た。

そしてINV_10の価値は実装後に明らかになった。

Tool Selectorが動き出してから、クイックリプライボタンに「簡単な炒め物を教えて」を追加したのだが、これがTYPE_2(RAG・40秒)に落ちていた。原因は「炒め物」がTYPE_1のキーワード辞書に入っていなかったこと。

対処は1行のCypherをNeo4j Browserで実行するだけだった。

MERGE (q:QueryPattern {pattern_id: "use_itamemono"})
SET q.node_type = "use_case",
    q.param = "炒め物",
    q.keywords = ["炒め物", "炒める", "炒め", "いため"],
    q.tool_type = "TYPE_1"

Renderを再デプロイしてTool Selectorの辞書が読み込まれると、「簡単な炒め物を教えて」が即時返答に変わった。app.pyもtool_selector.pyも変更ゼロ。これがINV_10「コードのデプロイなしにキーワード更新が可能」の実証だった。


結:「おはよう」が爆速になってびっくりした

何度かRenderでdeploy failedになった。原因はspaCy日本語モデルの扱いで、python -m spacy downloadをbuildCommandに入れてもモデルが実行環境に引き継がれないという問題だった。解決策はrequirements.txtにwhlファイルのURLを直接書くことだった。

ja_core_news_sm @ https://github.com/explosion/spacy-models/releases/download/ja_core_news_sm-3.8.0/ja_core_news_sm-3.8.0-py3-none-any.whl

これで安定してデプロイが通るようになった。

デプロイ後にRenderのログを見ると:

✓ Tool Selector辞書読み込み完了 (TYPE_0: 19件, TYPE_1: 40件)
Tool Selector: TYPE_0 ← 「こんにちは!」
Tool Selector: TYPE_1 ← 「熊本のクレソン料理は?」
Tool Selector: TYPE_1 ← 「秋向けのレシピは?」
Tool Selector: TYPE_0 ← 「ありがとう」

「こんにちは!」を送ると、レスポンスが爆速で返ってきた。体感で0.5秒以内だと思う。「ありがとう」も即座だった。今まで全部40秒かけていたのが嘘のようだった。

ストリーミング機能も、この過程でやっと正しく動くようになった。SSE(Server-Sent Events)形式に変更したことで、文字がぱらぱらと流れるように画面に出てくる。Renderのnginxプロキシがバッファリングしていた問題が解消された結果だ。

今回の開発を通じて学んだのは、設計と実装の間には「検証」を挟む余地があるということだ。コードを書く前にAlloyで形式検証をして、仕様の矛盾を潰してから実装する流れは、一見遠回りに見えてむしろ速かった。Kilo Codeへの実装指示が明確になるので、戻り作業が減る。

次回のVol.7ではPhase 4「マルチペルソナ × 複数モデル議論AI」に挑戦する予定だ。


まとめ

今回実装したTool Selectorのポイントをまとめる。

設計フロー(要求工学5ステップ)

  1. 課題抽出:全質問に40秒かけていた問題を構造化
  2. シナリオ収集:10ペルソナ × 90問でキーワードを列挙
  3. KAOSゴールツリー:NetworkXで可視化してAIとの対話を明確化
  4. Alloy形式検証:分類ミスゼロを数学的に証明
  5. 要求仕様:INV_7〜10として不変条件に明文化

実装結果

  • TYPE_0(挨拶・定型):0.5秒以内に返答(LLM・Embedding不使用)
  • TYPE_1(構造化検索):3秒以内に返答(LLM不使用・Cypher直接検索)
  • TYPE_2(意味検索):従来のRAGパイプライン継続

INV_10の実証:Neo4jにCypherを1行追加するだけでキーワードを追加できた。コードのデプロイ不要。

農家がAIを使いこなせる時代を、実装で証明していく。


参考

Discussion