← 記事一覧へ
AI生成画像

社内文書Q&Aを本番で動かす:権限・更新・評価・運用の設計

文書に質問できるRAGを、複数人が使う本番の仕組みにする。取り込みの差分更新、権限での絞り込み、検索の改善、評価、運用までの設計をまとめる。

AI作成 · 約14分 · RAGの基本記事を読んだ人向け
ローカルLLMRAG運用
この記事で扱うこと

本番では、モデルの賢さより、周りの仕組みが品質を決めます。文書を最新に保つ取り込み、検索の前の権限、変更のたびに測る評価、ログと監視の4つを先に設計します。

試作と本番の違い

文書に質問できるシステムは、自分1人が試す形でした。これを複数人が使う社内システムにすると、モデル以外の問題が増えます。本番では、先に次の要件を決めておきます。

要件決めること決めないとどうなるか
利用者人数、同時に使う人数重い時間帯に待たされる(メモリの見積もりを参照)
文書量、形式(PDF、Office、Wikiなど)、置き場所取り込めない文書が出る
更新どのくらいの頻度で変わり、どのくらいで反映するか古い規程で答える
権限誰がどの文書を見てよいか見せてはいけない文書の内容が答えに出る
品質「合格」の基準と、測り方良くなったのか悪くなったのか分からない
運用障害時、ログ、担当者止まったときに誰も直せない

この記事は、特定の製品を勧めるものではありません。製品ごとの機能と設定は変わるので、考え方と設計の勘所を中心に書きます。コードは、仕組みが分かる最小の形です。

全体のアーキテクチャ

本番の構成は、「質問に答える経路」と「文書を取り込む経路」の2本に分けて考えます。2つをつなぐのが、検索用の索引です。

社内文書Q&Aの本番構成社内ネットワークの中利用者認証・APIゲートウェイSSOで、誰が聞いたかを確定する質問サービス検索 → 権限 → 回答 → 検証推論サーバー検索(権限で絞る)索引ストア全文索引 + ベクトル索引 + 閲覧できるグループ取り込み(文書が変わるたび)文書置き場変換分割埋め込み差分だけ更新質問ログ・評価・監視質問・資料・答え・反応を残し、定期的に測る
図1 質問に答える経路と、文書を取り込む経路を、検索の索引でつなぐ。権限は索引のデータで絞り、ログと評価が全体を見る。
  • 質問の経路:認証で誰かを確定し、質問サービスが検索して、権限で絞り、モデルに答えさせ、答えを検証して返す
  • 取り込みの経路:文書置き場の変更を検知して、変換、分割、埋め込みをして、索引を更新する
  • ログと評価:どちらの経路も、記録を残し、評価セットで定期的に測る

取り込み:文書を最新に保つ

プロトタイプでは、文書を起動のたびに全部読み込みました。本番では、文書が変わったときだけ、その文書の分だけを作り直します。

  • 文書の内容のハッシュを保存しておき、変わったものだけを再処理する
  • 文書が消えたら、索引からも消す(消し忘れると、削除したはずの内容で答える)
  • 文書ごとに、出典(パスやURL)、更新日、閲覧できるグループを一緒に持つ
  • PDFやOfficeファイルは、先にテキストに変換する。表や図は崩れやすいので、変換結果を目で確かめる
  • 分割は、見出しの構造を保つ。見出しをチャンクの先頭に付けると、検索で見つけやすくなる
ingest.py(索引のテーブル)
import hashlib
import re
import sqlite3

from rag import split  # 前の記事の分割関数

