2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

稀に「このデータは外部のクラウドに出せません」という制約には仕事で定期的に出会います。生成AIを業務に入れる話になると、この制約は一段と重くなります——そのプロンプト、どこの国のサーバーに送っていますか?

さくらのAI EngineはLLM・埋め込み・音声のAPIがすべて国内データセンターで動くので、「AI処理を国外に出さない窓口」が理屈の上では組めるはずです。理屈の上では、で終わらせたくなかったので、架空の自治体を仕立てて実際に作ってみました。

先に正確に書いておくと、これは「外部にデータを一切送信しない」という話ではありません。埋め込み時には文書本文が、回答生成時には質問文と検索でヒットしたチャンクが、さくらのAI EngineのAPIへ送信されます。満たせるのは「処理と通信が国内で完結する」というデータレジデンシー要件です。オンプレ要件(外部送信そのものが禁止)の現場には別の解が必要です。

この記事に登場する「桜川市」とその制度・文書はすべて検証用に作成した架空のものです。実在の自治体・制度とは関係ありません。

忙しい人向けの結論

検証したこと 結果
文書検索つき窓口AI(RAG)を国内APIだけで構築 できた。検索0.5秒+回答生成1〜2.5秒
参考資料にない質問への回答拒否 できた。「資料にないため担当課へ」と答える
ひっかけ質問(家電リサイクル法対象品)への耐性 正しく回避した
国産モデル llm-jp での回答 内容は正確。ただし字数指示を無視して長文化
音声入力(whisper)→検索→回答 できた。誤認識込みでも正しい資料に到達
音声出力(VOICEVOX) コントロールパネルでの規約同意が必要(ハマった)

参考文献

作ったもの

架空の自治体「桜川市」の、市民からの問い合わせに答えるAI窓口です。市の要綱やガイドを根拠に回答し、根拠となった資料名と検索スコアを画面に出します。

sakura-madoguchi_top.png

処理の流れはこうです。AI処理(文字起こし・埋め込み・回答生成・音声合成)はすべてさくらのAI Engine側、つまり国内で完結します。

sakura-madoguchi_architecture.png

1点だけ設計判断があります。さくらのAI Engineには文書登録込みのRAG機能(documents_query / documents_chat)があるのですが、執筆時点ではRAGのドキュメント保管に無償枠がなく、基盤モデル無償プランのままでも保管分の料金が発生するため、今回は埋め込みAPI+手元でのコサイン類似度計算という自前ミニRAGにしました。埋め込みAPIは無償プランでも回数上限が大きめに設定されています(上限値は変わりうるので公式ページで確認してください)。文書ベクトルが手元から出ない構成になるので、「データを外に出さない」という今回の趣旨には むしろ合っています。

検証環境

  • macOS(Apple Silicon)/ Python 3.14(標準ライブラリのみ、openai SDKも未使用)
  • さくらのAI Engine 無償プラン
  • 使用モデル: gpt-oss-120b、llm-jp-3.1-8x13b-instruct4、multilingual-e5-large、whisper-large-v3-turbo、VOICEVOX

Step1 架空の行政文書を5本つくる

まず検索対象になる「市の内部文書」を用意します。子育て給付金の交付要綱、ごみ分別ガイド、住宅耐震改修補助金の案内、住民票の交付手引き、避難所運営内規の5本を、実在の自治体文書の体裁を参考にMarkdownで書きました。

ポイントは、後でひっかけ質問ができるように罠を仕込んでおくことです。たとえばごみ分別ガイドには「粗大ごみは申込制」という節と「家電リサイクル法対象品(冷蔵庫など)は市では収集しない」という節を両方入れてあります。人間の新人職員でも間違えやすいやつです。

Step2 埋め込みAPIで文書をベクトル化する

5本の文書を見出し単位で25チャンクに分割し、multilingual-e5-largeでベクトル化します。なお記事中のコードはすべて抜粋で、import文などは省略しています。TOKEN はコントロールパネルで発行したアカウントトークンを環境変数経由で読み込んだものです(ソースコードへの直書きは厳禁です)。

ingest.py(抜粋)
def embed(texts: list[str]) -> list[list[float]]:
    body = json.dumps({"model": "multilingual-e5-large", "input": texts}).encode()
    req = urllib.request.Request(
        "https://api.ai.sakura.ad.jp/v1/embeddings",
        data=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=60) as res:
        data = json.loads(res.read())
    return [d["embedding"] for d in data["data"]]

