RAG 基盤 / 設計

選べる棚をつくる

社内外の多数のデータベースを一つの棚に並べ、必要なものを選び、 自分の使い慣れたエージェントを挿して分析する。 棚に載せられるのは、三つの条件を通ったものだけである。

2026-08-16  ·  SQLite / FastAPI / MCP / Tailscale

棚とは何か

一つの巨大な RAG を作るのではない。性質の違う多数のデータベースを並べた棚を作る。 定量と定性、社内と社外、テキストと画像と音声、分野もばらばらでよい。 利用者は必要なものを選び、必要なだけ組み合わせる。

選ぶ
棚を眺め、商品ラベルを読んで、使うコーパスを決める
挿す
Claude Code / Antigravity / 手元のスクリプト。クライアントは問わない
使う
対話・横断検索・その場で書く Python。生成は各自の契約枠で

この形にすると、必ず同じ問いが返ってくる。「全部抜かれたらどうするのか」。 並べる数が増えるほど、この問いは重くなる。

そして「読み取り専用にします」は答えになっていない。 読み取り専用が防ぐのは改変であって、抜き取りではない。 任意の SQL と Python を許した時点で、全件取得も、分割してのページングも、書き出しも成立する。 同様に、エージェントに読ませた行はその時点でモデル提供者へ渡っている。 守るべき対象は「DB へのアクセス」ではなく、何行がコンテキストに乗るかである。

出品条件

そこで、棚に載せる側に条件を課す。 三つを通っていないデータベースは棚に並べない。 逆に言えば、通っているものは自由に使わせてよい — この線引きが棚を成立させる。

構造で削り、流量で絞り、全部を記録する。
この三つが同時に効いていれば、その上の利用は制限しなくてよい。

三つは順番ではなく層である。どれか一つでは足りず、どれかを厚くしても他の薄さを埋められない。 そして全部が単一のゲートを通るため、使うこと自体が履歴として溜まっていく。

削る
構造で限定する
個人情報を落とし、統計的に意味のある記号へ置き換える。DB のスキーマそのものが利用範囲の定義になる。
絞る
流量を制限する
薄いラッパーが列単位の可否・1クエリの行数・セッション累積を強制する。DB ファイルには触らせない。
記録する
完全に追跡する
誰が・いつ・どのクエリで・何行を持ち出したか。全件を改ざん困難な形で残す。

商品ラベル

棚である以上、選ぶための情報が要る。各コーパスは登録時に次を宣言し、 レジストリがそれを返す。人間が読むだけでなく、 エージェントがこれを読んで自分で選ぶので、宣伝文ではなく仕様として書く。

項目なぜ要るか
コーパス IDstore-analytics参照が壊れない安定した名前
説明店舗別の来店・購買明細選択の主材料。エージェントはここだけを見て選ぶ
期間・粒度2026-04〜 / 明細行集計できる最小単位が決まる
規模1,240 万行分析の現実性の見積り
加工の内訳ALLOW MASK DENY の一覧 成分表示。何が残っているかで使えるかが決まる
答えられない問い個人の年齢/小規模店の単独分析先に書けば、失敗する試行をまるごと省ける
更新週次 / 最終ビルド 2026-08-14鮮度。古い棚は使われなくなる
提供形態派生ファイル配布 / ラッパー経由どう繋ぐかが変わる
バジェット20,000 行 / 日使用量の見積り
出所・ライセンス社内 / 第三者提供二次利用と再配布の可否

「答えられない問い」を書く欄があるのが、この設計の要点である。 エージェントは書いていなければ必ず試し、失敗し、それでも別の角度から進もうとする。 できないことを先に宣言するほうが、できることを飾るより役に立つ。

削る — スキーマを policy にする

最も確実な対策は、機微なデータをそもそも置かないことである。 分析用途の大半は氏名も電話番号も必要としない。必要なのは「店舗 × 月 × カテゴリの件数」であり、 そこに個票が存在する理由がない。

ただし列を消すだけでは分析が壊れる。同一人物の再来訪も、店舗をまたぐ移動も追えなくなる。 そこで削除ではなく置換を採る。識別子は、統計的性質を保ったまま元へ戻せない記号へ変換する。

以下は小売の来店データを棚に載せる場合の一例である。 コーパスごとに列も判断も変わるが、 ALLOW MASK DENY の三択に振り分けるという形は共通で、この表がそのまま商品ラベルの成分表示になる。

