社内AIチャットは、作った直後よりも「直したあと」のほうが怖い仕組みです。しきい値を少し変えた、文書を入れ替えた、AIのモデルを新しくした。どれも小さな変更なのに、ある質問には答えられなくなったり、見てはいけない部署の文書が出てしまったりします。しかも画面で数件試しただけでは気づけません。第7回ではPDFを取り込み、ページ番号つきの出典で答えさせるところまで作りました。
「AIが出す答えの品質を、どうやって確認すればいいのか分からない。直したあとに前より悪くなっていないかも不安」
先に結論をお伝えします。「この質問には、この文書を根拠に、こう答えてほしい」という評価ケースを先に書いておき、変更のたびに自動で全件流すのが、いちばん現実的な品質管理です。第8回では、評価ケースの書き方、採点の仕組み、AIのキーが無くても回せる評価の作り方を、動くコードで説明します。
この記事で作るのは、次の3つです。
- 評価ケースを書いたファイル(
eval/cases.json) - 本番と同じ経路(検索→回答)にケースを流して採点する仕組み(
app/eval/) - 変更で品質が下がったら失敗する自動テスト(
tests/test_eval.py)
社内AIチャットの品質を「確認」でなく「測定」にする理由
AIの回答は毎回少しずつ文章が変わるため、「画面で試して問題なさそう」という確認では、変更の影響を見落とします。そこで、品質を次の3つの観点に分けて、機械的に判定できる形にします。
| 観点 | 何を確かめるか | 例 |
|---|---|---|
| 答えられるか | 正しい文書が出典に入り、必要な言葉が回答に含まれる | 「午前休は何時まで?」→ 休暇規程、「12時」 |
| 答えないべき時に答えないか | 根拠が無い質問で「見つかりません」と返す | 「社長の好きな食べ物は?」 |
| 見せてはいけないものを出さないか | 権限の無い部署の文書名・内容が回答や出典に出ない | 人事部の給与表が、他部署の質問に出ない |
3つ目が特に重要です。「答えられる」質問は、使われれば不具合に気づけます。一方、「出てはいけないものが出ていないか」は、誰も気づかないまま漏えいが続くことがあるため、自動テストで守る価値が高い観点です。
評価ケースを書く(eval/cases.json)
評価ケースは、プログラムではなく人が読める一覧にします。業務の担当者が「この質問も入れて」と言えるようにするためです。
[
{
"id": "vacation-half-day",
"question": "午前休は何時までですか?",
"expect_source": "vacation.md",
"expect_text": ["12時"]
},
{
"id": "hr-hidden-from-others",
"question": "一般職の月額給与はいくら?",
"forbid": ["salary-grade", "20万円", "24万円"]
},
{
"id": "no-answer-ceo-food",
"question": "社長の好きな食べ物は?",
"expect_not_found": true
}
]
(eval/cases.json から抜粋。全体は7件で、PDFの出典・部署が違う場合の見え方も含みます)
項目の意味は次のとおりです。
expect_source:出典に含まれてほしい文書名(部分一致)expect_text:回答に含まれてほしい言葉forbid:回答にも出典にも出てはいけない言葉(権限の無い文書の名前や数字)expect_not_found:根拠が無いので答えないことを期待するdepartments:質問者が見てよい部署(全社向けのallは常に含まれる)
本番と同じ経路で採点する(app/eval/runner.py)
採点の部品は、第5回で作った回答処理(answer_question)をそのまま呼びます。評価用に別の経路を作ると、「評価では通るが本番では通らない」ことが起きるためです。
# app/eval/runner.py(抜粋)
def run_case(case: EvalCase, searcher: Searcher, llm: LLM, max_distance: float) -> CaseResult:
departments = sorted({"all", *case.departments})
answer = answer_question(case.question, searcher(case.question, departments), llm, max_distance)
reasons: list[str] = []
if case.expect_not_found:
if answer.found:
reasons.append("根拠が無いはずなのに答えてしまった")
else:
if (case.expect_source or case.expect_text) and not answer.found:
reasons.append("答えられるはずなのに「見つからない」と返した")
if case.expect_source and not any(case.expect_source in s for s in answer.sources):
reasons.append(f"出典に {case.expect_source} が無い(出典: {answer.sources})")
for word in case.expect_text:
if word not in answer.text:
reasons.append(f"回答に「{word}」が含まれない")
visible = answer.text + "\n" + "\n".join(answer.sources)
for word in case.forbid:
if word in visible:
reasons.append(f"出てはいけない「{word}」が回答または出典に含まれる")
return CaseResult(case, not reasons, reasons)
不合格のときは、何が悪かったかを文章(reasons)で残します。「失敗しました」だけでは、直す人が原因を探すところから始めることになるからです。
APIキー無しで回す:AIの代役を使う(app/eval/fake_llm.py)
評価のたびに本物のAIを呼ぶと、費用がかかり、結果も毎回少し揺れます。そこで、渡された文書の本文を番号つきでそのまま並べて返す「代役」(ExtractiveLLM)を用意しました。
# app/eval/fake_llm.py(抜粋)
DOCUMENT = re.compile(r'<document index="(\d+)"[^\n]*>\n(.*?)\n</document>', re.DOTALL)
class ExtractiveLLM:
def generate(self, system: str, user: str) -> str:
docs = DOCUMENT.findall(user)
if not docs:
return "NO_ANSWER"
return "\n".join(f"{body}[{index}]" for index, body in docs)
この代役で測れるのは、検索が正しい文書を探せたか・権限の無い文書がAIに渡っていないか・根拠が無い質問を弾けているかまでです。AIの文章のうまさや、文書に無いことを言っていないかは測れません。そこは次の節で扱う「本物のAIでの評価」の役割です。
変更で品質が下がったら失敗する自動テスト(tests/test_eval.py)
評価ケースを pytest から呼び、既存のサンプル文書をDBに取り込んで全件流します。
# tests/test_eval.py(抜粋)
def test_regression_suite_against_sample_docs():
with connect(DATABASE_URL) as conn:
... # スキーマ適用・TRUNCATE のあと、sample_docs を取り込む
results = run_all(load_cases(), searcher, ExtractiveLLM(), get_settings().max_distance)
assert not has_regression(results), summarize(results)
実行すると、評価ケース7件のうち6件が合格し、残る1件は後述の「既知の不具合」として記録されます。
OK vacation-half-day: 午前休は何時までですか?
OK expense-deadline: 経費の申請期限は?
OK business-trip-pdf: 出張の宿泊費の上限は?
OK hr-visible-to-hr: 一般職の月額給与はいくら?
OK hr-hidden-from-others: 一般職の月額給与はいくら?
OK sales-hidden-from-hr: 承認なしで出せる値引き率の上限は?
既知 no-answer-ceo-food: 社長の好きな食べ物は?
- 根拠が無いはずなのに答えてしまった
合計 7件: 合格 6 / 既知の不具合 1 / 新たな不合格 0
(開発環境での実行結果。単体で python -m app.eval を実行しても、同じ形式で表示され、後退があれば終了コード1で終わります)
わざと壊して、テストが見つけるか確かめる
評価が役に立つかは、壊したときに失敗するかで確かめます。第6回の「部署の絞り込み」(store.py の department = ANY(%s))を一時的に外して流すと、次のように表示されました。
NG hr-hidden-from-others: 一般職の月額給与はいくら?
- 出てはいけない「salary-grade」が回答または出典に含まれる
- 出てはいけない「20万円」が回答または出典に含まれる
NG sales-hidden-from-hr: 承認なしで出せる値引き率の上限は?
- 出てはいけない「discount-policy」が回答または出典に含まれる
合計 7件: 合格 4 / 既知の不具合 1 / 新たな不合格 2
画面を数回触っただけでは見落とすかもしれない権限の穴を、評価ケースが検出しました。確認後は元に戻してあります。
「既知の不具合」を隠さず、増えていないことを見張る
先ほどの結果で、「社長の好きな食べ物は?」は不合格のままです。第3回で作った簡易な検索(HashingEmbedder)では、無関係な質問でも距離が0.87前後に収まり、第5回のしきい値0.9を通り抜けてしまうためです。これは評価で初めて数値として確認できた弱点です。
このケースには known_issue に理由を書き、「既知の不具合」として扱います。
{
"id": "no-answer-ceo-food",
"question": "社長の好きな食べ物は?",
"expect_not_found": true,
"known_issue": "簡易埋め込み(HashingEmbedder)では無関係な質問も距離0.87前後で近く見え、しきい値0.9を抜けてしまう。本物の埋め込みモデルに替えたら外す"
}
既知の不具合は失敗として数えませんが、結果には「既知」と毎回表示されます。不具合を放置するのでも、テストを赤いままにして誰も見なくなるのでもなく、「分かっている弱点」として一覧に残すための仕組みです。known_issue は理由が空だと使えないようにしてあり(test_case_file_is_valid)、言い訳なしに外せなくなることを防いでいます。
本物のAIで評価するとき(未確認の範囲)
python -m app.eval --real-llm で、.env のAPIキーを使い、実際のAIに答えさせて同じ評価を流せます。ただし、この記事の開発環境にはキーが無く、本物のAIでの評価結果は確認していません。実行する場合は、次の点に注意してください。
- 回答の文章は毎回変わるので、
expect_textは「12時」のような短い言葉にとどめる - 実行のたびにAPI費用がかかる。評価の件数が増えたら、毎回の自動テストではなく、日次や変更前などに回す
- 評価用のDBは本番と分ける(
--real-llmなし・ありとも、実行時にsample_docsを取り込み直すため)
つまずきやすい点・注意
- 評価ケースの数を増やすのが目的ではありません。実際に現場から出た質問、過去に間違えた質問を優先して足すほうが、守る価値があります
- 評価に通ったからといって、本番で間違えないという意味ではありません。評価は「決めた質問での後退を見つける網」です
- 評価ケースに本物の機密(社員の給与など)を書かないでください。連載のサンプルは架空の規程です
発注者向けメモ
社内AI検索を開発会社に頼むとき、納品物に「評価ケースと、その実行手順」が入っているかを確認してください。評価ケースが無いと、改修やAIモデルの変更のたびに「前より悪くなっていないか」を人の目で確かめることになり、運用費用が増えやすくなります。
- 評価ケースは、発注側の業務担当者が一緒に作れるか(現場の質問が反映されるか)
- 権限の無い文書が出ないことを、自動テストで確認しているか
- 「答えない」べき質問を、評価に含めているか
- AIのモデルや設定を変えたとき、評価をやり直す運用になっているか
- 評価でのAPI費用が、月額の運用費に含まれているか
開発会社への質問例は次のとおりです。
- 「回答の品質を、変更のたびにどうやって確認していますか。評価用の質問リストはいただけますか」
- 「部署の違う文書が出てしまわないことを、自動テストで確認していますか」
- 「AIのモデルを新しいものに替えたとき、回答が悪くなっていないかをどう確かめますか」
- 「評価で『答えられなかった』『誤って答えた』質問は、どのように記録・共有されますか」
まとめと次回予告
第8回では、評価ケースをcases.jsonに書き、本番と同じ経路で採点し、後退があれば自動テストが失敗する仕組みを作りました。AIのキーが無くても回せる代役を使うことで、検索・権限・「答えない」判定を無料で毎回確認できます。評価で見つかった弱点は、隠さず「既知の不具合」として残します。
次回の第9回は、ここまで作ったアプリをコンテナにまとめ、Google Cloud Runへデプロイする回です。シークレット(APIキー)の渡し方や、運用時の注意を扱います。
この連載の記事一覧
この記事は連載「FastAPIで作る社内ナレッジ検索(RAG)」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【FastAPIで作る社内ナレッジ検索(RAG) 第0回】全体像と、動く最小APIを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第1回】社内文書を読み込んで、検索しやすい断片に分ける
- 【FastAPIで作る社内ナレッジ検索(RAG) 第2回】PostgreSQLとpgvectorをDocker Composeで動かし、文書の断片を保存する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第3回】質問に近い社内文書を探す(ベクトル検索)を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第4回】見つけた社内文書を根拠に、出典つきで生成AIに回答させる
- 【FastAPIで作る社内ナレッジ検索(RAG) 第5回】答えが社内文書に無いときは「見つかりません」と返す仕組みを作る
- 【FastAPIで作る社内ナレッジ検索(RAG) 第6回】部署ごとに見てよい文書だけを検索する、アクセス制御を実装する
- 【FastAPIで作る社内ナレッジ検索(RAG) 第7回】社内のPDFを取り込み、ページ番号つきの出典で答えさせる
- 【FastAPIで作る社内ナレッジ検索(RAG) 第8回】回答の品質を評価ケースで測り、直したつもりの後退を自動テストで見つける(この記事)










コメント
コメント一覧 (5件)
[…] 社内AIチャットは、手元のパソコンで動いた時点では「まだ誰も使えない」状態です。社員が使えるようにするには、サーバー上で24時間動かし、パスワードやAPIキーを安全に渡し、DBにつなぎ、社外の人からは見えないようにする必要があります。第8回では評価ケースで回答の品質を自動テストできるようにしました。今回は、いよいよ公開の準備です。 […]
[…] […]
[…] […]
[…] […]
[…] […]