選べる棚をつくる
社内外の多数のデータベースを一つの棚に並べ、必要なものを選び、 自分の使い慣れたエージェントを挿して分析する。 棚に載せられるのは、三つの条件を通ったものだけである。
棚とは何か
一つの巨大な RAG を作るのではない。性質の違う多数のデータベースを並べた棚を作る。 定量と定性、社内と社外、テキストと画像と音声、分野もばらばらでよい。 利用者は必要なものを選び、必要なだけ組み合わせる。
この形にすると、必ず同じ問いが返ってくる。「全部抜かれたらどうするのか」。 並べる数が増えるほど、この問いは重くなる。
そして「読み取り専用にします」は答えになっていない。 読み取り専用が防ぐのは改変であって、抜き取りではない。 任意の SQL と Python を許した時点で、全件取得も、分割してのページングも、書き出しも成立する。 同様に、エージェントに読ませた行はその時点でモデル提供者へ渡っている。 守るべき対象は「DB へのアクセス」ではなく、何行がコンテキストに乗るかである。
出品条件
そこで、棚に載せる側に条件を課す。 三つを通っていないデータベースは棚に並べない。 逆に言えば、通っているものは自由に使わせてよい — この線引きが棚を成立させる。
構造で削り、流量で絞り、全部を記録する。
この三つが同時に効いていれば、その上の利用は制限しなくてよい。
三つは順番ではなく層である。どれか一つでは足りず、どれかを厚くしても他の薄さを埋められない。 そして全部が単一のゲートを通るため、使うこと自体が履歴として溜まっていく。
商品ラベル
棚である以上、選ぶための情報が要る。各コーパスは登録時に次を宣言し、 レジストリがそれを返す。人間が読むだけでなく、 エージェントがこれを読んで自分で選ぶので、宣伝文ではなく仕様として書く。
| 項目 | 例 | なぜ要るか |
|---|---|---|
| コーパス ID | store-analytics | 参照が壊れない安定した名前 |
| 説明 | 店舗別の来店・購買明細 | 選択の主材料。エージェントはここだけを見て選ぶ |
| 期間・粒度 | 2026-04〜 / 明細行 | 集計できる最小単位が決まる |
| 規模 | 1,240 万行 | 分析の現実性の見積り |
| 加工の内訳 | ALLOW MASK DENY の一覧 | 成分表示。何が残っているかで使えるかが決まる |
| 答えられない問い | 個人の年齢/小規模店の単独分析 | 先に書けば、失敗する試行をまるごと省ける |
| 更新 | 週次 / 最終ビルド 2026-08-14 | 鮮度。古い棚は使われなくなる |
| 提供形態 | 派生ファイル配布 / ラッパー経由 | どう繋ぐかが変わる |
| バジェット | 20,000 行 / 日 | 使用量の見積り |
| 出所・ライセンス | 社内 / 第三者提供 | 二次利用と再配布の可否 |
「答えられない問い」を書く欄があるのが、この設計の要点である。 エージェントは書いていなければ必ず試し、失敗し、それでも別の角度から進もうとする。 できないことを先に宣言するほうが、できることを飾るより役に立つ。
削る — スキーマを policy にする
最も確実な対策は、機微なデータをそもそも置かないことである。 分析用途の大半は氏名も電話番号も必要としない。必要なのは「店舗 × 月 × カテゴリの件数」であり、 そこに個票が存在する理由がない。
ただし列を消すだけでは分析が壊れる。同一人物の再来訪も、店舗をまたぐ移動も追えなくなる。 そこで削除ではなく置換を採る。識別子は、統計的性質を保ったまま元へ戻せない記号へ変換する。
以下は小売の来店データを棚に載せる場合の一例である。 コーパスごとに列も判断も変わるが、 ALLOW MASK DENY の三択に振り分けるという形は共通で、この表がそのまま商品ラベルの成分表示になる。
| 元の列 | 扱い | 置換後 | 保たれる分析 |
|---|---|---|---|
customer_name | DENY | —(列ごと存在しない) | なし。必要としない |
phone, email | DENY | — | なし |
customer_id | MASK | ソルト付きハッシュ c_8f3a… |
再来訪率・LTV・店舗間の移動。同一性は保たれる |
address | MASK | メッシュコード / 市区町村まで | 商圏分析・距離帯別の傾向 |
birth_date | MASK | 5歳幅の年代 35-39 | 年代別構成。個人の特定には粗すぎる |
visited_at | ALLOW | そのまま | 時系列・曜日・時間帯 |
store_id, category, amount |
ALLOW | そのまま | 売上・構成比・店舗比較 |
ソルトはコーパスごとに固定し、再構築しても同じ値になるようにする。ここがぶれると時系列が切れる。 逆にソルトを漏らせば全部が戻るので、ソルトの管理が匿名化の管理そのものになる。
この派生 DB は、守るべきものを含まないためファイルごと配ってよい。
Claude Code に ATTACH させ、任意の SQL と pandas を許す。制約は要らない。
多くの分析要求はここで終わる。
絞る — 薄いラッパーが唯一の経路になる
それでも生データが要る場合にだけ、二番目の層を敷く。 エージェントは DB ファイルに触れず、ラッパー経由でしか到達できない。
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 版)。上の設計に先行するもの。