嘘をつかないチャットボットを作った
社内ドキュメントに答える Q&A ツールを作って学んだこと。答えを書くのは AI、でも「その答えを出していいか」を決めるのはコード側、という設計の話。
どのチームにも同じ悩みがある。大事な情報が、誰も読みたがらないドキュメントに埋まっている。規程、運用手順書、契約書の条文、オンボーディング資料。答えはどこかにあると分かっている。ただ、探すのに 20 分は使いたくない。
素直に思いつく解決策はチャットボットだ。ドキュメントを読ませて、質問して、答えを返してもらう。そして素直に作った場合の素直な問題がひとつある ― 平気で嘘をつく。堂々と。存在しない条番号を出し、ありもしない引用を作り、「信じてくれ」という口調で。
なので、嘘をつけないやつを作った。正確には、「嘘をついたら、ユーザーに見せる前に捕まる」やつを。
実際に触れる場所はここ:ai-assistant.bikashlama.com。アカウントは管理者発行制なので、ちゃんと中を覗きたい方は声をかけてください。
どんなアプリか
チームのメンバーがサインインし、ドキュメント(PDF、Markdown、テキスト)をアップロードし、アシスタントに質問する。答えはストリーミングで返ってきて、小さな番号バッジ ― [1]、[2] ― が付く。クリックすると、その主張の裏付けになっているドキュメントの一節が横からスライドしてくる。ちゃんとした裏付けがなければ、アシスタントは推測せずに「答えられません」と返す。
たとえば 「第 51 条は契約不履行について何と定めているか?」 と聞くと、短い答えが流れてきて、該当する文の横に [1] が付く。クリックすればソースが横から出る。
ドキュメントに書いてないことを聞くと ― 「今日の天気は?」 ― こう返ってくる。「こんにちは。アップロードされた社内ドキュメントについての質問にお答えします。」 答えも作らない、引用もでっち上げない、天気予報も出さない。
管理者には別の画面がある。誰が使っているか、ユーザーごとに AI コストをいくら使ったか、どのメッセージに 👍 / 👎 が付いたか。
中心の考え方:AI が書く、決めるのはコード
「引用付きの AI」を謳うデモは大体こう動く。モデルに「ソースを引用してください」と頼む。モデルが引用らしきものを付ける。それを信じる。これはいい設計ではない。モデルは「引用に見えるテキストを書く」のは得意で、「本物の引用だけを書く」のは苦手だからだ。
なのでこのアプリでは、LLM に最終判断を持たせない。全体はこう動く:
-
ドキュメントをアップロードすると、少しずつ重なりを持たせながら細かく切る(1 ピース 500 語くらい、50 語のオーバーラップで、文が境界で分断されないように)。それぞれを「意味の指紋」であるベクトルに変換して、Postgres に保存する。
-
質問が来たら、質問にも同じ指紋を作る。データベースが、それに一番近い 5 ピースを返す。どれも似ていなければ ― 私は基準をコサイン類似度 0.15 に置いた ― LLM を呼ぶ前に質問を断る。トークンを使わない、間違った答えも作らない。
-
似たピースが見つかれば、
[1]から[5]の番号を振って、質問と一緒に LLM に渡す。「これらを使って答えて。主張の横にはその番号を書いて」と伝える。 -
答えがストリーミングで流れてくる間、私のコードが見張っている。ストリームが終わったら、パーサが LLM の書いた
[N]を全部拾って、一つずつチェックする。Nはリストにある本物の番号か? そもそも一つでも引用したか? 有効な引用がゼロだったら、流したテキストは捨てて、あの「見つかりませんでした」の文言に差し替える。
大事なのは最後の一手だ。LLM に自分の答えを採点させない。もし LLM が美しくもっともらしい、でもソースがゼロの答えを書いたとしても、ユーザーに表示されるのは断りの文言だ。パーサは「答えの見栄えの良さ」には興味がない。
普通の RAG デモとどう違うか
作りながら大事だと気付いた順に:
断りはプロンプトの技じゃない。 よくある RAG アプリはシステムプロンプトに「わからないときは断ってください」と書いて、モデルが従うことを祈る。私のはコードで打ち切る ― ベクトル検索が空なら、LLM は起動すらしない。うまく丸め込めるプロンプトの一行に頼っていない。
引用は信じるんじゃなくて検証する。 有効な引用番号のリストは、私のコードが持つ集合だ。LLM は [7] と書きたければ書けばいい。ただしソースが 5 つしかなければ [7] は落ちる。有効なものが一つも残らなければ、答えごと差し替える。
コストは後回しじゃなく最初に置く。 OpenAI 呼び出しは全部、トークン数と推定コストを usage_events テーブルに書くラッパーを通る。ユーザーごとに日次のトークン枠(デフォルト 100k)がある。使い切っていたら、アプリのコードがリクエストを止める ― OpenAI 呼び出し自体が発生しない。管理者はユーザー別・日別・モデル別の使用量を見られる。
シークレットは LLM のコンテキストに絶対に入れない。 API キーは環境変数から、OpenAI と話すアダプタの中でしか読まない。セッション Cookie が持つのは { userId, isAdmin } だけ ― メールもハッシュも、LLM に見せたくないものは何一つ入っていない。
これらは最終的に「LLM に触るコードすべてに適用する 8 つのルール」という形にまとまった。そのルール自体は別記事にまとめてある ― LLM ハーネスを作って学んだこと。
スタック、手短に
面白味の少ないツールを選んで、それぞれ得意なことに使う、というのを心がけた。
| レイヤ | 選んだもの | 理由 |
|---|---|---|
| ランタイム | Next.js 15 on Node | UI・API・ストリーミングを 1 プロセスで。別バックエンドを立てなくていい |
| 言語 | TypeScript (strict) | 私のうっかりミスをビルド時にたいてい捕まえてくれる |
| DB | Postgres + pgvector | 普通のテーブルとベクトル検索が同じ場所にある。運用するものが 1 つ減る |
| 本番 | Vercel + Neon | main に push すればデプロイ。無料枠でデモが回る |
| 開発 | Docker Compose | 1 コマンドでローカル Postgres が立ち上がる |
| LLM | OpenAI (text-embedding-3-small + gpt-4o-mini) | 品質を満たす一番安いモデル |
| UI | Tailwind + shadcn/ui + Radix + next-themes | ダークモードで悩まない |
| 認証 | iron-session Cookie + bcrypt | 公開サインアップなし。アカウントは管理者が発行する |
| ログ | pino + AsyncLocalStorage に入れたリクエスト ID | どのログ行にも同じ ID が付く ― 上から DB ドライバの奥まで。ID で grep すれば 1 リクエストの動きが端から端まで読める |
| ストリーミング | OpenAI のストリームの上に Server-Sent Events | モデルが吐いた順にトークンが画面に出る |
あとは既製品 ― PDF パーサ、トースト、アイコン、それだけ。
個人的に気に入ってる部分
ストリーム後の引用検証。 ここは作っていて楽しかった。ユーザーはトークンが届いた瞬間に画面で見たい ― それ以外はもっさり感じる。でも、ストリームが終わってから引用チェックを始めて、実は一つもなかったと分かった時にはもう遅い。美しい嘘の答えをユーザーに見せてしまっている。解:テキストは仮扱いで流し、最後に「検証済み」イベントを送る。検証が失敗したら、クライアントが流し済みのテキストを断り文言に差し替える。画面が動く UI に「書いてからコミット」パターンを持ち込んだ形だ。
HNSW と IVFFlat の入れ替え。 マイグレーション 0003 では pgvector の ivfflat を使っていた。テストではすべて動いた。本番の小さいコーパスで、クエリが毎回 0 行を返した。原因は、ivfflat は lists パラメータが行数より大きいと無言で 0 行を返す仕様だったこと。マイグレーション 0005 で HNSW に置き換え。この罠はない。以降、この件はデプロイの手順書に「gotcha」として書いてある。もう一度午後を溶かしたくないので。
非同期コードを貫くリクエスト追跡。 全リクエストはミドルウェアで x-request-id を付けられる。その ID は Node の AsyncLocalStorage にリクエストの寿命だけ入り、pino の mixin が全ログ行に自動で仕込む ― DB ドライバの奥のログも含めて。本番で何かがおかしくなったら、リクエスト ID を grep するだけで、1 ユーザーの 1 ターンの全貌がタイムスタンプを縫い合わせなくても読める。
なんでも環境変数で調整可能に。 チャンクサイズ、取ってくる件数、類似度の閾値、日次のトークン枠、使うチャットモデル、JPY-USD 換算レート ― 全部 .env.example に、本番向けの妥当なデフォルト付きで並んでいる。検索チューニングにコード変更もデプロイもいらない。
まだイケてないところ
v1 の姿を正直に書いておく:
- チャンクは語数で数えている、トークン数じゃない。 実際の BPE トークンは単語より小さい。500 語のチャンクは約 650 トークン ― OpenAI の 8191 トークン上限にはずっと収まる ― けど、設定値の名前は正確じゃない。
tiktokenを組み込めばもっと正直になる。 - チャンカが空白で盲目的に切る。 見出しとその段落が別チャンクに割れることがある。セクション境界を意識した切り方なら、構造化ドキュメントに強い。
- 全質問共通の類似度の閾値ひとつ。 0.4 で当たる質問もあれば、0.15 でしか当たらないのもある。1 つの数字は妥協で、断りすぎるか、弱い一致を通しすぎるかのどちらかに寄る。
- 検索後の再ランキングがない。 pgvector の HNSW は 近似 だ。1 万チャンクを超えると、良いシステムは top-20 を取ってから小さめのモデルで本当の top-5 に絞る。私のは HNSW の順番を素直に信じている。
- ドキュメントのバージョン管理がない。 同じ規程をアップロードし直すと、古いチャンクの隣に新しいチャンクが並ぶだけ。古いのが検索で勝って去年の答えを返すことがありうる。
- PDF の表は潰れる。
pdf-parseで表は乱れたテキストになる。画像や図はそのまま落ちる。 - シングルテナント。 コーパスは 1 つ、管理者グループは 1 つ。マルチ企業に対応させるには全テーブルを触る作業になる。
- リロードすると引用バッジが消える。
[N]はメッセージが新しいうちは動く。DB に引用の対応を保存していないので、リロードすると素のテキストに戻る。地味にイラっとする、直せる、けどまだスキーマ変更に見合ってない、というやつ。 - フィードバックがどこにも繋がっていない。 👍/👎 は保存されるけど、検索チューニングへのループも、ユーザーへの「見ました」もない。
- コスト上限がユーザー日次だけ。 時間単位も組織単位もない。乗っ取られたアカウントは 1 日分の予算を 1 分で燃やせる。
- ログインにレート制限がない。 bcrypt で総当たりは高コストになるけど、不可能じゃない。
/api/auth/loginの IP 単位のスロットリングは TODO。 - ユーザーデータのエクスポート・削除がない。 社内ツールなら問題ない。外部ユーザーに出す前には必須。
- チャットが線形。 分岐も「別の切り口で答え直して」ボタンもない。打ち直すしかない。
次にやるなら
このプロジェクトを続けるなら、私ならこの順で手を付ける。
使われるほど賢くなる検索。 👎 が付いた質問は全部、その時に引用されていたチャンク(断りなら null)と一緒に評価ファイルに追記される。検索チューニングが、実ユーザーの摩擦を回帰テストとして持てるようになる。
失敗を本当に理解する。 各 👎 とコメントを、小さな分類プロンプトに流して「検索ミス / 統合ミス / トピック外」に振り分ける。管理画面が「何が失敗したか」だけでなく「どう失敗したか」も見せられるようになる。
チャンク単位のフィードバック。 メッセージ全体じゃなくて、引用バッジ 1 つ 1 つに 👎。繰り返しフラグが立ったチャンクは、再インデックスか削除のキューへ。
改訂を尊重するドキュメントライフサイクル。 ドキュメントをアップロードし直したら、古い版のチャンクを historical マーク、検索でデフォルト除外。監査ログは全部残す。
プロンプトテンプレートの変数化。 保存済みスニペットを「コピペするテキスト」から「穴埋めフォーム」に。本文の {{contract_name}} を、挿入時に小さなモーダルで埋める。
まともなコスト制御。 時間単位・組織単位の上限。ログインの IP スロットリング。公開エンドポイント全部の IP スロットリング。
マルチテナント。 全テーブルに corpus_id。1 デプロイで複数企業を分けてホストできるように。
デモモード。 小さな公開向けキュレーションコーパスと強めのレート制限で、管理者招待なしで誰でも触れる別デプロイ。ポートフォリオからリンクする。
最後のがこのプロジェクトを「ただの作品」から「ポートフォリオピース」に変える一手だと思っている。リクルーターは流し読みする。動くデモはスクショに勝つ。