元の列扱い置換後保たれる分析
customer_nameDENY —(列ごと存在しない)なし。必要としない
phone, emailDENY なし
customer_idMASK ソルト付きハッシュ c_8f3a… 再来訪率・LTV・店舗間の移動。同一性は保たれる
addressMASK メッシュコード / 市区町村まで商圏分析・距離帯別の傾向
birth_dateMASK 5歳幅の年代 35-39年代別構成。個人の特定には粗すぎる
visited_atALLOW そのまま時系列・曜日・時間帯
store_id, category, amount ALLOWそのまま売上・構成比・店舗比較

ソルトはコーパスごとに固定し、再構築しても同じ値になるようにする。ここがぶれると時系列が切れる。 逆にソルトを漏らせば全部が戻るので、ソルトの管理が匿名化の管理そのものになる。

この派生 DB は、守るべきものを含まないためファイルごと配ってよい。 Claude Code に ATTACH させ、任意の SQL と pandas を許す。制約は要らない。 多くの分析要求はここで終わる。

絞る — 薄いラッパーが唯一の経路になる

それでも生データが要る場合にだけ、二番目の層を敷く。 エージェントは DB ファイルに触れず、ラッパー経由でしか到達できない。

agent
Claude Code / Antigravity。ファイルは見えない
wrapper
authorizer で列単位に判定 → 行数上限 → 累積バジェット → k 未満の群を除去
sqlite
mode=ro。所有者はサービス専用ユーザーのみ

SQL を文字列として検査してはいけない。SQLite には操作ごと・列ごとに発火する認可コールバックがあり、 準備(prepare)の段階で拒否できる。サブクエリでも CTE でも View 経由でも必ず通るため、 書き方の工夫では回避されない。

# 既定は DENY。許可した操作だけが通る
def authorizer(action, arg1, arg2, db_name, trigger):
    if action == sqlite3.SQLITE_SELECT:
        return sqlite3.SQLITE_OK
    if action == sqlite3.SQLITE_READ:
        table, column = arg1, arg2
        if table in DENIED_TABLES:
            return sqlite3.SQLITE_DENY      # 文ごと失敗させる
        if (table, column) in MASKED_COLUMNS:
            return sqlite3.SQLITE_IGNORE    # その列は NULL として読まれる
        return sqlite3.SQLITE_OK
    if action == sqlite3.SQLITE_FUNCTION:
        return sqlite3.SQLITE_OK if arg2 in ALLOWED_FUNCS else sqlite3.SQLITE_DENY
    return sqlite3.SQLITE_DENY              # ATTACH / PRAGMA / 書込 / load_extension

con = sqlite3.connect("file:analytics.db?mode=ro", uri=True)
con.set_authorizer(authorizer)

SQLITE_IGNORE はエラーにせず値だけを NULL にする。 集計は通るのに個票は取れない、という挙動が一行で書ける。

流量の制御

authorizer は「何を読めるか」しか見ない。「どれだけ持ち出せるか」は別に要る。

制御既定値止まる攻撃実効
1クエリの行数上限1,000 行 + truncated フラグ 単発の全件取得
セッション累積バジェット20,000 行 / 日 分割ページングによる吸い出し最大
最小グループサイズk = 5 未満の群は返さない 集計を経由した個票の特定
実行時間の上限progress handler で中断 暴走クエリによる占有可用性のみ

累積バジェットが実質的に最も効く。 1,000 行ずつ丁寧に取り続ける動きは、必ずここで止まる。 truncated を素直に返せば、エージェントは自分で「絞るか集計する」方向へ切り替える。

記録する — 全クエリが一箇所を通る

全アクセスが単一の関数を通るため、追跡は設計上ほぼ無償で手に入る。 これを中途半端にすると、前の二層があっても「漏れたことに気づけない」状態になる。

記録する項目用途
識別情報(Cloudflare Access のアイデンティティ / サービストークン)誰が。人とエージェントを区別する
受け取った SQL の原文意図の再現。要約せず生で残す
authorizer の判定結果(拒否・マスクされた列)何を取ろうとして止められたか
返却行数と累積消費バジェットの消費曲線。異常な傾きを拾う
実行時刻・所要時間・接続元通常と異なる時間帯・場所の検出
コーパス ID とスキーマのバージョン後から結果を再現するために必須