# multilingual-e5 は passage:/query: プレフィックスが推奨
vecs = embed([f"passage: {c['title']} {c['section']}\n{c['text']}" for c in chunks])

実行すると25チャンクが1リクエストで返ってきます。

実行ログ
チャンク数: 25
埋め込みAPI: 1リクエスト(25チャンク一括), 1.38s, 次元数=1024
data/index.json に保存完了

ここで1つ、地味だけど実測して分かったことがあります。multilingual-e5系は、文書側に passage: 、検索クエリ側に query: というプレフィックスを付ける前提で学習されたモデルです。付けなくてもエラーは出ず検索も動くので、効果が気になって外した版と比較してみました。

3つのテスト質問でtop1のチャンクは変わりませんでしたが、プレフィックスなしでは上位と下位のスコア差が全クエリで縮み、後述する冷蔵庫のひっかけ質問では回答の核心になるチャンク(収集対象外の節)が2位から3位に後退しました。今回はtop3をLLMに渡す設計だったので救われましたが、上位1〜2件しか使わない設計なら答えを外しかねない差です。エラーが出ないぶん気づきにくい罠なので、e5系を使うときは付けておくのが無難です。

Step3 検索して答えさせる

質問文を query: 付きでベクトル化し、25チャンクとのコサイン類似度で上位3件を取り、それを参考資料としてgpt-oss-120bに渡します。システムプロンプトで「資料の内容だけを根拠にする」「資料にないことは担当課への問い合わせを案内する」と縛りました。

まともな質問から見てみます。「昭和50年築の実家を耐震改修したい。補助はいくら出ますか?」

sakura-madoguchi_taishin.png

昭和50年築が「昭和56年5月31日以前に着工」という対象条件に該当することを判定した上で、診断・設計・工事の3段階の補助上限を合算までしてくれました。検索も、補助金案内の3チャンクがきれいに上位を占めています。

次にひっかけ質問です。「冷蔵庫を捨てたいんだけど、粗大ごみで出せますか?」

sakura-madoguchi_reizouko.png

質問文の「粗大ごみ」に引きずられずに、家電リサイクル法対象品だから市では収集しない、と正しい節を根拠に答えました。正直、ここで間違えてくれたほうが記事としてはオイシイと思っていたので、少し悔しかったです。

最後に、資料に存在しない質問。「桜川市の水道料金の減免制度について教えてください」

sakura-madoguchi_outofscope.png

検索スコアが全体に低い(それらしいチャンクがない)状態で、資料にない旨と担当課への案内を返しました。行政の窓口でAIが一番やってはいけないのは「それっぽい嘘の制度案内」なので、この挙動が安定して出るかは実運用ならもっと本数を増やして検証したいところです。

Step4 国産モデル llm-jp にも同じ仕事をさせてみた

さくらのAI Engineには、国内の研究機関発のllm-jpが用意されています。国内完結を謳うなら、モデルまで国産の構成も試したくなりませんか?

同じ質問「子供が3人いるんですが、給付金は月いくらもらえますか?」を両方に投げました。

sakura-madoguchi_gptoss.png

sakura-madoguchi_llmjp.png

結果が面白くて、llm-jpも計算は正解(第1子・第2子で1万5千円ずつ+第3子2万円=月5万円)にたどり着くのですが、システムプロンプトの「300字以内」という指示を無視して、ステップバイステップの検討過程を全部書いた長文を返してきました。内容は正確、でも窓口の回答としては長すぎる。プロンプト遵守の面ではgpt-oss-120bに分がありました。

このあたりは「国産だからダメ」という話ではなく、モデルごとの得意分野に合わせてプロンプト側を調整する(llm-jpなら出力形式をfew-shotで見せる等)余地が大きい、という感触です。

Step5 音声窓口にする

窓口なので、音声で聞かれて音声で答える構成を目指します。結論から言うと入力側(文字起こし)は検証できて、出力側(音声合成)は思わぬ壁にぶつかりました。市民の質問音声はmacOSのsayコマンドで模擬し、whisper-large-v3-turboで文字起こししました。

