ホームページにチャット機能を搭載するときに考えておくこと | 「書いていないことを言わせない」ために測って決めたこと
このホームページに、訪問者の質問へその場で答えるRAGチャットを置きました。
RAG(検索拡張生成)は、サイトの文章を検索して根拠を渡してからAIに回答させる仕組みです。動かすだけなら難しくありません。難しかったのは、会社の名前で答える以上、もっともらしい誤案内をどう防ぐかでした。
この記事では使用サービスの紹介ではなく、「どこまで答えるか」の線をどう測り、どのように仕組みで守ったかをまとめます。
置く前に、3つの答えを用意した
公開サイトのチャットは、匿名で利用できる入口です。そこで、次のどれかが未解決なら公開しないと決めました。
- レート制限:過剰な利用を抑えられるか
- 費用上限:想定外の利用でも受付を止められるか
- 出力の担保:AIが言ってはいけないことを止められるか
構成は、ブラウザのウィジェットからCloudflare Workerへ新規発言だけをPOSTし、Workers AIでクエリ埋め込みを生成、Neon Postgres+pgvectorで検索、OpenAIの小型モデルで回答をSSE中継する形です。課金が発生する生成処理は、retrievalが根拠を返したときだけ呼び出します。上限に達した場合はサイトを壊さず、チャットの受付だけを止めて問い合わせフォームへ切り替えます。
テーマとWorkerは別系統でデプロイしています。テーマ側の設定値が空ならウィジェット自体を表示しません。Workerだけが未公開、あるいはURLが差し替わった状態で、送信先のない入力欄を出さないためのフェイルクローズです。
クロールとチャンク化では「無言で消す」を避ける
索引に入れるのは、公開HTMLだけ
RAGの回答品質は、モデルより先に「何を読ませるか」で決まります。索引はサイトマップから取得した公開ページだけで作成し、社内文書、リポジトリ、提案書は読みません。
これには安全上の理由があります。社内には古い価格表や個別顧客向けの資料が残ることがあります。注意して使うのではなく、読める経路を作らない。訪問者が読めない情報をチャットも答えないよう、構造で制限します。
抽出では、本文の範囲とチャンクの切り方が重要でした。ページによってはmain要素がなく、逆にmainの中には共通CTAや関連記事も入ります。共通ブロックにはテンプレート側で除外印を付け、本文だけを取り込みます。
チャンクは固定文字数ではなく見出し単位で切ります。見出しの言葉も本文に含め、短い節は次の節とつなげます。商品名が見出しにしかない場合や、短いFAQが無言で消える事故を避けるためです。
実際には3つの問題がありました。第一に、main要素がないトップページが丸ごと索引から落ちたこと。これはbodyへフォールバックし、ヘッダー・フッターなど共通領域をセレクタで除外して対応しました。第二に、main内の共通CTAや「関連する記事」が検索を汚すこと。全ページに同じ文章が混ざると、質問と関係のない診断導線が上位になります。テンプレートの除外属性をクロール側で解釈する形にしました。
第三に、短い見出し節の扱いです。最小文字数に届かない節を捨てる実装では、ランディングページの重要な説明が消えました。短い節を単独で索引化するのではなく、次の節に繰り越して意味のある塊にします。索引生成では「失敗したときに落とす」よりも、「何を落としたかを検出できる」ことが重要です。
検索は3系統を組み合わせる
意味の近さだけで検索すると、言い換えには強くても固有名詞を取りこぼします。一方、日本語では一般的な全文検索だけにも限界があります。空白のない日本語を単語として適切に分けられず、サービス名が一致しないからです。
そこで、次の3系統をRRF(Reciprocal Rank Fusion)で融合しました。
| 検索方法 | 得意な質問 |
|---|---|
| ベクトル検索(dense) | 言い換えを含む自然文 |
| 全文検索(tsvector) | 英数字の製品名・ツール名 |
| 文字列類似(trigram) | 日本語のサービス名・固有名詞 |
RRFは順位を合成するためのスコアであり、絶対的なしきい値には使えません。同じ長い記事の断片ばかりが上位を占めないよう、1ページから採用するチャンクも上位2件に制限しています。
日本語サイトでtrigramを加えた理由は実務的です。PostgreSQLのsimple構成によるtsvectorは、日本語を形態素解析しません。空白のない文章は大きな一塊になり、サービス名で検索しても期待した一致になりません。trigramは文字列の連続性を見るため、分かち書きなしでも日本語の固有名詞を拾えます。一方で、言い換えには弱いのでdense検索の代替ではありません。得意領域が異なる検索器を、順位融合で補完させています。
しきい値は「正解」を探すためではなく、測って決める
根拠が弱い質問には答えない。その境界を、最初は類似度0.55に置きました。しかし、本番索引に対して実際にありそうな30問で測り直すと外れていました。
| 質問の種類 | dense類似度 |
|---|---|
| 答えるべき22問 | 0.503〜0.777 |
| 答えてはいけない8問 | 0.379〜0.604 |
範囲は重なります。どこに線を引いても、取りこぼしか誤って答える質問が残ります。0.55では4件を取りこぼし、0.50では取りこぼしはなくなる一方で3件に反応しました。
今回は0.50を採用しました。サイトに書いてあることを「書いていない」と返すのは、訪問者にとって明確な不利益だからです。ただし、ここで大切なのは、retrievalのゲートを真偽判定の唯一の門にしないことです。これは主に「AIを呼ぶ費用の門」。回答の安全性は、その後のプロンプト制約と出力検査を合わせて守ります。
短いサービス名のようにベクトル類似度が伸びない場合は、文字列がほぼ完全に一致することも根拠として認めています。
この判断で分かったのは、評価セットを「正答率を見せるための資料」にしてはいけないということです。最初に用意した丁寧な合成質問では、どの質問も検索しやすく、しきい値を高く置いても問題が見えませんでした。本番を想定した省略語、くだけた聞き方、商品名だけの質問を混ぜると、境界付近に大量のケースが出ます。しきい値は一度決めて終わりではなく、サイトや質問の傾向が変わるたびに再測定する前提で扱うべきです。
プロンプトだけを信用しない
生成AIには「資料にないことは答えない」と指示していますが、それだけでは不十分です。プロンプトにはretrieved chunkを資料として明示し、資料中に命令文があっても実行しないよう扱います。区切り文字を資料に含めない処理も入れ、プロンプトインジェクションの経路を狭めています。さらに、生成結果を画面へ流す前に、決定的なルールで検査します。
検査対象は、掲載外の金額や割合、成果保証の表現、守れない約束、個人情報に関する語、本文中のURLなどです。SSEストリームは末尾を少しバッファし、確定した文字列だけをクライアントへ流します。違反パターンが確定した瞬間に中継を打ち切り、定型の案内へ切り替えます。
価格は特に厳格です。サイトで公開している表現は、必要な2文が完全一致したときだけ通します。「〜台から」を「〜から」に言い換えるだけでも受け取り方が変わるため、モデルの要約に委ねません。
また、本文にURLを書かせません。出典リンクは検索で見つかったページのURLを画面側が表示します。これなら、索引に存在しない外部サイトへAIが誘導する経路をなくせます。チャットからフォームへ進む導線も、モデルは列挙値だけを返し、実URLは画面側で対応づけます。
会話ログも、伏せてから保存する
改善のために会話は記録しますが、メールアドレス、電話番号、URL、長い数字列は保存前に伏せます。IPアドレスやユーザーエージェントは保存しません。
「後で消す」のではなく「伏せてから保存する」ことが重要です。さらに、画面に表示する保存期間とWorkerの設定値をテストで突き合わせます。表示だけ短く、実際は長く保存してしまうずれを、注意力だけに依存しないためです。
ブラウザから送るのはUUID v4のセッションIDと新しい発言だけで、会話履歴はサーバ側で管理します。履歴を丸ごと送る方式では、過去のAI回答を改ざんして送れてしまうためです。
マスキングにも順序があります。電話番号のような形式を、単なる長い数字列より先に処理しないと、後段の置換に食われます。逆に数字列の途中を電話番号として消すと、法人番号など別の値を誤認します。正規表現は「書けた」だけで終わらせず、前後が数字ではないことを条件にしたテストケースまで含めて初めて運用できます。
文言・リンクの責務を分ける
Workerが返すのは、`no_sources`、`blocked`、`turn_limit` のような状態コードです。画面に出す日本語文と遷移先URLはテーマ側が持ちます。生成側が自由な文言やURLを返す設計にすると、表示文言と実際の導線が別々に更新され、静かに食い違います。
そこで、状態コードと許可した出口の列挙値が両側で一致しているかをテストします。これはRAG固有の技術ではありませんが、AIを含むシステムでは特に効きます。モデルの出力を減らすほど、後段で検証できる契約が増えるからです。
評価と運用を分ける
検証には二つのレイヤーがあります。第一はretrievalだけを叩く評価セットです。質問、期待するページ、許容する類似度を用意し、生成AIを呼ばずに検索器の変更を評価します。費用がかからず、本番ログも汚さないため、チャンク化やランキングを変えるたびに回せます。
第二は生成まで通す評価です。こちらはプロンプト、モデル、出力検査、ストリーミング中断を含めて確認します。コストも会話ログへの混入もあるので、入口を評価用に分け、通常の集計から除外します。最初にこれを分けず、本番ログがテスト会話で埋まったことがありました。
運用では、記事公開時に索引を更新し、毎週は「しきい値を超えた異常があるか」だけを見ます。毎回すべてのダッシュボードを眺める運用は続きません。月に一度、実際の質問文と出力検査の発火内容を読み、質問に対してサイトに答えがあるかを判断します。
未解決のものも、未解決として管理する
日本語の質問には、全文がひらがなで入力された場合に検索精度が下がるケースがあります。漢字混じりなら拾える質問でも、全てひらがなだとdense類似度が大きく下がります。しかし現時点で、これは合成評価で見つけた問題であり、実際の会話では観測していません。
そのため、今は複雑な正規化処理を追加していません。「実ログで発生したら検討する」「候補は読み仮名・形態素解析・別埋め込みモデル」と、判断条件と選択肢を記録しています。合成データだけを根拠に最適化しないことも、しきい値の失敗から得た教訓です。
運用で見るべきは、答えられなかった質問
記事公開時には索引を更新し、毎週は異常な数値がないかだけを短時間で確認します。月に一度は、実際の質問文を読みます。
特に価値があるのは、根拠がなく答えられなかった質問です。本当に書いていないならFAQや記事に追加する。書いてあるのに答えられないなら、索引、見出し、しきい値を見直す。RAGチャットは案内窓口であると同時に、サイトの情報不足を教える観測装置にもなります。
まとめ
RAGチャットで難しいのは、検索精度を上げることだけではありません。何を情報源にし、どこで生成を止め、誰が回答の責任を持つかを決めることです。
- 索引は公開HTMLだけに限定する
- 日本語の固有名詞も拾えるよう、検索を複数系統で組み合わせる
- しきい値は実際の言い回しで測って決める
- プロンプトインジェクション対策と出力の決定的検査を組み合わせる
- ログは伏せてから保存し、答えられなかった質問を改善に使う
RAGは「何でも答えるAI」を作るための仕組みではありません。根拠のある範囲で答え、根拠がないときは無理に答えない。その線を測り、仕組みで守り続けるための技術だと考えています。