db = sqlite3.connect("index.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS docs (path TEXT PRIMARY KEY, hash TEXT, groups TEXT);
CREATE TABLE IF NOT EXISTS chunks (id INTEGER PRIMARY KEY, path TEXT, ord INTEGER, text TEXT);
""")
ingest.py(差分取り込み)
# 取り込み: 変わった文書だけ作り直し、なくなった文書は索引から消す
def sync(files):  # files: {パス: (本文, 閲覧できるグループの集合)}
    changed = removed = 0
    for path, (text, groups) in files.items():
        digest = hashlib.sha256(text.encode()).hexdigest()
        row = db.execute("SELECT hash FROM docs WHERE path = ?", (path,)).fetchone()
        if row and row[0] == digest:
            continue  # 変わっていない
        db.execute("DELETE FROM chunks WHERE path = ?", (path,))
        for order, chunk in enumerate(split(text)):
            db.execute("INSERT INTO chunks (path, ord, text) VALUES (?, ?, ?)", (path, order, chunk))
        db.execute("INSERT OR REPLACE INTO docs VALUES (?, ?, ?)", (path, digest, ",".join(sorted(groups))))
        changed += 1  # ここで、このチャンクの埋め込みも計算し直す
    for (path,) in db.execute("SELECT path FROM docs").fetchall():
        if path not in files:
            db.execute("DELETE FROM chunks WHERE path = ?", (path,))
            db.execute("DELETE FROM docs WHERE path = ?", (path,))
            removed += 1
    db.commit()
    return changed, removed

埋め込みモデルを入れ替えると、既存のベクトルは使えなくなります。このときは、全文書のベクトルを作り直します。どのモデルで作ったベクトルかを、索引に記録しておくと、入れ替えのときに困りません。

権限は、検索の前に絞る

社内文書には、全員が見てよいものと、特定の部署だけが見てよいものが混ざります。ここで大事なのは、権限の確認を、モデルに任せないことです。「この人には見せないで」とプロンプトに書いても、モデルが従う保証はありません。

権限で絞る位置よくない:検索のあとで隠す全文書を検索モデルへ検索結果すべて答えから隠す出力の段階でモデルは、見てはいけない文書を読んでしまっているよい:検索の前に絞るグループで絞る権限を確認検索見てよい文書だけモデルへ絞った結果だけ見てはいけない文書は、モデルに渡らない
図2 権限は、検索の対象を決める段階で適用する。モデルに「見せないで」と頼むのは、守りにならない。

絞り込みは、検索の前(または検索と同時)に行います。見てはいけない文書は、そもそも検索の対象に入れません。

ingest.py(権限で絞る)
# 権限: 検索の前に、その人が見られるチャンクだけに絞る
def visible_chunks(user_groups):
    rows = db.execute("SELECT chunks.id, chunks.path, chunks.text, docs.groups FROM chunks JOIN docs USING (path)").fetchall()
    return [(i, path, text) for i, path, text, groups in rows if set(groups.split(",")) & set(user_groups)]
  • 認証は、社内の既存の仕組み(SSOなど)に任せ、ユーザーのグループを取得する
  • 権限が変わった(退職、異動)ときに、すぐ反映される経路を作る。索引側にグループをコピーしている場合は、同期の遅れが穴になる
  • 「文書があること自体」を隠したいなら、「資料が見つかりません」という答え方も、権限で絞った結果に基づいて返す

検索の質を上げる

答えの品質は、ほとんど、検索で決まります。モデルに渡す資料が間違っていれば、どんなモデルでも正しく答えられません。

  • ハイブリッド検索:単語の検索(固有名詞や型番に強い)と、ベクトルの検索(言い換えに強い)を両方使う
  • 結果の統合:2つの点数は比べられないので、順位だけを使って合わせる方法(RRF)がよく使われる
  • 再ランキング:上位数十件を、より精密な方法で並べ替えて、数件に絞る
  • メタデータで絞る:部署、文書の種類、更新日などで、検索の範囲を狭める
ingest.py(順位で統合する)
# 検索結果の統合: 順位だけを使う(RRF)。単語検索とベクトル検索の点数は比べられないので、順位で合わせる
def rrf(rankings, k=60):
    score = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking, start=1):
            score[chunk_id] = score.get(chunk_id, 0) + 1 / (k + rank)
    return sorted(score, key=score.get, reverse=True)

回答を作って、検証する

プロトタイプでは、モデルの答えをそのまま返しました。本番では、返す前に確認します。

  • 引用の検証:答えに付いた資料の番号が、実際に渡した資料の番号か確認する。番号がなければ、根拠のない答えとして扱う
  • 答えない選択:検索で何も見つからない、または関連が弱いときは、モデルを呼ばずに「分からない」と返す
  • 出典の表示:答えと一緒に、元の文書へのリンクを出す。人が原文を確かめられるようにする
  • プロンプトインジェクションへの備え:文書の中の指示に従わせない。外部の人が書ける文書(問い合わせ、Wikiの編集)は、とくに警戒する
ingest.py(引用の検証)
# 引用の検証: 答えに付いた番号が、実際に渡した資料の番号か
def check_citations(answer, found_count):
    cited = {int(n) for n in re.findall(r"\[(\d+)\]", answer)}
    return bool(cited) and cited <= set(range(1, found_count + 1))

引用の番号が正しくても、内容が合っているとは限りません。番号の検証は最低限の確認で、内容の確認は、次の評価で行います。

評価:変えるたびに測る

分割の大きさ、検索の方法、モデル、プロンプトのどれかを変えると、品質が上がることも下がることもあります。感覚で判断せず、同じ質問のセットで測って比べます。

  1. 実際の質問を、50〜100個集める(現場の人に聞くか、問い合わせの履歴を使う)
  2. それぞれに、正解の根拠になる文書の部分を付ける
  3. 変更のたびに、検索の成績(上位k件に正解が入った割合)を測る
  4. 答えは、人が抜き取りで確認する。モデルに採点させる方法もあるが、モデルの採点も間違えるので、人の確認を併用する
ingest.py(検索の評価)
# 評価: 正解のチャンクが、上位k件に入っている質問の割合
def recall_at_k(cases, search, k=5):  # cases: [(質問, 正解のチャンクIDの集合)]
    hits = sum(1 for question, expected in cases if expected & set(search(question)[:k]))
    return hits / len(cases)
  • 「資料にない質問」も、評価セットに入れる。正しく「分からない」と返せるかを測る
  • 質問は、実際の利用で増やす。「役に立たなかった」の記録から、評価セットに足していく
  • 数字が下がったら、直前の変更を戻せるようにしておく

運用:動かし続ける

項目見るもの対応
応答時間検索とモデルの所要時間、待ち行列の長さ同時利用が増えたら、機材かモデルを見直す
「分からない」の割合急に増えたら、文書か検索の問題取り込みの失敗、文書の欠けを調べる
フィードバック「役に立った/立たなかった」立たなかった質問を、評価セットに足す
取り込み失敗した文書、最終更新の時刻失敗を通知する。古い索引のまま動かさない
ログ質問、見つけた資料、答え保存期間とアクセス権を決める
  • ログの扱い:質問や答えに、機密や個人情報が入ります。ログも、文書と同じ強さで守る
  • 障害時:モデルが止まっても、検索の結果(関連する文書の一覧)だけは返せるようにしておくと、完全に止まらない
  • 更新:モデル、埋め込みモデル、検索の設定を変えるときは、評価セットで測ってから切り替える
  • バックアップ:索引は文書から作り直せるが、時間がかかる。作り直しにかかる時間を知っておく

確認リスト

  • 利用者数、文書量、更新頻度、権限の単位を決めたか
  • 文書の変更と削除が、決めた時間内に索引に反映されるか
  • 権限での絞り込みを、検索の前に行っているか。モデルのプロンプトに頼っていないか
  • 見てはいけない文書が答えに出ないことを、テストで確かめたか
  • 評価セットがあり、変更のたびに測っているか。「資料にない質問」を含んでいるか
  • 答えの引用を検証し、出典のリンクを出しているか
  • ログの保存期間とアクセス権を決めたか
  • モデルや検索が止まったときの動作を決めたか
  • 誰が運用し、問題が出たときに誰が直すか決めたか

公式リファレンス

Ollama — Documentation ↗vLLM — Documentation ↗Claude Docs — Reduce hallucinations ↗
PAIBOT / DEVELOPMENT JOURNAL記事一覧に戻る →

知りたいことを探す

タイトル・タグ・本文から検索 esc で閉じる