MENU

問い合わせ


    【FastAPIで作る社内ナレッジ検索(RAG) 第8回】回答の品質を評価ケースで測り、直したつもりの後退を自動テストで見つける

    社内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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (5件)

      目次