ログはラッパーとは別の場所へ追記専用で書く。同じ DB に書けば、 ラッパーを止められる立場の人間がログも消せる。 拒否されたクエリこそ残す価値がある。何を取ろうとしたかは、取れたものより多くを語る。

選んで、組み合わせる

棚である以上、買い物は一点では終わらない。 売上のコーパスと、地域統計のコーパスと、過去の提案資料のコーパスを同時に開いて、 横断で問いを立てるのが本来の使い方になる。接続は ID を並べるだけでよい。

# エージェント側は、選んだコーパスの ID を渡すだけ
search(corpus=["store-analytics", "region-stats", "decks-2024"],
       q="火曜の来店が落ちている地域の共通点", k=20)

横断でつまずくのは、たいてい埋め込みモデルの不一致である。 別々のモデルで作られたベクトルは、スコアを直接比べても意味を持たない。 これは RRF(Reciprocal Rank Fusion)でスコアではなく順位を統合することで回避できる。 各コーパスが BM25 でも独自ベクトルでも構わなくなるので、 出品側に単一の埋め込みモデルを強制せずに済む。棚を広げるうえでこれは効く。

横断で決めておくこと設計
ランキングの統合RRF。順位で統合するので埋め込みモデルが揃っていなくてよい
バジェットの単位買い物客ごと。コーパスごとにすると、選ぶ数だけ持ち出せてしまう
出典の返却結果には必ずコーパス ID・版・ライセンスを付ける。混ぜた成果物の扱いが後から追える
一部が落ちたとき落ちたコーパスを明示して残りで返す。黙って欠落させない

バジェットを買い物客ごとに置くのが要点である。 コーパス単位で 20,000 行を許すと、10 個選んだ利用者は 200,000 行を持ち出せる。 棚が広がるほど、制限は横断側に置かないと意味を失う。

その上は自由でよい

三層が敷かれていれば、上で何をしても構わない。ここを制限すると、そもそもの狙いが死ぬ。

やること経路誰の費用か
コーパスを選んで対話するMCP list_corpora / search 各自のサブスク(公式クライアント経由)
その場で Python を書いて分析する派生 DB を ATTACH、または /query 各自のサブスク
他人のライブラリを横断で引くレジストリに登録された複数コーパス同上
資料数十本を読ませて要約するNotebookLM / Gem(作らない)Workspace に内包済み

ホストは一度もモデルを呼ばない。検索と集計だけを返し、生成は各クライアントが自分の契約枠で行う。 クエリ側の埋め込みもローカル GPU で回せば、ホストの従量課金はゼロになり、 残るのはストレージと電気代だけになる。

溜まる — 制約の副産物として

三層を敷く費用に対する見返りは、安全だけではない。 すべての利用が単一のゲートを通るため、使うこと自体が履歴を生む。 蓄積のために別の仕組みを足す必要はなく、記録を止めないだけでよい。

重要なのは順序である。蓄積は無条件・無差別に行い、選別は後段に置く。 入口に「価値があるものだけ残す」という人手の判断を挟むと、何も残らなくなる。 記録は安く、選別は後からでも、あるいは機械でもできる。

自動で溜まるものいつ発生するか追加の手間
全クエリの原文と結果メタラッパーを通るたびなし(トラッカーの出力そのもの)
試行錯誤の連鎖同上(セッション単位で連結)なし
拒否・マスクされた要求同上なし。取れなかった記録は取れた記録より雄弁
コーパス版と、それを作ったコード派生 DB をビルドするたびなし(書かなければビルドできない)
コーパスの癖・答えられない問い触って分かるたびCLAUDE.md に追記するだけ
成果物(コード・出力)コミットのたび作業を git 上で行っていれば、なし

ログのスキーマは、後で作るランク付けのために今決める

蓄積したログから「どの問いが価値ある問いだったか」を選び出すのは、別途アルゴリズムの仕事である。 ただしそのとき使う信号は、記録していなければ後から復元できない。 アルゴリズムを作る前に、素材の側を決めておく必要がある。