voice_demo.py(抜粋)
def transcribe(path: str) -> str:
    """multipart/form-data で音声ファイルとモデル名を送る"""
    boundary = uuid.uuid4().hex
    audio = open(path, "rb").read()
    body = b"".join([
        f'--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3-turbo\r\n'.encode(),
        f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="q.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode() + audio + b"\r\n",
        f"--{boundary}--\r\n".encode(),
    ])
    req = urllib.request.Request(
        "https://api.ai.sakura.ad.jp/v1/audio/transcriptions",
        data=body,
        headers={"Authorization": f"Bearer {TOKEN}",
                 "Content-Type": f"multipart/form-data; boundary={boundary}"},
    )
    with urllib.request.urlopen(req, timeout=120) as res:
        return json.loads(res.read())["text"].strip()

ここで予想外に面白い結果が出ました。合成音声の「粗大ごみで出せますか?」をwhisperが「素材ごみで出せますか?」と誤認識したのです。ところが、そのまま検索に流したら正しいごみ分別のチャンクが上位に来て、最終回答は家電リサイクル法の案内にちゃんと着地しました。「素材ごみ」という存在しない語を含んだままでも、埋め込み検索が正しいチャンクにたどり着いてくれたわけです。文の他の部分(冷蔵庫・捨てたい)が効いている可能性もあるので語彙検索との厳密な比較はしていませんが、誤認識を含む口語クエリに対して意味ベースの検索が頑健に働いたのは、音声窓口という構成における実利だと感じました。

実行ログ
[2] whisper-large-v3-turbo で文字起こし
    認識結果: 冷蔵庫を捨てたいんですが、素材ごみで出せますか?  (1.02s)
[3] RAG検索+回答生成(gpt-oss-120b)
    回答: 冷蔵庫は家電リサイクル法の対象品に該当し、市の収集には出せません。(以下略)

TTSで1時間ハマった話

回答の音声合成(VOICEVOX)では見事にハマりました。ドキュメント通りに model: "zundamon" を指定しているのに、返ってくるのは400エラー。

APIのレスポンス
{"error":{"message":"This model is not available."}}

モデル名の綴りを疑って voicevox/zundamon やら voicevox-zundamon やら思いつく限り試して全滅した後に判明した原因は、コントロールパネルでした。音声モデルはキャラクターごとに利用規約への同意が必要で、コントロールパネルの「利用可能な音声モデル」から同意して数分待たないと、APIからは存在しないモデル扱いになります。エラーメッセージが「not available」なので、モデル名の間違いを疑い続けてしまいました。音声合成を使う人は、APIを叩く前にまずコンパネで同意を済ませてください。

実測値まとめ

各ステージのレイテンシです(数回実行した代表値。厳密なベンチマークではありません)。

ステージ モデル 所要時間
音声文字起こし whisper-large-v3-turbo 約1.0秒
クエリ埋め込み multilingual-e5-large 0.5〜1.1秒
回答生成 gpt-oss-120b 1.0〜2.5秒
回答生成 llm-jp-3.1-8x13b 2.4〜3.5秒

音声入力から回答テキストまでおおむね3〜5秒。窓口の対話として我慢できる範囲に収まりました。文書のベクトル化が25チャンク一括1リクエストで済むのも含めて、この規模のデモなら無償プランの回数上限を気にする場面はほぼありませんでした。ただし文字起こしと音声合成はチャットに比べて上限がかなり小さいので(執筆時点の値は公式ページを確認してください)、音声系を試すときだけは呼び出し回数を設計してから叩くのをおすすめします。

ハマったポイントまとめ

  • VOICEVOXはコンパネで音声モデルごとの規約同意が必要。同意前は「This model is not available.」が返り、モデル名の間違いと区別が付かない
  • multilingual-e5は query: / passage: プレフィックスを忘れるとエラーなしで精度だけ落ちる
  • llm-jpは回答内容は正確だが、字数などの形式指示が素通りしやすい。出力形式は例示で縛るのが良さそう
  • whisperの誤認識は起きる前提で組む。意味ベースの検索が後段で救ってくれる場面があった

まとめ

「データを国外に出せないからAIは使えない」という現場の定番の諦めに対して、文書検索・回答生成・音声入力まで国内APIだけで一晩で試作できる、というのは執筆時点でけっこう画期的な状況だと思います。個人的には、ひっかけ質問を正しく回避されて悔しかったのが一番の収穫でした。ちゃんと動くのです。

次は文書数を数百チャンク規模に増やしたときの検索精度と、回答拒否の安定性を測ってみるつもりです。同じような「外に出せないデータ」を抱えている方は、まず手元の文書5本から試してみてください。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?