CREATE TABLE query_log (
  id             INTEGER PRIMARY KEY,
  ts             TEXT    NOT NULL,
  session_id     TEXT    NOT NULL,  -- 精緻化の連鎖をまとめる単位
  parent_id      INTEGER,           -- 直前のクエリ。試行錯誤の形が残る
  identity       TEXT    NOT NULL,  -- 人とエージェントを区別する
  corpus_id      TEXT    NOT NULL,
  corpus_version TEXT    NOT NULL,  -- 後から結果を再現するために必須
  sql_raw        TEXT    NOT NULL,  -- 要約せず原文で
  sql_hash       TEXT    NOT NULL,  -- 正規化後のハッシュ。反復の計数はこれでしか成立しない
  rows_returned  INTEGER,
  truncated      INTEGER,
  denied         TEXT,              -- 拒否・マスクされた列
  duration_ms    INTEGER,
  error          TEXT
);

後段のランク付けが使うであろう信号は、この形なら全部そろう。 sql_hash正規化した SQL のハッシュで、 「同じ問いを何人が別々に立てたか」を数えられる唯一の手がかりになる。 原文だけを残すと、空白や別名の違いで同一性が失われ、後から数え直せない。 parent_id は連鎖を残す。五回続けて絞り込まれたクエリは外れであり、 そこでセッションが終わったクエリは当たりである可能性が高い — この判断も、連鎖が記録されていなければ下せない。

置き場は二つに分ける

溜まるもの置き場理由
クエリログデータ側ホストの追記専用ストア 量が多く機械可読。ラッパーを止められる人が消せない場所に
変換コード / CLAUDE.md / 成果物コーパスごとの Git リポジトリ 所有者・レビュー・巻き戻しが構造で付く

共有ファイルシステムに置いてはいけない。属人化して棚卸しできなくなり、 監査項目の「退職者のスクリプトが残っていないか」を自ら生産することになる。 作業ディレクトリを共有する必要はなく、共有するのはデータと履歴だけでよい

リポジトリは、派生 DB を一つ作る時点で自然に発生する。 削って置換するコードは書かねばならず、癖も触れば分かる。 追加作業はなく、それを個人の作業ディレクトリではなくリポジトリに置くというだけの違いである。 ここだけは最初から git に置く。後から回収するのが最も面倒な部分になる。

前提条件

これを外すと三層すべてが飾りになる

ラッパーが唯一の経路であること。 エージェントが同じホストへ SSH できるなら、sqlite3 で直接叩けてしまう。 DB ファイルはサービス専用ユーザーが 0600 で所有し、 エージェント側のユーザーからは読めない状態にする。到達できるのはラッパーのポートのみ。

これは、このコーパスに関して意図的に「逃げ道」を捨てるという取引である。 全体方針として逃げ道は保つべきだが、生データを含む層だけは例外にする。

エージェント側の設定(コマンドの拒否リスト、ツール呼び出しを遮るフック)は有用だが、 ガードレールであって境界ではない。セッションを開いた本人は書き換えられる。 境界は常に「置かないこと」と「サーバー側で強制すること」の二つだけである。

作る順番

需要を測る前に積まない。多くの要求は最初の二段で尽きる。

資料系を既存ツールに入れる

過去のプレゼン、議事録、方針文書。何も作らない。ここで需要のどれだけが標準ツールで吸収されるかを測る。

棚に一品だけ並べる

対象を一つ選び、上表の方針で削って置換する。商品ラベルを書き、読み取り専用で配る。この時点で制約はラベルだけでよい。

二品目を並べ、横断を通す

一品では棚にならない。二つ目で初めてレジストリと RRF が要る。買い物客ごとのバジェットもここで置く。

ラッパーとトラッカーを足す

「生データが要る」という具体的な要求が出てから。authorizer とバジェットで 100 行程度。追記専用のログを別ストアへ。ここまで来て初めて「自由に使ってよい」と言える。

先に固定すべきもの

あとから変えると全再構築になる項目が三つある。着手前に決めておく。

ハッシュのソルト。コーパス単位で固定する。変えると時系列が切れ、漏らすと匿名化が解ける。
埋め込みモデル。ベクトル検索を使うなら、全コーパスで揃える。変更は全再インデックスを意味する。
チャンク ID の安定性。再構築で ID が変わる設計にすると、引用したアウトプットのリンクが一斉に切れる。

設計方針の記述であり、個別の導入内容を保証するものではない。 棚に載せる前に、対象データの法的性質(個人情報該当性、第三者提供の扱い)を確認すること。 匿名化が十分かどうかは、技術だけでは判定できない。

RAG事業 — サービス説明

既存資料(2026-03 版)。上の設計に先行するもの。